add school year model
This commit is contained in:
@@ -16,9 +16,9 @@ class AdministratorTeacherSubmissionController extends Controller
|
|||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
public function index(): JsonResponse
|
public function index(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
return response()->json($this->service->report());
|
return response()->json($this->service->report($request->query()));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function notify(Request $request): JsonResponse
|
public function notify(Request $request): JsonResponse
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ class EmailExtractorController extends BaseApiController
|
|||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'ok' => true,
|
'ok' => true,
|
||||||
|
'users' => $emails['users'] ?? [],
|
||||||
|
'parents' => $emails['parents'] ?? [],
|
||||||
'emails' => new EmailExtractorEmailsResource($emails),
|
'emails' => new EmailExtractorEmailsResource($emails),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\SchoolYears;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\Core\BaseApiController;
|
||||||
|
use App\Http\Requests\SchoolYears\CloseSchoolYearRequest;
|
||||||
|
use App\Http\Requests\SchoolYears\PreviewCloseSchoolYearRequest;
|
||||||
|
use App\Http\Requests\SchoolYears\SaveSchoolYearRequest;
|
||||||
|
use App\Services\SchoolYears\SchoolYearClosureService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class SchoolYearController extends BaseApiController
|
||||||
|
{
|
||||||
|
public function __construct(private SchoolYearClosureService $service)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json(['ok' => true, 'data' => $this->service->list()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(SaveSchoolYearRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'data' => $this->service->createYear($request->validated(), $this->getCurrentUserId()),
|
||||||
|
], Response::HTTP_CREATED);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(SaveSchoolYearRequest $request, int $schoolYear): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'data' => $this->service->updateYear($schoolYear, $request->validated(), $this->getCurrentUserId()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function summary(int $schoolYear): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json(['ok' => true, 'data' => $this->service->summary($schoolYear)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function validateClose(PreviewCloseSchoolYearRequest $request, int $schoolYear): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'validation' => $this->service->validateClose($schoolYear, $request->validated()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function previewClose(PreviewCloseSchoolYearRequest $request, int $schoolYear): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'data' => $this->service->previewClose($schoolYear, $request->validated()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function close(CloseSchoolYearRequest $request, int $schoolYear): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$result = $this->service->close($schoolYear, $request->validated(), $this->getCurrentUserId());
|
||||||
|
} catch (ValidationException $e) {
|
||||||
|
throw $e;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return response()->json([
|
||||||
|
'ok' => false,
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
], Response::HTTP_CONFLICT);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['ok' => true, 'data' => $result]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reopen(Request $request, int $schoolYear): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'data' => $this->service->reopen($schoolYear, $this->getCurrentUserId()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function parentBalances(Request $request, int $schoolYear): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'data' => $this->service->parentBalances($schoolYear, $request->query('to_school_year')),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function promotionPreview(Request $request, int $schoolYear): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'data' => $this->service->promotionPreview($schoolYear, $request->query('to_school_year')),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Web;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\SchoolYears\SchoolYearClosureService;
|
||||||
|
use App\Services\SchoolYears\SchoolYearResolver;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
use PHPOpenSourceSaver\JWTAuth\Facades\JWTAuth;
|
||||||
|
|
||||||
|
class SchoolYearAdminPageController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private SchoolYearClosureService $service,
|
||||||
|
private SchoolYearResolver $resolver
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Request $request): View
|
||||||
|
{
|
||||||
|
$years = $this->service->list();
|
||||||
|
$selectedId = (int) ($request->query('year') ?? 0);
|
||||||
|
$activeYear = collect($years)->firstWhere('is_current', true);
|
||||||
|
|
||||||
|
$defaultNext = $activeYear
|
||||||
|
? ($this->resolver->suggestNextName((string) $activeYear['name']) ?? '')
|
||||||
|
: '';
|
||||||
|
[$defaultStart, $defaultEnd] = $defaultNext !== ''
|
||||||
|
? $this->resolver->inferDatesFromName($defaultNext)
|
||||||
|
: ['', ''];
|
||||||
|
|
||||||
|
return view('admin.school-years', [
|
||||||
|
'pageConfig' => [
|
||||||
|
'jwt' => JWTAuth::fromUser($request->user()),
|
||||||
|
'selected_year_id' => $selectedId,
|
||||||
|
'docs_url' => url('/docs'),
|
||||||
|
'default_draft' => [
|
||||||
|
'name' => $defaultNext,
|
||||||
|
'start_date' => $defaultStart,
|
||||||
|
'end_date' => $defaultEnd,
|
||||||
|
],
|
||||||
|
'api' => [
|
||||||
|
'index' => url('/api/v1/school-years'),
|
||||||
|
'store' => url('/api/v1/school-years'),
|
||||||
|
'update' => url('/api/v1/school-years/__ID__'),
|
||||||
|
'summary' => url('/api/v1/school-years/__ID__/summary'),
|
||||||
|
'validate_close' => url('/api/v1/school-years/__ID__/validate-close'),
|
||||||
|
'preview_close' => url('/api/v1/school-years/__ID__/preview-close'),
|
||||||
|
'close' => url('/api/v1/school-years/__ID__/close'),
|
||||||
|
'reopen' => url('/api/v1/school-years/__ID__/reopen'),
|
||||||
|
'parent_balances' => url('/api/v1/school-years/__ID__/parent-balances'),
|
||||||
|
'promotion_preview' => url('/api/v1/school-years/__ID__/promotion-preview'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validate([
|
||||||
|
'name' => ['required', 'string', 'max:50'],
|
||||||
|
'start_date' => ['required', 'date'],
|
||||||
|
'end_date' => ['required', 'date', 'after:start_date'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$year = $this->service->createYear($payload, optional($request->user())->id);
|
||||||
|
} catch (ValidationException $e) {
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('admin.school-years', ['year' => $year['id']])
|
||||||
|
->with('status', sprintf('Draft school year %s created.', $year['name']));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, int $schoolYear): RedirectResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validate([
|
||||||
|
'name' => ['required', 'string', 'max:50'],
|
||||||
|
'start_date' => ['required', 'date'],
|
||||||
|
'end_date' => ['required', 'date', 'after:start_date'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$updated = $this->service->updateYear($schoolYear, $payload, optional($request->user())->id);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('admin.school-years', ['year' => $updated['id']])
|
||||||
|
->with('status', sprintf('School year %s updated.', $updated['name']));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function validateClose(Request $request, int $schoolYear): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'validation' => $this->service->validateClose($schoolYear, $this->validatedClosePayload($request)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function previewClose(Request $request, int $schoolYear): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'data' => $this->service->previewClose($schoolYear, $this->validatedClosePayload($request)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function close(Request $request, int $schoolYear): RedirectResponse
|
||||||
|
{
|
||||||
|
$payload = $this->validatedClosePayload($request);
|
||||||
|
$payload['confirmation'] = (string) $request->validate([
|
||||||
|
'confirmation' => ['required', 'string', 'max:80'],
|
||||||
|
])['confirmation'];
|
||||||
|
|
||||||
|
$result = $this->service->close($schoolYear, $payload, optional($request->user())->id);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('admin.school-years', ['year' => $result['new_school_year']['id']])
|
||||||
|
->with('status', sprintf(
|
||||||
|
'Closed %s and opened %s.',
|
||||||
|
$result['closed_school_year']['name'],
|
||||||
|
$result['new_school_year']['name']
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reopen(Request $request, int $schoolYear): RedirectResponse
|
||||||
|
{
|
||||||
|
$year = $this->service->reopen($schoolYear, optional($request->user())->id);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('admin.school-years', ['year' => $year['id']])
|
||||||
|
->with('status', sprintf('School year %s is now active.', $year['name']));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validatedClosePayload(Request $request): array
|
||||||
|
{
|
||||||
|
return $request->validate([
|
||||||
|
'new_school_year' => ['required', 'array'],
|
||||||
|
'new_school_year.name' => ['required', 'string', 'max:50'],
|
||||||
|
'new_school_year.start_date' => ['required', 'date'],
|
||||||
|
'new_school_year.end_date' => ['required', 'date', 'after:new_school_year.start_date'],
|
||||||
|
'transfer_unpaid_balances' => ['nullable', 'boolean'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Models\Configuration;
|
||||||
|
use App\Services\SchoolYears\SchoolYearWriteGuard;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use RuntimeException;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class EnsureSchoolYearEditable
|
||||||
|
{
|
||||||
|
public function __construct(private SchoolYearWriteGuard $guard)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
if (!in_array(strtoupper($request->method()), ['POST', 'PUT', 'PATCH', 'DELETE'], true)) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->guard->assertEditable($this->resolveSchoolYears($request));
|
||||||
|
} catch (RuntimeException $e) {
|
||||||
|
return response()->json([
|
||||||
|
'ok' => false,
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
], 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveSchoolYears(Request $request): array
|
||||||
|
{
|
||||||
|
$years = [];
|
||||||
|
|
||||||
|
foreach (['school_year', 'current_school_year'] as $field) {
|
||||||
|
$value = $request->input($field, $request->query($field));
|
||||||
|
if (is_string($value) && trim($value) !== '') {
|
||||||
|
$years[] = trim($value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$routeYears = [
|
||||||
|
['param' => 'invoiceId', 'table' => 'invoices', 'column' => 'school_year'],
|
||||||
|
['param' => 'invoice', 'table' => 'invoices', 'column' => 'school_year'],
|
||||||
|
['param' => 'paymentId', 'table' => 'payments', 'column' => 'school_year'],
|
||||||
|
['param' => 'payment', 'table' => 'payments', 'column' => 'school_year'],
|
||||||
|
['param' => 'refundId', 'table' => 'refunds', 'column' => 'school_year'],
|
||||||
|
['param' => 'refund', 'table' => 'refunds', 'column' => 'school_year'],
|
||||||
|
['param' => 'promotionId', 'table' => 'student_promotion_records', 'column' => 'current_school_year'],
|
||||||
|
['param' => 'promotion', 'table' => 'student_promotion_records', 'column' => 'current_school_year'],
|
||||||
|
['param' => 'eventCharge', 'table' => 'event_charges', 'column' => 'school_year'],
|
||||||
|
['param' => 'eventId', 'table' => 'events', 'column' => 'school_year'],
|
||||||
|
['param' => 'classSection', 'table' => 'classSection', 'column' => 'school_year'],
|
||||||
|
['param' => 'classSectionId', 'table' => 'classSection', 'column' => 'school_year'],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($routeYears as $mapping) {
|
||||||
|
$value = $request->route($mapping['param']);
|
||||||
|
if ($value === null || $value === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolved = $this->lookupYear((string) $value, $mapping['table'], $mapping['column']);
|
||||||
|
if ($resolved !== null) {
|
||||||
|
$years[] = $resolved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($years === []) {
|
||||||
|
$current = trim((string) (Configuration::getConfig('school_year') ?? ''));
|
||||||
|
if ($current !== '') {
|
||||||
|
$years[] = $current;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $years;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function lookupYear(string $id, string $table, string $column): ?string
|
||||||
|
{
|
||||||
|
if (!ctype_digit($id) || !DB::getSchemaBuilder()->hasTable($table)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$primaryKey = match ($table) {
|
||||||
|
'classSection' => 'class_section_id',
|
||||||
|
'student_promotion_records' => 'promotion_id',
|
||||||
|
default => 'id',
|
||||||
|
};
|
||||||
|
|
||||||
|
$value = DB::table($table)->where($primaryKey, (int) $id)->value($column);
|
||||||
|
|
||||||
|
return is_string($value) && trim($value) !== '' ? trim($value) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\SchoolYears;
|
||||||
|
|
||||||
|
class CloseSchoolYearRequest extends PreviewCloseSchoolYearRequest
|
||||||
|
{
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return array_merge(parent::rules(), [
|
||||||
|
'confirmation' => ['required', 'string', 'max:80'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\SchoolYears;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class PreviewCloseSchoolYearRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'new_school_year' => ['required', 'array'],
|
||||||
|
'new_school_year.name' => ['required', 'string', 'max:50'],
|
||||||
|
'new_school_year.start_date' => ['required', 'date'],
|
||||||
|
'new_school_year.end_date' => ['required', 'date', 'after:new_school_year.start_date'],
|
||||||
|
'transfer_unpaid_balances' => ['nullable', 'boolean'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\SchoolYears;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class SaveSchoolYearRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:50'],
|
||||||
|
'start_date' => ['required', 'date'],
|
||||||
|
'end_date' => ['required', 'date', 'after:start_date'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
class AuditLog extends BaseModel
|
||||||
|
{
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
protected $table = 'audit_logs';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'action',
|
||||||
|
'table_name',
|
||||||
|
'record_id',
|
||||||
|
'old_value',
|
||||||
|
'new_value',
|
||||||
|
'metadata',
|
||||||
|
'created_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'user_id' => 'integer',
|
||||||
|
'record_id' => 'integer',
|
||||||
|
'old_value' => 'array',
|
||||||
|
'new_value' => 'array',
|
||||||
|
'metadata' => 'array',
|
||||||
|
'created_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
class ParentAccount extends BaseModel
|
||||||
|
{
|
||||||
|
protected $table = 'parent_accounts';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'parent_id',
|
||||||
|
'school_year',
|
||||||
|
'opening_balance',
|
||||||
|
'current_balance',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'parent_id' => 'integer',
|
||||||
|
'opening_balance' => 'decimal:2',
|
||||||
|
'current_balance' => 'decimal:2',
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
class ParentBalanceTransfer extends BaseModel
|
||||||
|
{
|
||||||
|
protected $table = 'parent_balance_transfers';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'parent_id',
|
||||||
|
'from_school_year',
|
||||||
|
'to_school_year',
|
||||||
|
'amount',
|
||||||
|
'status',
|
||||||
|
'source_summary_json',
|
||||||
|
'new_invoice_id',
|
||||||
|
'created_by',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'parent_id' => 'integer',
|
||||||
|
'amount' => 'decimal:2',
|
||||||
|
'source_summary_json' => 'array',
|
||||||
|
'new_invoice_id' => 'integer',
|
||||||
|
'created_by' => 'integer',
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
class SchoolYear extends BaseModel
|
||||||
|
{
|
||||||
|
public const STATUS_DRAFT = 'draft';
|
||||||
|
public const STATUS_ACTIVE = 'active';
|
||||||
|
public const STATUS_CLOSED = 'closed';
|
||||||
|
public const STATUS_ARCHIVED = 'archived';
|
||||||
|
|
||||||
|
protected $table = 'school_years';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'start_date',
|
||||||
|
'end_date',
|
||||||
|
'status',
|
||||||
|
'is_current',
|
||||||
|
'closed_at',
|
||||||
|
'closed_by',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'start_date' => 'date',
|
||||||
|
'end_date' => 'date',
|
||||||
|
'is_current' => 'boolean',
|
||||||
|
'closed_at' => 'datetime',
|
||||||
|
'closed_by' => 'integer',
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -12,14 +12,18 @@ class AdministratorSharedService
|
|||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getSemester(): string
|
public function getSemester(?string $override = null): string
|
||||||
{
|
{
|
||||||
return (string) ($this->configuration->getConfig('semester') ?? '');
|
$value = trim((string) $override);
|
||||||
|
|
||||||
|
return $value !== '' ? $value : (string) ($this->configuration->getConfig('semester') ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getSchoolYear(): string
|
public function getSchoolYear(?string $override = null): string
|
||||||
{
|
{
|
||||||
return (string) ($this->configuration->getConfig('school_year') ?? '');
|
$value = trim((string) $override);
|
||||||
|
|
||||||
|
return $value !== '' ? $value : (string) ($this->configuration->getConfig('school_year') ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getPreviousSchoolYear(string $schoolYear): string
|
public function getPreviousSchoolYear(string $schoolYear): string
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ class AdministratorTeacherSubmissionService
|
|||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
public function report(): array
|
public function report(array $filters = []): array
|
||||||
{
|
{
|
||||||
return $this->reportService->report();
|
return $this->reportService->report($filters);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function sendNotifications(Request $request, int $adminId): array
|
public function sendNotifications(Request $request, int $adminId): array
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ class TeacherSubmissionNotificationService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$missingItemsPayload = $request->input('missing_items', []);
|
$missingItemsPayload = $request->input('missing_items', []);
|
||||||
$targets = $this->extractTargets($notify);
|
$schoolYear = $this->shared->getSchoolYear($request->input('school_year'));
|
||||||
|
$semester = $this->shared->getSemester($request->input('semester'));
|
||||||
|
$targets = $this->extractTargets($notify, $schoolYear, $semester);
|
||||||
|
|
||||||
if (empty($targets)) {
|
if (empty($targets)) {
|
||||||
return [
|
return [
|
||||||
@@ -72,8 +74,7 @@ class TeacherSubmissionNotificationService
|
|||||||
$sectionName = $classSections->get($classSectionId)?->class_section_name ?? "Section {$classSectionId}";
|
$sectionName = $classSections->get($classSectionId)?->class_section_name ?? "Section {$classSectionId}";
|
||||||
$teacherName = trim(($teacher->firstname ?? '') . ' ' . ($teacher->lastname ?? '')) ?: 'Teacher';
|
$teacherName = trim(($teacher->firstname ?? '') . ' ' . ($teacher->lastname ?? '')) ?: 'Teacher';
|
||||||
|
|
||||||
$missingPayload = $missingItemsPayload[$classSectionId][$teacherId] ?? '';
|
$missingItems = $this->resolveMissingItems($missingItemsPayload, $classSectionId, $teacherId);
|
||||||
$missingItems = $this->support->parseMissingItemsPayload((string) $missingPayload);
|
|
||||||
|
|
||||||
$missingNote = !empty($missingItems)
|
$missingNote = !empty($missingItems)
|
||||||
? '<p>Outstanding items: ' . e($this->support->formatMissingItemsText($missingItems)) . '.</p>'
|
? '<p>Outstanding items: ' . e($this->support->formatMissingItemsText($missingItems)) . '.</p>'
|
||||||
@@ -111,8 +112,8 @@ class TeacherSubmissionNotificationService
|
|||||||
'notification_category' => 'teacher_submissions',
|
'notification_category' => 'teacher_submissions',
|
||||||
'message' => $this->support->truncateNotificationMessage($body),
|
'message' => $this->support->truncateNotificationMessage($body),
|
||||||
'status' => $status,
|
'status' => $status,
|
||||||
'school_year' => $this->shared->getSchoolYear(),
|
'school_year' => $schoolYear,
|
||||||
'semester' => $this->shared->getSemester(),
|
'semester' => $semester,
|
||||||
'sent_at' => now(),
|
'sent_at' => now(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -134,8 +135,31 @@ class TeacherSubmissionNotificationService
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function extractTargets(array $notify): array
|
protected function extractTargets(array $notify, string $schoolYear, string $semester): array
|
||||||
{
|
{
|
||||||
|
if ($this->isFlatTeacherIdList($notify)) {
|
||||||
|
$teacherIds = array_values(array_unique(array_map('intval', array_filter($notify, static fn ($value) => (int) $value > 0))));
|
||||||
|
if (empty($teacherIds)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ClassSection::query()
|
||||||
|
->select('teacher_class.class_section_id', 'teacher_class.teacher_id')
|
||||||
|
->join('teacher_class', 'teacher_class.class_section_id', '=', 'classSection.class_section_id')
|
||||||
|
->whereIn('teacher_class.teacher_id', $teacherIds)
|
||||||
|
->where('teacher_class.school_year', $schoolYear)
|
||||||
|
->where('teacher_class.semester', $semester)
|
||||||
|
->orderBy('teacher_class.class_section_id')
|
||||||
|
->get()
|
||||||
|
->map(static fn ($row): array => [
|
||||||
|
'class_section_id' => (int) $row->class_section_id,
|
||||||
|
'teacher_id' => (int) $row->teacher_id,
|
||||||
|
])
|
||||||
|
->filter(static fn (array $row): bool => $row['class_section_id'] > 0 && $row['teacher_id'] > 0)
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
$targets = [];
|
$targets = [];
|
||||||
|
|
||||||
foreach ($notify as $sectionIdRaw => $teachers) {
|
foreach ($notify as $sectionIdRaw => $teachers) {
|
||||||
@@ -159,4 +183,39 @@ class TeacherSubmissionNotificationService
|
|||||||
|
|
||||||
return array_values($targets);
|
return array_values($targets);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function isFlatTeacherIdList(array $notify): bool
|
||||||
|
{
|
||||||
|
foreach ($notify as $value) {
|
||||||
|
if (is_array($value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function resolveMissingItems($payload, int $classSectionId, int $teacherId): array
|
||||||
|
{
|
||||||
|
if (!is_array($payload)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($payload[$classSectionId]) && is_array($payload[$classSectionId])) {
|
||||||
|
$sectionPayload = $payload[$classSectionId];
|
||||||
|
if (array_key_exists($teacherId, $sectionPayload) || array_key_exists((string) $teacherId, $sectionPayload)) {
|
||||||
|
$value = $sectionPayload[$teacherId] ?? $sectionPayload[(string) $teacherId] ?? null;
|
||||||
|
|
||||||
|
return $this->support->parseMissingItemsPayload($value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists($teacherId, $payload) || array_key_exists((string) $teacherId, $payload)) {
|
||||||
|
$value = $payload[$teacherId] ?? $payload[(string) $teacherId] ?? null;
|
||||||
|
|
||||||
|
return $this->support->parseMissingItemsPayload($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,10 +17,10 @@ class TeacherSubmissionReportService
|
|||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
public function report(): array
|
public function report(array $filters = []): array
|
||||||
{
|
{
|
||||||
$semester = $this->shared->getSemester();
|
$semester = $this->shared->getSemester($filters['semester'] ?? null);
|
||||||
$schoolYear = $this->shared->getSchoolYear();
|
$schoolYear = $this->shared->getSchoolYear($filters['school_year'] ?? null);
|
||||||
|
|
||||||
$assignmentRows = DB::table('teacher_class as tc')
|
$assignmentRows = DB::table('teacher_class as tc')
|
||||||
->select([
|
->select([
|
||||||
@@ -230,6 +230,8 @@ class TeacherSubmissionReportService
|
|||||||
'rows' => $rows,
|
'rows' => $rows,
|
||||||
'semester' => $semester,
|
'semester' => $semester,
|
||||||
'schoolYear' => $schoolYear,
|
'schoolYear' => $schoolYear,
|
||||||
|
'currentSemester' => $this->shared->getSemester(),
|
||||||
|
'currentSchoolYear' => $this->shared->getSchoolYear(),
|
||||||
'notificationHistory' => $historyMap,
|
'notificationHistory' => $historyMap,
|
||||||
'summary' => $summary,
|
'summary' => $summary,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -83,8 +83,21 @@ class TeacherSubmissionSupportService
|
|||||||
: mb_substr($text, 0, $limit) . '…';
|
: mb_substr($text, 0, $limit) . '…';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function parseMissingItemsPayload(string $payload): array
|
public function parseMissingItemsPayload($payload): array
|
||||||
{
|
{
|
||||||
|
if (is_array($payload)) {
|
||||||
|
$items = [];
|
||||||
|
foreach ($payload as $item) {
|
||||||
|
$item = trim((string) $item);
|
||||||
|
if ($item !== '') {
|
||||||
|
$items[] = $item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($items));
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = trim((string) $payload);
|
||||||
if ($payload === '') {
|
if ($payload === '') {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Laravel named routes (`route()`), cookie-session URLs, SPA paths, and absolute URLs for `public/` paths.
|
* Laravel named routes (`route()`), cookie-session URLs, SPA paths, and absolute URLs for `public/` paths.
|
||||||
*/
|
*/
|
||||||
@@ -48,7 +50,11 @@ final class ApplicationUrlService
|
|||||||
/** Named route `docs.home` (GET `/`; usually redirects to the docs client). */
|
/** Named route `docs.home` (GET `/`; usually redirects to the docs client). */
|
||||||
public function docsHomeUrl(bool $absolute = true): string
|
public function docsHomeUrl(bool $absolute = true): string
|
||||||
{
|
{
|
||||||
return route('docs.home', [], $absolute);
|
if (Route::has('docs.home')) {
|
||||||
|
return route('docs.home', [], $absolute);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $absolute ? url('/app/home') : '/app/home';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cookie-session SPA paths from `routes/web.php`. Named route `login`. */
|
/** Cookie-session SPA paths from `routes/web.php`. Named route `login`. */
|
||||||
|
|||||||
@@ -48,18 +48,39 @@ class AttendanceManagementService
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
$rows = $query->orderByDesc('follow_up_required')
|
$schoolYear = trim((string) ($filters['school_year'] ?? ''));
|
||||||
|
$semester = $this->normalizeSemester($filters['semester'] ?? null);
|
||||||
|
$limit = max(1, (int) ($filters['limit'] ?? 250));
|
||||||
|
|
||||||
|
$rowCollection = $query->orderByDesc('follow_up_required')
|
||||||
->orderBy('follow_up_completed')
|
->orderBy('follow_up_completed')
|
||||||
->orderBy('person_name')
|
->orderBy('person_name')
|
||||||
->limit((int) ($filters['limit'] ?? 250))
|
|
||||||
->get()
|
->get()
|
||||||
->map(fn ($r) => (array) $r)
|
->map(fn ($r) => $this->hydrateAcademicContext((array) $r))
|
||||||
|
->filter(function (array $row) use ($schoolYear, $semester): bool {
|
||||||
|
if ($schoolYear !== '' && (string) ($row['school_year'] ?? '') !== $schoolYear) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ($semester !== '' && (string) ($row['semester'] ?? '') !== $semester) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
$rows = $rowCollection
|
||||||
|
->take($limit)
|
||||||
|
->values()
|
||||||
->all();
|
->all();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'date' => $date,
|
'date' => $date,
|
||||||
'summary' => $this->summaryForDate($date),
|
'school_year' => $schoolYear,
|
||||||
'filters' => $this->availableFilters(),
|
'semester' => $semester,
|
||||||
|
'current_year' => $this->currentSchoolYear(),
|
||||||
|
'current_semester' => $this->currentSemester(),
|
||||||
|
'summary' => $this->summaryForDate($date, $rowCollection->values()->all(), $schoolYear, $semester),
|
||||||
|
'filters' => $this->availableFilters($rows),
|
||||||
'rows' => $rows,
|
'rows' => $rows,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -75,6 +96,7 @@ class AttendanceManagementService
|
|||||||
$status = $isLate ? self::STATUS_LATE : self::STATUS_PRESENT;
|
$status = $isLate ? self::STATUS_LATE : self::STATUS_PRESENT;
|
||||||
$counts = $this->countsFor($person['type'], $person['id'], $entryAt->toDateString(), $status);
|
$counts = $this->countsFor($person['type'], $person['id'], $entryAt->toDateString(), $status);
|
||||||
$badgeExceptionCount = $this->badgeExceptionCount($person['type'], $person['id'], $entryAt->toDateString()) + 1;
|
$badgeExceptionCount = $this->badgeExceptionCount($person['type'], $person['id'], $entryAt->toDateString()) + 1;
|
||||||
|
$academicContext = $this->academicContextForDate($entryAt);
|
||||||
|
|
||||||
$row = [
|
$row = [
|
||||||
'person_type' => $person['type'],
|
'person_type' => $person['type'],
|
||||||
@@ -83,6 +105,8 @@ class AttendanceManagementService
|
|||||||
'role_grade' => $person['role_grade'],
|
'role_grade' => $person['role_grade'],
|
||||||
'badge_id' => $person['badge_id'],
|
'badge_id' => $person['badge_id'],
|
||||||
'event_date' => $entryAt->toDateString(),
|
'event_date' => $entryAt->toDateString(),
|
||||||
|
'school_year' => $academicContext['school_year'],
|
||||||
|
'semester' => $academicContext['semester'],
|
||||||
'attendance_status' => $status,
|
'attendance_status' => $status,
|
||||||
'report_status' => $reportStatus,
|
'report_status' => $reportStatus,
|
||||||
'entry_time' => $entryAt,
|
'entry_time' => $entryAt,
|
||||||
@@ -144,6 +168,7 @@ class AttendanceManagementService
|
|||||||
|
|
||||||
$status = $entryAt->gt($this->schoolStartFor($entryAt)) ? self::STATUS_LATE : self::STATUS_PRESENT;
|
$status = $entryAt->gt($this->schoolStartFor($entryAt)) ? self::STATUS_LATE : self::STATUS_PRESENT;
|
||||||
$counts = $this->countsFor($person['type'], $person['id'], $entryAt->toDateString(), $status);
|
$counts = $this->countsFor($person['type'], $person['id'], $entryAt->toDateString(), $status);
|
||||||
|
$academicContext = $this->academicContextForDate($entryAt);
|
||||||
$row = [
|
$row = [
|
||||||
'person_type' => $person['type'],
|
'person_type' => $person['type'],
|
||||||
'person_id' => $person['id'],
|
'person_id' => $person['id'],
|
||||||
@@ -151,6 +176,8 @@ class AttendanceManagementService
|
|||||||
'role_grade' => $person['role_grade'],
|
'role_grade' => $person['role_grade'],
|
||||||
'badge_id' => $badge,
|
'badge_id' => $badge,
|
||||||
'event_date' => $entryAt->toDateString(),
|
'event_date' => $entryAt->toDateString(),
|
||||||
|
'school_year' => $academicContext['school_year'],
|
||||||
|
'semester' => $academicContext['semester'],
|
||||||
'attendance_status' => $status,
|
'attendance_status' => $status,
|
||||||
'report_status' => $reportStatus,
|
'report_status' => $reportStatus,
|
||||||
'entry_time' => $entryAt,
|
'entry_time' => $entryAt,
|
||||||
@@ -189,6 +216,7 @@ class AttendanceManagementService
|
|||||||
$reportStatus = $authorized ? 'reported' : $this->normalizeReportStatus($data['report_status'] ?? null);
|
$reportStatus = $authorized ? 'reported' : $this->normalizeReportStatus($data['report_status'] ?? null);
|
||||||
$counts = $this->countsFor($person['type'], $person['id'], $exitAt->toDateString(), self::STATUS_EARLY_DISMISSAL);
|
$counts = $this->countsFor($person['type'], $person['id'], $exitAt->toDateString(), self::STATUS_EARLY_DISMISSAL);
|
||||||
$badgeExceptions = $this->badgeExceptionCount($person['type'], $person['id'], $exitAt->toDateString()) + ((($data['exit_method'] ?? '') === 'manual_exit') ? 1 : 0);
|
$badgeExceptions = $this->badgeExceptionCount($person['type'], $person['id'], $exitAt->toDateString()) + ((($data['exit_method'] ?? '') === 'manual_exit') ? 1 : 0);
|
||||||
|
$academicContext = $this->academicContextForDate($exitAt);
|
||||||
$row = [
|
$row = [
|
||||||
'person_type' => $person['type'],
|
'person_type' => $person['type'],
|
||||||
'person_id' => $person['id'],
|
'person_id' => $person['id'],
|
||||||
@@ -196,6 +224,8 @@ class AttendanceManagementService
|
|||||||
'role_grade' => $person['role_grade'],
|
'role_grade' => $person['role_grade'],
|
||||||
'badge_id' => $person['badge_id'],
|
'badge_id' => $person['badge_id'],
|
||||||
'event_date' => $exitAt->toDateString(),
|
'event_date' => $exitAt->toDateString(),
|
||||||
|
'school_year' => $academicContext['school_year'],
|
||||||
|
'semester' => $academicContext['semester'],
|
||||||
'attendance_status' => self::STATUS_EARLY_DISMISSAL,
|
'attendance_status' => self::STATUS_EARLY_DISMISSAL,
|
||||||
'report_status' => $reportStatus,
|
'report_status' => $reportStatus,
|
||||||
'exit_time' => $exitAt,
|
'exit_time' => $exitAt,
|
||||||
@@ -233,6 +263,7 @@ class AttendanceManagementService
|
|||||||
$date = $this->dateOnly($data['date'] ?? now()->toDateString());
|
$date = $this->dateOnly($data['date'] ?? now()->toDateString());
|
||||||
$reportStatus = $this->normalizeReportStatus($data['report_status'] ?? null);
|
$reportStatus = $this->normalizeReportStatus($data['report_status'] ?? null);
|
||||||
$counts = $this->countsFor($person['type'], $person['id'], $date, self::STATUS_ABSENT);
|
$counts = $this->countsFor($person['type'], $person['id'], $date, self::STATUS_ABSENT);
|
||||||
|
$academicContext = $this->academicContextForDate(Carbon::parse($date));
|
||||||
$row = [
|
$row = [
|
||||||
'person_type' => $person['type'],
|
'person_type' => $person['type'],
|
||||||
'person_id' => $person['id'],
|
'person_id' => $person['id'],
|
||||||
@@ -240,6 +271,8 @@ class AttendanceManagementService
|
|||||||
'role_grade' => $person['role_grade'],
|
'role_grade' => $person['role_grade'],
|
||||||
'badge_id' => $person['badge_id'],
|
'badge_id' => $person['badge_id'],
|
||||||
'event_date' => $date,
|
'event_date' => $date,
|
||||||
|
'school_year' => $academicContext['school_year'],
|
||||||
|
'semester' => $academicContext['semester'],
|
||||||
'attendance_status' => self::STATUS_ABSENT,
|
'attendance_status' => self::STATUS_ABSENT,
|
||||||
'report_status' => $reportStatus,
|
'report_status' => $reportStatus,
|
||||||
'reason' => $data['reason'] ?? null,
|
'reason' => $data['reason'] ?? null,
|
||||||
@@ -290,6 +323,10 @@ class AttendanceManagementService
|
|||||||
throw new \RuntimeException('Attendance management event not found.');
|
throw new \RuntimeException('Attendance management event not found.');
|
||||||
}
|
}
|
||||||
$count = (int) DB::table('late_slip_reprints')->where('attendance_management_event_id', $eventId)->count() + 1;
|
$count = (int) DB::table('late_slip_reprints')->where('attendance_management_event_id', $eventId)->count() + 1;
|
||||||
|
$academicContext = [
|
||||||
|
'school_year' => (string) ($event['school_year'] ?? $this->currentSchoolYear()),
|
||||||
|
'semester' => (string) ($event['semester'] ?? $this->currentSemester()),
|
||||||
|
];
|
||||||
$row = [
|
$row = [
|
||||||
'attendance_management_event_id' => $eventId,
|
'attendance_management_event_id' => $eventId,
|
||||||
'late_slip_log_id' => $data['late_slip_log_id'] ?? null,
|
'late_slip_log_id' => $data['late_slip_log_id'] ?? null,
|
||||||
@@ -297,6 +334,8 @@ class AttendanceManagementService
|
|||||||
'student_name' => $event['person_name'] ?? null,
|
'student_name' => $event['person_name'] ?? null,
|
||||||
'slip_number' => $data['slip_number'] ?? ('AME-'.$eventId),
|
'slip_number' => $data['slip_number'] ?? ('AME-'.$eventId),
|
||||||
'reprinted_at' => now(),
|
'reprinted_at' => now(),
|
||||||
|
'school_year' => $academicContext['school_year'],
|
||||||
|
'semester' => $academicContext['semester'],
|
||||||
'reprinted_by' => $actor?->id,
|
'reprinted_by' => $actor?->id,
|
||||||
'reason' => $data['reason'] ?? 'Reprint requested',
|
'reason' => $data['reason'] ?? 'Reprint requested',
|
||||||
'reprint_count' => $count,
|
'reprint_count' => $count,
|
||||||
@@ -370,8 +409,8 @@ class AttendanceManagementService
|
|||||||
private function printLateSlip(array $event, ?User $actor): array
|
private function printLateSlip(array $event, ?User $actor): array
|
||||||
{
|
{
|
||||||
$payload = [
|
$payload = [
|
||||||
'school_year' => Configuration::getConfigValueByKey('school_year') ?? '',
|
'school_year' => (string) ($event['school_year'] ?? $this->currentSchoolYear()),
|
||||||
'semester' => Configuration::getConfigValueByKey('semester') ?? '',
|
'semester' => (string) ($event['semester'] ?? $this->currentSemester()),
|
||||||
'student_name' => $event['person_name'] ?? '',
|
'student_name' => $event['person_name'] ?? '',
|
||||||
'slip_date' => $event['event_date'] ?? now()->toDateString(),
|
'slip_date' => $event['event_date'] ?? now()->toDateString(),
|
||||||
'time_in' => Carbon::parse($event['official_entry_time'] ?? now())->format('H:i:s'),
|
'time_in' => Carbon::parse($event['official_entry_time'] ?? now())->format('H:i:s'),
|
||||||
@@ -389,12 +428,15 @@ class AttendanceManagementService
|
|||||||
|
|
||||||
private function recordBadgeException(int $eventId, array $person, Carbon $date, string $reason, int $count, ?User $actor, ?string $notes): void
|
private function recordBadgeException(int $eventId, array $person, Carbon $date, string $reason, int $count, ?User $actor, ?string $notes): void
|
||||||
{
|
{
|
||||||
|
$academicContext = $this->academicContextForDate($date);
|
||||||
DB::table('badge_exceptions')->insert([
|
DB::table('badge_exceptions')->insert([
|
||||||
'attendance_management_event_id' => $eventId,
|
'attendance_management_event_id' => $eventId,
|
||||||
'person_type' => $person['type'],
|
'person_type' => $person['type'],
|
||||||
'person_id' => $person['id'],
|
'person_id' => $person['id'],
|
||||||
'person_name' => $person['name'],
|
'person_name' => $person['name'],
|
||||||
'exception_date' => $date->toDateString(),
|
'exception_date' => $date->toDateString(),
|
||||||
|
'school_year' => $academicContext['school_year'],
|
||||||
|
'semester' => $academicContext['semester'],
|
||||||
'exception_type' => 'manual_entry',
|
'exception_type' => 'manual_entry',
|
||||||
'badge_status' => $reason,
|
'badge_status' => $reason,
|
||||||
'exception_count' => $count,
|
'exception_count' => $count,
|
||||||
@@ -431,28 +473,55 @@ class AttendanceManagementService
|
|||||||
->count();
|
->count();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function summaryForDate(string $date): array
|
private function summaryForDate(string $date, array $rows = [], string $schoolYear = '', string $semester = ''): array
|
||||||
{
|
{
|
||||||
$base = DB::table('attendance_management_events')->whereDate('event_date', $date);
|
$badgeExceptions = DB::table('badge_exceptions')->whereDate('exception_date', $date);
|
||||||
|
$this->applyAcademicFilters($badgeExceptions, $schoolYear, $semester);
|
||||||
|
|
||||||
|
$lateSlipReprints = DB::table('late_slip_reprints')->whereDate('reprinted_at', $date);
|
||||||
|
$this->applyAcademicFilters($lateSlipReprints, $schoolYear, $semester);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'present' => (clone $base)->where('attendance_status', self::STATUS_PRESENT)->count(),
|
'present' => $this->countRowsByStatus($rows, self::STATUS_PRESENT),
|
||||||
'absent' => (clone $base)->where('attendance_status', self::STATUS_ABSENT)->count(),
|
'absent' => $this->countRowsByStatus($rows, self::STATUS_ABSENT),
|
||||||
'late' => (clone $base)->where('attendance_status', self::STATUS_LATE)->count(),
|
'late' => $this->countRowsByStatus($rows, self::STATUS_LATE),
|
||||||
'early_dismissal' => (clone $base)->where('attendance_status', self::STATUS_EARLY_DISMISSAL)->count(),
|
'early_dismissal' => $this->countRowsByStatus($rows, self::STATUS_EARLY_DISMISSAL),
|
||||||
'not_reported' => (clone $base)->where('report_status', 'not_reported')->count(),
|
'not_reported' => $this->countRowsByReportStatus($rows, 'not_reported'),
|
||||||
'follow_up_required' => (clone $base)->where('follow_up_required', true)->where('follow_up_completed', false)->count(),
|
'follow_up_required' => $this->countRowsRequiringFollowUp($rows),
|
||||||
'badge_exceptions' => DB::table('badge_exceptions')->whereDate('exception_date', $date)->count(),
|
'badge_exceptions' => $badgeExceptions->count(),
|
||||||
'late_slip_reprints' => DB::table('late_slip_reprints')->whereDate('reprinted_at', $date)->count(),
|
'late_slip_reprints' => $lateSlipReprints->count(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function availableFilters(): array
|
private function availableFilters(array $rows = []): array
|
||||||
{
|
{
|
||||||
|
$schoolYears = array_values(array_unique(array_filter(array_map(
|
||||||
|
static fn (array $row): string => trim((string) ($row['school_year'] ?? '')),
|
||||||
|
$rows,
|
||||||
|
))));
|
||||||
|
rsort($schoolYears);
|
||||||
|
|
||||||
|
$currentYear = $this->currentSchoolYear();
|
||||||
|
if ($currentYear !== '' && ! in_array($currentYear, $schoolYears, true)) {
|
||||||
|
array_unshift($schoolYears, $currentYear);
|
||||||
|
}
|
||||||
|
|
||||||
|
$semesters = array_values(array_unique(array_filter(array_map(
|
||||||
|
static fn (array $row): string => trim((string) ($row['semester'] ?? '')),
|
||||||
|
$rows,
|
||||||
|
))));
|
||||||
|
$currentSemester = $this->currentSemester();
|
||||||
|
if ($currentSemester !== '' && ! in_array($currentSemester, $semesters, true)) {
|
||||||
|
array_unshift($semesters, $currentSemester);
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'attendance_status' => [self::STATUS_PRESENT, self::STATUS_ABSENT, self::STATUS_ABSENT_PENDING, self::STATUS_LATE, self::STATUS_LATE_WITHOUT_SLIP, self::STATUS_EARLY_DISMISSAL],
|
'attendance_status' => [self::STATUS_PRESENT, self::STATUS_ABSENT, self::STATUS_ABSENT_PENDING, self::STATUS_LATE, self::STATUS_LATE_WITHOUT_SLIP, self::STATUS_EARLY_DISMISSAL],
|
||||||
'report_status' => ['reported', 'not_reported', 'pending_clarification'],
|
'report_status' => ['reported', 'not_reported', 'pending_clarification'],
|
||||||
'risk_level' => ['low', 'medium', 'high', 'very_high', 'critical', 'severe'],
|
'risk_level' => ['low', 'medium', 'high', 'very_high', 'critical', 'severe'],
|
||||||
'person_type' => ['student', 'staff', 'contractor', 'visitor'],
|
'person_type' => ['student', 'staff', 'contractor', 'visitor'],
|
||||||
|
'school_years' => $schoolYears,
|
||||||
|
'semesters' => $semesters,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,6 +531,91 @@ class AttendanceManagementService
|
|||||||
return Carbon::parse($date->toDateString().' '.$configured, $date->timezone);
|
return Carbon::parse($date->toDateString().' '.$configured, $date->timezone);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function hydrateAcademicContext(array $row): array
|
||||||
|
{
|
||||||
|
$date = trim((string) ($row['event_date'] ?? ''));
|
||||||
|
if ($date === '') {
|
||||||
|
return $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
$context = $this->academicContextForDate(Carbon::parse($date));
|
||||||
|
if (trim((string) ($row['school_year'] ?? '')) === '') {
|
||||||
|
$row['school_year'] = $context['school_year'];
|
||||||
|
}
|
||||||
|
if (trim((string) ($row['semester'] ?? '')) === '') {
|
||||||
|
$row['semester'] = $context['semester'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function academicContextForDate(Carbon $date): array
|
||||||
|
{
|
||||||
|
$derivedYear = $this->deriveSchoolYearForDate($date);
|
||||||
|
$derivedSemester = $this->deriveSemesterForDate($date);
|
||||||
|
|
||||||
|
if ($date->isSameDay(now())) {
|
||||||
|
return [
|
||||||
|
'school_year' => $this->currentSchoolYear() ?: $derivedYear,
|
||||||
|
'semester' => $this->currentSemester() ?: $derivedSemester,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'school_year' => $derivedYear ?: $this->currentSchoolYear(),
|
||||||
|
'semester' => $derivedSemester ?: $this->currentSemester(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function deriveSchoolYearForDate(Carbon $date): string
|
||||||
|
{
|
||||||
|
$startYear = $date->month >= 8 ? $date->year : ($date->year - 1);
|
||||||
|
|
||||||
|
return sprintf('%d-%d', $startYear, $startYear + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function deriveSemesterForDate(Carbon $date): string
|
||||||
|
{
|
||||||
|
return ($date->month >= 8 || $date->month === 1) ? 'Fall' : 'Spring';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function currentSchoolYear(): string
|
||||||
|
{
|
||||||
|
return trim((string) (Configuration::getConfigValueByKey('school_year') ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function currentSemester(): string
|
||||||
|
{
|
||||||
|
return $this->normalizeSemester(Configuration::getConfig('semester'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function applyAcademicFilters($query, string $schoolYear = '', string $semester = ''): void
|
||||||
|
{
|
||||||
|
if ($schoolYear !== '') {
|
||||||
|
$query->where('school_year', $schoolYear);
|
||||||
|
}
|
||||||
|
if ($semester !== '') {
|
||||||
|
$query->where('semester', $semester);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function countRowsByStatus(array $rows, string $status): int
|
||||||
|
{
|
||||||
|
return count(array_filter($rows, static fn (array $row): bool => (string) ($row['attendance_status'] ?? '') === $status));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function countRowsByReportStatus(array $rows, string $status): int
|
||||||
|
{
|
||||||
|
return count(array_filter($rows, static fn (array $row): bool => (string) ($row['report_status'] ?? '') === $status));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function countRowsRequiringFollowUp(array $rows): int
|
||||||
|
{
|
||||||
|
return count(array_filter($rows, static function (array $row): bool {
|
||||||
|
return (bool) ($row['follow_up_required'] ?? false) && ! (bool) ($row['follow_up_completed'] ?? false);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
private function dateTime($value): Carbon { return $value instanceof Carbon ? $value : Carbon::parse((string) $value); }
|
private function dateTime($value): Carbon { return $value instanceof Carbon ? $value : Carbon::parse((string) $value); }
|
||||||
private function dateOnly($value): string { return Carbon::parse((string) $value)->toDateString(); }
|
private function dateOnly($value): string { return Carbon::parse((string) $value)->toDateString(); }
|
||||||
|
|
||||||
@@ -471,6 +625,19 @@ class AttendanceManagementService
|
|||||||
return in_array($v, ['reported', 'not_reported', 'pending_clarification'], true) ? $v : 'pending_clarification';
|
return in_array($v, ['reported', 'not_reported', 'pending_clarification'], true) ? $v : 'pending_clarification';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function normalizeSemester($value): string
|
||||||
|
{
|
||||||
|
$semester = strtolower(trim((string) $value));
|
||||||
|
if ($semester === 'fall') {
|
||||||
|
return 'Fall';
|
||||||
|
}
|
||||||
|
if ($semester === 'spring') {
|
||||||
|
return 'Spring';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
private function combinationCode(int $abs, int $late, int $ed, int $badge): string
|
private function combinationCode(int $abs, int $late, int $ed, int $badge): string
|
||||||
{
|
{
|
||||||
$parts = [];
|
$parts = [];
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\SchoolYears;
|
||||||
|
|
||||||
|
use App\Models\Configuration;
|
||||||
|
use App\Models\SchoolYear;
|
||||||
|
|
||||||
|
class SchoolYearResolver
|
||||||
|
{
|
||||||
|
public function ensureCurrentTracked(): ?SchoolYear
|
||||||
|
{
|
||||||
|
$current = trim((string) (Configuration::getConfig('school_year') ?? ''));
|
||||||
|
if ($current === '') {
|
||||||
|
return SchoolYear::query()->where('is_current', true)->orderByDesc('id')->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
$existing = SchoolYear::query()->where('name', $current)->first();
|
||||||
|
if ($existing) {
|
||||||
|
if ($existing->status !== SchoolYear::STATUS_ACTIVE || !$existing->is_current) {
|
||||||
|
SchoolYear::query()->where('id', '!=', $existing->id)->where('is_current', true)->update([
|
||||||
|
'is_current' => false,
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
$existing->status = SchoolYear::STATUS_ACTIVE;
|
||||||
|
$existing->is_current = true;
|
||||||
|
$existing->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $existing->refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
[$startDate, $endDate] = $this->inferDatesFromName($current);
|
||||||
|
SchoolYear::query()->where('is_current', true)->update([
|
||||||
|
'is_current' => false,
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return SchoolYear::query()->create([
|
||||||
|
'name' => $current,
|
||||||
|
'start_date' => $startDate,
|
||||||
|
'end_date' => $endDate,
|
||||||
|
'status' => SchoolYear::STATUS_ACTIVE,
|
||||||
|
'is_current' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function inferDatesFromName(string $schoolYear): array
|
||||||
|
{
|
||||||
|
if (preg_match('/^(\\d{4})-(\\d{4})$/', trim($schoolYear), $matches) === 1) {
|
||||||
|
return [
|
||||||
|
sprintf('%s-09-01', $matches[1]),
|
||||||
|
sprintf('%s-06-30', $matches[2]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$year = (int) date('Y');
|
||||||
|
|
||||||
|
return [
|
||||||
|
sprintf('%d-09-01', $year),
|
||||||
|
sprintf('%d-06-30', $year + 1),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function suggestNextName(string $schoolYear): ?string
|
||||||
|
{
|
||||||
|
if (preg_match('/^(\\d{4})-(\\d{4})$/', trim($schoolYear), $matches) !== 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf('%d-%d', (int) $matches[1] + 1, (int) $matches[2] + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\SchoolYears;
|
||||||
|
|
||||||
|
use App\Models\SchoolYear;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class SchoolYearWriteGuard
|
||||||
|
{
|
||||||
|
public function assertEditable(array $schoolYears): void
|
||||||
|
{
|
||||||
|
if (!Schema::hasTable('school_years')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$years = array_values(array_unique(array_filter(array_map(
|
||||||
|
static fn ($year) => is_string($year) ? trim($year) : null,
|
||||||
|
$schoolYears
|
||||||
|
))));
|
||||||
|
|
||||||
|
if ($years === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$statuses = SchoolYear::query()
|
||||||
|
->whereIn('name', $years)
|
||||||
|
->get(['name', 'status'])
|
||||||
|
->keyBy('name');
|
||||||
|
|
||||||
|
foreach ($years as $year) {
|
||||||
|
$row = $statuses->get($year);
|
||||||
|
if ($row && $row->status !== SchoolYear::STATUS_ACTIVE) {
|
||||||
|
throw new RuntimeException(sprintf(
|
||||||
|
'School year %s is %s and read-only.',
|
||||||
|
$year,
|
||||||
|
$row->status
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
'parent.progress' => \App\Http\Middleware\EnsureParentProgressAccess::class,
|
'parent.progress' => \App\Http\Middleware\EnsureParentProgressAccess::class,
|
||||||
'teacher.progress' => \App\Http\Middleware\EnsureClassProgressTeacherPortalAccess::class,
|
'teacher.progress' => \App\Http\Middleware\EnsureClassProgressTeacherPortalAccess::class,
|
||||||
'class_progress.teacher' => \App\Http\Middleware\EnsureClassProgressTeacherPortalAccess::class,
|
'class_progress.teacher' => \App\Http\Middleware\EnsureClassProgressTeacherPortalAccess::class,
|
||||||
|
'school_year.editable' => \App\Http\Middleware\EnsureSchoolYearEditable::class,
|
||||||
'print_requests.teacher' => \App\Http\Middleware\EnsurePrintRequestsTeacherAccess::class,
|
'print_requests.teacher' => \App\Http\Middleware\EnsurePrintRequestsTeacherAccess::class,
|
||||||
'print_requests.admin' => \App\Http\Middleware\EnsurePrintRequestsAdminAccess::class,
|
'print_requests.admin' => \App\Http\Middleware\EnsurePrintRequestsAdminAccess::class,
|
||||||
'badge_scan.logs' => \App\Http\Middleware\EnsureBadgeScanLogsAccess::class,
|
'badge_scan.logs' => \App\Http\Middleware\EnsureBadgeScanLogsAccess::class,
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
if (!Schema::hasTable('school_years')) {
|
||||||
|
Schema::create('school_years', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name', 50)->unique();
|
||||||
|
$table->date('start_date');
|
||||||
|
$table->date('end_date');
|
||||||
|
$table->string('status', 20)->default('draft')->index();
|
||||||
|
$table->boolean('is_current')->default(false)->index();
|
||||||
|
$table->timestamp('closed_at')->nullable();
|
||||||
|
$table->unsignedBigInteger('closed_by')->nullable()->index();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Schema::hasTable('parent_accounts')) {
|
||||||
|
Schema::create('parent_accounts', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->unsignedBigInteger('parent_id')->index();
|
||||||
|
$table->string('school_year', 50)->index();
|
||||||
|
$table->decimal('opening_balance', 12, 2)->default(0);
|
||||||
|
$table->decimal('current_balance', 12, 2)->default(0);
|
||||||
|
$table->timestamps();
|
||||||
|
$table->unique(['parent_id', 'school_year'], 'parent_accounts_parent_year_unique');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Schema::hasTable('parent_balance_transfers')) {
|
||||||
|
Schema::create('parent_balance_transfers', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->unsignedBigInteger('parent_id')->index();
|
||||||
|
$table->string('from_school_year', 50)->index();
|
||||||
|
$table->string('to_school_year', 50)->index();
|
||||||
|
$table->decimal('amount', 12, 2)->default(0);
|
||||||
|
$table->string('status', 30)->default('pending')->index();
|
||||||
|
$table->json('source_summary_json')->nullable();
|
||||||
|
$table->unsignedBigInteger('new_invoice_id')->nullable()->index();
|
||||||
|
$table->unsignedBigInteger('created_by')->nullable()->index();
|
||||||
|
$table->timestamps();
|
||||||
|
$table->unique(['parent_id', 'from_school_year', 'to_school_year'], 'parent_balance_transfers_parent_year_unique');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Schema::hasTable('audit_logs')) {
|
||||||
|
Schema::create('audit_logs', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->unsignedBigInteger('user_id')->nullable()->index();
|
||||||
|
$table->string('action', 100)->index();
|
||||||
|
$table->string('table_name', 100)->nullable()->index();
|
||||||
|
$table->unsignedBigInteger('record_id')->nullable()->index();
|
||||||
|
$table->json('old_value')->nullable();
|
||||||
|
$table->json('new_value')->nullable();
|
||||||
|
$table->json('metadata')->nullable();
|
||||||
|
$table->timestamp('created_at')->nullable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Schema::hasTable('invoices') && !Schema::hasColumn('invoices', 'balance_transfer_id')) {
|
||||||
|
Schema::table('invoices', function (Blueprint $table) {
|
||||||
|
$table->unsignedBigInteger('balance_transfer_id')->nullable()->index()->after('semester');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->seedCurrentSchoolYear();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
if (Schema::hasTable('invoices') && Schema::hasColumn('invoices', 'balance_transfer_id')) {
|
||||||
|
Schema::table('invoices', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('balance_transfer_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Schema::dropIfExists('audit_logs');
|
||||||
|
Schema::dropIfExists('parent_balance_transfers');
|
||||||
|
Schema::dropIfExists('parent_accounts');
|
||||||
|
Schema::dropIfExists('school_years');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seedCurrentSchoolYear(): void
|
||||||
|
{
|
||||||
|
if (!Schema::hasTable('configuration') || !Schema::hasTable('school_years')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$current = DB::table('configuration')
|
||||||
|
->where('config_key', 'school_year')
|
||||||
|
->orderByDesc('id')
|
||||||
|
->value('config_value');
|
||||||
|
|
||||||
|
$current = is_string($current) ? trim($current) : '';
|
||||||
|
if ($current === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$exists = DB::table('school_years')->where('name', $current)->exists();
|
||||||
|
if ($exists) {
|
||||||
|
DB::table('school_years')->where('name', $current)->update([
|
||||||
|
'status' => 'active',
|
||||||
|
'is_current' => true,
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$startDate, $endDate] = $this->inferDates($current);
|
||||||
|
|
||||||
|
DB::table('school_years')->insert([
|
||||||
|
'name' => $current,
|
||||||
|
'start_date' => $startDate,
|
||||||
|
'end_date' => $endDate,
|
||||||
|
'status' => 'active',
|
||||||
|
'is_current' => true,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function inferDates(string $schoolYear): array
|
||||||
|
{
|
||||||
|
if (preg_match('/^(\\d{4})-(\\d{4})$/', $schoolYear, $matches) === 1) {
|
||||||
|
return [
|
||||||
|
sprintf('%s-09-01', $matches[1]),
|
||||||
|
sprintf('%s-06-30', $matches[2]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$year = (int) date('Y');
|
||||||
|
|
||||||
|
return [
|
||||||
|
sprintf('%d-09-01', $year),
|
||||||
|
sprintf('%d-06-30', $year + 1),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
};
|
||||||
+165
@@ -0,0 +1,165 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
if (Schema::hasTable('attendance_management_events')) {
|
||||||
|
Schema::table('attendance_management_events', function (Blueprint $table) {
|
||||||
|
if (! Schema::hasColumn('attendance_management_events', 'school_year')) {
|
||||||
|
$table->string('school_year', 20)->nullable()->after('event_date')->index();
|
||||||
|
}
|
||||||
|
if (! Schema::hasColumn('attendance_management_events', 'semester')) {
|
||||||
|
$table->string('semester', 20)->nullable()->after('school_year')->index();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
DB::table('attendance_management_events')
|
||||||
|
->select('id', 'event_date', 'school_year', 'semester')
|
||||||
|
->orderBy('id')
|
||||||
|
->chunkById(200, function ($rows): void {
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$schoolYear = trim((string) ($row->school_year ?? ''));
|
||||||
|
$semester = trim((string) ($row->semester ?? ''));
|
||||||
|
if ($schoolYear !== '' && $semester !== '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$date = CarbonImmutable::parse((string) $row->event_date);
|
||||||
|
DB::table('attendance_management_events')
|
||||||
|
->where('id', $row->id)
|
||||||
|
->update([
|
||||||
|
'school_year' => $schoolYear !== '' ? $schoolYear : $this->deriveSchoolYearForDate($date),
|
||||||
|
'semester' => $semester !== '' ? $semester : $this->deriveSemesterForDate($date),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Schema::hasTable('badge_exceptions')) {
|
||||||
|
Schema::table('badge_exceptions', function (Blueprint $table) {
|
||||||
|
if (! Schema::hasColumn('badge_exceptions', 'school_year')) {
|
||||||
|
$table->string('school_year', 20)->nullable()->after('exception_date')->index();
|
||||||
|
}
|
||||||
|
if (! Schema::hasColumn('badge_exceptions', 'semester')) {
|
||||||
|
$table->string('semester', 20)->nullable()->after('school_year')->index();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
DB::table('badge_exceptions')
|
||||||
|
->select('id', 'exception_date', 'school_year', 'semester')
|
||||||
|
->orderBy('id')
|
||||||
|
->chunkById(200, function ($rows): void {
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$schoolYear = trim((string) ($row->school_year ?? ''));
|
||||||
|
$semester = trim((string) ($row->semester ?? ''));
|
||||||
|
if ($schoolYear !== '' && $semester !== '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$date = CarbonImmutable::parse((string) $row->exception_date);
|
||||||
|
DB::table('badge_exceptions')
|
||||||
|
->where('id', $row->id)
|
||||||
|
->update([
|
||||||
|
'school_year' => $schoolYear !== '' ? $schoolYear : $this->deriveSchoolYearForDate($date),
|
||||||
|
'semester' => $semester !== '' ? $semester : $this->deriveSemesterForDate($date),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Schema::hasTable('late_slip_reprints')) {
|
||||||
|
Schema::table('late_slip_reprints', function (Blueprint $table) {
|
||||||
|
if (! Schema::hasColumn('late_slip_reprints', 'school_year')) {
|
||||||
|
$table->string('school_year', 20)->nullable()->after('reprinted_at')->index();
|
||||||
|
}
|
||||||
|
if (! Schema::hasColumn('late_slip_reprints', 'semester')) {
|
||||||
|
$table->string('semester', 20)->nullable()->after('school_year')->index();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
DB::table('late_slip_reprints')
|
||||||
|
->leftJoin('attendance_management_events as ame', 'ame.id', '=', 'late_slip_reprints.attendance_management_event_id')
|
||||||
|
->select(
|
||||||
|
'late_slip_reprints.id',
|
||||||
|
'late_slip_reprints.reprinted_at',
|
||||||
|
'late_slip_reprints.school_year',
|
||||||
|
'late_slip_reprints.semester',
|
||||||
|
'ame.school_year as event_school_year',
|
||||||
|
'ame.semester as event_semester',
|
||||||
|
)
|
||||||
|
->orderBy('late_slip_reprints.id')
|
||||||
|
->chunkById(200, function ($rows): void {
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$schoolYear = trim((string) ($row->school_year ?? ''));
|
||||||
|
$semester = trim((string) ($row->semester ?? ''));
|
||||||
|
if ($schoolYear !== '' && $semester !== '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$date = CarbonImmutable::parse((string) $row->reprinted_at);
|
||||||
|
DB::table('late_slip_reprints')
|
||||||
|
->where('id', $row->id)
|
||||||
|
->update([
|
||||||
|
'school_year' => $schoolYear !== '' ? $schoolYear : (trim((string) ($row->event_school_year ?? '')) ?: $this->deriveSchoolYearForDate($date)),
|
||||||
|
'semester' => $semester !== '' ? $semester : (trim((string) ($row->event_semester ?? '')) ?: $this->deriveSemesterForDate($date)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}, 'late_slip_reprints.id', 'id');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
if (Schema::hasTable('late_slip_reprints')) {
|
||||||
|
Schema::table('late_slip_reprints', function (Blueprint $table) {
|
||||||
|
if (Schema::hasColumn('late_slip_reprints', 'semester')) {
|
||||||
|
$table->dropColumn('semester');
|
||||||
|
}
|
||||||
|
if (Schema::hasColumn('late_slip_reprints', 'school_year')) {
|
||||||
|
$table->dropColumn('school_year');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Schema::hasTable('badge_exceptions')) {
|
||||||
|
Schema::table('badge_exceptions', function (Blueprint $table) {
|
||||||
|
if (Schema::hasColumn('badge_exceptions', 'semester')) {
|
||||||
|
$table->dropColumn('semester');
|
||||||
|
}
|
||||||
|
if (Schema::hasColumn('badge_exceptions', 'school_year')) {
|
||||||
|
$table->dropColumn('school_year');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Schema::hasTable('attendance_management_events')) {
|
||||||
|
Schema::table('attendance_management_events', function (Blueprint $table) {
|
||||||
|
if (Schema::hasColumn('attendance_management_events', 'semester')) {
|
||||||
|
$table->dropColumn('semester');
|
||||||
|
}
|
||||||
|
if (Schema::hasColumn('attendance_management_events', 'school_year')) {
|
||||||
|
$table->dropColumn('school_year');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function deriveSchoolYearForDate(CarbonImmutable $date): string
|
||||||
|
{
|
||||||
|
$startYear = $date->month >= 8 ? $date->year : ($date->year - 1);
|
||||||
|
|
||||||
|
return sprintf('%d-%d', $startYear, $startYear + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function deriveSemesterForDate(CarbonImmutable $date): string
|
||||||
|
{
|
||||||
|
return ($date->month >= 8 || $date->month === 1) ? 'Fall' : 'Spring';
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
http://localhost:5173/app/administrator/attendance/badge-scans
|
||||||
|
http://localhost:5173/app/administrator/sections/auto-distribute
|
||||||
|
http://localhost:5173/app/administrator/sections/promotion-totals
|
||||||
|
http://localhost:5173/app/administrator/parent-email-extractor
|
||||||
|
http://localhost:5173/app/administrator/parent_profiles
|
||||||
|
http://localhost:5173/app/administrator/student_profiles
|
||||||
|
http://localhost:5173/app/whatsapp?school_year=2025-2026
|
||||||
|
http://localhost:5173/app/competition-winners
|
||||||
|
http://localhost:5173/app/admin/progress
|
||||||
|
http://localhost:5173/app/administrator/subject-curriculum
|
||||||
|
http://localhost:5173/app/administrator/teacher-submissions
|
||||||
|
http://localhost:5173/app/administrator/calendar
|
||||||
|
http://localhost:5173/app/administrator/events?school_year=2025-2026
|
||||||
|
http://localhost:5173/app/administrator/exam-drafts
|
||||||
|
http://localhost:5173/app/administrator/family
|
||||||
|
http://localhost:5173/app/families/import-legacy
|
||||||
|
http://localhost:5173/app/inventory/book
|
||||||
|
http://localhost:5173/app/inventory
|
||||||
|
http://localhost:5173/app/inventory/kitchen
|
||||||
|
http://localhost:5173/app/inventory/office
|
||||||
|
http://localhost:5173/app/inventory/summary-all
|
||||||
|
http://localhost:5173/app/inventory/movements
|
||||||
|
http://localhost:5173/app/administrator/notifications_alerts
|
||||||
|
http://localhost:5173/app/administrator/certificates?school_year=2025-2026
|
||||||
|
http://localhost:5173/app/administrator/class-prep
|
||||||
|
http://localhost:5173/app/admin/print-requests
|
||||||
|
http://localhost:5173/app/administrator/printables/report-cards
|
||||||
|
http://localhost:5173/app/grading/decisions?school_year=2025-2026
|
||||||
|
http://localhost:5173/app/grading/below-60/decisions?school_year=2025-2026
|
||||||
|
http://localhost:5173/app/grading?school_year=2025-2026&semester=Fall
|
||||||
|
http://localhost:5173/app/staff
|
||||||
|
http://localhost:5173/app/administrator/teacher_class_assignment
|
||||||
|
http://localhost:5173/app/administrator/emergency-contacts
|
||||||
|
http://localhost:5173/app/administrator/enroll-withdraw/enrollment-withdrawal
|
||||||
|
http://localhost:5173/app/grading/placement
|
||||||
|
http://localhost:5173/app/administrator/removed_students
|
||||||
|
http://localhost:5173/app/administrator/calendar_view?school_year=2025-2026
|
||||||
|
http://localhost:5173/app/administrator/student_class_assignment
|
||||||
|
http://localhost:5173/app/administrator/student_profiles
|
||||||
|
http://localhost:5173/app/administrator/absence
|
||||||
|
http://localhost:5173/app/administrator/ip_bans
|
||||||
|
http://localhost:5173/app/user/user-list
|
||||||
File diff suppressed because it is too large
Load Diff
+36
-18
@@ -126,6 +126,7 @@ use App\Http\Controllers\Api\Documentation\DocsCatalogController;
|
|||||||
use App\Http\Controllers\Api\Parents\AuthorizedUserInviteController;
|
use App\Http\Controllers\Api\Parents\AuthorizedUserInviteController;
|
||||||
use App\Http\Controllers\Api\Administrator\AdministratorPromotionController;
|
use App\Http\Controllers\Api\Administrator\AdministratorPromotionController;
|
||||||
use App\Http\Controllers\Api\Parents\ParentPromotionController;
|
use App\Http\Controllers\Api\Parents\ParentPromotionController;
|
||||||
|
use App\Http\Controllers\Api\SchoolYears\SchoolYearController;
|
||||||
use App\Http\Controllers\Api\Public\PublicWinnersController;
|
use App\Http\Controllers\Api\Public\PublicWinnersController;
|
||||||
use App\Http\Controllers\Api\ScannerController;
|
use App\Http\Controllers\Api\ScannerController;
|
||||||
use App\Http\Controllers\Api\Staff\TimeOffNotificationController;
|
use App\Http\Controllers\Api\Staff\TimeOffNotificationController;
|
||||||
@@ -154,6 +155,10 @@ Route::middleware('auth.docs')->group(function () {
|
|||||||
|
|
||||||
Route::get('docs/public', [ApiDocsAdminController::class, 'public'])->name('api-docs.public');
|
Route::get('docs/public', [ApiDocsAdminController::class, 'public'])->name('api-docs.public');
|
||||||
|
|
||||||
|
// Legacy email extractor endpoints still used by the SPA parity page.
|
||||||
|
Route::middleware('auth:api')->get('emails', [EmailExtractorController::class, 'emails']);
|
||||||
|
Route::middleware('auth:api')->post('compare', [EmailExtractorController::class, 'compare']);
|
||||||
|
|
||||||
Route::get('access_denied', [AccessDeniedController::class, 'accessDenied'])->name('access_denied');
|
Route::get('access_denied', [AccessDeniedController::class, 'accessDenied'])->name('access_denied');
|
||||||
|
|
||||||
Route::get('certificates/verify/{token}', [CertificateController::class, 'verify'])
|
Route::get('certificates/verify/{token}', [CertificateController::class, 'verify'])
|
||||||
@@ -302,13 +307,13 @@ Route::prefix('v1')->group(function () {
|
|||||||
->name('api.v1.administrator.attendance-templates.bootstrap');
|
->name('api.v1.administrator.attendance-templates.bootstrap');
|
||||||
|
|
||||||
Route::get('absence', [AdministratorAbsenceController::class, 'index']);
|
Route::get('absence', [AdministratorAbsenceController::class, 'index']);
|
||||||
Route::post('absence', [AdministratorAbsenceController::class, 'store']);
|
Route::post('absence', [AdministratorAbsenceController::class, 'store'])->middleware('school_year.editable');
|
||||||
|
|
||||||
Route::get('dashboard/metrics', [AdministratorDashboardController::class, 'metrics']);
|
Route::get('dashboard/metrics', [AdministratorDashboardController::class, 'metrics']);
|
||||||
Route::get('dashboard/user-search', [AdministratorDashboardController::class, 'userSearch']);
|
Route::get('dashboard/user-search', [AdministratorDashboardController::class, 'userSearch']);
|
||||||
|
|
||||||
Route::get('teacher-submissions', [AdministratorTeacherSubmissionController::class, 'index']);
|
Route::get('teacher-submissions', [AdministratorTeacherSubmissionController::class, 'index']);
|
||||||
Route::post('teacher-submissions/notify', [AdministratorTeacherSubmissionController::class, 'notify']);
|
Route::post('teacher-submissions/notify', [AdministratorTeacherSubmissionController::class, 'notify'])->middleware('school_year.editable');
|
||||||
|
|
||||||
Route::prefix('trophy')->group(function () {
|
Route::prefix('trophy')->group(function () {
|
||||||
Route::get('/', [AdministratorTrophyController::class, 'index']);
|
Route::get('/', [AdministratorTrophyController::class, 'index']);
|
||||||
@@ -317,22 +322,22 @@ Route::prefix('v1')->group(function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
Route::get('teacher-class/assignments', [TeacherClassAssignmentController::class, 'index']);
|
Route::get('teacher-class/assignments', [TeacherClassAssignmentController::class, 'index']);
|
||||||
Route::post('teacher-class/assign', [TeacherClassAssignmentController::class, 'store']);
|
Route::post('teacher-class/assign', [TeacherClassAssignmentController::class, 'store'])->middleware('school_year.editable');
|
||||||
Route::post('teacher-class/delete', [TeacherClassAssignmentController::class, 'destroy']);
|
Route::post('teacher-class/delete', [TeacherClassAssignmentController::class, 'destroy'])->middleware('school_year.editable');
|
||||||
|
|
||||||
Route::get('notifications/alerts', [AdministratorNotificationController::class, 'alerts']);
|
Route::get('notifications/alerts', [AdministratorNotificationController::class, 'alerts']);
|
||||||
Route::post('notifications/alerts', [AdministratorNotificationController::class, 'saveAlerts']);
|
Route::post('notifications/alerts', [AdministratorNotificationController::class, 'saveAlerts'])->middleware('school_year.editable');
|
||||||
Route::get('notifications/print-recipients', [AdministratorNotificationController::class, 'printRecipients']);
|
Route::get('notifications/print-recipients', [AdministratorNotificationController::class, 'printRecipients']);
|
||||||
Route::post('notifications/print-recipients', [AdministratorNotificationController::class, 'savePrintRecipients']);
|
Route::post('notifications/print-recipients', [AdministratorNotificationController::class, 'savePrintRecipients'])->middleware('school_year.editable');
|
||||||
|
|
||||||
Route::get('enrollment-withdrawal', [AdministratorEnrollmentController::class, 'index']);
|
Route::get('enrollment-withdrawal', [AdministratorEnrollmentController::class, 'index']);
|
||||||
Route::get('enrollment-withdrawal/new-students', [AdministratorEnrollmentController::class, 'newStudents']);
|
Route::get('enrollment-withdrawal/new-students', [AdministratorEnrollmentController::class, 'newStudents']);
|
||||||
Route::post('enrollment-withdrawal/update-statuses', [AdministratorEnrollmentController::class, 'updateStatuses']);
|
Route::post('enrollment-withdrawal/update-statuses', [AdministratorEnrollmentController::class, 'updateStatuses'])->middleware('school_year.editable');
|
||||||
Route::get('enroll-withdrawal', [AdministratorEnrollmentController::class, 'index']);
|
Route::get('enroll-withdrawal', [AdministratorEnrollmentController::class, 'index']);
|
||||||
Route::get('enroll-withdrawal/new-students', [AdministratorEnrollmentController::class, 'newStudents']);
|
Route::get('enroll-withdrawal/new-students', [AdministratorEnrollmentController::class, 'newStudents']);
|
||||||
Route::post('enroll-withdrawal/update-statuses', [AdministratorEnrollmentController::class, 'updateStatuses']);
|
Route::post('enroll-withdrawal/update-statuses', [AdministratorEnrollmentController::class, 'updateStatuses'])->middleware('school_year.editable');
|
||||||
|
|
||||||
Route::prefix('promotions')->group(function () {
|
Route::prefix('promotions')->middleware('school_year.editable')->group(function () {
|
||||||
Route::get('/', [AdministratorPromotionController::class, 'index']);
|
Route::get('/', [AdministratorPromotionController::class, 'index']);
|
||||||
Route::get('summary', [AdministratorPromotionController::class, 'summary']);
|
Route::get('summary', [AdministratorPromotionController::class, 'summary']);
|
||||||
Route::post('evaluate', [AdministratorPromotionController::class, 'evaluate']);
|
Route::post('evaluate', [AdministratorPromotionController::class, 'evaluate']);
|
||||||
@@ -392,9 +397,9 @@ Route::prefix('v1')->group(function () {
|
|||||||
Route::get('/', [ExpenseController::class, 'index']);
|
Route::get('/', [ExpenseController::class, 'index']);
|
||||||
Route::get('options', [ExpenseController::class, 'options']);
|
Route::get('options', [ExpenseController::class, 'options']);
|
||||||
Route::get('{id}', [ExpenseController::class, 'show']);
|
Route::get('{id}', [ExpenseController::class, 'show']);
|
||||||
Route::post('/', [ExpenseController::class, 'store']);
|
Route::post('/', [ExpenseController::class, 'store'])->middleware('school_year.editable');
|
||||||
Route::put('{id}', [ExpenseController::class, 'update']);
|
Route::put('{id}', [ExpenseController::class, 'update'])->middleware('school_year.editable');
|
||||||
Route::post('{id}/status', [ExpenseController::class, 'updateStatus']);
|
Route::post('{id}/status', [ExpenseController::class, 'updateStatus'])->middleware('school_year.editable');
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::prefix('reports')->group(function () {
|
Route::prefix('reports')->group(function () {
|
||||||
@@ -471,7 +476,20 @@ Route::prefix('v1')->group(function () {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::middleware('auth:api')->prefix('attendance')->group(function () {
|
Route::middleware(['auth:api', 'admin.access'])->prefix('school-years')->group(function () {
|
||||||
|
Route::get('/', [SchoolYearController::class, 'index']);
|
||||||
|
Route::post('/', [SchoolYearController::class, 'store']);
|
||||||
|
Route::patch('{schoolYear}', [SchoolYearController::class, 'update'])->whereNumber('schoolYear');
|
||||||
|
Route::get('{schoolYear}/summary', [SchoolYearController::class, 'summary'])->whereNumber('schoolYear');
|
||||||
|
Route::post('{schoolYear}/validate-close', [SchoolYearController::class, 'validateClose'])->whereNumber('schoolYear');
|
||||||
|
Route::post('{schoolYear}/preview-close', [SchoolYearController::class, 'previewClose'])->whereNumber('schoolYear');
|
||||||
|
Route::post('{schoolYear}/close', [SchoolYearController::class, 'close'])->whereNumber('schoolYear');
|
||||||
|
Route::post('{schoolYear}/reopen', [SchoolYearController::class, 'reopen'])->whereNumber('schoolYear');
|
||||||
|
Route::get('{schoolYear}/parent-balances', [SchoolYearController::class, 'parentBalances'])->whereNumber('schoolYear');
|
||||||
|
Route::get('{schoolYear}/promotion-preview', [SchoolYearController::class, 'promotionPreview'])->whereNumber('schoolYear');
|
||||||
|
});
|
||||||
|
|
||||||
|
Route::middleware(['auth:api', 'school_year.editable'])->prefix('attendance')->group(function () {
|
||||||
// Teacher
|
// Teacher
|
||||||
Route::get('/teacher/grid', [TeacherAttendanceApiController::class, 'grid']);
|
Route::get('/teacher/grid', [TeacherAttendanceApiController::class, 'grid']);
|
||||||
Route::get('/teacher/form', [TeacherAttendanceApiController::class, 'form']);
|
Route::get('/teacher/form', [TeacherAttendanceApiController::class, 'form']);
|
||||||
@@ -513,7 +531,7 @@ Route::prefix('v1')->group(function () {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::middleware('auth:api')->prefix('attendance-tracking')->group(function () {
|
Route::middleware(['auth:api', 'school_year.editable'])->prefix('attendance-tracking')->group(function () {
|
||||||
Route::get('/pending-violations', [AttendanceTrackingController::class, 'pendingViolations']);
|
Route::get('/pending-violations', [AttendanceTrackingController::class, 'pendingViolations']);
|
||||||
Route::get('/notified-violations', [AttendanceTrackingController::class, 'notifiedViolations']);
|
Route::get('/notified-violations', [AttendanceTrackingController::class, 'notifiedViolations']);
|
||||||
Route::get('/student-case/{studentId}', [AttendanceTrackingController::class, 'studentCase']);
|
Route::get('/student-case/{studentId}', [AttendanceTrackingController::class, 'studentCase']);
|
||||||
@@ -525,7 +543,7 @@ Route::prefix('v1')->group(function () {
|
|||||||
Route::post('/save-notification-note', [AttendanceTrackingController::class, 'saveNotificationNote']);
|
Route::post('/save-notification-note', [AttendanceTrackingController::class, 'saveNotificationNote']);
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::middleware('auth:api')->prefix('attendance-comment-templates')->group(function () {
|
Route::middleware(['auth:api', 'school_year.editable'])->prefix('attendance-comment-templates')->group(function () {
|
||||||
Route::get('/', [AttendanceCommentTemplateController::class, 'index'])->name('api.v1.attendance-comment-templates.index');
|
Route::get('/', [AttendanceCommentTemplateController::class, 'index'])->name('api.v1.attendance-comment-templates.index');
|
||||||
Route::get('list-data', [AttendanceCommentTemplateController::class, 'listData'])
|
Route::get('list-data', [AttendanceCommentTemplateController::class, 'listData'])
|
||||||
->name('api.v1.attendance-comment-templates.list-data');
|
->name('api.v1.attendance-comment-templates.list-data');
|
||||||
@@ -538,19 +556,19 @@ Route::prefix('v1')->group(function () {
|
|||||||
/*
|
/*
|
||||||
| Legacy attendance template list/save/delete endpoints (auth:api).
|
| Legacy attendance template list/save/delete endpoints (auth:api).
|
||||||
*/
|
*/
|
||||||
Route::middleware('auth:api')->prefix('attendance-templates')->group(function () {
|
Route::middleware(['auth:api', 'school_year.editable'])->prefix('attendance-templates')->group(function () {
|
||||||
Route::get('/', [AttendanceCommentTemplateController::class, 'listData'])->name('api.v1.attendance-templates.legacy');
|
Route::get('/', [AttendanceCommentTemplateController::class, 'listData'])->name('api.v1.attendance-templates.legacy');
|
||||||
Route::post('save', [AttendanceCommentTemplateController::class, 'legacySave']);
|
Route::post('save', [AttendanceCommentTemplateController::class, 'legacySave']);
|
||||||
Route::post('delete', [AttendanceCommentTemplateController::class, 'legacyDelete']);
|
Route::post('delete', [AttendanceCommentTemplateController::class, 'legacyDelete']);
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::middleware('auth:api')->prefix('assignments')->group(function () {
|
Route::middleware(['auth:api', 'school_year.editable'])->prefix('assignments')->group(function () {
|
||||||
Route::get('/', [AssignmentApiController::class, 'index']);
|
Route::get('/', [AssignmentApiController::class, 'index']);
|
||||||
Route::post('/', [AssignmentApiController::class, 'store']);
|
Route::post('/', [AssignmentApiController::class, 'store']);
|
||||||
Route::get('/class-assignment-data', [AssignmentApiController::class, 'classAssignmentData']);
|
Route::get('/class-assignment-data', [AssignmentApiController::class, 'classAssignmentData']);
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::middleware('auth:api')->group(function () {
|
Route::middleware(['auth:api', 'school_year.editable'])->group(function () {
|
||||||
// Legacy alias: same handler as GET /api/v1/scores/homework
|
// Legacy alias: same handler as GET /api/v1/scores/homework
|
||||||
Route::get('teacher/homework-list', [HomeworkScoreController::class, 'index']);
|
Route::get('teacher/homework-list', [HomeworkScoreController::class, 'index']);
|
||||||
// Legacy alias: teacher add-homework page and save action
|
// Legacy alias: teacher add-homework page and save action
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
use App\Http\Controllers\Api\Auth\AuthSessionController;
|
use App\Http\Controllers\Api\Auth\AuthSessionController;
|
||||||
use App\Http\Controllers\Api\Auth\SessionTimeoutController;
|
use App\Http\Controllers\Api\Auth\SessionTimeoutController;
|
||||||
use App\Http\Controllers\Api\System\DashboardRedirectController;
|
use App\Http\Controllers\Api\System\DashboardRedirectController;
|
||||||
|
use App\Http\Controllers\Web\SchoolYearAdminPageController;
|
||||||
use App\Http\Controllers\Web\TeacherCalendarPageController;
|
use App\Http\Controllers\Web\TeacherCalendarPageController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
@@ -80,6 +81,27 @@ Route::middleware('web')->group(function () {
|
|||||||
->name('teacher.calendar');
|
->name('teacher.calendar');
|
||||||
Route::middleware('auth')->get('app/teacher/calendar', [TeacherCalendarPageController::class, 'show'])
|
Route::middleware('auth')->get('app/teacher/calendar', [TeacherCalendarPageController::class, 'show'])
|
||||||
->name('app.teacher.calendar');
|
->name('app.teacher.calendar');
|
||||||
|
Route::middleware(['auth', 'admin.access'])->get('administrator/school-years', [SchoolYearAdminPageController::class, 'show'])
|
||||||
|
->name('admin.school-years');
|
||||||
|
Route::middleware(['auth', 'admin.access'])->get('app/administrator/school-years', [SchoolYearAdminPageController::class, 'show'])
|
||||||
|
->name('app.admin.school-years');
|
||||||
|
Route::middleware(['auth', 'admin.access'])->post('administrator/school-years', [SchoolYearAdminPageController::class, 'store'])
|
||||||
|
->name('admin.school-years.store');
|
||||||
|
Route::middleware(['auth', 'admin.access'])->patch('administrator/school-years/{schoolYear}', [SchoolYearAdminPageController::class, 'update'])
|
||||||
|
->whereNumber('schoolYear')
|
||||||
|
->name('admin.school-years.update');
|
||||||
|
Route::middleware(['auth', 'admin.access'])->post('administrator/school-years/{schoolYear}/validate-close', [SchoolYearAdminPageController::class, 'validateClose'])
|
||||||
|
->whereNumber('schoolYear')
|
||||||
|
->name('admin.school-years.validate-close');
|
||||||
|
Route::middleware(['auth', 'admin.access'])->post('administrator/school-years/{schoolYear}/preview-close', [SchoolYearAdminPageController::class, 'previewClose'])
|
||||||
|
->whereNumber('schoolYear')
|
||||||
|
->name('admin.school-years.preview-close');
|
||||||
|
Route::middleware(['auth', 'admin.access'])->post('administrator/school-years/{schoolYear}/close', [SchoolYearAdminPageController::class, 'close'])
|
||||||
|
->whereNumber('schoolYear')
|
||||||
|
->name('admin.school-years.close');
|
||||||
|
Route::middleware(['auth', 'admin.access'])->post('administrator/school-years/{schoolYear}/reopen', [SchoolYearAdminPageController::class, 'reopen'])
|
||||||
|
->whereNumber('schoolYear')
|
||||||
|
->name('admin.school-years.reopen');
|
||||||
Route::middleware('auth')->get('teacher/absence-vacation', function () {
|
Route::middleware('auth')->get('teacher/absence-vacation', function () {
|
||||||
return redirect('/app/teacher/absence-vacation');
|
return redirect('/app/teacher/absence-vacation');
|
||||||
})->name('teacher.absence-vacation');
|
})->name('teacher.absence-vacation');
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace Tests\Feature\Api\Administrator;
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Administrator\TeacherSubmissionNotificationService;
|
use App\Services\Administrator\TeacherSubmissionNotificationService;
|
||||||
use App\Services\Administrator\TeacherSubmissionReportService;
|
use App\Services\Administrator\TeacherSubmissionReportService;
|
||||||
|
use App\Http\Middleware\EnsurePrintRequestsAdminAccess;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Laravel\Sanctum\Sanctum;
|
use Laravel\Sanctum\Sanctum;
|
||||||
use Mockery;
|
use Mockery;
|
||||||
@@ -22,12 +23,14 @@ class AdministratorTeacherSubmissionControllerTest extends TestCase
|
|||||||
|
|
||||||
public function test_can_get_teacher_submission_report(): void
|
public function test_can_get_teacher_submission_report(): void
|
||||||
{
|
{
|
||||||
|
$this->withoutMiddleware(EnsurePrintRequestsAdminAccess::class);
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
Sanctum::actingAs($user);
|
Sanctum::actingAs($user, [], 'api');
|
||||||
|
|
||||||
$mock = Mockery::mock(TeacherSubmissionReportService::class);
|
$mock = Mockery::mock(TeacherSubmissionReportService::class);
|
||||||
$mock->shouldReceive('report')
|
$mock->shouldReceive('report')
|
||||||
->once()
|
->once()
|
||||||
|
->with([])
|
||||||
->andReturn([
|
->andReturn([
|
||||||
'rows' => [],
|
'rows' => [],
|
||||||
'semester' => 'Fall',
|
'semester' => 'Fall',
|
||||||
@@ -55,8 +58,9 @@ class AdministratorTeacherSubmissionControllerTest extends TestCase
|
|||||||
|
|
||||||
public function test_notify_requires_notify_payload(): void
|
public function test_notify_requires_notify_payload(): void
|
||||||
{
|
{
|
||||||
|
$this->withoutMiddleware(EnsurePrintRequestsAdminAccess::class);
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
Sanctum::actingAs($user);
|
Sanctum::actingAs($user, [], 'api');
|
||||||
|
|
||||||
$response = $this->postJson('/api/v1/administrator/teacher-submissions/notify', [
|
$response = $this->postJson('/api/v1/administrator/teacher-submissions/notify', [
|
||||||
'missing_items' => [],
|
'missing_items' => [],
|
||||||
@@ -68,8 +72,9 @@ class AdministratorTeacherSubmissionControllerTest extends TestCase
|
|||||||
|
|
||||||
public function test_notify_calls_service(): void
|
public function test_notify_calls_service(): void
|
||||||
{
|
{
|
||||||
|
$this->withoutMiddleware(EnsurePrintRequestsAdminAccess::class);
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
Sanctum::actingAs($user);
|
Sanctum::actingAs($user, [], 'api');
|
||||||
|
|
||||||
$payload = [
|
$payload = [
|
||||||
'notify' => [
|
'notify' => [
|
||||||
|
|||||||
+96
-15
@@ -32,8 +32,8 @@ class AdministratorTeacherSubmissionNotifyApiTest extends TestCase
|
|||||||
|
|
||||||
public function test_notify_endpoint_requires_notify_payload(): void
|
public function test_notify_endpoint_requires_notify_payload(): void
|
||||||
{
|
{
|
||||||
$admin = User::factory()->create();
|
$admin = $this->seedAdminUser('admin.notify.required@example.com');
|
||||||
Sanctum::actingAs($admin);
|
Sanctum::actingAs($admin, [], 'api');
|
||||||
|
|
||||||
$response = $this->postJson('/api/v1/administrator/teacher-submissions/notify', [
|
$response = $this->postJson('/api/v1/administrator/teacher-submissions/notify', [
|
||||||
'missing_items' => [],
|
'missing_items' => [],
|
||||||
@@ -47,13 +47,8 @@ class AdministratorTeacherSubmissionNotifyApiTest extends TestCase
|
|||||||
{
|
{
|
||||||
Mail::fake();
|
Mail::fake();
|
||||||
|
|
||||||
$admin = User::factory()->create([
|
$admin = $this->seedAdminUser('admin@test.com', 900, 'Admin', 'One');
|
||||||
'id' => 900,
|
Sanctum::actingAs($admin, [], 'api');
|
||||||
'firstname' => 'Admin',
|
|
||||||
'lastname' => 'One',
|
|
||||||
'email' => 'admin@test.com',
|
|
||||||
]);
|
|
||||||
Sanctum::actingAs($admin);
|
|
||||||
|
|
||||||
User::factory()->create([
|
User::factory()->create([
|
||||||
'id' => 100,
|
'id' => 100,
|
||||||
@@ -117,12 +112,8 @@ class AdministratorTeacherSubmissionNotifyApiTest extends TestCase
|
|||||||
{
|
{
|
||||||
Mail::fake();
|
Mail::fake();
|
||||||
|
|
||||||
$admin = User::factory()->create([
|
$admin = $this->seedAdminUser('admin.two@test.com', 901, 'Admin', 'Two');
|
||||||
'id' => 901,
|
Sanctum::actingAs($admin, [], 'api');
|
||||||
'firstname' => 'Admin',
|
|
||||||
'lastname' => 'Two',
|
|
||||||
]);
|
|
||||||
Sanctum::actingAs($admin);
|
|
||||||
|
|
||||||
User::factory()->create([
|
User::factory()->create([
|
||||||
'id' => 101,
|
'id' => 101,
|
||||||
@@ -169,4 +160,94 @@ class AdministratorTeacherSubmissionNotifyApiTest extends TestCase
|
|||||||
'semester' => 'Fall',
|
'semester' => 'Fall',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_notify_endpoint_accepts_flat_teacher_ids_from_spa_payload(): void
|
||||||
|
{
|
||||||
|
Mail::fake();
|
||||||
|
|
||||||
|
$admin = $this->seedAdminUser('admin3@test.com', 902, 'Admin', 'Three');
|
||||||
|
Sanctum::actingAs($admin, [], 'api');
|
||||||
|
|
||||||
|
User::factory()->create([
|
||||||
|
'id' => 102,
|
||||||
|
'firstname' => 'Flat',
|
||||||
|
'lastname' => 'Teacher',
|
||||||
|
'email' => 'flat@test.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('classSection')->insert([
|
||||||
|
[
|
||||||
|
'class_section_id' => 12,
|
||||||
|
'class_section_name' => 'Grade 7A',
|
||||||
|
'class_id' => 7,
|
||||||
|
'semester' => 'Fall',
|
||||||
|
'school_year' => '2025-2026',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('teacher_class')->insert([
|
||||||
|
'class_section_id' => 12,
|
||||||
|
'teacher_id' => 102,
|
||||||
|
'position' => 'main',
|
||||||
|
'school_year' => '2025-2026',
|
||||||
|
'semester' => 'Fall',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'notify' => [102],
|
||||||
|
'missing_items' => [
|
||||||
|
102 => ['midterm scores', 'attendance'],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$response = $this->postJson('/api/v1/administrator/teacher-submissions/notify', $payload);
|
||||||
|
|
||||||
|
$response->assertOk()
|
||||||
|
->assertJson([
|
||||||
|
'sent' => 1,
|
||||||
|
'failed' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('teacher_submission_notification_history', [
|
||||||
|
'teacher_id' => 102,
|
||||||
|
'class_section_id' => 12,
|
||||||
|
'admin_id' => 902,
|
||||||
|
'notification_category' => 'teacher_submissions',
|
||||||
|
'status' => 'sent',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seedAdminUser(string $email, ?int $id = null, string $firstname = 'Admin', string $lastname = 'User'): User
|
||||||
|
{
|
||||||
|
$roleId = DB::table('roles')->insertGetId([
|
||||||
|
'name' => 'administrator',
|
||||||
|
'slug' => 'administrator',
|
||||||
|
'is_active' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$userId = DB::table('users')->insertGetId(array_filter([
|
||||||
|
'id' => $id,
|
||||||
|
'firstname' => $firstname,
|
||||||
|
'lastname' => $lastname,
|
||||||
|
'cellphone' => '5555555555',
|
||||||
|
'email' => $email,
|
||||||
|
'address_street' => '123 Main',
|
||||||
|
'city' => 'City',
|
||||||
|
'state' => 'ST',
|
||||||
|
'zip' => '12345',
|
||||||
|
'accept_school_policy' => 1,
|
||||||
|
'password' => bcrypt('secret'),
|
||||||
|
'user_type' => 'primary',
|
||||||
|
'semester' => 'Fall',
|
||||||
|
'school_year' => '2025-2026',
|
||||||
|
'status' => 'Active',
|
||||||
|
], static fn ($value) => $value !== null));
|
||||||
|
|
||||||
|
DB::table('user_roles')->insert([
|
||||||
|
'user_id' => $userId,
|
||||||
|
'role_id' => $roleId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return User::query()->findOrFail($userId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+86
-10
@@ -4,7 +4,6 @@ namespace Tests\Feature\Api\Administrator;
|
|||||||
|
|
||||||
use App\Models\AttendanceDay;
|
use App\Models\AttendanceDay;
|
||||||
use App\Models\Configuration;
|
use App\Models\Configuration;
|
||||||
use App\Models\ScoreComment;
|
|
||||||
use App\Models\SemesterScore;
|
use App\Models\SemesterScore;
|
||||||
use App\Models\TeacherSubmissionNotificationHistory;
|
use App\Models\TeacherSubmissionNotificationHistory;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
@@ -35,8 +34,8 @@ class AdministratorTeacherSubmissionReportApiTest extends TestCase
|
|||||||
|
|
||||||
public function test_teacher_submission_report_endpoint_returns_rows_summary_and_history(): void
|
public function test_teacher_submission_report_endpoint_returns_rows_summary_and_history(): void
|
||||||
{
|
{
|
||||||
$admin = User::factory()->create(['id' => 900, 'firstname' => 'Admin', 'lastname' => 'One']);
|
$admin = $this->seedAdminUser('admin.report.one@test.com', 900, 'Admin', 'One');
|
||||||
Sanctum::actingAs($admin);
|
Sanctum::actingAs($admin, [], 'api');
|
||||||
|
|
||||||
User::factory()->create([
|
User::factory()->create([
|
||||||
'id' => 100,
|
'id' => 100,
|
||||||
@@ -114,8 +113,7 @@ class AdministratorTeacherSubmissionReportApiTest extends TestCase
|
|||||||
'participation_score' => '9',
|
'participation_score' => '9',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
ScoreComment::unguard();
|
DB::table('score_comments')->insert([
|
||||||
ScoreComment::query()->create([
|
|
||||||
'student_id' => 1,
|
'student_id' => 1,
|
||||||
'class_section_id' => 10,
|
'class_section_id' => 10,
|
||||||
'school_year' => '2025-2026',
|
'school_year' => '2025-2026',
|
||||||
@@ -123,7 +121,7 @@ class AdministratorTeacherSubmissionReportApiTest extends TestCase
|
|||||||
'score_type' => 'midterm',
|
'score_type' => 'midterm',
|
||||||
'comment' => 'Good progress',
|
'comment' => 'Good progress',
|
||||||
]);
|
]);
|
||||||
ScoreComment::query()->create([
|
DB::table('score_comments')->insert([
|
||||||
'student_id' => 1,
|
'student_id' => 1,
|
||||||
'class_section_id' => 10,
|
'class_section_id' => 10,
|
||||||
'school_year' => '2025-2026',
|
'school_year' => '2025-2026',
|
||||||
@@ -199,8 +197,8 @@ class AdministratorTeacherSubmissionReportApiTest extends TestCase
|
|||||||
|
|
||||||
public function test_teacher_submission_report_returns_no_students_status_for_empty_section(): void
|
public function test_teacher_submission_report_returns_no_students_status_for_empty_section(): void
|
||||||
{
|
{
|
||||||
$admin = User::factory()->create();
|
$admin = $this->seedAdminUser('admin.report.empty@test.com');
|
||||||
Sanctum::actingAs($admin);
|
Sanctum::actingAs($admin, [], 'api');
|
||||||
|
|
||||||
User::factory()->create([
|
User::factory()->create([
|
||||||
'id' => 200,
|
'id' => 200,
|
||||||
@@ -246,8 +244,8 @@ class AdministratorTeacherSubmissionReportApiTest extends TestCase
|
|||||||
|
|
||||||
public function test_teacher_submission_report_limits_history_to_three_entries(): void
|
public function test_teacher_submission_report_limits_history_to_three_entries(): void
|
||||||
{
|
{
|
||||||
$admin = User::factory()->create(['id' => 901, 'firstname' => 'Admin', 'lastname' => 'Two']);
|
$admin = $this->seedAdminUser('admin.report.two@test.com', 901, 'Admin', 'Two');
|
||||||
Sanctum::actingAs($admin);
|
Sanctum::actingAs($admin, [], 'api');
|
||||||
|
|
||||||
User::factory()->create([
|
User::factory()->create([
|
||||||
'id' => 300,
|
'id' => 300,
|
||||||
@@ -298,4 +296,82 @@ class AdministratorTeacherSubmissionReportApiTest extends TestCase
|
|||||||
$history = $response->json('notificationHistory.12.300');
|
$history = $response->json('notificationHistory.12.300');
|
||||||
$this->assertCount(3, $history);
|
$this->assertCount(3, $history);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_teacher_submission_report_honors_school_year_and_semester_query_filters(): void
|
||||||
|
{
|
||||||
|
$admin = $this->seedAdminUser('admin.report.filters@test.com');
|
||||||
|
Sanctum::actingAs($admin, [], 'api');
|
||||||
|
|
||||||
|
User::factory()->create([
|
||||||
|
'id' => 400,
|
||||||
|
'firstname' => 'Yearly',
|
||||||
|
'lastname' => 'Teacher',
|
||||||
|
'email' => 'yearly@test.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('classSection')->insert([
|
||||||
|
[
|
||||||
|
'class_section_id' => 20,
|
||||||
|
'class_section_name' => 'Grade 8A',
|
||||||
|
'class_id' => 8,
|
||||||
|
'semester' => 'Spring',
|
||||||
|
'school_year' => '2024-2025',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('teacher_class')->insert([
|
||||||
|
[
|
||||||
|
'class_section_id' => 20,
|
||||||
|
'teacher_id' => 400,
|
||||||
|
'position' => 'main',
|
||||||
|
'school_year' => '2024-2025',
|
||||||
|
'semester' => 'Spring',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->getJson('/api/v1/administrator/teacher-submissions?school_year=2024-2025&semester=Spring');
|
||||||
|
|
||||||
|
$response->assertOk()
|
||||||
|
->assertJson([
|
||||||
|
'semester' => 'Spring',
|
||||||
|
'schoolYear' => '2024-2025',
|
||||||
|
'currentSemester' => 'Fall',
|
||||||
|
'currentSchoolYear' => '2025-2026',
|
||||||
|
])
|
||||||
|
->assertJsonCount(1, 'rows');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seedAdminUser(string $email, ?int $id = null, string $firstname = 'Admin', string $lastname = 'User'): User
|
||||||
|
{
|
||||||
|
$roleId = DB::table('roles')->insertGetId([
|
||||||
|
'name' => 'administrator',
|
||||||
|
'slug' => 'administrator',
|
||||||
|
'is_active' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$userId = DB::table('users')->insertGetId(array_filter([
|
||||||
|
'id' => $id,
|
||||||
|
'firstname' => $firstname,
|
||||||
|
'lastname' => $lastname,
|
||||||
|
'cellphone' => '5555555555',
|
||||||
|
'email' => $email,
|
||||||
|
'address_street' => '123 Main',
|
||||||
|
'city' => 'City',
|
||||||
|
'state' => 'ST',
|
||||||
|
'zip' => '12345',
|
||||||
|
'accept_school_policy' => 1,
|
||||||
|
'password' => bcrypt('secret'),
|
||||||
|
'user_type' => 'primary',
|
||||||
|
'semester' => 'Fall',
|
||||||
|
'school_year' => '2025-2026',
|
||||||
|
'status' => 'Active',
|
||||||
|
], static fn ($value) => $value !== null));
|
||||||
|
|
||||||
|
DB::table('user_roles')->insert([
|
||||||
|
'user_id' => $userId,
|
||||||
|
'role_id' => $roleId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return User::query()->findOrFail($userId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class EmailExtractorControllerTest extends TestCase
|
|||||||
public function test_emails_endpoint_returns_users_and_parents(): void
|
public function test_emails_endpoint_returns_users_and_parents(): void
|
||||||
{
|
{
|
||||||
$user = $this->createUser();
|
$user = $this->createUser();
|
||||||
Sanctum::actingAs($user);
|
Sanctum::actingAs($user, [], 'api');
|
||||||
|
|
||||||
DB::table('parents')->insert([
|
DB::table('parents')->insert([
|
||||||
'secondparent_firstname' => 'Parent',
|
'secondparent_firstname' => 'Parent',
|
||||||
@@ -31,13 +31,38 @@ class EmailExtractorControllerTest extends TestCase
|
|||||||
|
|
||||||
$response->assertOk();
|
$response->assertOk();
|
||||||
$response->assertJsonPath('ok', true);
|
$response->assertJsonPath('ok', true);
|
||||||
|
$this->assertContains('admin@example.com', $response->json('users'));
|
||||||
|
$this->assertContains('parent2@example.com', $response->json('parents'));
|
||||||
$this->assertContains('parent2@example.com', $response->json('emails.parents'));
|
$this->assertContains('parent2@example.com', $response->json('emails.parents'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_legacy_emails_endpoint_returns_top_level_arrays(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser();
|
||||||
|
Sanctum::actingAs($user, [], 'api');
|
||||||
|
|
||||||
|
DB::table('parents')->insert([
|
||||||
|
'secondparent_firstname' => 'Parent',
|
||||||
|
'secondparent_lastname' => 'Legacy',
|
||||||
|
'secondparent_email' => 'legacy-parent@example.com',
|
||||||
|
'secondparent_phone' => '5555555555',
|
||||||
|
'firstparent_id' => 1,
|
||||||
|
'secondparent_id' => 3,
|
||||||
|
'school_year' => '2025-2026',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->getJson('/api/emails');
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertJsonPath('ok', true);
|
||||||
|
$this->assertContains('admin@example.com', $response->json('users'));
|
||||||
|
$this->assertContains('legacy-parent@example.com', $response->json('parents'));
|
||||||
|
}
|
||||||
|
|
||||||
public function test_compare_endpoint_returns_comparison(): void
|
public function test_compare_endpoint_returns_comparison(): void
|
||||||
{
|
{
|
||||||
$user = $this->createUser();
|
$user = $this->createUser();
|
||||||
Sanctum::actingAs($user);
|
Sanctum::actingAs($user, [], 'api');
|
||||||
|
|
||||||
DB::table('parents')->insert([
|
DB::table('parents')->insert([
|
||||||
'secondparent_firstname' => 'Parent',
|
'secondparent_firstname' => 'Parent',
|
||||||
|
|||||||
@@ -0,0 +1,343 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Api\V1\SchoolYears;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class SchoolYearControllerTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_preview_close_returns_promotion_and_balance_preview(): void
|
||||||
|
{
|
||||||
|
$this->seedClosureData();
|
||||||
|
|
||||||
|
$this->actingAs(User::query()->findOrFail(1), 'api');
|
||||||
|
|
||||||
|
$response = $this->postJson('/api/v1/school-years/1/preview-close', [
|
||||||
|
'new_school_year' => [
|
||||||
|
'name' => '2026-2027',
|
||||||
|
'start_date' => '2026-09-01',
|
||||||
|
'end_date' => '2027-06-30',
|
||||||
|
],
|
||||||
|
'transfer_unpaid_balances' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertOk()
|
||||||
|
->assertJsonPath('data.validation.can_close', true)
|
||||||
|
->assertJsonPath('data.promotion_preview.summary.promote', 1)
|
||||||
|
->assertJsonPath('data.parent_balances.summary.total_old_unpaid_balance', 200);
|
||||||
|
|
||||||
|
$this->assertSame(200.0, (float) $response->json('data.parent_balances.rows.0.amount_to_transfer'));
|
||||||
|
$this->assertSame('promote', $response->json('data.promotion_preview.rows.0.promotion_action'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_can_create_draft_school_year_via_api(): void
|
||||||
|
{
|
||||||
|
$this->seedClosureData();
|
||||||
|
|
||||||
|
$this->actingAs(User::query()->findOrFail(1), 'api');
|
||||||
|
|
||||||
|
$response = $this->postJson('/api/v1/school-years', [
|
||||||
|
'name' => '2026-2027',
|
||||||
|
'start_date' => '2026-09-01',
|
||||||
|
'end_date' => '2027-06-30',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertCreated()
|
||||||
|
->assertJsonPath('data.name', '2026-2027')
|
||||||
|
->assertJsonPath('data.status', 'draft');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('school_years', [
|
||||||
|
'name' => '2026-2027',
|
||||||
|
'status' => 'draft',
|
||||||
|
'is_current' => 0,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_can_update_draft_school_year_via_api(): void
|
||||||
|
{
|
||||||
|
$this->seedClosureData();
|
||||||
|
DB::table('school_years')->insert([
|
||||||
|
'id' => 2,
|
||||||
|
'name' => '2027-2028',
|
||||||
|
'start_date' => '2027-09-01',
|
||||||
|
'end_date' => '2028-06-30',
|
||||||
|
'status' => 'draft',
|
||||||
|
'is_current' => 0,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs(User::query()->findOrFail(1), 'api');
|
||||||
|
|
||||||
|
$response = $this->patchJson('/api/v1/school-years/2', [
|
||||||
|
'name' => '2028-2029',
|
||||||
|
'start_date' => '2028-09-01',
|
||||||
|
'end_date' => '2029-06-30',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertOk()
|
||||||
|
->assertJsonPath('data.name', '2028-2029')
|
||||||
|
->assertJsonPath('data.start_date', '2028-09-01');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('school_years', [
|
||||||
|
'id' => 2,
|
||||||
|
'name' => '2028-2029',
|
||||||
|
'start_date' => '2028-09-01 00:00:00',
|
||||||
|
'end_date' => '2029-06-30 00:00:00',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_close_school_year_creates_new_active_year_enrollment_and_balance_transfer(): void
|
||||||
|
{
|
||||||
|
$this->seedClosureData();
|
||||||
|
|
||||||
|
$this->actingAs(User::query()->findOrFail(1), 'api');
|
||||||
|
|
||||||
|
$response = $this->postJson('/api/v1/school-years/1/close', [
|
||||||
|
'new_school_year' => [
|
||||||
|
'name' => '2026-2027',
|
||||||
|
'start_date' => '2026-09-01',
|
||||||
|
'end_date' => '2027-06-30',
|
||||||
|
],
|
||||||
|
'transfer_unpaid_balances' => true,
|
||||||
|
'confirmation' => 'CLOSE 2025-2026',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertOk()
|
||||||
|
->assertJsonPath('data.closed_school_year.status', 'closed')
|
||||||
|
->assertJsonPath('data.new_school_year.status', 'active');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('school_years', [
|
||||||
|
'name' => '2025-2026',
|
||||||
|
'status' => 'closed',
|
||||||
|
'is_current' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('school_years', [
|
||||||
|
'name' => '2026-2027',
|
||||||
|
'status' => 'active',
|
||||||
|
'is_current' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('configuration', [
|
||||||
|
'config_key' => 'school_year',
|
||||||
|
'config_value' => '2026-2027',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('enrollments', [
|
||||||
|
'student_id' => 100,
|
||||||
|
'parent_id' => 10,
|
||||||
|
'school_year' => '2026-2027',
|
||||||
|
'enrollment_status' => 'enrolled',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('parent_balance_transfers', [
|
||||||
|
'parent_id' => 10,
|
||||||
|
'from_school_year' => '2025-2026',
|
||||||
|
'to_school_year' => '2026-2027',
|
||||||
|
'amount' => 200.00,
|
||||||
|
'status' => 'transferred',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('invoices', [
|
||||||
|
'parent_id' => 10,
|
||||||
|
'school_year' => '2026-2027',
|
||||||
|
'total_amount' => 200.00,
|
||||||
|
'balance' => 200.00,
|
||||||
|
'description' => 'Opening balance transferred from 2025-2026',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_closed_school_year_blocks_legacy_invoice_generation(): void
|
||||||
|
{
|
||||||
|
$this->seedClosureData();
|
||||||
|
|
||||||
|
$this->actingAs(User::query()->findOrFail(1), 'api');
|
||||||
|
|
||||||
|
$this->postJson('/api/v1/school-years/1/close', [
|
||||||
|
'new_school_year' => [
|
||||||
|
'name' => '2026-2027',
|
||||||
|
'start_date' => '2026-09-01',
|
||||||
|
'end_date' => '2027-06-30',
|
||||||
|
],
|
||||||
|
'transfer_unpaid_balances' => true,
|
||||||
|
'confirmation' => 'CLOSE 2025-2026',
|
||||||
|
])->assertOk();
|
||||||
|
|
||||||
|
$response = $this->postJson('/api/v1/finance/invoices/generate', [
|
||||||
|
'parent_id' => 10,
|
||||||
|
'school_year' => '2025-2026',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertStatus(409)
|
||||||
|
->assertJsonPath('message', 'School year 2025-2026 is closed and read-only.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seedClosureData(): void
|
||||||
|
{
|
||||||
|
DB::table('configuration')->insert([
|
||||||
|
['config_key' => 'school_year', 'config_value' => '2025-2026'],
|
||||||
|
['config_key' => 'semester', 'config_value' => 'Fall'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('roles')->insert([
|
||||||
|
['id' => 1, 'name' => 'administrator', 'slug' => 'administrator', 'is_active' => 1],
|
||||||
|
['id' => 2, 'name' => 'parent', 'slug' => 'parent', 'is_active' => 1],
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('users')->insert([
|
||||||
|
[
|
||||||
|
'id' => 1,
|
||||||
|
'school_id' => 1,
|
||||||
|
'firstname' => 'Admin',
|
||||||
|
'lastname' => 'User',
|
||||||
|
'cellphone' => '5555555555',
|
||||||
|
'email' => 'admin@example.com',
|
||||||
|
'address_street' => '123 Main',
|
||||||
|
'city' => 'City',
|
||||||
|
'state' => 'ST',
|
||||||
|
'zip' => '12345',
|
||||||
|
'accept_school_policy' => 1,
|
||||||
|
'is_verified' => 1,
|
||||||
|
'status' => 'Active',
|
||||||
|
'is_suspended' => 0,
|
||||||
|
'failed_attempts' => 0,
|
||||||
|
'password' => bcrypt('secret'),
|
||||||
|
'semester' => 'Fall',
|
||||||
|
'school_year' => '2025-2026',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 10,
|
||||||
|
'school_id' => 1,
|
||||||
|
'firstname' => 'Parent',
|
||||||
|
'lastname' => 'User',
|
||||||
|
'cellphone' => '5555555555',
|
||||||
|
'email' => 'parent@example.com',
|
||||||
|
'address_street' => '123 Main',
|
||||||
|
'city' => 'City',
|
||||||
|
'state' => 'ST',
|
||||||
|
'zip' => '12345',
|
||||||
|
'accept_school_policy' => 1,
|
||||||
|
'is_verified' => 1,
|
||||||
|
'status' => 'Active',
|
||||||
|
'is_suspended' => 0,
|
||||||
|
'failed_attempts' => 0,
|
||||||
|
'password' => bcrypt('secret'),
|
||||||
|
'semester' => 'Fall',
|
||||||
|
'school_year' => '2025-2026',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('user_roles')->insert([
|
||||||
|
['user_id' => 1, 'role_id' => 1],
|
||||||
|
['user_id' => 10, 'role_id' => 2],
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('school_years')->insert([
|
||||||
|
'id' => 1,
|
||||||
|
'name' => '2025-2026',
|
||||||
|
'start_date' => '2025-09-01',
|
||||||
|
'end_date' => '2026-06-30',
|
||||||
|
'status' => 'active',
|
||||||
|
'is_current' => 1,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('students')->insert([
|
||||||
|
'id' => 100,
|
||||||
|
'parent_id' => 10,
|
||||||
|
'firstname' => 'Sara',
|
||||||
|
'lastname' => 'Student',
|
||||||
|
'school_id' => 'S-100',
|
||||||
|
'age' => 9,
|
||||||
|
'gender' => 'Female',
|
||||||
|
'is_active' => 1,
|
||||||
|
'photo_consent' => 1,
|
||||||
|
'year_of_registration' => '2025',
|
||||||
|
'school_year' => '2025-2026',
|
||||||
|
'semester' => 'Fall',
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('classes')->insert([
|
||||||
|
['id' => 3, 'class_name' => 'Grade 3', 'schedule' => 'Sunday 9:00', 'school_year' => '2025-2026', 'semester' => 'Fall', 'capacity' => 30],
|
||||||
|
['id' => 4, 'class_name' => 'Grade 4', 'schedule' => 'Sunday 10:00', 'school_year' => '2026-2027', 'semester' => 'Fall', 'capacity' => 30],
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('classSection')->insert([
|
||||||
|
[
|
||||||
|
'class_section_id' => 301,
|
||||||
|
'class_section_name' => '3A',
|
||||||
|
'class_id' => 3,
|
||||||
|
'semester' => 'Fall',
|
||||||
|
'school_year' => '2025-2026',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'class_section_id' => 401,
|
||||||
|
'class_section_name' => '4A',
|
||||||
|
'class_id' => 4,
|
||||||
|
'semester' => 'Fall',
|
||||||
|
'school_year' => '2026-2027',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('student_class')->insert([
|
||||||
|
'student_id' => 100,
|
||||||
|
'class_section_id' => 301,
|
||||||
|
'school_year' => '2025-2026',
|
||||||
|
'semester' => 'Fall',
|
||||||
|
'is_event_only' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('enrollments')->insert([
|
||||||
|
'student_id' => 100,
|
||||||
|
'class_section_id' => 301,
|
||||||
|
'parent_id' => 10,
|
||||||
|
'enrollment_date' => '2025-09-05',
|
||||||
|
'enrollment_status' => 'enrolled',
|
||||||
|
'withdrawal_date' => null,
|
||||||
|
'is_withdrawn' => 0,
|
||||||
|
'admission_status' => 'accepted',
|
||||||
|
'semester' => 'Fall',
|
||||||
|
'school_year' => '2025-2026',
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('student_decisions')->insert([
|
||||||
|
'student_id' => 100,
|
||||||
|
'school_year' => '2025-2026',
|
||||||
|
'class_section_name' => '3A',
|
||||||
|
'year_score' => 85,
|
||||||
|
'decision' => 'promoted',
|
||||||
|
'source' => 'manual',
|
||||||
|
'notes' => 'Ready for promotion',
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('invoices')->insert([
|
||||||
|
'id' => 500,
|
||||||
|
'parent_id' => 10,
|
||||||
|
'invoice_number' => 'INV-500',
|
||||||
|
'total_amount' => 500,
|
||||||
|
'balance' => 200,
|
||||||
|
'paid_amount' => 300,
|
||||||
|
'has_discount' => 0,
|
||||||
|
'issue_date' => '2026-05-10',
|
||||||
|
'due_date' => '2026-06-10',
|
||||||
|
'status' => 'unpaid',
|
||||||
|
'description' => 'Tuition balance',
|
||||||
|
'school_year' => '2025-2026',
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
'updated_by' => 1,
|
||||||
|
'semester' => 'Fall',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Web;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class SchoolYearAdminPageTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$compiledPath = storage_path('framework/views');
|
||||||
|
if (!is_dir($compiledPath)) {
|
||||||
|
mkdir($compiledPath, 0777, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
config([
|
||||||
|
'app.key' => 'base64:' . base64_encode(random_bytes(32)),
|
||||||
|
'app.cipher' => 'AES-256-CBC',
|
||||||
|
'jwt.secret' => bin2hex(random_bytes(32)),
|
||||||
|
'view.compiled' => $compiledPath,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_can_view_school_year_page(): void
|
||||||
|
{
|
||||||
|
$this->seedAdminContext();
|
||||||
|
|
||||||
|
$response = $this->actingAs(User::query()->findOrFail(1))
|
||||||
|
->get('/administrator/school-years');
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertSee('School Years Management');
|
||||||
|
$response->assertSee('JWT Authorization: Bearer');
|
||||||
|
$response->assertSee('/api/v1/school-years');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_can_create_draft_school_year_from_page(): void
|
||||||
|
{
|
||||||
|
$this->seedAdminContext();
|
||||||
|
|
||||||
|
$response = $this->actingAs(User::query()->findOrFail(1))
|
||||||
|
->post('/administrator/school-years', [
|
||||||
|
'name' => '2026-2027',
|
||||||
|
'start_date' => '2026-09-01',
|
||||||
|
'end_date' => '2027-06-30',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertRedirect();
|
||||||
|
$this->assertDatabaseHas('school_years', [
|
||||||
|
'name' => '2026-2027',
|
||||||
|
'status' => 'draft',
|
||||||
|
'is_current' => 0,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seedAdminContext(): void
|
||||||
|
{
|
||||||
|
DB::table('configuration')->insert([
|
||||||
|
['config_key' => 'school_year', 'config_value' => '2025-2026'],
|
||||||
|
['config_key' => 'semester', 'config_value' => 'Fall'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('roles')->insert([
|
||||||
|
'id' => 1,
|
||||||
|
'name' => 'administrator',
|
||||||
|
'slug' => 'administrator',
|
||||||
|
'is_active' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('users')->insert([
|
||||||
|
'id' => 1,
|
||||||
|
'school_id' => 1,
|
||||||
|
'firstname' => 'Admin',
|
||||||
|
'lastname' => 'User',
|
||||||
|
'gender' => 'Female',
|
||||||
|
'cellphone' => '5555555555',
|
||||||
|
'email' => 'admin@example.com',
|
||||||
|
'address_street' => '123 Main',
|
||||||
|
'city' => 'City',
|
||||||
|
'state' => 'ST',
|
||||||
|
'zip' => '12345',
|
||||||
|
'accept_school_policy' => 1,
|
||||||
|
'is_verified' => 1,
|
||||||
|
'status' => 'Active',
|
||||||
|
'is_suspended' => 0,
|
||||||
|
'failed_attempts' => 0,
|
||||||
|
'password' => bcrypt('secret'),
|
||||||
|
'semester' => 'Fall',
|
||||||
|
'school_year' => '2025-2026',
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('user_roles')->insert([
|
||||||
|
'user_id' => 1,
|
||||||
|
'role_id' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('school_years')->insert([
|
||||||
|
'id' => 1,
|
||||||
|
'name' => '2025-2026',
|
||||||
|
'start_date' => '2025-09-01',
|
||||||
|
'end_date' => '2026-06-30',
|
||||||
|
'status' => 'active',
|
||||||
|
'is_current' => 1,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Services\AttendanceManagement;
|
||||||
|
|
||||||
|
use App\Services\AttendanceManagement\AttendanceManagementService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class AttendanceManagementServiceTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_badge_scan_persists_school_year_and_semester_for_the_event_date(): void
|
||||||
|
{
|
||||||
|
$this->seedConfiguration();
|
||||||
|
$this->seedUserWithBadge('RFID-100');
|
||||||
|
|
||||||
|
$service = new AttendanceManagementService();
|
||||||
|
|
||||||
|
$row = $service->badgeScan([
|
||||||
|
'badge_scan' => 'RFID-100',
|
||||||
|
'scan_time' => '2026-02-10 08:30:00',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame('2025-2026', $row['school_year']);
|
||||||
|
$this->assertSame('Spring', $row['semester']);
|
||||||
|
$this->assertDatabaseHas('attendance_management_events', [
|
||||||
|
'id' => $row['id'],
|
||||||
|
'badge_id' => 'RFID-100',
|
||||||
|
'school_year' => '2025-2026',
|
||||||
|
'semester' => 'Spring',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_dashboard_derives_academic_context_for_legacy_rows_and_applies_filters(): void
|
||||||
|
{
|
||||||
|
DB::table('attendance_management_events')->insert([
|
||||||
|
'person_type' => 'student',
|
||||||
|
'person_id' => 10,
|
||||||
|
'person_name' => 'Legacy Student',
|
||||||
|
'event_date' => '2026-02-10',
|
||||||
|
'attendance_status' => AttendanceManagementService::STATUS_PRESENT,
|
||||||
|
'report_status' => 'reported',
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$service = new AttendanceManagementService();
|
||||||
|
|
||||||
|
$data = $service->dashboard([
|
||||||
|
'date' => '2026-02-10',
|
||||||
|
'school_year' => '2025-2026',
|
||||||
|
'semester' => 'Spring',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertCount(1, $data['rows']);
|
||||||
|
$this->assertSame('2025-2026', $data['rows'][0]['school_year']);
|
||||||
|
$this->assertSame('Spring', $data['rows'][0]['semester']);
|
||||||
|
$this->assertSame(1, $data['summary']['present']);
|
||||||
|
|
||||||
|
$empty = $service->dashboard([
|
||||||
|
'date' => '2026-02-10',
|
||||||
|
'school_year' => '2025-2026',
|
||||||
|
'semester' => 'Fall',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertCount(0, $empty['rows']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seedConfiguration(): void
|
||||||
|
{
|
||||||
|
DB::table('configuration')->insert([
|
||||||
|
['config_key' => 'school_year', 'config_value' => '2025-2026'],
|
||||||
|
['config_key' => 'semester', 'config_value' => 'Fall'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seedUserWithBadge(string $rfidTag): void
|
||||||
|
{
|
||||||
|
DB::table('users')->insert([
|
||||||
|
'id' => 100,
|
||||||
|
'school_id' => 1,
|
||||||
|
'firstname' => 'Badge',
|
||||||
|
'lastname' => 'User',
|
||||||
|
'cellphone' => '5555555555',
|
||||||
|
'email' => 'badge-user@example.com',
|
||||||
|
'address_street' => '123 Main',
|
||||||
|
'city' => 'City',
|
||||||
|
'state' => 'ST',
|
||||||
|
'zip' => '12345',
|
||||||
|
'accept_school_policy' => 1,
|
||||||
|
'is_verified' => 1,
|
||||||
|
'status' => 'Active',
|
||||||
|
'is_suspended' => 0,
|
||||||
|
'failed_attempts' => 0,
|
||||||
|
'password' => bcrypt('secret'),
|
||||||
|
'semester' => 'Fall',
|
||||||
|
'school_year' => '2025-2026',
|
||||||
|
'rfid_tag' => $rfidTag,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user