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,65 @@
<?php
namespace App\Http\Controllers\Api\Inventory;
use App\Http\Controllers\Api\BaseApiController;
use App\Http\Requests\Inventory\InventoryCategoryIndexRequest;
use App\Http\Requests\Inventory\InventoryCategoryStoreRequest;
use App\Http\Requests\Inventory\InventoryCategoryUpdateRequest;
use App\Http\Resources\Inventory\InventoryCategoryResource;
use App\Services\Inventory\InventoryCategoryService;
use Illuminate\Http\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
class InventoryCategoryController extends BaseApiController
{
public function __construct(private InventoryCategoryService $categories)
{
parent::__construct();
}
public function index(InventoryCategoryIndexRequest $request): JsonResponse
{
$payload = $request->validated();
$rows = $this->categories->list($payload['type'] ?? null);
return $this->success([
'categories' => InventoryCategoryResource::collection($rows),
]);
}
public function store(InventoryCategoryStoreRequest $request): JsonResponse
{
$result = $this->categories->create($request->validated());
if (!($result['ok'] ?? false)) {
return $this->error($result['message'] ?? 'Failed to save category.', Response::HTTP_UNPROCESSABLE_ENTITY);
}
return $this->respondCreated(new InventoryCategoryResource($result['category']));
}
public function update(InventoryCategoryUpdateRequest $request, int $id): JsonResponse
{
try {
$result = $this->categories->update($id, $request->validated());
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage(), Response::HTTP_UNPROCESSABLE_ENTITY);
}
if (!($result['ok'] ?? false)) {
return $this->error($result['message'] ?? 'Failed to update category.', Response::HTTP_UNPROCESSABLE_ENTITY);
}
return $this->success(new InventoryCategoryResource($result['category']));
}
public function destroy(int $id): JsonResponse
{
$result = $this->categories->delete($id);
if (!($result['ok'] ?? false)) {
return $this->error($result['message'] ?? 'Failed to delete category.', Response::HTTP_UNPROCESSABLE_ENTITY);
}
return $this->respondDeleted();
}
}
@@ -0,0 +1,154 @@
<?php
namespace App\Http\Controllers\Api\Inventory;
use App\Http\Controllers\Api\BaseApiController;
use App\Http\Requests\Inventory\InventoryAdjustRequest;
use App\Http\Requests\Inventory\InventoryAuditRequest;
use App\Http\Requests\Inventory\InventoryItemIndexRequest;
use App\Http\Requests\Inventory\InventoryItemStoreRequest;
use App\Http\Requests\Inventory\InventoryItemUpdateRequest;
use App\Http\Requests\Inventory\InventoryTeacherDistributionRequest;
use App\Http\Requests\Inventory\InventoryTeacherDistributionStoreRequest;
use App\Http\Resources\Inventory\InventoryItemResource;
use App\Services\Inventory\InventoryItemService;
use App\Services\Inventory\InventorySummaryService;
use App\Services\Inventory\InventoryTeacherDistributionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
class InventoryController extends BaseApiController
{
public function __construct(
private InventoryItemService $items,
private InventorySummaryService $summary,
private InventoryTeacherDistributionService $distribution
) {
parent::__construct();
}
public function index(InventoryItemIndexRequest $request): JsonResponse
{
$result = $this->items->list($request->validated());
return $this->success([
'type' => $result['type'],
'items' => InventoryItemResource::collection($result['items']),
'categories' => $result['categories'],
'conditionOptions' => $result['conditionOptions'],
'schoolYears' => $result['schoolYears'],
'selectedYear' => $result['selectedYear'],
'currentYear' => $result['currentYear'],
'semester' => $result['semester'],
'userNames' => $result['userNames'],
]);
}
public function show(int $id): JsonResponse
{
$item = $this->items->find($id);
if (!$item) {
return $this->error('Item not found.', Response::HTTP_NOT_FOUND);
}
return $this->success(new InventoryItemResource($item));
}
public function store(InventoryItemStoreRequest $request): JsonResponse
{
$result = $this->items->create($request->validated(), (int) auth()->id());
if (!($result['ok'] ?? false)) {
return $this->error($result['message'] ?? 'Failed to create item.', Response::HTTP_UNPROCESSABLE_ENTITY);
}
return $this->respondCreated(new InventoryItemResource($result['item']));
}
public function update(InventoryItemUpdateRequest $request, int $id): JsonResponse
{
$result = $this->items->update($id, $request->validated(), (int) auth()->id());
if (!($result['ok'] ?? false)) {
return $this->error($result['message'] ?? 'Failed to update item.', Response::HTTP_UNPROCESSABLE_ENTITY);
}
return $this->success(new InventoryItemResource($result['item']));
}
public function destroy(int $id): JsonResponse
{
$result = $this->items->delete($id);
if (!($result['ok'] ?? false)) {
return $this->error($result['message'] ?? 'Failed to delete item.', Response::HTTP_UNPROCESSABLE_ENTITY);
}
return $this->respondDeleted();
}
public function audit(InventoryAuditRequest $request, int $id): JsonResponse
{
$result = $this->items->auditClassroom($id, $request->validated(), (int) auth()->id());
if (!($result['ok'] ?? false)) {
return $this->error($result['message'] ?? 'Failed to audit item.', Response::HTTP_UNPROCESSABLE_ENTITY);
}
return $this->success(new InventoryItemResource($result['item']));
}
public function adjust(InventoryAdjustRequest $request, int $id): JsonResponse
{
$result = $this->items->adjustStock($id, $request->validated(), (int) auth()->id());
if (!($result['ok'] ?? false)) {
return $this->error($result['message'] ?? 'Failed to adjust stock.', Response::HTTP_UNPROCESSABLE_ENTITY);
}
return $this->success();
}
public function summary(string $type): JsonResponse
{
$payload = $this->summary->summary($type, request()->query('school_year'));
return $this->success($payload);
}
public function summaryAll(): JsonResponse
{
$payload = $this->summary->summaryAll(
request()->query('school_year'),
request()->query('type')
);
return $this->success($payload);
}
public function teacherDistribution(InventoryTeacherDistributionRequest $request): JsonResponse
{
$payload = $request->validated();
$result = $this->distribution->formData(
(int) auth()->id(),
$payload['class_section_id'] ?? null,
$payload['item_id'] ?? null
);
if (!($result['ok'] ?? false)) {
return $this->error($result['message'] ?? 'Unable to load distribution form.', Response::HTTP_UNPROCESSABLE_ENTITY);
}
return $this->success($result);
}
public function teacherDistributionStore(InventoryTeacherDistributionStoreRequest $request): JsonResponse
{
try {
$result = $this->distribution->distribute((int) auth()->id(), $request->validated());
} catch (\Throwable $e) {
Log::error('Inventory distribution failed: ' . $e->getMessage());
return $this->error('Unable to distribute inventory.', Response::HTTP_INTERNAL_SERVER_ERROR);
}
if (!($result['ok'] ?? false)) {
return $this->error($result['message'] ?? 'Unable to distribute inventory.', Response::HTTP_UNPROCESSABLE_ENTITY);
}
return $this->success($result);
}
}
@@ -0,0 +1,70 @@
<?php
namespace App\Http\Controllers\Api\Inventory;
use App\Http\Controllers\Api\BaseApiController;
use App\Http\Requests\Inventory\InventoryMovementBulkDeleteRequest;
use App\Http\Requests\Inventory\InventoryMovementIndexRequest;
use App\Http\Requests\Inventory\InventoryMovementStoreRequest;
use App\Http\Requests\Inventory\InventoryMovementUpdateRequest;
use App\Http\Resources\Inventory\InventoryMovementResource;
use App\Services\Inventory\InventoryMovementService;
use Illuminate\Http\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
class InventoryMovementController extends BaseApiController
{
public function __construct(private InventoryMovementService $movements)
{
parent::__construct();
}
public function index(InventoryMovementIndexRequest $request): JsonResponse
{
$rows = $this->movements->list($request->validated());
return $this->success([
'movements' => InventoryMovementResource::collection($rows),
]);
}
public function store(InventoryMovementStoreRequest $request): JsonResponse
{
$result = $this->movements->create($request->validated(), (int) auth()->id());
if (!($result['ok'] ?? false)) {
return $this->error($result['message'] ?? 'Failed to create movement.', Response::HTTP_UNPROCESSABLE_ENTITY);
}
return $this->respondCreated();
}
public function update(InventoryMovementUpdateRequest $request, int $id): JsonResponse
{
$result = $this->movements->update($id, $request->validated());
if (!($result['ok'] ?? false)) {
return $this->error($result['message'] ?? 'Failed to update movement.', Response::HTTP_UNPROCESSABLE_ENTITY);
}
return $this->success();
}
public function destroy(int $id): JsonResponse
{
$result = $this->movements->delete($id);
if (!($result['ok'] ?? false)) {
return $this->error($result['message'] ?? 'Failed to delete movement.', Response::HTTP_UNPROCESSABLE_ENTITY);
}
return $this->respondDeleted();
}
public function bulkDelete(InventoryMovementBulkDeleteRequest $request): JsonResponse
{
$result = $this->movements->bulkDelete($request->validated()['ids']);
if (!($result['ok'] ?? false)) {
return $this->error($result['message'] ?? 'Bulk delete failed.', Response::HTTP_UNPROCESSABLE_ENTITY);
}
return $this->success($result);
}
}
@@ -0,0 +1,62 @@
<?php
namespace App\Http\Controllers\Api\Inventory;
use App\Http\Controllers\Api\BaseApiController;
use App\Http\Requests\Inventory\SupplierIndexRequest;
use App\Http\Requests\Inventory\SupplierStoreRequest;
use App\Http\Requests\Inventory\SupplierUpdateRequest;
use App\Http\Resources\Inventory\SupplierResource;
use App\Services\Inventory\SupplierService;
use Illuminate\Http\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
class SupplierController extends BaseApiController
{
public function __construct(private SupplierService $suppliers)
{
parent::__construct();
}
public function index(SupplierIndexRequest $request): JsonResponse
{
$payload = $request->validated();
$perPage = (int) ($payload['per_page'] ?? 20);
$result = $this->suppliers->list($payload, $perPage);
return $this->success([
'suppliers' => SupplierResource::collection($result['items']),
'pagination' => $result['pagination'],
]);
}
public function store(SupplierStoreRequest $request): JsonResponse
{
$result = $this->suppliers->create($request->validated());
if (!($result['ok'] ?? false)) {
return $this->error($result['message'] ?? 'Failed to save supplier.', Response::HTTP_UNPROCESSABLE_ENTITY);
}
return $this->respondCreated(new SupplierResource($result['supplier']));
}
public function update(SupplierUpdateRequest $request, int $id): JsonResponse
{
$result = $this->suppliers->update($id, $request->validated());
if (!($result['ok'] ?? false)) {
return $this->error($result['message'] ?? 'Failed to update supplier.', Response::HTTP_UNPROCESSABLE_ENTITY);
}
return $this->success(new SupplierResource($result['supplier']));
}
public function destroy(int $id): JsonResponse
{
$result = $this->suppliers->delete($id);
if (!($result['ok'] ?? false)) {
return $this->error($result['message'] ?? 'Failed to delete supplier.', Response::HTTP_UNPROCESSABLE_ENTITY);
}
return $this->respondDeleted();
}
}
@@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers\Api\Inventory;
use App\Http\Controllers\Api\BaseApiController;
use App\Http\Requests\Inventory\SupplyCategoryStoreRequest;
use App\Http\Requests\Inventory\SupplyCategoryUpdateRequest;
use App\Http\Resources\Inventory\SupplyCategoryResource;
use App\Services\Inventory\SupplyCategoryService;
use Illuminate\Http\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
class SupplyCategoryController extends BaseApiController
{
public function __construct(private SupplyCategoryService $categories)
{
parent::__construct();
}
public function index(): JsonResponse
{
$rows = $this->categories->list();
return $this->success([
'categories' => SupplyCategoryResource::collection($rows),
]);
}
public function store(SupplyCategoryStoreRequest $request): JsonResponse
{
$result = $this->categories->create($request->validated());
if (!($result['ok'] ?? false)) {
return $this->error($result['message'] ?? 'Failed to save category.', Response::HTTP_UNPROCESSABLE_ENTITY);
}
return $this->respondCreated(new SupplyCategoryResource($result['category']));
}
public function update(SupplyCategoryUpdateRequest $request, int $id): JsonResponse
{
$result = $this->categories->update($id, $request->validated());
if (!($result['ok'] ?? false)) {
return $this->error($result['message'] ?? 'Failed to update category.', Response::HTTP_UNPROCESSABLE_ENTITY);
}
return $this->success(new SupplyCategoryResource($result['category']));
}
public function destroy(int $id): JsonResponse
{
$result = $this->categories->delete($id);
if (!($result['ok'] ?? false)) {
return $this->error($result['message'] ?? 'Failed to delete category.', Response::HTTP_UNPROCESSABLE_ENTITY);
}
return $this->respondDeleted();
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Http\Requests\Inventory;
use App\Http\Requests\ApiFormRequest;
class InventoryAdjustRequest extends ApiFormRequest
{
public function authorize(): bool
{
return auth()->check();
}
public function rules(): array
{
return [
'mode' => ['required', 'in:in,out,adjust'],
'quantity' => ['required', 'integer', 'min:1'],
'reason' => ['nullable', 'string', 'max:120'],
'note' => ['nullable', 'string'],
];
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Http\Requests\Inventory;
use App\Http\Requests\ApiFormRequest;
class InventoryAuditRequest extends ApiFormRequest
{
public function authorize(): bool
{
return auth()->check();
}
public function rules(): array
{
return [
'needs_repair_qty' => ['nullable', 'integer', 'min:0'],
'need_replace_qty' => ['nullable', 'integer', 'min:0'],
'cannot_find_qty' => ['nullable', 'integer', 'min:0'],
];
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests\Inventory;
use App\Http\Requests\ApiFormRequest;
class InventoryCategoryIndexRequest extends ApiFormRequest
{
public function authorize(): bool
{
return auth()->check();
}
public function rules(): array
{
return [
'type' => ['nullable', 'string', 'max:20'],
];
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Requests\Inventory;
use App\Http\Requests\ApiFormRequest;
class InventoryCategoryStoreRequest extends ApiFormRequest
{
public function authorize(): bool
{
return auth()->check();
}
public function rules(): array
{
return [
'type' => ['required', 'string', 'max:20'],
'name' => ['required', 'string', 'max:120'],
'description' => ['nullable', 'string'],
'grade_min' => ['nullable', 'integer', 'min:0', 'max:13'],
'grade_max' => ['nullable', 'integer', 'min:0', 'max:13'],
];
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Requests\Inventory;
use App\Http\Requests\ApiFormRequest;
class InventoryCategoryUpdateRequest extends ApiFormRequest
{
public function authorize(): bool
{
return auth()->check();
}
public function rules(): array
{
return [
'type' => ['sometimes', 'string', 'max:20'],
'name' => ['sometimes', 'string', 'max:120'],
'description' => ['nullable', 'string'],
'grade_min' => ['nullable', 'integer', 'min:0', 'max:13'],
'grade_max' => ['nullable', 'integer', 'min:0', 'max:13'],
];
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Http\Requests\Inventory;
use App\Http\Requests\ApiFormRequest;
class InventoryItemIndexRequest extends ApiFormRequest
{
public function authorize(): bool
{
return auth()->check();
}
public function rules(): array
{
return [
'type' => ['nullable', 'string', 'max:20'],
'school_year' => ['nullable', 'string', 'max:20'],
'semester' => ['nullable', 'string', 'max:20'],
'q' => ['nullable', 'string', 'max:120'],
];
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests\Inventory;
use App\Http\Requests\ApiFormRequest;
class InventoryItemStoreRequest extends ApiFormRequest
{
public function authorize(): bool
{
return auth()->check();
}
public function rules(): array
{
return [
'type' => ['required', 'string', 'max:20'],
'category_id' => ['nullable', 'integer', 'min:1'],
'name' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'quantity' => ['nullable', 'integer'],
'unit' => ['nullable', 'string', 'max:50'],
'condition' => ['nullable', 'string', 'max:50'],
'isbn' => ['nullable', 'string', 'max:50'],
'edition' => ['nullable', 'string', 'max:50'],
'sku' => ['nullable', 'string', 'max:50'],
'notes' => ['nullable', 'string'],
];
}
}
@@ -0,0 +1,29 @@
<?php
namespace App\Http\Requests\Inventory;
use App\Http\Requests\ApiFormRequest;
class InventoryItemUpdateRequest extends ApiFormRequest
{
public function authorize(): bool
{
return auth()->check();
}
public function rules(): array
{
return [
'category_id' => ['nullable', 'integer', 'min:1'],
'name' => ['sometimes', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'quantity' => ['nullable', 'integer'],
'unit' => ['nullable', 'string', 'max:50'],
'condition' => ['nullable', 'string', 'max:50'],
'isbn' => ['nullable', 'string', 'max:50'],
'edition' => ['nullable', 'string', 'max:50'],
'sku' => ['nullable', 'string', 'max:50'],
'notes' => ['nullable', 'string'],
];
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Http\Requests\Inventory;
use App\Http\Requests\ApiFormRequest;
class InventoryMovementBulkDeleteRequest extends ApiFormRequest
{
public function authorize(): bool
{
return auth()->check();
}
public function rules(): array
{
return [
'ids' => ['required', 'array', 'min:1'],
'ids.*' => ['integer', 'min:1'],
];
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Http\Requests\Inventory;
use App\Http\Requests\ApiFormRequest;
class InventoryMovementIndexRequest extends ApiFormRequest
{
public function authorize(): bool
{
return auth()->check();
}
public function rules(): array
{
return [
'school_year' => ['nullable', 'string', 'max:20'],
'semester' => ['nullable', 'string', 'max:20'],
];
}
}
@@ -0,0 +1,29 @@
<?php
namespace App\Http\Requests\Inventory;
use App\Http\Requests\ApiFormRequest;
class InventoryMovementStoreRequest extends ApiFormRequest
{
public function authorize(): bool
{
return auth()->check();
}
public function rules(): array
{
return [
'item_id' => ['required', 'integer', 'min:1'],
'qty_change' => ['required', 'integer'],
'movement_type' => ['required', 'in:initial,in,out,adjust,distribution'],
'reason' => ['nullable', 'string', 'max:120'],
'note' => ['nullable', 'string'],
'semester' => ['nullable', 'string', 'max:20'],
'school_year' => ['nullable', 'string', 'max:20'],
'teacher_id' => ['nullable', 'integer', 'min:1'],
'student_id' => ['nullable', 'integer', 'min:1'],
'class_section_id' => ['nullable', 'integer', 'min:1'],
];
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Http\Requests\Inventory;
use App\Http\Requests\ApiFormRequest;
class InventoryMovementUpdateRequest extends ApiFormRequest
{
public function authorize(): bool
{
return auth()->check();
}
public function rules(): array
{
return [
'qty_change' => ['required', 'integer'],
'movement_type' => ['required', 'in:initial,in,out,adjust,distribution'],
'reason' => ['nullable', 'string', 'max:120'],
'note' => ['nullable', 'string'],
'semester' => ['nullable', 'string', 'max:20'],
'school_year' => ['nullable', 'string', 'max:20'],
'teacher_id' => ['nullable', 'integer', 'min:1'],
'student_id' => ['nullable', 'integer', 'min:1'],
'class_section_id' => ['nullable', 'integer', 'min:1'],
];
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Http\Requests\Inventory;
use App\Http\Requests\ApiFormRequest;
class InventoryTeacherDistributionRequest extends ApiFormRequest
{
public function authorize(): bool
{
return auth()->check();
}
public function rules(): array
{
return [
'class_section_id' => ['nullable', 'integer', 'min:1'],
'item_id' => ['nullable', 'integer', 'min:1'],
];
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Requests\Inventory;
use App\Http\Requests\ApiFormRequest;
class InventoryTeacherDistributionStoreRequest extends ApiFormRequest
{
public function authorize(): bool
{
return auth()->check();
}
public function rules(): array
{
return [
'item_id' => ['required', 'integer', 'min:1'],
'class_section_id' => ['nullable', 'integer', 'min:1'],
'student_ids' => ['required', 'array', 'min:1'],
'student_ids.*' => ['integer', 'min:1'],
'note' => ['nullable', 'string'],
];
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Http\Requests\Inventory;
use App\Http\Requests\ApiFormRequest;
class SupplierIndexRequest extends ApiFormRequest
{
public function authorize(): bool
{
return auth()->check();
}
public function rules(): array
{
return [
'q' => ['nullable', 'string', 'max:120'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
];
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Requests\Inventory;
use App\Http\Requests\ApiFormRequest;
class SupplierStoreRequest extends ApiFormRequest
{
public function authorize(): bool
{
return auth()->check();
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => ['nullable', 'email', 'max:255'],
'phone' => ['nullable', 'string', 'max:50'],
'address' => ['nullable', 'string', 'max:255'],
'notes' => ['nullable', 'string'],
];
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Requests\Inventory;
use App\Http\Requests\ApiFormRequest;
class SupplierUpdateRequest extends ApiFormRequest
{
public function authorize(): bool
{
return auth()->check();
}
public function rules(): array
{
return [
'name' => ['sometimes', 'string', 'max:255'],
'email' => ['nullable', 'email', 'max:255'],
'phone' => ['nullable', 'string', 'max:50'],
'address' => ['nullable', 'string', 'max:255'],
'notes' => ['nullable', 'string'],
];
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests\Inventory;
use App\Http\Requests\ApiFormRequest;
class SupplyCategoryStoreRequest extends ApiFormRequest
{
public function authorize(): bool
{
return auth()->check();
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
];
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests\Inventory;
use App\Http\Requests\ApiFormRequest;
class SupplyCategoryUpdateRequest extends ApiFormRequest
{
public function authorize(): bool
{
return auth()->check();
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
];
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Http\Resources\Inventory;
use Illuminate\Http\Resources\Json\JsonResource;
class InventoryCategoryResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => (int) ($this['id'] ?? 0),
'type' => (string) ($this['type'] ?? ''),
'name' => (string) ($this['name'] ?? ''),
'description' => $this['description'] ?? null,
'grade_min' => $this['grade_min'] ?? null,
'grade_max' => $this['grade_max'] ?? null,
];
}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Http\Resources\Inventory;
use Illuminate\Http\Resources\Json\JsonResource;
class InventoryItemResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => (int) ($this['id'] ?? 0),
'type' => (string) ($this['type'] ?? ''),
'category_id' => $this['category_id'] ?? null,
'name' => (string) ($this['name'] ?? ''),
'description' => $this['description'] ?? null,
'quantity' => (int) ($this['quantity'] ?? 0),
'good_qty' => $this['good_qty'] ?? null,
'needs_repair_qty' => $this['needs_repair_qty'] ?? null,
'need_replace_qty' => $this['need_replace_qty'] ?? null,
'cannot_find_qty' => $this['cannot_find_qty'] ?? null,
'unit' => $this['unit'] ?? null,
'condition' => $this['condition'] ?? null,
'isbn' => $this['isbn'] ?? null,
'edition' => $this['edition'] ?? null,
'sku' => $this['sku'] ?? null,
'notes' => $this['notes'] ?? null,
'semester' => $this['semester'] ?? null,
'school_year' => $this['school_year'] ?? null,
'updated_by' => $this['updated_by'] ?? null,
];
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Http\Resources\Inventory;
use Illuminate\Http\Resources\Json\JsonResource;
class InventoryMovementResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => (int) ($this['id'] ?? 0),
'item_id' => (int) ($this['item_id'] ?? 0),
'item_name' => $this['item_name'] ?? null,
'qty_change' => (int) ($this['qty_change'] ?? 0),
'movement_type' => (string) ($this['movement_type'] ?? ''),
'reason' => $this['reason'] ?? null,
'note' => $this['note'] ?? null,
'semester' => $this['semester'] ?? null,
'school_year' => $this['school_year'] ?? null,
'performed_by' => $this['performed_by'] ?? null,
'performed_by_name' => $this['performed_by_name'] ?? null,
'teacher_id' => $this['teacher_id'] ?? null,
'teacher_name' => $this['teacher_name'] ?? null,
'student_id' => $this['student_id'] ?? null,
'student_name' => $this['student_name'] ?? null,
'class_section_id' => $this['class_section_id'] ?? null,
'class_section_name' => $this['class_section_name'] ?? null,
];
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Http\Resources\Inventory;
use Illuminate\Http\Resources\Json\JsonResource;
class SupplierResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => (int) ($this['id'] ?? 0),
'name' => (string) ($this['name'] ?? ''),
'email' => $this['email'] ?? null,
'phone' => $this['phone'] ?? null,
'address' => $this['address'] ?? null,
'notes' => $this['notes'] ?? null,
];
}
}
@@ -0,0 +1,16 @@
<?php
namespace App\Http\Resources\Inventory;
use Illuminate\Http\Resources\Json\JsonResource;
class SupplyCategoryResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => (int) ($this['id'] ?? 0),
'name' => (string) ($this['name'] ?? ''),
];
}
}
+12
View File
@@ -7,4 +7,16 @@ use App\Models\BaseModel;
class Supplier extends BaseModel class Supplier extends BaseModel
{ {
protected $table = 'suppliers'; protected $table = 'suppliers';
public $timestamps = true;
protected $fillable = [
'name',
'email',
'phone',
'address',
'notes',
'created_at',
'updated_at',
];
} }
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace App\Models;
use App\Models\BaseModel;
class SupplyCategory extends BaseModel
{
protected $table = 'supply_categories';
public $timestamps = true;
protected $fillable = [
'name',
'created_at',
'updated_at',
];
}
@@ -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];
}
}
-487
View File
@@ -1,487 +0,0 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use CodeIgniter\HTTP\ResponseInterface;
use App\Models\InvoiceModel;
use App\Models\PaymentModel;
use App\Models\StudentClassModel;
use App\Models\ConfigurationModel;
class FamilyAdminController extends BaseController
{
public function index(): ResponseInterface
{
$db = \Config\Database::connect();
$studentId = (int) ($this->request->getGet('student_id') ?? 0);
$guardianId = (int) ($this->request->getGet('guardian_id') ?? 0);
$data = ['student' => null, 'families' => [], 'guardians' => [], 'students' => []];
// Simple student list for select
$data['students'] = $db->query("SELECT id, CONCAT(lastname, ', ', firstname) AS name FROM students ORDER BY lastname, firstname")->getResultArray();
// Preload search datasets (students + guardians/parents)
$data['searchStudents'] = $db->query("SELECT id, firstname, lastname FROM students ORDER BY lastname, firstname")->getResultArray();
$data['searchGuardians'] = $db->query(
"SELECT DISTINCT u.id, u.firstname, u.lastname, u.email, u.cellphone
FROM family_guardians fg
JOIN users u ON u.id = fg.user_id
ORDER BY u.lastname, u.firstname"
)->getResultArray();
// If a guardian is provided, resolve one of their students and redirect to use existing flow
if (!$studentId && $guardianId) {
$row = $db->query(
"SELECT s.id AS student_id
FROM family_guardians fg
JOIN family_students fs ON fs.family_id = fg.family_id
JOIN students s ON s.id = fs.student_id
WHERE fg.user_id = ?
ORDER BY s.lastname, s.firstname
LIMIT 1",
[$guardianId]
)->getRowArray();
if (!$row) {
$row = $db->query(
"SELECT s.id AS student_id
FROM students s
WHERE s.parent_id = ?
ORDER BY s.lastname, s.firstname
LIMIT 1",
[$guardianId]
)->getRowArray();
}
if ($row && !empty($row['student_id'])) {
return redirect()->to(site_url('family?student_id='.(int)$row['student_id']));
}
}
if ($studentId) {
$data['student'] = $db->query("SELECT id, firstname, lastname FROM students WHERE id = ?", [$studentId])->getRowArray();
// Fetch all families for this student with extended fields
$families = $db->query(
"SELECT f.id, f.family_code, f.household_name, f.address_line1, f.address_line2, f.city, f.state, f.postal_code, f.country,
f.primary_phone, f.preferred_lang, f.preferred_contact_method, f.is_active,
fs.is_primary_home
FROM family_students fs
JOIN families f ON f.id = fs.family_id
WHERE fs.student_id = ?
ORDER BY fs.is_primary_home DESC, f.household_name",
[$studentId]
)->getResultArray();
// Enrich each family with guardians, students, and financials
$invoiceModel = new InvoiceModel();
$paymentModel = new PaymentModel();
$studentClassModel = new StudentClassModel();
$configModel = new ConfigurationModel();
$schoolYear = (string) ($configModel->getConfig('school_year') ?? '');
foreach ($families as &$fam) {
$fid = (int) $fam['id'];
// Guardians (with contact info)
$guardians = $db->query(
"SELECT u.id AS user_id, u.firstname, u.lastname, u.email, u.cellphone,
u.address_street, u.city, u.state, u.zip,
fg.relation, fg.is_primary, fg.receive_emails, fg.receive_sms
FROM family_guardians fg
JOIN users u ON u.id = fg.user_id
WHERE fg.family_id = ?
ORDER BY fg.is_primary DESC, u.lastname, u.firstname",
[$fid]
)->getResultArray();
$fam['guardians'] = $guardians;
// Students in this family
$studentsRows = $db->query(
"SELECT s.id, s.firstname, s.lastname
FROM family_students fs
JOIN students s ON s.id = fs.student_id
WHERE fs.family_id = ?
ORDER BY s.lastname, s.firstname",
[$fid]
)->getResultArray();
// Enrich with grade label if available
if (!empty($studentsRows)) {
foreach ($studentsRows as &$sr) {
$sid = (int) ($sr['id'] ?? 0);
$sr['grade'] = $sid ? (string) ($studentClassModel->getClassSectionsByStudentId($sid, $schoolYear) ?? '') : '';
}
unset($sr);
}
$fam['students'] = $studentsRows;
// Financials: invoices and payments for all guardians (by user_id)
$parentIds = array_map(static fn($g) => (int)($g['user_id'] ?? 0), $guardians);
$parentIds = array_values(array_filter($parentIds));
$fam['invoices'] = [];
$fam['payments'] = [];
$fam['finance_summary'] = [
'invoices_count' => 0,
'total_amount' => 0.0,
'paid_amount' => 0.0,
'balance' => 0.0,
];
if (!empty($parentIds)) {
// Invoices
$invRows = $db->table('invoices')
->select('id, parent_id, invoice_number, status, total_amount, paid_amount, balance, issue_date, due_date')
->whereIn('parent_id', $parentIds)
->orderBy('issue_date', 'DESC')
->get()->getResultArray();
$fam['invoices'] = $invRows;
// Map invoice id -> number for payments table
$invoiceMap = [];
foreach ($invRows as $ir) {
$invoiceMap[(int)$ir['id']] = (string)($ir['invoice_number'] ?? '');
}
$fam['invoice_map'] = $invoiceMap;
// Aggregate summary
foreach ($invRows as $ir) {
$fam['finance_summary']['invoices_count']++;
$fam['finance_summary']['total_amount'] += (float)($ir['total_amount'] ?? 0);
$fam['finance_summary']['paid_amount'] += (float)($ir['paid_amount'] ?? 0);
$fam['finance_summary']['balance'] += (float)($ir['balance'] ?? 0);
}
// Recent payments (limit 10)
$payRows = $db->table('payments')
->select('id, parent_id, invoice_id, paid_amount, balance, payment_method, payment_date, status')
->whereIn('parent_id', $parentIds)
->orderBy('payment_date', 'DESC')
->limit(10)
->get()->getResultArray();
$fam['payments'] = $payRows;
}
}
unset($fam);
$data['families'] = $families;
// Back-compat: also expose guardians of first family for old UI pieces
if (!empty($families)) {
$familyId = (int)$families[0]['id'];
$data['guardians'] = $db->query(
"SELECT u.id, u.firstname, u.lastname, u.email, fg.relation, fg.is_primary, fg.receive_emails
FROM family_guardians fg JOIN users u ON u.id = fg.user_id
WHERE fg.family_id = ? ORDER BY fg.is_primary DESC, u.lastname, u.firstname",
[$familyId]
)->getResultArray();
}
}
return service('response')->setBody(view('family/index', $data));
}
// GET /family/search?q=..
public function search(): ResponseInterface
{
$db = \Config\Database::connect();
$q = trim((string)($this->request->getGet('q') ?? ''));
if ($q === '') {
return $this->response->setJSON(['items' => []]);
}
$qs = '%' . $db->escapeLikeString($q) . '%';
// Students suggestions
$students = $db->query(
"SELECT id, firstname, lastname
FROM students
WHERE CONCAT_WS(' ', firstname, lastname) LIKE ?
ORDER BY lastname, firstname
LIMIT 8",
[$qs]
)->getResultArray();
// Parents/Guardians suggestions (users)
$guardians = $db->query(
"SELECT id, firstname, lastname, email, cellphone
FROM users
WHERE (
CONCAT_WS(' ', firstname, lastname) LIKE ?
OR email LIKE ?
OR cellphone LIKE ?
)
ORDER BY lastname, firstname
LIMIT 8",
[$qs, $qs, $qs]
)->getResultArray();
$items = [];
foreach ($students as $s) {
$items[] = [
'type' => 'student',
'id' => (int)$s['id'],
'label'=> trim(($s['firstname'] ?? '').' '.($s['lastname'] ?? '')),
'sub' => 'Student',
];
}
foreach ($guardians as $g) {
$items[] = [
'type' => 'guardian',
'id' => (int)$g['id'],
'label' => trim(($g['firstname'] ?? '').' '.($g['lastname'] ?? '')),
'sub' => trim(($g['email'] ?? '').' '.($g['cellphone'] ?? '')),
];
}
return $this->response->setJSON(['items' => $items]);
}
// GET /family/card?student_id=.. | guardian_id=.. | family_id=..
public function card(): ResponseInterface
{
$db = \Config\Database::connect();
$studentId = (int) ($this->request->getGet('student_id') ?? 0);
$guardianId = (int) ($this->request->getGet('guardian_id') ?? 0);
$familyId = (int) ($this->request->getGet('family_id') ?? 0);
if (!$familyId) {
if ($studentId) {
$row = $db->query(
"SELECT f.id
FROM family_students fs
JOIN families f ON f.id = fs.family_id
WHERE fs.student_id = ?
ORDER BY fs.is_primary_home DESC, f.household_name
LIMIT 1",
[$studentId]
)->getRowArray();
if (!empty($row['id'])) $familyId = (int) $row['id'];
} elseif ($guardianId) {
// 1) Try via guardians link
$row = $db->query(
"SELECT f.id
FROM family_guardians fg
JOIN families f ON f.id = fg.family_id
WHERE fg.user_id = ?
ORDER BY f.household_name
LIMIT 1",
[$guardianId]
)->getRowArray();
if (!empty($row['id'])) {
$familyId = (int) $row['id'];
} else {
// 2) Fallback via students.parent_id → family_students
$row = $db->query(
"SELECT f.id
FROM students s
JOIN family_students fs ON fs.student_id = s.id
JOIN families f ON f.id = fs.family_id
WHERE s.parent_id = ?
ORDER BY fs.is_primary_home DESC, f.household_name
LIMIT 1",
[$guardianId]
)->getRowArray();
if (!empty($row['id'])) $familyId = (int) $row['id'];
}
}
}
if (!$familyId) {
return $this->response->setStatusCode(404)->setBody('<div class="p-3 text-danger">Family not found.</div>');
}
$family = $db->query(
"SELECT f.id, f.family_code, f.household_name, f.address_line1, f.address_line2, f.city, f.state, f.postal_code, f.country,
f.primary_phone, f.preferred_lang, f.preferred_contact_method, f.is_active
FROM families f
WHERE f.id = ?",
[$familyId]
)->getRowArray();
if (!$family) {
return $this->response->setStatusCode(404)->setBody('<div class="p-3 text-danger">Family not found.</div>');
}
// Hydrate with guardians, students (+grades), invoices, payments
$invoiceModel = new \App\Models\InvoiceModel();
$paymentModel = new \App\Models\PaymentModel();
$studentClassModel = new \App\Models\StudentClassModel();
$configModel = new \App\Models\ConfigurationModel();
$schoolYear = (string) ($configModel->getConfig('school_year') ?? '');
// Guardians
$guardians = $db->query(
"SELECT u.id AS user_id, u.firstname, u.lastname, u.email, u.cellphone,
u.address_street, u.city, u.state, u.zip,
fg.relation, fg.is_primary, fg.receive_emails, fg.receive_sms
FROM family_guardians fg
JOIN users u ON u.id = fg.user_id
WHERE fg.family_id = ?
ORDER BY fg.is_primary DESC, u.lastname, u.firstname",
[$familyId]
)->getResultArray();
$family['guardians'] = $guardians;
// Students
$studentsRows = $db->query(
"SELECT s.id, s.firstname, s.lastname
FROM family_students fs
JOIN students s ON s.id = fs.student_id
WHERE fs.family_id = ?
ORDER BY s.lastname, s.firstname",
[$familyId]
)->getResultArray();
if (!empty($studentsRows)) {
foreach ($studentsRows as &$sr) {
$sid = (int) ($sr['id'] ?? 0);
$sr['grade'] = $sid ? (string) ($studentClassModel->getClassSectionsByStudentId($sid, $schoolYear) ?? '') : '';
}
unset($sr);
}
$family['students'] = $studentsRows;
// Financials
$parentIds = array_map(static fn($g) => (int)($g['user_id'] ?? 0), $guardians);
$parentIds = array_values(array_filter($parentIds));
$family['invoices'] = [];
$family['payments'] = [];
$family['finance_summary'] = [
'invoices_count' => 0,
'total_amount' => 0.0,
'paid_amount' => 0.0,
'balance' => 0.0,
];
// Emergency contacts (by guardian/parent)
$family['emergency_contacts'] = [];
if (!empty($parentIds)) {
// Map guardian name by user_id
$gmap = [];
foreach ($guardians as $g) {
$gid = (int)($g['user_id'] ?? 0);
if ($gid > 0) $gmap[$gid] = trim(($g['firstname'] ?? '') . ' ' . ($g['lastname'] ?? ''));
}
$ecRows = $db->table('emergency_contacts')
->select('id, parent_id, emergency_contact_name, relation, cellphone, email, school_year, semester, created_at, updated_at')
->whereIn('parent_id', $parentIds)
->orderBy('updated_at', 'DESC')
->get()->getResultArray();
if (!empty($ecRows)) {
foreach ($ecRows as &$ec) {
$pid = (int)($ec['parent_id'] ?? 0);
$ec['parent_label'] = $gmap[$pid] ?? ('Parent #'.$pid);
}
unset($ec);
$family['emergency_contacts'] = $ecRows;
}
}
if (!empty($parentIds)) {
// Invoices
$invRows = $db->table('invoices')
->select('id, parent_id, invoice_number, status, total_amount, paid_amount, balance, issue_date, due_date')
->whereIn('parent_id', $parentIds)
->orderBy('issue_date', 'DESC')
->get()->getResultArray();
$family['invoices'] = $invRows;
$invoiceMap = [];
foreach ($invRows as $ir) {
$invoiceMap[(int)$ir['id']] = (string)($ir['invoice_number'] ?? '');
}
$family['invoice_map'] = $invoiceMap;
foreach ($invRows as $ir) {
$family['finance_summary']['invoices_count']++;
$family['finance_summary']['total_amount'] += (float)($ir['total_amount'] ?? 0);
$family['finance_summary']['paid_amount'] += (float)($ir['paid_amount'] ?? 0);
$family['finance_summary']['balance'] += (float)($ir['balance'] ?? 0);
}
// Payments
$payRows = $db->table('payments')
->select('id, parent_id, invoice_id, paid_amount, balance, payment_method, payment_date, status')
->whereIn('parent_id', $parentIds)
->orderBy('payment_date', 'DESC')
->limit(10)
->get()->getResultArray();
$family['payments'] = $payRows;
}
return service('response')->setBody(view('family/card', ['f' => $family]));
}
public function composeEmail()
{
$to = trim((string)$this->request->getGet('to'));
$name = trim((string)$this->request->getGet('name'));
$returnUrl = trim((string)$this->request->getGet('return_url'));
if ($returnUrl === '') {
$returnUrl = trim((string)$this->request->getServer('HTTP_REFERER'));
}
if ($returnUrl === '') {
$returnUrl = site_url('family');
}
return view('family/compose_email', [
'to' => $to,
'name' => $name,
'return_url' => $returnUrl,
]);
}
public function sendComposeEmail()
{
if (!$this->request->is('post')) {
return redirect()->to(site_url('family'));
}
$to = trim((string)$this->request->getPost('to'));
$subject = trim((string)$this->request->getPost('subject'));
$html = (string)($this->request->getPost('html') ?? '');
$returnUrl = trim((string)$this->request->getPost('return_url'));
if ($returnUrl === '' || !$this->isLocalReturnUrl($returnUrl)) {
$returnUrl = site_url('family');
}
if ($to === '' || !filter_var($to, FILTER_VALIDATE_EMAIL)) {
return redirect()->back()->withInput()->with('error', 'Please enter a valid email address.');
}
if ($subject === '') {
return redirect()->back()->withInput()->with('error', 'Subject is required.');
}
if (trim($html) === '') {
return redirect()->back()->withInput()->with('error', 'Email body is required.');
}
$wrapped = view('emails/custom_html', [
'subject' => $subject,
'body_html' => $html,
]);
$mailer = new \App\Controllers\View\EmailController();
$ok = $mailer->sendEmail($to, $subject, $wrapped, 'communication');
if ($ok) {
return redirect()->to($returnUrl)->with('status', 'Email sent.');
}
return redirect()->to($returnUrl)->with('error', 'Unable to send email.');
}
private function isLocalReturnUrl(string $url): bool
{
$url = trim($url);
if ($url === '') return false;
$parts = parse_url($url);
if ($parts === false) return false;
if (!empty($parts['scheme']) || !empty($parts['host'])) {
$base = parse_url(site_url('/'));
if (!$base || empty($base['host'])) return false;
$hostMatch = strcasecmp((string)($parts['host'] ?? ''), (string)$base['host']) === 0;
return $hostMatch;
}
// Relative URL (e.g., /family?x=1 or family?x=1)
return true;
}
}
-477
View File
@@ -1,477 +0,0 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use CodeIgniter\Database\BaseConnection;
// MODELS you should already have (or create tiny ones if not)
use App\Models\FamilyModel;
use App\Models\FamilyGuardianModel;
use App\Models\FamilyStudentModel;
// Your app's models
use App\Models\StudentModel;
use App\Models\UserModel;
/**
* FamilyController
*
* Handles creating/linking Family ←→ Guardians (users) ←→ Students,
* including bootstrap from existing schema where students.parent_id is the
* “first parent”, and a second parent may be present as a users row
* or only as an email (stub user creation).
*/
class FamilyController extends BaseController
{
protected FamilyModel $families;
protected FamilyGuardianModel $guardians;
protected FamilyStudentModel $familyStudents;
protected StudentModel $students;
protected UserModel $users;
protected BaseConnection $db;
public function __construct()
{
$this->families = new FamilyModel();
$this->guardians = new FamilyGuardianModel();
$this->familyStudents = new FamilyStudentModel();
$this->students = new StudentModel();
$this->users = new UserModel();
$this->db = \Config\Database::connect();
}
/* =========================================================
* SECTION A — APIs your UI uses
* =======================================================*/
// GET /api/students/{id}/families
public function familiesByStudent(int $studentId)
{
$rows = $this->db->query(
"SELECT f.*, fs.is_primary_home
FROM family_students fs
JOIN families f ON f.id = fs.family_id
WHERE fs.student_id = ? AND f.is_active = 1
ORDER BY fs.is_primary_home DESC, f.household_name",
[$studentId]
)->getResultArray();
return $this->response->setJSON(['data' => $rows]);
}
// GET /api/families/{id}/guardians
public function guardiansByFamily(int $familyId)
{
$rows = $this->db->query(
"SELECT u.id AS user_id, u.firstname, u.lastname, u.email,
fg.relation, fg.is_primary, fg.receive_emails, fg.receive_sms
FROM family_guardians fg
JOIN users u ON u.id = fg.user_id
WHERE fg.family_id = ?
ORDER BY fg.is_primary DESC, u.lastname, u.firstname",
[$familyId]
)->getResultArray();
return $this->response->setJSON(['data' => $rows]);
}
/* =========================================================
* SECTION B — Bootstrap & Linking helpers
* =======================================================*/
// POST /families/bootstrap
// Creates/ensures families for every student with parent_id,
// links the student to that family, and (optionally) links a second parent.
public function bootstrap()
{
// Optionally restrict this to admins
// if (! $this->userCan('families.bootstrap')) return $this->deny();
// Guard: ensure required tables exist
foreach (['families','family_students','family_guardians'] as $t) {
if (! $this->db->tableExists($t)) {
return $this->response->setStatusCode(500)->setJSON([
'status' => 'error',
'message' => "Missing required table '{$t}'. Run migrations: php spark migrate",
]);
}
}
$this->db->transStart();
// 1) Get all distinct primary parents from students.parent_id
$primaryParents = $this->db->query(
"SELECT DISTINCT parent_id FROM students WHERE parent_id IS NOT NULL"
)->getResultArray();
$created = 0;
$linkedStudents = 0;
$linkedGuardians = 0;
foreach ($primaryParents as $row) {
$primaryUserId = (int) $row['parent_id'];
if ($primaryUserId <= 0) continue;
$familyId = $this->ensureFamilyForPrimaryParent($primaryUserId, $created);
// Link all students of this primary parent
$students = $this->db->query(
"SELECT id FROM students WHERE parent_id = ?",
[$primaryUserId]
)->getResultArray();
foreach ($students as $s) {
$linkedStudents += $this->attachStudentToFamily((int)$s['id'], $familyId);
}
// Ensure primary parent is guardian on that family
$linkedGuardians += $this->attachGuardianUser($familyId, $primaryUserId, 'primary', true, 1, 0);
}
$this->db->transComplete();
$payload = [
'status' => $this->db->transStatus() ? 'ok' : 'error',
'families_created' => $created,
'students_linked' => $linkedStudents,
'guardians_linked' => $linkedGuardians,
];
// If accessed via GET (browser), redirect back with flash
if (strtolower($this->request->getMethod()) === 'get') {
$msg = json_encode($payload);
return redirect()->to('/family')->with('message', "Bootstrap result: {$msg}");
}
return $this->response->setJSON($payload);
}
// POST /families/attach-second-by-user
// body: student_id, user_id, relation='secondary'
public function attachSecondByUser()
{
$studentId = (int) $this->request->getPost('student_id');
$userId = (int) $this->request->getPost('user_id');
$relation = (string) ($this->request->getPost('relation') ?? 'secondary');
if (!$studentId || !$userId) return $this->bad('student_id and user_id required');
$familyId = $this->familyIdFromStudentPrimary($studentId);
if (!$familyId) return $this->bad('No primary family found for this student');
$rows = $this->attachGuardianUser($familyId, $userId, $relation, false, 1, 0);
return $this->response->setJSON(['status' => 'ok', 'attached' => $rows, 'family_id' => $familyId]);
}
// POST /families/attach-second-by-email
// body: student_id, email, firstname, lastname, relation='secondary'
// Creates a stub user if email not in users, then links.
public function attachSecondByEmail()
{
$studentId = (int) $this->request->getPost('student_id');
$email = trim((string)$this->request->getPost('email'));
$first = trim((string)$this->request->getPost('firstname'));
$last = trim((string)$this->request->getPost('lastname'));
$relation = (string) ($this->request->getPost('relation') ?? 'secondary');
if (!$studentId || !$email) return $this->bad('student_id and email required');
$familyId = $this->familyIdFromStudentPrimary($studentId);
if (!$familyId) return $this->bad('No primary family found for this student');
$user = $this->users->where('email', $email)->first();
if (!$user) {
// Create a minimal/stub user; adjust fields to your schema
$this->users->insert([
'firstname' => $first ?: '',
'lastname' => $last ?: '',
'email' => $email,
'status' => 'Active',
'user_type' => 'secondary', // align with secondary guardian role
]);
$userId = (int) $this->users->getInsertID();
} else {
$userId = (int) $user['id'];
}
$rows = $this->attachGuardianUser($familyId, $userId, $relation, false, 1, 0);
return $this->response->setJSON(['status' => 'ok', 'attached' => $rows, 'family_id' => $familyId, 'user_id' => $userId]);
}
/* =========================================================
* SECTION C — Maintenance actions
* =======================================================*/
// POST /families/set-primary-home
// body: family_id, student_id, is_primary_home (0/1)
public function setPrimaryHome()
{
$familyId = (int) $this->request->getPost('family_id');
$studentId = (int) $this->request->getPost('student_id');
$flag = (int) $this->request->getPost('is_primary_home');
if (!$familyId || !$studentId) return $this->bad('family_id and student_id required');
$this->familyStudents
->where(['family_id' => $familyId, 'student_id' => $studentId])
->set(['is_primary_home' => $flag ? 1 : 0])
->update();
return $this->response->setJSON(['status' => 'ok']);
}
// POST /families/set-guardian-flags
// body: family_id, user_id, receive_emails(0/1), is_primary(0/1), receive_sms(0/1)
public function setGuardianFlags()
{
$familyId = (int) $this->request->getPost('family_id');
$userId = (int) $this->request->getPost('user_id');
if (!$familyId || !$userId) return $this->bad('family_id and user_id required');
$data = [];
foreach (['receive_emails', 'is_primary', 'receive_sms', 'relation'] as $k) {
if (null !== $this->request->getPost($k)) {
$val = $this->request->getPost($k);
$data[$k] = in_array($k, ['receive_emails', 'is_primary', 'receive_sms'])
? (int)$val
: (string)$val;
}
}
if (!$data) return $this->bad('No flags provided');
$this->guardians->where(['family_id' => $familyId, 'user_id' => $userId])->set($data)->update();
return $this->response->setJSON(['status' => 'ok']);
}
// POST /families/unlink-guardian
// body: family_id, user_id
public function unlinkGuardian()
{
$familyId = (int) $this->request->getPost('family_id');
$userId = (int) $this->request->getPost('user_id');
if (!$familyId || !$userId) return $this->bad('family_id and user_id required');
$this->guardians->where(['family_id' => $familyId, 'user_id' => $userId])->delete();
return $this->response->setJSON(['status' => 'ok']);
}
// POST /families/unlink-student
// body: family_id, student_id
public function unlinkStudent()
{
$familyId = (int) $this->request->getPost('family_id');
$studentId = (int) $this->request->getPost('student_id');
if (!$familyId || !$studentId) return $this->bad('family_id and student_id required');
$this->familyStudents->where(['family_id' => $familyId, 'student_id' => $studentId])->delete();
return $this->response->setJSON(['status' => 'ok']);
}
/* =========================================================
* SECTION D — Private helpers
* =======================================================*/
// Ensure there is exactly one family per primary parent (users.id)
// Returns $familyId and increments $created by reference if newly created.
protected function ensureFamilyForPrimaryParent(int $primaryUserId, int &$createdCounter): int
{
$code = 'FAM-' . $primaryUserId;
$row = $this->families->where('family_code', $code)->first();
if ($row) return (int)$row['id'];
$this->families->insert([
'family_code' => $code,
'household_name' => 'Family of User ' . $primaryUserId,
'is_active' => 1,
]);
$createdCounter++;
return (int)$this->families->getInsertID();
}
// Attach a student to a family (idempotent; returns rows affected >0 if inserted)
protected function attachStudentToFamily(int $studentId, int $familyId): int
{
// INSERT IGNORE pattern
$sql = "INSERT IGNORE INTO family_students (family_id, student_id, is_primary_home)
VALUES (?, ?, 1)";
$this->db->query($sql, [$familyId, $studentId]);
return $this->db->affectedRows();
}
// Attach a guardian user to family (idempotent)
protected function attachGuardianUser(
int $familyId,
int $userId,
string $relation = 'primary',
bool $isPrimary = false,
int $receiveEmails = 1,
int $receiveSms = 0
): int {
$sql = "INSERT IGNORE INTO family_guardians (family_id, user_id, relation, is_primary, receive_emails, receive_sms)
VALUES (?, ?, ?, ?, ?, ?)";
$this->db->query($sql, [$familyId, $userId, $relation, $isPrimary ? 1 : 0, $receiveEmails, $receiveSms]);
return $this->db->affectedRows();
}
// Find the “primary” family for a student = family created from parent_id
protected function familyIdFromStudentPrimary(int $studentId): ?int
{
// 1) Get primary parent id for the student
$st = $this->students->select('parent_id')->find($studentId);
if (!$st || empty($st['parent_id'])) return null;
// 2) The canonical family uses code FAM-{parent_id}
$code = 'FAM-' . (int)$st['parent_id'];
$row = $this->families->select('id')->where('family_code', $code)->first();
if ($row) return (int)$row['id'];
// If not found, fall back to any family linked already
$row = $this->db->query(
"SELECT family_id FROM family_students WHERE student_id = ? ORDER BY is_primary_home DESC LIMIT 1",
[$studentId]
)->getRowArray();
return $row ? (int)$row['family_id'] : null;
}
// Simple 400
protected function bad(string $msg)
{
return $this->response->setStatusCode(400)->setJSON(['status' => 'error', 'message' => $msg]);
}
// Example permission check hook (plug your own logic)
protected function userCan(string $perm): bool
{
$perms = (array) (session('permissions') ?? []);
return in_array($perm, $perms, true);
}
protected function deny()
{
return redirect()->to('/access_denied')->setStatusCode(403);
}
// Legacy import helper: map secondary parents from legacy 'parents' table
public function importSecondParentsFromLegacy()
{
// Protect this route (e.g., admin only) via filter in routes
$L = [
'table' => 'parents',
'firstparent_id' => 'firstparent_id', // users.id of primary (first) parent
'second_user_id' => 'secondparent_id', // users.id of second parent (optional)
'second_email' => 'secondparent_email',
'second_firstname' => 'secondparent_firstname',
'second_lastname' => 'secondparent_lastname',
];
if (!$this->db->tableExists($L['table'])) {
return $this->response->setJSON(['status' => 'error', 'message' => "Legacy table '{$L['table']}' not found"]);
}
foreach (['families','family_students','family_guardians'] as $t) {
if (! $this->db->tableExists($t)) {
return $this->response->setStatusCode(500)->setJSON([
'status' => 'error',
'message' => "Missing required table '{$t}'. Run migrations: php spark migrate",
]);
}
}
$rows = $this->db->table($L['table'])
->select(
"{$L['firstparent_id']} AS first_id, " .
"{$L['second_user_id']} AS second_id, " .
"{$L['second_email']} AS email, " .
"{$L['second_firstname']} AS firstname, " .
"{$L['second_lastname']} AS lastname",
false
)
->groupStart()
->where("{$L['second_user_id']} !=", 0)
->orWhere("{$L['second_email']} !=", '')
->groupEnd()
->get()->getResultArray();
$createdUsers = 0;
$linked = 0;
$skipped = 0;
foreach ($rows as $r) {
$firstId = (int)($r['first_id'] ?? 0);
$secondId = (int)($r['second_id'] ?? 0);
$email = trim((string)($r['email'] ?? ''));
if (!$firstId || (!$secondId && $email === '')) {
$skipped++;
continue;
}
// Ensure/find family by primary parent
$code = 'FAM-' . $firstId;
$fam = $this->families->select('id')->where('family_code', $code)->first();
if ($fam) {
$familyId = (int)$fam['id'];
} else {
$tmp = 0; // counter sink
$familyId = $this->ensureFamilyForPrimaryParent($firstId, $tmp);
}
// Resolve/create user
$userId = 0;
if ($secondId > 0) {
$userId = $secondId;
} else {
$user = $this->users->where('email', $email)->first();
if (!$user) {
$this->users->insert([
'firstname' => $r['firstname'] ?? '',
'lastname' => $r['lastname'] ?? '',
'email' => $email,
'status' => 'Active',
'user_type' => 'secondary',
]);
$userId = (int)$this->users->getInsertID();
$createdUsers++;
} else {
$userId = (int)$user['id'];
}
}
// Ensure all students for this primary parent are linked to this family
$stuRows = $this->db->query("SELECT id FROM students WHERE parent_id = ?", [$firstId])->getResultArray();
foreach ($stuRows as $sr) {
$sid = (int)($sr['id'] ?? 0);
if ($sid > 0) {
$this->attachStudentToFamily($sid, $familyId);
}
}
// Link as guardian (idempotent)
$linked += $this->attachGuardianUser($familyId, $userId, 'guardian', false, 1, 0);
}
$payload = [
'status' => 'ok',
'created_users' => $createdUsers,
'guardians_linked' => $linked,
'skipped' => $skipped,
'total_source' => count($rows),
];
if (strtolower($this->request->getMethod()) === 'get') {
$msg = json_encode($payload);
return redirect()->to('/family')->with('message', "Import legacy result: {$msg}");
}
return $this->response->setJSON($payload);
}
}
File diff suppressed because it is too large Load Diff
-77
View File
@@ -1,77 +0,0 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Models\SupplierModel;
class SupplierController extends BaseController
{
protected $model;
public function __construct()
{
$this->model = new SupplierModel();
}
public function index()
{
$q = trim($this->request->getGet('q') ?? '');
$builder = $this->model;
if ($q !== '') {
$builder = $builder->groupStart()
->like('name', $q)->orLike('email', $q)->orLike('phone', $q)
->groupEnd();
}
return view('inventory/suppliers_index', [
'suppliers' => $builder->orderBy('name')->paginate(20),
'pager' => $this->model->pager,
'q' => $q,
]);
}
public function create()
{
return view('inventory/suppliers_form', [
'title' => 'Add Supplier',
'action' => site_url('inventory/suppliers/store'),
'supplier' => null,
]);
}
public function store()
{
$data = $this->request->getPost(['name','email','phone','address','notes']);
if (!$this->model->save($data)) {
return redirect()->back()->withInput()->with('error', implode("\n", $this->model->errors()));
}
return redirect()->to(site_url('inventory/suppliers'))->with('success', 'Supplier saved.');
}
public function edit($id)
{
$s = $this->model->find($id);
if (!$s) return redirect()->to('inventory/suppliers')->with('error', 'Not found.');
return view('inventory/suppliers_form', [
'title' => 'Edit Supplier',
'action' => site_url('inventory/suppliers/update/'.$id),
'supplier' => $s,
]);
}
public function update($id)
{
$data = $this->request->getPost(['name','email','phone','address','notes']);
$data['id'] = $id;
if (!$this->model->save($data)) {
return redirect()->back()->withInput()->with('error', implode("\n", $this->model->errors()));
}
return redirect()->to(site_url('inventory/suppliers'))->with('success', 'Supplier updated.');
}
public function delete($id)
{
$this->model->delete($id);
return redirect()->to(site_url('inventory/suppliers'))->with('success', 'Supplier deleted.');
}
}
-69
View File
@@ -1,69 +0,0 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Models\SupplyCategoryModel;
class SupplyCategoryController extends BaseController
{
protected $model;
public function __construct()
{
$this->model = new SupplyCategoryModel();
}
public function index()
{
return view('inventory/categories_index', [
'categories' => $this->model->orderBy('name')->findAll(),
]);
}
public function create()
{
return view('inventory/categories_form', [
'title' => 'Add Category',
'action' => site_url('inventory/categories/store'),
'category' => null,
]);
}
public function store()
{
$data = $this->request->getPost(['name']);
if (!$this->model->save($data)) {
return redirect()->back()->withInput()->with('error', implode("\n", $this->model->errors()));
}
return redirect()->to(site_url('inventory/categories'))->with('success', 'Category saved.');
}
public function edit($id)
{
$cat = $this->model->find($id);
if (!$cat) return redirect()->to('inventory/categories')->with('error', 'Not found.');
return view('inventory/categories_form', [
'title' => 'Edit Category',
'action' => site_url('inventory/categories/update/'.$id),
'category' => $cat,
]);
}
public function update($id)
{
$data = $this->request->getPost(['name']);
$data['id'] = (int) $id;
if (!$this->model->save($data)) {
return redirect()->back()->withInput()->with('error', implode("\n", $this->model->errors()));
}
return redirect()->to(site_url('inventory/categories'))->with('success', 'Category updated.');
}
public function delete($id)
{
$this->model->delete($id);
return redirect()->to(site_url('inventory/categories'))->with('success', 'Category deleted.');
}
}
@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('supply_categories', function (Blueprint $table) {
$table->id();
$table->string('name')->unique();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('supply_categories');
}
};
@@ -0,0 +1,44 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('suppliers', function (Blueprint $table) {
if (!Schema::hasColumn('suppliers', 'email')) {
$table->string('email')->nullable()->after('name');
}
if (!Schema::hasColumn('suppliers', 'phone')) {
$table->string('phone')->nullable()->after('email');
}
if (!Schema::hasColumn('suppliers', 'address')) {
$table->string('address')->nullable()->after('phone');
}
if (!Schema::hasColumn('suppliers', 'notes')) {
$table->text('notes')->nullable()->after('address');
}
});
}
public function down(): void
{
Schema::table('suppliers', function (Blueprint $table) {
if (Schema::hasColumn('suppliers', 'notes')) {
$table->dropColumn('notes');
}
if (Schema::hasColumn('suppliers', 'address')) {
$table->dropColumn('address');
}
if (Schema::hasColumn('suppliers', 'phone')) {
$table->dropColumn('phone');
}
if (Schema::hasColumn('suppliers', 'email')) {
$table->dropColumn('email');
}
});
}
};
+1 -1
View File
@@ -1310,7 +1310,7 @@
} }
} }
}, },
"/api/login-activity": { "/api/v1/users/login-activity": {
"get": { "get": {
"operationId": "getLoginActivity", "operationId": "getLoginActivity",
"tags": ["Users"], "tags": ["Users"],
+2
View File
@@ -19,6 +19,8 @@ window.addEventListener('load', () => {
SwaggerUIBundle({ SwaggerUIBundle({
url: "{{ url('/api/documentation/swagger.json') }}", url: "{{ url('/api/documentation/swagger.json') }}",
dom_id: '#swagger-ui', dom_id: '#swagger-ui',
operationsSorter: 'alpha',
tagsSorter: 'alpha',
docExpansion: 'none', docExpansion: 'none',
defaultModelsExpandDepth: -1, defaultModelsExpandDepth: -1,
persistAuthorization: true persistAuthorization: true
+38
View File
@@ -51,6 +51,8 @@ use App\Http\Controllers\Api\System\SemesterRangeController;
use App\Http\Controllers\Api\Scores\HomeworkController; use App\Http\Controllers\Api\Scores\HomeworkController;
use App\Http\Controllers\Api\Frontend\InfoIconController; use App\Http\Controllers\Api\Frontend\InfoIconController;
use App\Http\Controllers\Api\Inventory\InventoryController; use App\Http\Controllers\Api\Inventory\InventoryController;
use App\Http\Controllers\Api\Inventory\InventoryCategoryController;
use App\Http\Controllers\Api\Inventory\InventoryMovementController;
use App\Http\Controllers\Api\Auth\IpBanController; use App\Http\Controllers\Api\Auth\IpBanController;
use App\Http\Controllers\Api\Frontend\LandingPageController; use App\Http\Controllers\Api\Frontend\LandingPageController;
use App\Http\Controllers\Api\Attendance\LateSlipLogsController; use App\Http\Controllers\Api\Attendance\LateSlipLogsController;
@@ -86,6 +88,7 @@ use App\Http\Controllers\Api\Auth\SessionTimeoutController;
use App\Http\Controllers\Api\Reports\SlipPrinterController; use App\Http\Controllers\Api\Reports\SlipPrinterController;
use App\Http\Controllers\Api\Staff\StaffController; use App\Http\Controllers\Api\Staff\StaffController;
use App\Http\Controllers\Api\Inventory\SupplierController; use App\Http\Controllers\Api\Inventory\SupplierController;
use App\Http\Controllers\Api\Inventory\SupplyCategoryController;
use App\Http\Controllers\Api\Staff\TeacherController; use App\Http\Controllers\Api\Staff\TeacherController;
use App\Http\Controllers\Api\StatsController; use App\Http\Controllers\Api\StatsController;
use App\Http\Controllers\Api\Frontend\UiController; use App\Http\Controllers\Api\Frontend\UiController;
@@ -306,6 +309,41 @@ Route::prefix('v1')->group(function () {
Route::post('send', [CommunicationController::class, 'send']); Route::post('send', [CommunicationController::class, 'send']);
}); });
Route::prefix('inventory')->group(function () {
Route::get('items', [InventoryController::class, 'index']);
Route::get('items/{id}', [InventoryController::class, 'show']);
Route::post('items', [InventoryController::class, 'store']);
Route::patch('items/{id}', [InventoryController::class, 'update']);
Route::delete('items/{id}', [InventoryController::class, 'destroy']);
Route::post('items/{id}/audit', [InventoryController::class, 'audit']);
Route::post('items/{id}/adjust', [InventoryController::class, 'adjust']);
Route::get('summary/{type}', [InventoryController::class, 'summary']);
Route::get('summary-all', [InventoryController::class, 'summaryAll']);
Route::get('teacher-distribution', [InventoryController::class, 'teacherDistribution']);
Route::post('teacher-distribution', [InventoryController::class, 'teacherDistributionStore']);
Route::get('categories', [InventoryCategoryController::class, 'index']);
Route::post('categories', [InventoryCategoryController::class, 'store']);
Route::patch('categories/{id}', [InventoryCategoryController::class, 'update']);
Route::delete('categories/{id}', [InventoryCategoryController::class, 'destroy']);
Route::get('movements', [InventoryMovementController::class, 'index']);
Route::post('movements', [InventoryMovementController::class, 'store']);
Route::patch('movements/{id}', [InventoryMovementController::class, 'update']);
Route::delete('movements/{id}', [InventoryMovementController::class, 'destroy']);
Route::post('movements/bulk-delete', [InventoryMovementController::class, 'bulkDelete']);
Route::get('suppliers', [SupplierController::class, 'index']);
Route::post('suppliers', [SupplierController::class, 'store']);
Route::patch('suppliers/{id}', [SupplierController::class, 'update']);
Route::delete('suppliers/{id}', [SupplierController::class, 'destroy']);
Route::get('supply-categories', [SupplyCategoryController::class, 'index']);
Route::post('supply-categories', [SupplyCategoryController::class, 'store']);
Route::patch('supply-categories/{id}', [SupplyCategoryController::class, 'update']);
Route::delete('supply-categories/{id}', [SupplyCategoryController::class, 'destroy']);
});
Route::prefix('family-admin')->group(function () { Route::prefix('family-admin')->group(function () {
Route::get('/', [FamilyAdminController::class, 'index']); Route::get('/', [FamilyAdminController::class, 'index']);
Route::get('search', [FamilyAdminController::class, 'search']); Route::get('search', [FamilyAdminController::class, 'search']);
BIN
View File
Binary file not shown.
@@ -0,0 +1,109 @@
<?php
namespace Tests\Feature\Api\V1\Inventory;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class InventoryCategoriesControllerTest extends TestCase
{
use RefreshDatabase;
public function test_index_returns_categories(): void
{
$user = $this->seedUser();
$this->seedCategory('classroom');
Sanctum::actingAs($user);
$response = $this->getJson('/api/v1/inventory/categories?type=classroom');
$response->assertOk();
$response->assertJsonPath('status', true);
$this->assertNotEmpty($response->json('data.categories'));
}
public function test_store_creates_category(): void
{
$user = $this->seedUser();
Sanctum::actingAs($user);
$response = $this->postJson('/api/v1/inventory/categories', [
'type' => 'office',
'name' => 'Supplies',
]);
$response->assertStatus(201);
$this->assertDatabaseHas('inventory_categories', ['name' => 'Supplies']);
}
public function test_update_changes_category(): void
{
$user = $this->seedUser();
$categoryId = $this->seedCategory('office');
Sanctum::actingAs($user);
$response = $this->patchJson('/api/v1/inventory/categories/' . $categoryId, [
'name' => 'Updated',
]);
$response->assertOk();
$this->assertDatabaseHas('inventory_categories', ['id' => $categoryId, 'name' => 'Updated']);
}
public function test_destroy_deletes_category(): void
{
$user = $this->seedUser();
$categoryId = $this->seedCategory('office');
Sanctum::actingAs($user);
$response = $this->deleteJson('/api/v1/inventory/categories/' . $categoryId);
$response->assertOk();
$this->assertDatabaseMissing('inventory_categories', ['id' => $categoryId]);
}
public function test_validation_fails(): void
{
$user = $this->seedUser();
Sanctum::actingAs($user);
$response = $this->postJson('/api/v1/inventory/categories', []);
$response->assertStatus(422);
}
private function seedUser(): User
{
$userId = DB::table('users')->insertGetId([
'firstname' => 'User',
'lastname' => 'One',
'cellphone' => '9999999999',
'email' => 'user@example.com',
'address_street' => '123 Street',
'city' => 'City',
'state' => 'ST',
'zip' => '12345',
'accept_school_policy' => 1,
'password' => bcrypt('password'),
'user_type' => 'primary',
'semester' => 'Fall',
'school_year' => '2025-2026',
'status' => 'Active',
]);
return User::query()->findOrFail($userId);
}
private function seedCategory(string $type): int
{
return DB::table('inventory_categories')->insertGetId([
'type' => $type,
'name' => ucfirst($type) . ' Category',
'created_at' => now(),
'updated_at' => now(),
]);
}
}
@@ -0,0 +1,339 @@
<?php
namespace Tests\Feature\Api\V1\Inventory;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class InventoryItemsControllerTest extends TestCase
{
use RefreshDatabase;
public function test_index_returns_items(): void
{
$user = $this->seedUser();
$this->seedConfig();
$categoryId = $this->seedInventoryCategory('classroom');
$this->seedInventoryItem($categoryId);
Sanctum::actingAs($user);
$response = $this->getJson('/api/v1/inventory/items?type=classroom');
$response->assertOk();
$response->assertJsonPath('status', true);
$this->assertNotEmpty($response->json('data.items'));
}
public function test_show_returns_item(): void
{
$user = $this->seedUser();
$this->seedConfig();
$categoryId = $this->seedInventoryCategory('classroom');
$itemId = $this->seedInventoryItem($categoryId);
Sanctum::actingAs($user);
$response = $this->getJson('/api/v1/inventory/items/' . $itemId);
$response->assertOk();
$response->assertJsonPath('status', true);
$response->assertJsonPath('data.id', $itemId);
}
public function test_store_creates_item(): void
{
$user = $this->seedUser();
$this->seedConfig();
$categoryId = $this->seedInventoryCategory('classroom');
Sanctum::actingAs($user);
$response = $this->postJson('/api/v1/inventory/items', [
'type' => 'classroom',
'category_id' => $categoryId,
'name' => 'Markers',
'quantity' => 5,
'condition' => 'good',
]);
$response->assertStatus(201);
$this->assertDatabaseHas('inventory_items', ['name' => 'Markers']);
$this->assertDatabaseHas('inventory_movements', ['movement_type' => 'initial']);
}
public function test_update_changes_item(): void
{
$user = $this->seedUser();
$this->seedConfig();
$categoryId = $this->seedInventoryCategory('classroom');
$itemId = $this->seedInventoryItem($categoryId);
Sanctum::actingAs($user);
$response = $this->patchJson('/api/v1/inventory/items/' . $itemId, [
'name' => 'Updated Item',
]);
$response->assertOk();
$this->assertDatabaseHas('inventory_items', ['id' => $itemId, 'name' => 'Updated Item']);
}
public function test_destroy_deletes_item(): void
{
$user = $this->seedUser();
$this->seedConfig();
$categoryId = $this->seedInventoryCategory('classroom');
$itemId = $this->seedInventoryItem($categoryId);
Sanctum::actingAs($user);
$response = $this->deleteJson('/api/v1/inventory/items/' . $itemId);
$response->assertOk();
$this->assertDatabaseMissing('inventory_items', ['id' => $itemId]);
}
public function test_audit_updates_classroom_item(): void
{
$user = $this->seedUser();
$this->seedConfig();
$categoryId = $this->seedInventoryCategory('classroom');
$itemId = $this->seedInventoryItem($categoryId);
Sanctum::actingAs($user);
$response = $this->postJson('/api/v1/inventory/items/' . $itemId . '/audit', [
'needs_repair_qty' => 1,
'need_replace_qty' => 1,
'cannot_find_qty' => 0,
]);
$response->assertOk();
$this->assertDatabaseHas('inventory_items', ['id' => $itemId, 'needs_repair_qty' => 1]);
}
public function test_adjust_creates_movement(): void
{
$user = $this->seedUser();
$this->seedConfig();
$categoryId = $this->seedInventoryCategory('classroom');
$itemId = $this->seedInventoryItem($categoryId, 0);
Sanctum::actingAs($user);
$response = $this->postJson('/api/v1/inventory/items/' . $itemId . '/adjust', [
'mode' => 'in',
'quantity' => 2,
'reason' => 'Restock',
]);
$response->assertOk();
$this->assertDatabaseHas('inventory_movements', ['item_id' => $itemId, 'movement_type' => 'in']);
}
public function test_summary_returns_aggregates(): void
{
$user = $this->seedUser();
$this->seedConfig();
$categoryId = $this->seedInventoryCategory('classroom');
$this->seedInventoryItem($categoryId);
Sanctum::actingAs($user);
$response = $this->getJson('/api/v1/inventory/summary/classroom');
$response->assertOk();
$response->assertJsonPath('status', true);
$this->assertNotEmpty($response->json('data.items'));
}
public function test_summary_all_returns_rows(): void
{
$user = $this->seedUser();
$this->seedConfig();
$categoryId = $this->seedInventoryCategory('classroom');
$this->seedInventoryItem($categoryId);
Sanctum::actingAs($user);
$response = $this->getJson('/api/v1/inventory/summary-all');
$response->assertOk();
$response->assertJsonPath('status', true);
}
public function test_teacher_distribution_endpoints(): void
{
$user = $this->seedUser();
$this->seedConfig();
$this->seedTeacherRole($user->id);
$classSectionId = $this->seedClassSection();
$this->seedTeacherClass($user->id, $classSectionId);
$studentId = $this->seedStudent(100, $user->id);
$this->seedStudentClass($studentId, $classSectionId);
$categoryId = $this->seedInventoryCategory('book');
$bookId = $this->seedInventoryItem($categoryId, 5, 'book');
Sanctum::actingAs($user);
$formResponse = $this->getJson('/api/v1/inventory/teacher-distribution?class_section_id=' . $classSectionId . '&item_id=' . $bookId);
$formResponse->assertOk();
$storeResponse = $this->postJson('/api/v1/inventory/teacher-distribution', [
'item_id' => $bookId,
'class_section_id' => $classSectionId,
'student_ids' => [$studentId],
]);
$storeResponse->assertOk();
$this->assertDatabaseHas('inventory_movements', ['item_id' => $bookId, 'movement_type' => 'distribution']);
}
public function test_validation_fails_for_store(): void
{
$user = $this->seedUser();
Sanctum::actingAs($user);
$response = $this->postJson('/api/v1/inventory/items', []);
$response->assertStatus(422);
$response->assertJsonPath('message', 'Validation failed.');
}
public function test_requires_authentication(): void
{
$response = $this->getJson('/api/v1/inventory/items');
$response->assertStatus(401);
}
private function seedConfig(): void
{
DB::table('configuration')->insert([
['config_key' => 'school_year', 'config_value' => '2025-2026'],
['config_key' => 'semester', 'config_value' => 'Fall'],
]);
}
private function seedUser(): User
{
$userId = DB::table('users')->insertGetId([
'firstname' => 'Teacher',
'lastname' => 'User',
'cellphone' => '9999999999',
'email' => 'teacher@example.com',
'address_street' => '123 Street',
'city' => 'City',
'state' => 'ST',
'zip' => '12345',
'accept_school_policy' => 1,
'password' => bcrypt('password'),
'user_type' => 'primary',
'semester' => 'Fall',
'school_year' => '2025-2026',
'status' => 'Active',
]);
return User::query()->findOrFail($userId);
}
private function seedInventoryCategory(string $type): int
{
return DB::table('inventory_categories')->insertGetId([
'type' => $type,
'name' => ucfirst($type) . ' Category',
'created_at' => now(),
'updated_at' => now(),
]);
}
private function seedInventoryItem(int $categoryId, int $quantity = 3, string $type = 'classroom'): int
{
return DB::table('inventory_items')->insertGetId([
'type' => $type,
'category_id' => $categoryId,
'name' => 'Item ' . $type,
'quantity' => $quantity,
'semester' => 'Fall',
'school_year' => '2025-2026',
'created_at' => now(),
'updated_at' => now(),
]);
}
private function seedTeacherRole(int $userId): void
{
$roleId = DB::table('roles')->insertGetId([
'name' => 'teacher',
'dashboard_route' => 'teacher',
'priority' => 1,
'is_active' => 1,
]);
DB::table('user_roles')->insert([
'user_id' => $userId,
'role_id' => $roleId,
'created_at' => now(),
'updated_at' => now(),
]);
}
private function seedClassSection(): int
{
DB::table('classes')->insert([
'id' => 1,
'class_name' => 'Grade 1',
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('classSection')->insert([
'class_section_id' => 200,
'class_section_name' => 'Grade 1',
'class_id' => 1,
'semester' => 'Fall',
'school_year' => '2025-2026',
'created_at' => now(),
'updated_at' => now(),
]);
return 200;
}
private function seedTeacherClass(int $userId, int $classSectionId): void
{
DB::table('teacher_class')->insert([
'teacher_id' => $userId,
'class_section_id' => $classSectionId,
'position' => 'main',
'school_year' => '2025-2026',
'created_at' => now(),
'updated_at' => now(),
]);
}
private function seedStudent(int $id, int $parentId): int
{
DB::table('students')->insert([
'id' => $id,
'school_id' => 'SCH1',
'firstname' => 'Student',
'lastname' => 'One',
'age' => 10,
'gender' => 'Male',
'photo_consent' => 1,
'parent_id' => $parentId,
'year_of_registration' => '2025-2026',
'semester' => 'Fall',
'school_year' => '2025-2026',
'is_active' => 1,
]);
return $id;
}
private function seedStudentClass(int $studentId, int $classSectionId): void
{
DB::table('student_class')->insert([
'student_id' => $studentId,
'class_section_id' => $classSectionId,
'school_year' => '2025-2026',
'semester' => 'Fall',
'is_event_only' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
}
}
@@ -0,0 +1,146 @@
<?php
namespace Tests\Feature\Api\V1\Inventory;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class InventoryMovementsControllerTest extends TestCase
{
use RefreshDatabase;
public function test_index_returns_movements(): void
{
$user = $this->seedUser();
$itemId = $this->seedItem();
$this->seedMovement($itemId);
Sanctum::actingAs($user);
$response = $this->getJson('/api/v1/inventory/movements');
$response->assertOk();
$response->assertJsonPath('status', true);
$this->assertNotEmpty($response->json('data.movements'));
}
public function test_store_creates_movement(): void
{
$user = $this->seedUser();
$itemId = $this->seedItem();
Sanctum::actingAs($user);
$response = $this->postJson('/api/v1/inventory/movements', [
'item_id' => $itemId,
'qty_change' => 2,
'movement_type' => 'in',
]);
$response->assertStatus(201);
$this->assertDatabaseHas('inventory_movements', ['item_id' => $itemId, 'movement_type' => 'in']);
}
public function test_update_changes_movement(): void
{
$user = $this->seedUser();
$itemId = $this->seedItem();
$movementId = $this->seedMovement($itemId);
Sanctum::actingAs($user);
$response = $this->patchJson('/api/v1/inventory/movements/' . $movementId, [
'qty_change' => 3,
'movement_type' => 'in',
]);
$response->assertOk();
$this->assertDatabaseHas('inventory_movements', ['id' => $movementId, 'qty_change' => 3]);
}
public function test_destroy_deletes_movement(): void
{
$user = $this->seedUser();
$itemId = $this->seedItem();
$movementId = $this->seedMovement($itemId);
Sanctum::actingAs($user);
$response = $this->deleteJson('/api/v1/inventory/movements/' . $movementId);
$response->assertOk();
$this->assertDatabaseMissing('inventory_movements', ['id' => $movementId]);
}
public function test_bulk_delete_removes_movements(): void
{
$user = $this->seedUser();
$itemId = $this->seedItem();
$first = $this->seedMovement($itemId);
$second = $this->seedMovement($itemId);
Sanctum::actingAs($user);
$response = $this->postJson('/api/v1/inventory/movements/bulk-delete', [
'ids' => [$first, $second],
]);
$response->assertOk();
$this->assertDatabaseMissing('inventory_movements', ['id' => $first]);
$this->assertDatabaseMissing('inventory_movements', ['id' => $second]);
}
private function seedUser(): User
{
$userId = DB::table('users')->insertGetId([
'firstname' => 'User',
'lastname' => 'One',
'cellphone' => '9999999999',
'email' => 'movement@example.com',
'address_street' => '123 Street',
'city' => 'City',
'state' => 'ST',
'zip' => '12345',
'accept_school_policy' => 1,
'password' => bcrypt('password'),
'user_type' => 'primary',
'semester' => 'Fall',
'school_year' => '2025-2026',
'status' => 'Active',
]);
return User::query()->findOrFail($userId);
}
private function seedItem(): int
{
$categoryId = DB::table('inventory_categories')->insertGetId([
'type' => 'office',
'name' => 'Office',
'created_at' => now(),
'updated_at' => now(),
]);
return DB::table('inventory_items')->insertGetId([
'type' => 'office',
'category_id' => $categoryId,
'name' => 'Printer',
'quantity' => 10,
'semester' => 'Fall',
'school_year' => '2025-2026',
'created_at' => now(),
'updated_at' => now(),
]);
}
private function seedMovement(int $itemId): int
{
return DB::table('inventory_movements')->insertGetId([
'item_id' => $itemId,
'qty_change' => 1,
'movement_type' => 'in',
'semester' => 'Fall',
'school_year' => '2025-2026',
'created_at' => now(),
'updated_at' => now(),
]);
}
}
@@ -0,0 +1,98 @@
<?php
namespace Tests\Feature\Api\V1\Inventory;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class SuppliersControllerTest extends TestCase
{
use RefreshDatabase;
public function test_index_returns_suppliers(): void
{
$user = $this->seedUser();
$this->seedSupplier('Supplier A');
Sanctum::actingAs($user);
$response = $this->getJson('/api/v1/inventory/suppliers');
$response->assertOk();
$response->assertJsonPath('status', true);
$this->assertNotEmpty($response->json('data.suppliers'));
}
public function test_store_creates_supplier(): void
{
$user = $this->seedUser();
Sanctum::actingAs($user);
$response = $this->postJson('/api/v1/inventory/suppliers', [
'name' => 'Supplier B',
'email' => 'b@example.com',
]);
$response->assertStatus(201);
$this->assertDatabaseHas('suppliers', ['name' => 'Supplier B']);
}
public function test_update_changes_supplier(): void
{
$user = $this->seedUser();
$supplierId = $this->seedSupplier('Supplier C');
Sanctum::actingAs($user);
$response = $this->patchJson('/api/v1/inventory/suppliers/' . $supplierId, [
'name' => 'Supplier Updated',
]);
$response->assertOk();
$this->assertDatabaseHas('suppliers', ['id' => $supplierId, 'name' => 'Supplier Updated']);
}
public function test_destroy_deletes_supplier(): void
{
$user = $this->seedUser();
$supplierId = $this->seedSupplier('Supplier D');
Sanctum::actingAs($user);
$response = $this->deleteJson('/api/v1/inventory/suppliers/' . $supplierId);
$response->assertOk();
$this->assertDatabaseMissing('suppliers', ['id' => $supplierId]);
}
private function seedUser(): User
{
$userId = DB::table('users')->insertGetId([
'firstname' => 'User',
'lastname' => 'One',
'cellphone' => '9999999999',
'email' => 'supplier@example.com',
'address_street' => '123 Street',
'city' => 'City',
'state' => 'ST',
'zip' => '12345',
'accept_school_policy' => 1,
'password' => bcrypt('password'),
'user_type' => 'primary',
'semester' => 'Fall',
'school_year' => '2025-2026',
'status' => 'Active',
]);
return User::query()->findOrFail($userId);
}
private function seedSupplier(string $name): int
{
return DB::table('suppliers')->insertGetId([
'name' => $name,
'created_at' => now(),
'updated_at' => now(),
]);
}
}
@@ -0,0 +1,97 @@
<?php
namespace Tests\Feature\Api\V1\Inventory;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class SupplyCategoriesControllerTest extends TestCase
{
use RefreshDatabase;
public function test_index_returns_categories(): void
{
$user = $this->seedUser();
$this->seedCategory('Paper');
Sanctum::actingAs($user);
$response = $this->getJson('/api/v1/inventory/supply-categories');
$response->assertOk();
$response->assertJsonPath('status', true);
$this->assertNotEmpty($response->json('data.categories'));
}
public function test_store_creates_category(): void
{
$user = $this->seedUser();
Sanctum::actingAs($user);
$response = $this->postJson('/api/v1/inventory/supply-categories', [
'name' => 'Cleaning',
]);
$response->assertStatus(201);
$this->assertDatabaseHas('supply_categories', ['name' => 'Cleaning']);
}
public function test_update_changes_category(): void
{
$user = $this->seedUser();
$categoryId = $this->seedCategory('Pens');
Sanctum::actingAs($user);
$response = $this->patchJson('/api/v1/inventory/supply-categories/' . $categoryId, [
'name' => 'Pens Updated',
]);
$response->assertOk();
$this->assertDatabaseHas('supply_categories', ['id' => $categoryId, 'name' => 'Pens Updated']);
}
public function test_destroy_deletes_category(): void
{
$user = $this->seedUser();
$categoryId = $this->seedCategory('Markers');
Sanctum::actingAs($user);
$response = $this->deleteJson('/api/v1/inventory/supply-categories/' . $categoryId);
$response->assertOk();
$this->assertDatabaseMissing('supply_categories', ['id' => $categoryId]);
}
private function seedUser(): User
{
$userId = DB::table('users')->insertGetId([
'firstname' => 'User',
'lastname' => 'One',
'cellphone' => '9999999999',
'email' => 'supply@example.com',
'address_street' => '123 Street',
'city' => 'City',
'state' => 'ST',
'zip' => '12345',
'accept_school_policy' => 1,
'password' => bcrypt('password'),
'user_type' => 'primary',
'semester' => 'Fall',
'school_year' => '2025-2026',
'status' => 'Active',
]);
return User::query()->findOrFail($userId);
}
private function seedCategory(string $name): int
{
return DB::table('supply_categories')->insertGetId([
'name' => $name,
'created_at' => now(),
'updated_at' => now(),
]);
}
}
@@ -0,0 +1,41 @@
<?php
namespace Tests\Unit\Services\Inventory;
use App\Services\Inventory\InventoryCategoryService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class InventoryCategoryServiceTest extends TestCase
{
use RefreshDatabase;
public function test_create_category(): void
{
$service = new InventoryCategoryService();
$result = $service->create([
'type' => 'office',
'name' => 'Supplies',
]);
$this->assertTrue($result['ok']);
$this->assertDatabaseHas('inventory_categories', ['name' => 'Supplies']);
}
public function test_update_category(): void
{
$categoryId = DB::table('inventory_categories')->insertGetId([
'type' => 'office',
'name' => 'Office',
'created_at' => now(),
'updated_at' => now(),
]);
$service = new InventoryCategoryService();
$result = $service->update($categoryId, ['name' => 'Updated']);
$this->assertTrue($result['ok']);
$this->assertDatabaseHas('inventory_categories', ['id' => $categoryId, 'name' => 'Updated']);
}
}
@@ -0,0 +1,78 @@
<?php
namespace Tests\Unit\Services\Inventory;
use App\Services\Inventory\InventoryContextService;
use App\Services\Inventory\InventoryItemService;
use App\Services\Inventory\InventoryMovementService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class InventoryItemServiceTest extends TestCase
{
use RefreshDatabase;
public function test_create_creates_item_and_movement(): void
{
$this->seedConfig();
$categoryId = $this->seedCategory();
$service = new InventoryItemService(new InventoryContextService(), new InventoryMovementService(new InventoryContextService()));
$result = $service->create([
'type' => 'classroom',
'category_id' => $categoryId,
'name' => 'Markers',
'quantity' => 2,
]);
$this->assertTrue($result['ok']);
$this->assertDatabaseHas('inventory_items', ['name' => 'Markers']);
$this->assertDatabaseHas('inventory_movements', ['movement_type' => 'initial']);
}
public function test_update_updates_item(): void
{
$this->seedConfig();
$categoryId = $this->seedCategory();
$itemId = $this->seedItem($categoryId);
$service = new InventoryItemService(new InventoryContextService(), new InventoryMovementService(new InventoryContextService()));
$result = $service->update($itemId, ['name' => 'Updated'], 1);
$this->assertTrue($result['ok']);
$this->assertDatabaseHas('inventory_items', ['id' => $itemId, 'name' => 'Updated']);
}
private function seedConfig(): void
{
DB::table('configuration')->insert([
['config_key' => 'school_year', 'config_value' => '2025-2026'],
['config_key' => 'semester', 'config_value' => 'Fall'],
]);
}
private function seedCategory(): int
{
return DB::table('inventory_categories')->insertGetId([
'type' => 'classroom',
'name' => 'Classroom',
'created_at' => now(),
'updated_at' => now(),
]);
}
private function seedItem(int $categoryId): int
{
return DB::table('inventory_items')->insertGetId([
'type' => 'classroom',
'category_id' => $categoryId,
'name' => 'Item',
'quantity' => 1,
'semester' => 'Fall',
'school_year' => '2025-2026',
'created_at' => now(),
'updated_at' => now(),
]);
}
}
@@ -0,0 +1,59 @@
<?php
namespace Tests\Unit\Services\Inventory;
use App\Services\Inventory\InventoryContextService;
use App\Services\Inventory\InventoryMovementService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class InventoryMovementServiceTest extends TestCase
{
use RefreshDatabase;
public function test_create_movement(): void
{
$this->seedConfig();
$itemId = $this->seedItem();
$service = new InventoryMovementService(new InventoryContextService());
$result = $service->create([
'item_id' => $itemId,
'qty_change' => 2,
'movement_type' => 'in',
], 1);
$this->assertTrue($result['ok']);
$this->assertDatabaseHas('inventory_movements', ['item_id' => $itemId]);
}
private function seedConfig(): void
{
DB::table('configuration')->insert([
['config_key' => 'school_year', 'config_value' => '2025-2026'],
['config_key' => 'semester', 'config_value' => 'Fall'],
]);
}
private function seedItem(): int
{
$categoryId = DB::table('inventory_categories')->insertGetId([
'type' => 'office',
'name' => 'Office',
'created_at' => now(),
'updated_at' => now(),
]);
return DB::table('inventory_items')->insertGetId([
'type' => 'office',
'category_id' => $categoryId,
'name' => 'Printer',
'quantity' => 10,
'semester' => 'Fall',
'school_year' => '2025-2026',
'created_at' => now(),
'updated_at' => now(),
]);
}
}
@@ -0,0 +1,54 @@
<?php
namespace Tests\Unit\Services\Inventory;
use App\Services\Inventory\InventoryContextService;
use App\Services\Inventory\InventorySummaryService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class InventorySummaryServiceTest extends TestCase
{
use RefreshDatabase;
public function test_summary_returns_items(): void
{
$this->seedConfig();
$this->seedItem();
$service = new InventorySummaryService(new InventoryContextService());
$result = $service->summary('classroom', '2025-2026');
$this->assertNotEmpty($result['items']);
}
private function seedConfig(): void
{
DB::table('configuration')->insert([
['config_key' => 'school_year', 'config_value' => '2025-2026'],
['config_key' => 'semester', 'config_value' => 'Fall'],
]);
}
private function seedItem(): void
{
$categoryId = DB::table('inventory_categories')->insertGetId([
'type' => 'classroom',
'name' => 'Classroom',
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('inventory_items')->insert([
'type' => 'classroom',
'category_id' => $categoryId,
'name' => 'Item',
'quantity' => 2,
'semester' => 'Fall',
'school_year' => '2025-2026',
'created_at' => now(),
'updated_at' => now(),
]);
}
}
@@ -0,0 +1,193 @@
<?php
namespace Tests\Unit\Services\Inventory;
use App\Services\Inventory\InventoryContextService;
use App\Services\Inventory\InventoryMovementService;
use App\Services\Inventory\InventoryTeacherDistributionService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class InventoryTeacherDistributionServiceTest extends TestCase
{
use RefreshDatabase;
public function test_form_data_requires_teacher_role(): void
{
$this->seedConfig();
$userId = $this->seedUser();
$service = new InventoryTeacherDistributionService(
new InventoryContextService(),
new InventoryMovementService(new InventoryContextService())
);
$result = $service->formData($userId, null, null);
$this->assertFalse($result['ok']);
}
public function test_distribute_success(): void
{
$this->seedConfig();
$userId = $this->seedUser();
$this->seedTeacherRole($userId);
$classSectionId = $this->seedClassSection();
$this->seedTeacherClass($userId, $classSectionId);
$studentId = $this->seedStudent(100, $userId);
$this->seedStudentClass($studentId, $classSectionId);
$categoryId = $this->seedCategory();
$bookId = $this->seedItem($categoryId);
$service = new InventoryTeacherDistributionService(
new InventoryContextService(),
new InventoryMovementService(new InventoryContextService())
);
$result = $service->distribute($userId, [
'item_id' => $bookId,
'class_section_id' => $classSectionId,
'student_ids' => [$studentId],
]);
$this->assertTrue($result['ok']);
$this->assertDatabaseHas('inventory_movements', ['item_id' => $bookId, 'movement_type' => 'distribution']);
}
private function seedConfig(): void
{
DB::table('configuration')->insert([
['config_key' => 'school_year', 'config_value' => '2025-2026'],
['config_key' => 'semester', 'config_value' => 'Fall'],
]);
}
private function seedUser(): int
{
return DB::table('users')->insertGetId([
'firstname' => 'Teacher',
'lastname' => 'User',
'cellphone' => '9999999999',
'email' => 'teacher@example.com',
'address_street' => '123 Street',
'city' => 'City',
'state' => 'ST',
'zip' => '12345',
'accept_school_policy' => 1,
'password' => bcrypt('password'),
'user_type' => 'primary',
'semester' => 'Fall',
'school_year' => '2025-2026',
'status' => 'Active',
]);
}
private function seedTeacherRole(int $userId): void
{
$roleId = DB::table('roles')->insertGetId([
'name' => 'teacher',
'dashboard_route' => 'teacher',
'priority' => 1,
'is_active' => 1,
]);
DB::table('user_roles')->insert([
'user_id' => $userId,
'role_id' => $roleId,
'created_at' => now(),
'updated_at' => now(),
]);
}
private function seedClassSection(): int
{
DB::table('classes')->insert([
'id' => 1,
'class_name' => 'Grade 1',
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('classSection')->insert([
'class_section_id' => 200,
'class_section_name' => 'Grade 1',
'class_id' => 1,
'semester' => 'Fall',
'school_year' => '2025-2026',
'created_at' => now(),
'updated_at' => now(),
]);
return 200;
}
private function seedTeacherClass(int $userId, int $classSectionId): void
{
DB::table('teacher_class')->insert([
'teacher_id' => $userId,
'class_section_id' => $classSectionId,
'position' => 'main',
'school_year' => '2025-2026',
'created_at' => now(),
'updated_at' => now(),
]);
}
private function seedStudent(int $id, int $parentId): int
{
DB::table('students')->insert([
'id' => $id,
'school_id' => 'SCH1',
'firstname' => 'Student',
'lastname' => 'One',
'age' => 10,
'gender' => 'Male',
'photo_consent' => 1,
'parent_id' => $parentId,
'year_of_registration' => '2025-2026',
'semester' => 'Fall',
'school_year' => '2025-2026',
'is_active' => 1,
]);
return $id;
}
private function seedStudentClass(int $studentId, int $classSectionId): void
{
DB::table('student_class')->insert([
'student_id' => $studentId,
'class_section_id' => $classSectionId,
'school_year' => '2025-2026',
'semester' => 'Fall',
'is_event_only' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
}
private function seedCategory(): int
{
return DB::table('inventory_categories')->insertGetId([
'type' => 'book',
'name' => 'Books',
'created_at' => now(),
'updated_at' => now(),
]);
}
private function seedItem(int $categoryId): int
{
return DB::table('inventory_items')->insertGetId([
'type' => 'book',
'category_id' => $categoryId,
'name' => 'Book',
'quantity' => 2,
'semester' => 'Fall',
'school_year' => '2025-2026',
'created_at' => now(),
'updated_at' => now(),
]);
}
}
@@ -0,0 +1,40 @@
<?php
namespace Tests\Unit\Services\Inventory;
use App\Services\Inventory\SupplierService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class SupplierServiceTest extends TestCase
{
use RefreshDatabase;
public function test_create_supplier(): void
{
$service = new SupplierService();
$result = $service->create([
'name' => 'Supplier A',
'email' => 'a@example.com',
]);
$this->assertTrue($result['ok']);
$this->assertDatabaseHas('suppliers', ['name' => 'Supplier A']);
}
public function test_update_supplier(): void
{
$id = DB::table('suppliers')->insertGetId([
'name' => 'Supplier B',
'created_at' => now(),
'updated_at' => now(),
]);
$service = new SupplierService();
$result = $service->update($id, ['name' => 'Supplier Updated']);
$this->assertTrue($result['ok']);
$this->assertDatabaseHas('suppliers', ['id' => $id, 'name' => 'Supplier Updated']);
}
}
@@ -0,0 +1,37 @@
<?php
namespace Tests\Unit\Services\Inventory;
use App\Services\Inventory\SupplyCategoryService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class SupplyCategoryServiceTest extends TestCase
{
use RefreshDatabase;
public function test_create_category(): void
{
$service = new SupplyCategoryService();
$result = $service->create(['name' => 'Paper']);
$this->assertTrue($result['ok']);
$this->assertDatabaseHas('supply_categories', ['name' => 'Paper']);
}
public function test_update_category(): void
{
$id = DB::table('supply_categories')->insertGetId([
'name' => 'Pens',
'created_at' => now(),
'updated_at' => now(),
]);
$service = new SupplyCategoryService();
$result = $service->update($id, ['name' => 'Pens Updated']);
$this->assertTrue($result['ok']);
$this->assertDatabaseHas('supply_categories', ['id' => $id, 'name' => 'Pens Updated']);
}
}