From 02ba2fe6b6869f7e21eb375dbb7de239e2735a77 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 9 Jul 2026 13:56:22 -0400 Subject: [PATCH] fix parent pages and teachers and admin --- .../ClassProgress/ClassProgressController.php | 112 ++++++- .../Api/Parents/ParentController.php | 15 + .../Api/Reports/FilesController.php | 36 ++ .../Api/Staff/TeacherController.php | 18 + .../ClassProgressStoreRequest.php | 2 +- .../Resources/Invoices/InvoiceResource.php | 14 + app/Models/Payment.php | 69 ++-- app/Models/TeacherClass.php | 65 ++++ app/Policies/ClassProgressReportPolicy.php | 2 +- app/Services/.DS_Store | Bin 8196 -> 10244 bytes .../TeacherSubmissionReportService.php | 17 +- .../ClassProgressMetaService.php | 35 +- .../ClassProgressMutationService.php | 9 +- .../ClassProgressQueryService.php | 149 ++++++++- .../Invoices/InvoicePaymentService.php | 71 +++- .../Parents/ParentEnrollmentService.php | 1 + app/Services/Parents/ParentInvoiceService.php | 312 +++++++++++++++++- .../Payments/PaymentManualService.php | 33 +- .../SchoolYears/SchoolYearClosureService.php | 143 +++++++- routes/api.php | 5 +- storage/uploads/exams/finals/final1.pdf | 1 + ...istratorTeacherSubmissionReportApiTest.php | 53 +++ .../ClassProgressControllerTest.php | 273 +++++++++++++++ .../CompetitionScoresControllerTest.php | 42 +++ .../Finance/PaymentManualControllerTest.php | 106 ++++++ .../Api/V1/Parents/ParentControllerTest.php | 46 +++ .../Api/V1/Reports/FilesControllerTest.php | 88 +++++ .../SchoolYears/SchoolYearControllerTest.php | 241 ++++++++++++-- .../Api/V1/Teachers/TeacherControllerTest.php | 2 + .../ClassProgressQueryServiceTest.php | 89 +++++ .../Invoices/InvoicePaymentServiceTest.php | 35 ++ .../Parents/ParentEnrollmentServiceTest.php | 61 ++++ .../Parents/ParentInvoiceServiceTest.php | 182 ++++++++++ .../Payments/PaymentManualServiceTest.php | 78 +++++ 34 files changed, 2306 insertions(+), 99 deletions(-) create mode 100644 storage/uploads/exams/finals/final1.pdf diff --git a/app/Http/Controllers/Api/ClassProgress/ClassProgressController.php b/app/Http/Controllers/Api/ClassProgress/ClassProgressController.php index 3f400cf4..4c0a3ebc 100644 --- a/app/Http/Controllers/Api/ClassProgress/ClassProgressController.php +++ b/app/Http/Controllers/Api/ClassProgress/ClassProgressController.php @@ -97,13 +97,22 @@ class ClassProgressController extends BaseApiController return $auth; } - if ($this->isLegacyTeacherProgressListRoute($request) && ! $this->hasAnyRole($auth, ['teacher', 'administrator', 'admin'])) { + if ($this->isLegacyTeacherProgressListRoute($request) && ! $this->hasAnyRole($auth, ['teacher', 'teacher_assistant', 'ta', 'administrator', 'admin'])) { return $this->respondError('Forbidden.', Response::HTTP_FORBIDDEN); } $filters = $request->validated(); $meta = $this->queryService->meta($filters['school_year'] ?? null, $filters['semester'] ?? null); $filters['school_year'] = $filters['school_year'] ?? ($meta['school_year'] ?? null); + if ($this->isLegacyTeacherProgressListRoute($request) && empty($filters['class_section_id'])) { + $resolvedClassSectionId = $this->resolveTeacherHistoryClassSectionId($auth, $filters); + if ($resolvedClassSectionId instanceof JsonResponse) { + return $resolvedClassSectionId; + } + if ($resolvedClassSectionId > 0) { + $filters['class_section_id'] = $resolvedClassSectionId; + } + } if ($this->isAdminProgressAliasRoute($request)) { $filters['group_by_week'] = true; } @@ -136,7 +145,12 @@ class ClassProgressController extends BaseApiController try { $filesBySubject = $request->filesBySubject(); - $reports = $this->mutationService->createReports($auth, $request->validated(), $filesBySubject); + $payload = $this->resolveTeacherStorePayload($auth, $request->validated()); + if ($payload instanceof JsonResponse) { + return $payload; + } + + $reports = $this->mutationService->createReports($auth, $payload, $filesBySubject); } catch (\InvalidArgumentException $e) { if ($e->getMessage() === 'CONFIRM_OVERWRITE') { return response()->json([ @@ -278,6 +292,100 @@ class ClassProgressController extends BaseApiController || $request->is('api/v1/admin/progress'); } + /** + * @param array $payload + * @return array|JsonResponse + */ + private function resolveTeacherStorePayload(User $user, array $payload): array|JsonResponse + { + if ($this->hasAnyRole($user, ['administrator', 'admin', 'principal', 'vice_principal', 'vice-principal'])) { + return $payload; + } + + if (! $this->hasAnyRole($user, ['teacher', 'teacher_assistant', 'ta'])) { + return $payload; + } + + $assignments = $this->queryService->teacherAssignments( + $user, + $payload['school_year'] ?? null, + $payload['semester'] ?? null + ); + + if ($assignments === []) { + return $this->respondError('No class section assigned to this teacher.', Response::HTTP_UNPROCESSABLE_ENTITY); + } + + $postedSectionId = (int) ($payload['class_section_id'] ?? 0); + $assignment = null; + if ($postedSectionId > 0) { + $assignment = collect($assignments)->first(function (array $assignment) use ($postedSectionId) { + return (int) ($assignment['class_section_id'] ?? 0) === $postedSectionId + || (int) ($assignment['class_section_pk'] ?? 0) === $postedSectionId; + }); + } + + if (! $assignment) { + $sessionClassSectionId = (int) session('class_section_id', 0); + if ($sessionClassSectionId > 0) { + $assignment = collect($assignments)->first(function (array $assignment) use ($sessionClassSectionId) { + return (int) ($assignment['class_section_id'] ?? 0) === $sessionClassSectionId + || (int) ($assignment['class_section_pk'] ?? 0) === $sessionClassSectionId; + }); + } + } + + $assignment ??= $assignments[0]; + $payload['class_section_id'] = (int) ($assignment['class_section_id'] ?? 0); + + if (empty($payload['school_year']) && ! empty($assignment['school_year'])) { + $payload['school_year'] = $assignment['school_year']; + } + if (empty($payload['semester']) && ! empty($assignment['semester'])) { + $payload['semester'] = $assignment['semester']; + } + + return $payload; + } + + /** + * @param array $filters + */ + private function resolveTeacherHistoryClassSectionId(User $user, array $filters): int|JsonResponse + { + if ($this->hasAnyRole($user, ['administrator', 'admin', 'principal', 'vice_principal', 'vice-principal'])) { + return 0; + } + + $assignments = $this->queryService->teacherAssignments( + $user, + $filters['school_year'] ?? null, + $filters['semester'] ?? null + ); + $assignedSectionIds = collect($assignments) + ->pluck('class_section_id') + ->map(fn ($id) => (int) $id) + ->filter(fn ($id) => $id > 0) + ->unique() + ->values() + ->all(); + + if ($assignedSectionIds === []) { + return $this->respondError('No class section assigned to this teacher.', Response::HTTP_UNPROCESSABLE_ENTITY); + } + + if (count($assignedSectionIds) === 1) { + return (int) $assignedSectionIds[0]; + } + + $sessionClassSectionId = (int) session('class_section_id', 0); + if ($sessionClassSectionId > 0 && in_array($sessionClassSectionId, $assignedSectionIds, true)) { + return $sessionClassSectionId; + } + + return (int) $assignedSectionIds[0]; + } + /** * @param list $roles */ diff --git a/app/Http/Controllers/Api/Parents/ParentController.php b/app/Http/Controllers/Api/Parents/ParentController.php index 77e45e0b..545dad6a 100644 --- a/app/Http/Controllers/Api/Parents/ParentController.php +++ b/app/Http/Controllers/Api/Parents/ParentController.php @@ -80,6 +80,21 @@ class ParentController extends BaseApiController ]); } + public function statement(ParentInvoiceRequest $request): JsonResponse + { + $guard = $this->parentIdOrUnauthorized(); + if ($guard instanceof JsonResponse) { + return $guard; + } + + $schoolYear = $request->validated()['school_year'] ?? null; + + return response()->json([ + 'ok' => true, + 'data' => $this->invoiceService->statement($guard, $schoolYear), + ]); + } + public function enrollments(ParentEnrollmentRequest $request): JsonResponse { $guard = $this->parentIdOrUnauthorized(); diff --git a/app/Http/Controllers/Api/Reports/FilesController.php b/app/Http/Controllers/Api/Reports/FilesController.php index 0d6a85a9..a75f21bb 100644 --- a/app/Http/Controllers/Api/Reports/FilesController.php +++ b/app/Http/Controllers/Api/Reports/FilesController.php @@ -9,6 +9,8 @@ use App\Services\Files\ExamDraftDownloadNameService; use App\Services\Files\FileServeService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Response; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; class FilesController extends BaseApiController { @@ -71,6 +73,7 @@ class FilesController extends BaseApiController public function examDraftFinal(FileNameRequest $request, string $name): Response|JsonResponse { + $name = $this->resolveFinalExamFileName($name); $downloadName = $this->draftNameService->build($name, 'finals'); return $this->serveFile( @@ -102,4 +105,37 @@ class FilesController extends BaseApiController return $this->fileService->serveInline($baseDir, $name, $allowedExtensions, $request, $downloadName, $nosniff); } + + private function resolveFinalExamFileName(string $name): string + { + if (! ctype_digit($name) || ! Schema::hasTable('exam_drafts')) { + return $name; + } + + $query = DB::table('exam_drafts')->where('id', (int) $name); + if (Schema::hasColumn('exam_drafts', 'final_pdf_file')) { + $query->select('final_file', 'final_pdf_file'); + } else { + $query->select('final_file'); + } + + $row = $query->first(); + if (! $row) { + return $name; + } + + $pdfFile = trim((string) ($row->final_pdf_file ?? '')); + if ($this->looksLikeExamFileName($pdfFile)) { + return $pdfFile; + } + + $finalFile = trim((string) ($row->final_file ?? '')); + + return $this->looksLikeExamFileName($finalFile) ? $finalFile : $name; + } + + private function looksLikeExamFileName(string $name): bool + { + return preg_match('/\.(doc|docx|pdf)\z/i', $name) === 1; + } } diff --git a/app/Http/Controllers/Api/Staff/TeacherController.php b/app/Http/Controllers/Api/Staff/TeacherController.php index 6c7093f1..a5dd39f6 100644 --- a/app/Http/Controllers/Api/Staff/TeacherController.php +++ b/app/Http/Controllers/Api/Staff/TeacherController.php @@ -47,6 +47,8 @@ class TeacherController extends BaseApiController $studentsBySection[(string) $sectionId] = TeacherStudentResource::collection($students); } + $this->storeActiveClassInSession($data); + return response()->json([ 'ok' => true, 'teacher' => $teacher, @@ -91,6 +93,8 @@ class TeacherController extends BaseApiController ], 422); } + $this->storeActiveClassInSession($data); + return response()->json([ 'ok' => true, 'active_class_section_id' => $requested, @@ -162,4 +166,18 @@ class TeacherController extends BaseApiController return $userId; } + + /** + * @param array $data + */ + private function storeActiveClassInSession(array $data): void + { + $classSectionId = (int) ($data['active_class_section_id'] ?? 0); + if ($classSectionId <= 0) { + return; + } + + session()->put('class_section_id', $classSectionId); + session()->put('class_section_name', $data['class_section_name'] ?? null); + } } diff --git a/app/Http/Requests/ClassProgress/ClassProgressStoreRequest.php b/app/Http/Requests/ClassProgress/ClassProgressStoreRequest.php index 81dbbdb1..c62746eb 100644 --- a/app/Http/Requests/ClassProgress/ClassProgressStoreRequest.php +++ b/app/Http/Requests/ClassProgress/ClassProgressStoreRequest.php @@ -12,7 +12,7 @@ class ClassProgressStoreRequest extends ClassProgressFormRequest public function rules(): array { $rules = [ - 'class_section_id' => ['required', 'integer', 'exists:classSection,class_section_id'], + 'class_section_id' => ['nullable', 'integer'], 'school_year' => ['nullable', 'string', 'max:20'], 'semester' => ['nullable', 'string', 'max:20'], 'week_start' => ['required', 'date_format:Y-m-d'], diff --git a/app/Http/Resources/Invoices/InvoiceResource.php b/app/Http/Resources/Invoices/InvoiceResource.php index e9db6cfb..4faad820 100644 --- a/app/Http/Resources/Invoices/InvoiceResource.php +++ b/app/Http/Resources/Invoices/InvoiceResource.php @@ -15,6 +15,7 @@ class InvoiceResource extends JsonResource 'invoice_number' => $this->resource['invoice_number'] ?? null, 'total_amount' => (float) ($this->resource['total_amount'] ?? 0), 'paid_amount' => (float) ($this->resource['paid_amount'] ?? 0), + 'discount' => (float) ($this->resource['discount'] ?? 0), 'balance' => (float) ($this->resource['balance'] ?? 0), 'status' => $this->resource['status'] ?? null, 'school_year' => $this->resource['school_year'] ?? null, @@ -26,6 +27,19 @@ class InvoiceResource extends JsonResource 'refund_amount' => (float) ($this->resource['refund_amount'] ?? 0), 'last_paid_amount' => (float) ($this->resource['last_paid_amount'] ?? 0), 'last_payment_date' => $this->resource['last_payment_date'] ?? null, + 'payments' => collect($this->resource['payments'] ?? []) + ->map(static fn ($payment) => [ + 'id' => (int) ($payment['id'] ?? 0), + 'invoice_id' => (int) ($payment['invoice_id'] ?? 0), + 'paid_amount' => (float) ($payment['paid_amount'] ?? 0), + 'balance' => (float) ($payment['balance'] ?? 0), + 'payment_method' => $payment['payment_method'] ?? null, + 'payment_date' => $payment['payment_date'] ?? null, + 'transaction_id' => $payment['transaction_id'] ?? null, + 'status' => $payment['status'] ?? null, + ]) + ->values() + ->all(), ]; } } diff --git a/app/Models/Payment.php b/app/Models/Payment.php index 02fc71ff..f8fde218 100644 --- a/app/Models/Payment.php +++ b/app/Models/Payment.php @@ -8,13 +8,23 @@ class Payment extends BaseModel { protected $table = 'payments'; - /** - * legacy: useTimestamps = false because DB handles created_at/updated_at (defaults/triggers). - * Keep OFF in Laravel too. - */ - public $timestamps = true; - - protected $fillable = ['parent_id', 'invoice_id', 'total_amount', 'paid_amount', 'balance', 'number_of_installments', 'transaction_id', 'check_file', 'check_number', 'payment_method', 'payment_date', 'semester', 'school_year', 'status', 'updated_by', 'created_at', 'updated_at']; + protected $fillable = [ + 'parent_id', + 'invoice_id', + 'total_amount', + 'paid_amount', + 'balance', + 'number_of_installments', + 'transaction_id', + 'check_file', + 'check_number', + 'payment_method', + 'payment_date', + 'semester', + 'school_year', + 'status', + 'updated_by', + ]; protected $casts = [ 'parent_id' => 'integer', @@ -26,10 +36,20 @@ class Payment extends BaseModel 'paid_amount' => 'decimal:2', 'balance' => 'decimal:2', - 'payment_date' => 'datetime', + 'payment_date' => 'date', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + + protected $attributes = [ + 'paid_amount' => '0.00', + 'balance' => '0.00', + 'payment_method' => 'Unknown', + 'status' => 'Pending', ]; /* Optional relationships */ + public function invoice() { return $this->belongsTo(Invoice::class, 'invoice_id'); @@ -67,19 +87,18 @@ class Payment extends BaseModel { /** @var self|null $payment */ $payment = static::query()->find($paymentId); + if (! $payment) { return false; } - $newBalance = (float) $payment->balance - (float) $amountPaid; - - // keep precision stable - $paidAmount = (float) $payment->paid_amount + (float) $amountPaid; + $newPaidAmount = round((float) $payment->paid_amount + $amountPaid, 2); + $newBalance = round((float) $payment->balance - $amountPaid, 2); $affected = static::query() ->whereKey($paymentId) ->update([ - 'paid_amount' => $paidAmount, + 'paid_amount' => $newPaidAmount, 'balance' => $newBalance, ]); @@ -101,10 +120,10 @@ class Payment extends BaseModel public static function generateNewTransactionId(): string { $currentYear = date('Y'); - $prefix = $currentYear.'-'; + $prefix = $currentYear . '-'; $latest = static::query() - ->where('transaction_id', 'like', $prefix.'%') + ->where('transaction_id', 'like', $prefix . '%') ->orderByDesc('transaction_id') ->first(); @@ -115,30 +134,34 @@ class Payment extends BaseModel $newNumber = 1; } - return $prefix.str_pad((string) $newNumber, 6, '0', STR_PAD_LEFT); + return $prefix . str_pad((string) $newNumber, 6, '0', STR_PAD_LEFT); } /** - * Get latest payment (date/amount/status) per invoice for given invoice IDs. - * Matches your legacy logic: statuses in ['Full','Paid','Partially Paid']. + * Get latest payment date/amount/status per invoice for given invoice IDs. + * Statuses included: Full, Paid, Partially Paid. */ public static function getPaymentsByInvoice($invoiceIds): array { if (! is_array($invoiceIds)) { $invoiceIds = [$invoiceIds]; } - $invoiceIds = array_values(array_unique(array_map('intval', array_filter($invoiceIds)))); + + $invoiceIds = array_values(array_unique(array_map( + 'intval', + array_filter($invoiceIds) + ))); + if (empty($invoiceIds)) { return []; } - // Subquery: latest payment_date per invoice (Full/Partial only) $latestSub = DB::table('payments') ->selectRaw('invoice_id, MAX(payment_date) AS max_date') + ->whereIn('invoice_id', $invoiceIds) ->whereIn('status', ['Full', 'Paid', 'Partially Paid']) ->groupBy('invoice_id'); - // Main query: join payments to the latest per invoice $rows = DB::table('payments as p') ->joinSub($latestSub, 'latest', function ($join) { $join->on('latest.invoice_id', '=', 'p.invoice_id') @@ -149,11 +172,13 @@ class Payment extends BaseModel ->select([ 'p.invoice_id', DB::raw('p.paid_amount AS last_paid_amount'), + DB::raw('p.balance AS balance'), + DB::raw('p.balance AS balance_after_payment'), DB::raw('p.payment_date AS last_payment_date'), DB::raw('p.status AS last_payment_status'), ]) ->get(); - return $rows->map(fn ($r) => (array) $r)->all(); + return $rows->map(fn ($row) => (array) $row)->all(); } } diff --git a/app/Models/TeacherClass.php b/app/Models/TeacherClass.php index ee4ee849..600c542b 100644 --- a/app/Models/TeacherClass.php +++ b/app/Models/TeacherClass.php @@ -5,6 +5,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; class TeacherClass extends BaseModel { @@ -162,6 +163,70 @@ class TeacherClass extends BaseModel ->all(); } + /** + * Distinct section identifiers this teacher can access, expanded across legacy + * `classSection.id` PKs and `classSection.class_section_id` business codes. + * + * @return list + */ + public static function distinctAccessibleSectionIdsForTeacher(int $userId): array + { + $sectionIds = static::distinctSectionIdsForTeacher($userId); + if ($sectionIds === []) { + return []; + } + + $expanded = []; + + if (Schema::hasTable('classSection')) { + $codeRows = DB::table('classSection') + ->select('id', 'class_section_id') + ->whereIn('class_section_id', $sectionIds) + ->get(); + $matchedRawIds = []; + + foreach ($codeRows as $row) { + $matchedRawIds[(int) $row->class_section_id] = true; + foreach ([(int) $row->id, (int) $row->class_section_id] as $sectionId) { + if ($sectionId > 0) { + $expanded[$sectionId] = true; + } + } + } + + $unmatchedSectionIds = array_values(array_filter( + $sectionIds, + static fn (int $sectionId): bool => ! isset($matchedRawIds[$sectionId]), + )); + + $pkRows = $unmatchedSectionIds === [] + ? collect() + : DB::table('classSection') + ->select('id', 'class_section_id') + ->whereIn('id', $unmatchedSectionIds) + ->get(); + + foreach ($pkRows as $row) { + foreach ([(int) $row->id, (int) $row->class_section_id] as $sectionId) { + if ($sectionId > 0) { + $expanded[$sectionId] = true; + } + } + } + } + + foreach ($sectionIds as $sectionId) { + if (! isset($expanded[(int) $sectionId])) { + $expanded[(int) $sectionId] = true; + } + } + + $ids = array_map('intval', array_keys($expanded)); + sort($ids); + + return $ids; + } + /** legacy: getClassSectionsByTeacherId() */ public static function getClassSectionsByTeacherId(int $teacherId): array { diff --git a/app/Policies/ClassProgressReportPolicy.php b/app/Policies/ClassProgressReportPolicy.php index 2fdb8f4c..03fe2ae4 100644 --- a/app/Policies/ClassProgressReportPolicy.php +++ b/app/Policies/ClassProgressReportPolicy.php @@ -51,7 +51,7 @@ class ClassProgressReportPolicy { return in_array( (int) $report->class_section_id, - TeacherClass::distinctSectionIdsForTeacher((int) $user->id), + TeacherClass::distinctAccessibleSectionIdsForTeacher((int) $user->id), true ); } diff --git a/app/Services/.DS_Store b/app/Services/.DS_Store index a2716819a4c1ae81275ab2ace701c0987f181f72..14e8a8596c9e423f61109a8c7e4d0c9c4e8f1613 100644 GIT binary patch delta 227 zcmZp1XbF&DU|?W$DortDU{C-uIe-{M3-C-V6q~50$jG%ZU^hP_*JK_6Ykm%f0ER?{ zN`_p9RE9i;lFhM#Da;sx*F^R+$)c%bC@u>w%FD^mO9vXwIC-Cl)aE#WmFycE<}(U1 y1BHMw&zf7PA6T~?hAZbPh1_Ka1xj?3OGoM&HBT-hkG6DcL{4VJL delta 104 zcmZn(XmOBWU|?W$DortDU;r^WfEYvza8E20o2aMA$i6XPH$Nl$WF7(Q$t%UhH}4l) t$u7tYQ~?AM+(5z=q6@vf( diff --git a/app/Services/Administrator/TeacherSubmissionReportService.php b/app/Services/Administrator/TeacherSubmissionReportService.php index d2884315..be9b1e0b 100644 --- a/app/Services/Administrator/TeacherSubmissionReportService.php +++ b/app/Services/Administrator/TeacherSubmissionReportService.php @@ -8,6 +8,7 @@ use App\Models\SemesterScore; use App\Models\TeacherSubmissionNotificationHistory; use Carbon\Carbon; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; class TeacherSubmissionReportService { @@ -25,6 +26,11 @@ class TeacherSubmissionReportService } catch (\Throwable) { $teacherClassSupportsSemester = false; } + try { + $studentClassSupportsSemester = Schema::hasColumn('student_class', 'semester'); + } catch (\Throwable) { + $studentClassSupportsSemester = false; + } $assignmentQuery = DB::table('teacher_class as tc') ->select([ @@ -99,10 +105,15 @@ class TeacherSubmissionReportService continue; } - $studentIds = DB::table('student_class') + $studentQuery = DB::table('student_class') ->where('class_section_id', $classSectionId) - ->where('semester', $semester) - ->where('school_year', $schoolYear) + ->where('school_year', $schoolYear); + + if ($studentClassSupportsSemester && $semester !== '') { + $studentQuery->where('semester', $semester); + } + + $studentIds = $studentQuery ->pluck('student_id') ->map(fn ($id) => (int) $id) ->filter() diff --git a/app/Services/ClassProgress/ClassProgressMetaService.php b/app/Services/ClassProgress/ClassProgressMetaService.php index 99039457..e95e288f 100644 --- a/app/Services/ClassProgress/ClassProgressMetaService.php +++ b/app/Services/ClassProgress/ClassProgressMetaService.php @@ -5,6 +5,7 @@ namespace App\Services\ClassProgress; use App\Models\Configuration; use App\Models\SubjectCurriculum; use App\Services\SemesterRangeService; +use Illuminate\Database\Eloquent\Builder; class ClassProgressMetaService { @@ -146,7 +147,17 @@ class ClassProgressMetaService $options = []; foreach ($this->subjectSections() as $slug => $section) { - $options[$slug] = SubjectCurriculum::getOptionsForClass((int) $classId, (string) $slug, $schoolYear); + $subjects = $this->subjectLookupValues((string) $slug, is_array($section) ? $section : []); + + $options[$slug] = SubjectCurriculum::query() + ->forClass((int) $classId) + ->where(function (Builder $query) use ($subjects) { + $query->whereIn('subject', $subjects); + }) + ->forSchoolYear($schoolYear) + ->ordered() + ->get() + ->all(); } return $options; @@ -198,4 +209,26 @@ class ClassProgressMetaService return $unitTitle; } + + /** + * Curriculum rows have existed with both short slugs (`quran`) and display + * subjects (`Quran/Arabic`). Query all configured aliases but keep the API + * response keyed by slug for the frontend. + * + * @param array $section + * @return list + */ + private function subjectLookupValues(string $slug, array $section): array + { + $values = [$slug]; + + foreach (['db_subject', 'label'] as $key) { + $value = trim((string) ($section[$key] ?? '')); + if ($value !== '') { + $values[] = $value; + } + } + + return array_values(array_unique($values)); + } } diff --git a/app/Services/ClassProgress/ClassProgressMutationService.php b/app/Services/ClassProgress/ClassProgressMutationService.php index bc6b3c47..23846711 100644 --- a/app/Services/ClassProgress/ClassProgressMutationService.php +++ b/app/Services/ClassProgress/ClassProgressMutationService.php @@ -362,10 +362,11 @@ class ClassProgressMutationService return; } - $assigned = TeacherClass::query() - ->where('teacher_id', $user->id) - ->where('class_section_id', $classSectionId) - ->exists(); + $assigned = in_array( + $classSectionId, + TeacherClass::distinctAccessibleSectionIdsForTeacher((int) $user->id), + true + ); if (! $assigned) { throw new \InvalidArgumentException('No class assignment found for this report.'); diff --git a/app/Services/ClassProgress/ClassProgressQueryService.php b/app/Services/ClassProgress/ClassProgressQueryService.php index b6e38664..f28691dd 100644 --- a/app/Services/ClassProgress/ClassProgressQueryService.php +++ b/app/Services/ClassProgress/ClassProgressQueryService.php @@ -25,6 +25,11 @@ class ClassProgressQueryService public function listReports(User $user, array $filters): LengthAwarePaginator { + $filterSectionIds = []; + if (! empty($filters['class_section_id'])) { + $filterSectionIds = $this->equivalentClassSectionIds((int) $filters['class_section_id']); + } + $query = ClassProgressReport::query() ->with(['classSection', 'teacher']) ->orderBy($filters['sort_by'] ?? 'week_start', $filters['sort_dir'] ?? 'desc'); @@ -44,22 +49,19 @@ class ClassProgressQueryService $query->whereRaw('1 = 0'); } } else { - $relaxedSectionIds = TeacherClass::distinctSectionIdsForTeacher((int) $user->id); + $relaxedSectionIds = TeacherClass::distinctAccessibleSectionIdsForTeacher((int) $user->id); if (! empty($filters['class_section_id'])) { - $sid = (int) $filters['class_section_id']; + $requestedSectionIds = $filterSectionIds !== [] ? $filterSectionIds : [(int) $filters['class_section_id']]; // Section filter: if this user has ever taught the section, show all rows for that section // (legacy DB often has `class_progress_reports` without matching current-year `teacher_class`). - if (! in_array($sid, $relaxedSectionIds, true)) { - $query->where('teacher_id', (int) $user->id); + if (array_intersect($requestedSectionIds, $relaxedSectionIds) === []) { + $query->whereRaw('1 = 0'); } + } elseif ($relaxedSectionIds !== []) { + $query->whereIn('class_section_id', $relaxedSectionIds); } else { - $query->where(function ($q) use ($user, $relaxedSectionIds) { - $q->where('teacher_id', (int) $user->id); - if ($relaxedSectionIds !== []) { - $q->orWhereIn('class_section_id', $relaxedSectionIds); - } - }); + $query->where('teacher_id', (int) $user->id); } } } elseif (! empty($filters['teacher_id'])) { @@ -67,7 +69,10 @@ class ClassProgressQueryService } if (! empty($filters['class_section_id'])) { - $query->where('class_section_id', (int) $filters['class_section_id']); + $query->whereIn( + 'class_section_id', + $filterSectionIds !== [] ? $filterSectionIds : [(int) $filters['class_section_id']] + ); } if (! empty($filters['subject'])) { @@ -203,7 +208,12 @@ class ClassProgressQueryService $schoolYear = (string) (config('school.school_year') ?? ''); } - return TeacherClass::getClassAssignmentsByUserId($user->id, $schoolYear, $semester); + $assignments = $this->teacherAssignmentsForTerm($user, $schoolYear !== '' ? $schoolYear : null); + if ($assignments !== []) { + return $assignments; + } + + return $this->teacherAssignmentsForTerm($user, null); } public function meta(?string $schoolYear = null, ?string $semester = null): array @@ -258,6 +268,119 @@ class ClassProgressQueryService return $options[$status] ?? 'Unknown'; } + /** + * Resolve the logged-in teacher's real assignment rows. The join accepts both + * legacy section identifiers used in this codebase. + * + * @return list> + */ + private function teacherAssignmentsForTerm(User $user, ?string $schoolYear): array + { + if (! Schema::hasTable('classSection') || ! Schema::hasTable('teacher_class')) { + return []; + } + + $teacherClassHasSchoolYear = Schema::hasColumn('teacher_class', 'school_year'); + $teacherClassHasSemester = Schema::hasColumn('teacher_class', 'semester'); + $schoolYearSelect = $teacherClassHasSchoolYear + ? 'COALESCE(NULLIF(tc.school_year, ""), cs_code.school_year, cs_pk.school_year) AS school_year' + : 'COALESCE(cs_code.school_year, cs_pk.school_year) AS school_year'; + $semesterSelect = $teacherClassHasSemester + ? 'COALESCE(NULLIF(tc.semester, ""), cs_code.semester, cs_pk.semester) AS semester' + : 'COALESCE(cs_code.semester, cs_pk.semester) AS semester'; + + $query = DB::table('teacher_class as tc') + ->selectRaw(" + COALESCE(cs_code.class_section_name, cs_pk.class_section_name) AS class_section_name, + COALESCE(cs_code.id, cs_pk.id) AS class_section_pk, + COALESCE(cs_code.class_section_id, cs_pk.class_section_id) AS class_section_id, + c.id AS class_id, + c.class_name, + tc.teacher_id, + tc.position, + {$schoolYearSelect}, + {$semesterSelect} + ") + ->leftJoin('classSection as cs_code', 'tc.class_section_id', '=', 'cs_code.class_section_id') + ->leftJoin('classSection as cs_pk', function ($join) { + $join->on('tc.class_section_id', '=', 'cs_pk.id') + ->whereNull('cs_code.class_section_id'); + }) + ->leftJoin('classes as c', DB::raw('COALESCE(cs_code.class_id, cs_pk.class_id)'), '=', 'c.id') + ->where('tc.teacher_id', (int) $user->id) + ->whereNotNull('tc.class_section_id') + ->whereRaw('COALESCE(cs_code.class_section_id, cs_pk.class_section_id) IS NOT NULL'); + + if ($schoolYear !== null && $schoolYear !== '') { + if ($teacherClassHasSchoolYear) { + $query->where(function ($yearQuery) use ($schoolYear) { + $yearQuery->where('tc.school_year', $schoolYear) + ->orWhere(function ($legacyYearQuery) use ($schoolYear) { + $legacyYearQuery->where(function ($blankYearQuery) { + $blankYearQuery->whereNull('tc.school_year') + ->orWhere('tc.school_year', ''); + })->whereRaw('COALESCE(cs_code.school_year, cs_pk.school_year) = ?', [$schoolYear]); + }); + }); + } else { + $query->whereRaw('COALESCE(cs_code.school_year, cs_pk.school_year) = ?', [$schoolYear]); + } + } + + return $query + ->orderByRaw("CASE tc.position WHEN 'main' THEN 0 WHEN 'ta' THEN 1 ELSE 2 END") + ->orderByRaw('COALESCE(cs_code.class_section_name, cs_pk.class_section_name) ASC') + ->get() + ->map(fn ($row) => [ + 'class_section_pk' => isset($row->class_section_pk) ? (int) $row->class_section_pk : null, + 'class_section_id' => (int) $row->class_section_id, + 'class_section_name' => (string) $row->class_section_name, + 'class_id' => isset($row->class_id) ? (int) $row->class_id : null, + 'class_name' => $row->class_name ?? null, + 'teacher_id' => (int) $row->teacher_id, + 'teacher_role' => ucfirst((string) $row->position), + 'school_year' => $row->school_year ?? null, + 'semester' => $row->semester ?? null, + ]) + ->unique('class_section_id') + ->values() + ->all(); + } + + /** + * @return list + */ + private function equivalentClassSectionIds(int $classSectionId): array + { + if ($classSectionId <= 0) { + return []; + } + + $ids = [$classSectionId => true]; + if (! Schema::hasTable('classSection')) { + return [$classSectionId]; + } + + $rows = DB::table('classSection') + ->select('id', 'class_section_id') + ->where('class_section_id', $classSectionId) + ->orWhere('id', $classSectionId) + ->get(); + + foreach ($rows as $row) { + foreach ([(int) $row->id, (int) $row->class_section_id] as $id) { + if ($id > 0) { + $ids[$id] = true; + } + } + } + + $ids = array_map('intval', array_keys($ids)); + sort($ids); + + return $ids; + } + private function isAdmin(User $user): bool { $roles = $user->roles() @@ -339,7 +462,7 @@ class ClassProgressQueryService return in_array( (int) $report->class_section_id, - TeacherClass::distinctSectionIdsForTeacher((int) $user->id), + TeacherClass::distinctAccessibleSectionIdsForTeacher((int) $user->id), true ); } diff --git a/app/Services/Invoices/InvoicePaymentService.php b/app/Services/Invoices/InvoicePaymentService.php index 51bb2275..1b3a71e8 100644 --- a/app/Services/Invoices/InvoicePaymentService.php +++ b/app/Services/Invoices/InvoicePaymentService.php @@ -35,8 +35,49 @@ class InvoicePaymentService } $invoiceIds = array_column($invoices, 'id'); + $payments = []; + $discounts = []; $refunds = []; if (! empty($invoiceIds)) { + $paymentTotals = DB::table('payments') + ->selectRaw('invoice_id, COALESCE(SUM(paid_amount),0) as paid_amount') + ->whereIn('invoice_id', $invoiceIds) + ->where(function ($query) { + $query->whereNotIn(DB::raw('LOWER(status)'), [ + 'pending', + 'void', + 'voided', + 'cancelled', + 'canceled', + 'failed', + 'rejected', + 'refunded', + 'chargeback', + 'declined', + 'reversed', + ])->orWhereNull('status'); + }) + ->groupBy('invoice_id') + ->get() + ->map(fn ($r) => (array) $r) + ->all(); + + foreach ($paymentTotals as $row) { + $payments[$row['invoice_id']] = (float) ($row['paid_amount'] ?? 0); + } + + $discountResults = DB::table('discount_usages') + ->selectRaw('invoice_id, COALESCE(SUM(discount_amount),0) as discount_amount') + ->whereIn('invoice_id', $invoiceIds) + ->groupBy('invoice_id') + ->get() + ->map(fn ($r) => (array) $r) + ->all(); + + foreach ($discountResults as $row) { + $discounts[$row['invoice_id']] = (float) ($row['discount_amount'] ?? 0); + } + $refundResults = DB::table('refunds') ->selectRaw('invoice_id, COALESCE(SUM(refund_paid_amount),0) as refund_paid_amount') ->whereIn('invoice_id', $invoiceIds) @@ -63,9 +104,20 @@ class InvoicePaymentService } foreach ($invoices as &$invoice) { - $invoice['refund_amount'] = $refunds[$invoice['id']] ?? 0.0; - $invoice['last_paid_amount'] = $lastPayments[$invoice['id']]['last_paid_amount'] ?? 0.0; - $invoice['last_payment_date'] = $lastPayments[$invoice['id']]['last_payment_date'] ?? null; + $invoiceId = (int) ($invoice['id'] ?? 0); + $totalAmount = round((float) ($invoice['total_amount'] ?? 0), 2); + $paidAmount = round((float) ($payments[$invoiceId] ?? $invoice['paid_amount'] ?? 0), 2); + $discountAmount = round((float) ($discounts[$invoiceId] ?? $invoice['discount'] ?? 0), 2); + $refundAmount = round((float) ($refunds[$invoiceId] ?? 0), 2); + $balance = max(0.0, round($totalAmount - $paidAmount - $discountAmount - $refundAmount, 2)); + + $invoice['paid_amount'] = $paidAmount; + $invoice['discount'] = $discountAmount; + $invoice['refund_amount'] = $refundAmount; + $invoice['balance'] = $balance; + $invoice['status'] = $this->deriveInvoiceStatus($invoice['status'] ?? null, $paidAmount, $balance); + $invoice['last_paid_amount'] = $lastPayments[$invoiceId]['last_paid_amount'] ?? 0.0; + $invoice['last_payment_date'] = $lastPayments[$invoiceId]['last_payment_date'] ?? null; } unset($invoice); @@ -77,4 +129,17 @@ class InvoicePaymentService 'dueDate' => $this->config->getDueDate(), ]; } + + private function deriveInvoiceStatus(?string $storedStatus, float $paidAmount, float $balance): string + { + if ($balance <= 0.00001) { + return 'Paid'; + } + + if ($paidAmount > 0) { + return 'Partially Paid'; + } + + return $storedStatus ?: 'Unpaid'; + } } diff --git a/app/Services/Parents/ParentEnrollmentService.php b/app/Services/Parents/ParentEnrollmentService.php index b08fa238..99b1ff09 100644 --- a/app/Services/Parents/ParentEnrollmentService.php +++ b/app/Services/Parents/ParentEnrollmentService.php @@ -218,6 +218,7 @@ class ParentEnrollmentService 'reason' => 'Withdrawal under review for student ID '.$studentId, 'request' => 'new', 'semester' => $semester, + 'refund_amount' => 0.0, 'refund_paid_amount' => 0.0, ]); } diff --git a/app/Services/Parents/ParentInvoiceService.php b/app/Services/Parents/ParentInvoiceService.php index f67b9e1f..1e5d751a 100644 --- a/app/Services/Parents/ParentInvoiceService.php +++ b/app/Services/Parents/ParentInvoiceService.php @@ -3,6 +3,11 @@ namespace App\Services\Parents; use App\Models\Invoice; +use App\Models\ParentAccount; +use App\Models\ParentBalanceTransfer; +use App\Models\User; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; class ParentInvoiceService { @@ -14,6 +19,311 @@ class ParentInvoiceService ->orderByDesc('created_at') ->get(); - return $rows->toArray(); + $invoiceIds = $rows + ->pluck('id') + ->map(fn ($id) => (int) $id) + ->filter(fn ($id) => $id > 0) + ->values() + ->all(); + + $paymentsByInvoice = $this->paymentsByInvoice($parentId, $invoiceIds); + $adjustments = $this->invoiceAdjustments($invoiceIds); + + return $rows + ->map(function (Invoice $invoice) use ($paymentsByInvoice, $adjustments): array { + $invoiceRow = $invoice->toArray(); + $invoiceId = (int) $invoice->id; + $payments = $paymentsByInvoice[(int) $invoice->id] ?? []; + $paidAmount = round(array_sum(array_map( + static fn (array $payment): float => (float) ($payment['paid_amount'] ?? 0), + $payments + )), 2); + $discountAmount = round((float) ($adjustments['discounts'][$invoiceId] ?? 0), 2); + $refundAmount = round((float) ($adjustments['refunds'][$invoiceId] ?? 0), 2); + $totalAmount = round((float) ($invoice->total_amount ?? 0), 2); + $balance = max(0.0, round($totalAmount - $paidAmount - $discountAmount - $refundAmount, 2)); + + $invoiceRow['payments'] = $payments; + $invoiceRow['paid_amount'] = $paidAmount; + $invoiceRow['discount'] = $discountAmount; + $invoiceRow['refund_amount'] = $refundAmount; + $invoiceRow['balance'] = $balance; + $invoiceRow['last_paid_amount'] = (float) ($payments[0]['paid_amount'] ?? 0); + $invoiceRow['last_payment_date'] = $payments[0]['payment_date'] ?? null; + $invoiceRow['status'] = $this->deriveInvoiceStatus($invoice->status, $paidAmount, $balance); + + return $invoiceRow; + }) + ->all(); + } + + public function statement(int $parentId, ?string $schoolYear = null): array + { + $parent = User::query()->find($parentId); + $account = $schoolYear + ? ParentAccount::query() + ->where('parent_id', $parentId) + ->where('school_year', $schoolYear) + ->first() + : null; + + $columns = [ + 'id', + 'invoice_number', + 'total_amount', + 'paid_amount', + 'balance', + 'status', + 'description', + 'school_year', + ]; + + if (Schema::hasColumn('invoices', 'invoice_type')) { + $columns[] = 'invoice_type'; + } + if (Schema::hasColumn('invoices', 'balance_transfer_id')) { + $columns[] = 'balance_transfer_id'; + } + + $invoices = Invoice::query() + ->where('parent_id', $parentId) + ->when($schoolYear, fn ($q) => $q->where('school_year', $schoolYear)) + ->orderBy('created_at') + ->get($columns); + + $transferSourceYears = $this->transferSourceYears($invoices->pluck('balance_transfer_id')->filter()->all()); + $invoiceIds = $invoices + ->pluck('id') + ->map(fn ($id) => (int) $id) + ->filter(fn ($id) => $id > 0) + ->values() + ->all(); + $adjustments = $this->invoiceAdjustments($invoiceIds); + + $lines = $invoices + ->map(function (Invoice $invoice) use ($transferSourceYears, $adjustments): array { + $transferId = (int) ($invoice->balance_transfer_id ?? 0); + $invoiceId = (int) $invoice->id; + $totalAmount = round((float) ($invoice->total_amount ?? 0), 2); + $paidAmount = round((float) ($adjustments['payments'][$invoiceId] ?? $invoice->paid_amount ?? 0), 2); + $discountAmount = round((float) ($adjustments['discounts'][$invoiceId] ?? 0), 2); + $refundAmount = round((float) ($adjustments['refunds'][$invoiceId] ?? 0), 2); + $balance = max(0.0, round($totalAmount - $paidAmount - $discountAmount - $refundAmount, 2)); + + return [ + 'label' => $invoice->description ?: ($invoice->invoice_number ?: sprintf('Invoice #%d', $invoice->id)), + 'amount' => $balance, + 'source_school_year' => $transferId > 0 + ? ($transferSourceYears[$transferId] ?? $invoice->school_year) + : $invoice->school_year, + 'invoice_id' => $invoice->id, + ]; + }) + ->values() + ->all(); + + $openingBalance = round((float) ($account?->opening_balance ?? 0), 2); + $currentBalance = round($openingBalance + array_sum(array_map( + static fn (array $line): float => (float) ($line['amount'] ?? 0), + $lines + )), 2); + + return [ + 'parent_id' => $parentId, + 'parent_name' => $this->parentName($parent), + 'school_year' => $schoolYear, + 'lines' => $lines, + 'opening_balance' => $openingBalance, + 'current_balance' => round($currentBalance, 2), + ]; + } + + private function parentName(?User $parent): ?string + { + if (! $parent) { + return null; + } + + $parts = array_filter([ + trim((string) ($parent->firstname ?? '')), + trim((string) ($parent->lastname ?? '')), + ]); + + if ($parts !== []) { + return implode(' ', $parts); + } + + return $parent->name ?? $parent->email ?? null; + } + + /** + * @param list $invoiceIds + * @return array>> + */ + private function paymentsByInvoice(int $parentId, array $invoiceIds): array + { + if ($invoiceIds === [] || ! Schema::hasTable('payments')) { + return []; + } + + $columns = [ + 'id', + 'parent_id', + 'invoice_id', + 'paid_amount', + Schema::hasColumn('payments', 'balance') ? 'balance' : DB::raw('NULL AS balance'), + Schema::hasColumn('payments', 'payment_method') ? 'payment_method' : DB::raw('NULL AS payment_method'), + Schema::hasColumn('payments', 'payment_date') ? 'payment_date' : DB::raw('NULL AS payment_date'), + Schema::hasColumn('payments', 'transaction_id') ? 'transaction_id' : DB::raw('NULL AS transaction_id'), + Schema::hasColumn('payments', 'status') ? 'status' : DB::raw('NULL AS status'), + Schema::hasColumn('payments', 'school_year') ? 'school_year' : DB::raw('NULL AS school_year'), + Schema::hasColumn('payments', 'semester') ? 'semester' : DB::raw('NULL AS semester'), + Schema::hasColumn('payments', 'created_at') ? 'created_at' : DB::raw('NULL AS created_at'), + ]; + + $query = DB::table('payments') + ->select($columns) + ->where('parent_id', $parentId) + ->whereIn('invoice_id', $invoiceIds); + + if (Schema::hasColumn('payments', 'status')) { + $query->whereNotIn(DB::raw('LOWER(status)'), ['pending', 'void', 'voided', 'cancelled', 'canceled', 'failed', 'rejected']); + } + if (Schema::hasColumn('payments', 'payment_date')) { + $query->orderByDesc('payment_date'); + } + if (Schema::hasColumn('payments', 'created_at')) { + $query->orderByDesc('created_at'); + } + + $rows = $query + ->get() + ->map(static fn ($row): array => [ + 'id' => (int) $row->id, + 'parent_id' => (int) $row->parent_id, + 'invoice_id' => (int) $row->invoice_id, + 'paid_amount' => (float) $row->paid_amount, + 'balance' => (float) $row->balance, + 'payment_method' => $row->payment_method, + 'payment_date' => $row->payment_date, + 'transaction_id' => $row->transaction_id, + 'status' => $row->status, + 'school_year' => $row->school_year, + 'semester' => $row->semester, + 'created_at' => $row->created_at, + ]) + ->all(); + + $grouped = []; + foreach ($rows as $row) { + $grouped[(int) $row['invoice_id']][] = $row; + } + + return $grouped; + } + + /** + * @param list $invoiceIds + * @return array{payments: array, discounts: array, refunds: array} + */ + private function invoiceAdjustments(array $invoiceIds): array + { + $invoiceIds = array_values(array_unique(array_filter(array_map('intval', $invoiceIds)))); + $adjustments = [ + 'payments' => [], + 'discounts' => [], + 'refunds' => [], + ]; + + if ($invoiceIds === []) { + return $adjustments; + } + + if (Schema::hasTable('payments')) { + $paymentQuery = DB::table('payments') + ->selectRaw('invoice_id, COALESCE(SUM(paid_amount),0) AS total') + ->whereIn('invoice_id', $invoiceIds); + + if (Schema::hasColumn('payments', 'status')) { + $paymentQuery->where(function ($query) { + $query->whereNotIn(DB::raw('LOWER(status)'), [ + 'pending', + 'void', + 'voided', + 'cancelled', + 'canceled', + 'failed', + 'rejected', + 'refunded', + 'chargeback', + 'declined', + 'reversed', + ])->orWhereNull('status'); + }); + } + + $adjustments['payments'] = $paymentQuery + ->groupBy('invoice_id') + ->pluck('total', 'invoice_id') + ->map(fn ($value) => (float) $value) + ->all(); + } + + if (Schema::hasTable('discount_usages')) { + $adjustments['discounts'] = DB::table('discount_usages') + ->selectRaw('invoice_id, COALESCE(SUM(discount_amount),0) AS total') + ->whereIn('invoice_id', $invoiceIds) + ->groupBy('invoice_id') + ->pluck('total', 'invoice_id') + ->map(fn ($value) => (float) $value) + ->all(); + } + + if (Schema::hasTable('refunds')) { + $adjustments['refunds'] = DB::table('refunds') + ->selectRaw('invoice_id, COALESCE(SUM(refund_paid_amount),0) AS total') + ->whereIn('invoice_id', $invoiceIds) + ->whereIn('status', ['Partial', 'Paid']) + ->groupBy('invoice_id') + ->pluck('total', 'invoice_id') + ->map(fn ($value) => (float) $value) + ->all(); + } + + return $adjustments; + } + + private function deriveInvoiceStatus(?string $storedStatus, float $paidAmount, float $balance): string + { + if ($paidAmount <= 0 && $balance > 0) { + return 'Unpaid'; + } + + if ($balance <= 0) { + return 'Paid'; + } + + if ($paidAmount > 0) { + return 'Partially Paid'; + } + + return $storedStatus ?: 'Unpaid'; + } + + /** + * @param array $transferIds + * @return array + */ + private function transferSourceYears(array $transferIds): array + { + $ids = array_values(array_unique(array_map('intval', $transferIds))); + if ($ids === []) { + return []; + } + + return ParentBalanceTransfer::query() + ->whereIn('id', $ids) + ->pluck('from_school_year', 'id') + ->all(); } } diff --git a/app/Services/Payments/PaymentManualService.php b/app/Services/Payments/PaymentManualService.php index 0a975af3..f4d4f29a 100644 --- a/app/Services/Payments/PaymentManualService.php +++ b/app/Services/Payments/PaymentManualService.php @@ -34,6 +34,7 @@ class PaymentManualService } $parentData = null; + $parentMatches = []; $students = []; $payments = []; $invoices = []; @@ -47,6 +48,14 @@ class PaymentManualService $builder->where(function ($q) use ($searchTerm, $searchClean, $searchFormatted) { $q->where('email', $searchTerm); + if (ctype_digit($searchTerm)) { + $q->orWhere('id', (int) $searchTerm); + } + + $q->orWhereRaw("CONCAT_WS(' ', firstname, lastname) LIKE ?", ['%'.$searchTerm.'%']) + ->orWhere('firstname', 'like', '%'.$searchTerm.'%') + ->orWhere('lastname', 'like', '%'.$searchTerm.'%'); + if (! empty($searchClean)) { if (strlen($searchClean) === 10) { $formattedPhone = substr($searchClean, 0, 3).'-'.substr($searchClean, 3, 3).'-'.substr($searchClean, 6, 4); @@ -62,11 +71,19 @@ class PaymentManualService if (! empty($searchFormatted)) { $q->orWhere('cellphone', $searchFormatted); } - }); + }) + ->orderByRaw('LOWER(firstname)') + ->orderByRaw('LOWER(lastname)') + ->orderByRaw('LOWER(email)') + ->limit(20); - $parent = (array) $builder->first(); + $parentRows = $builder->get() + ->map(fn ($row) => $this->sanitizeParentRow((array) $row)) + ->all(); + $parentMatches = $parentRows; + + $parent = $parentRows[0] ?? []; if (! empty($parent)) { - unset($parent['password']); $parentData = $parent; $parentId = (int) ($parent['id'] ?? 0); @@ -100,6 +117,7 @@ class PaymentManualService return [ 'parent' => $parentData, + 'parent_matches' => $parentMatches, 'students' => $students, 'payments' => $payments, 'invoices' => $invoices, @@ -133,7 +151,7 @@ class PaymentManualService $params[] = '%'.$digits.'%'; } - $sql .= ') ORDER BY lastname, firstname LIMIT 20'; + $sql .= ') ORDER BY LOWER(firstname), LOWER(lastname), LOWER(email) LIMIT 20'; $rows = DB::select($sql, $params); @@ -156,6 +174,13 @@ class PaymentManualService return $items; } + private function sanitizeParentRow(array $parent): array + { + unset($parent['password'], $parent['remember_token']); + + return $parent; + } + public function recordPayment( int $invoiceId, float $amount, diff --git a/app/Services/SchoolYears/SchoolYearClosureService.php b/app/Services/SchoolYears/SchoolYearClosureService.php index 57109131..18c91064 100644 --- a/app/Services/SchoolYears/SchoolYearClosureService.php +++ b/app/Services/SchoolYears/SchoolYearClosureService.php @@ -179,10 +179,8 @@ class SchoolYearClosureService $promotionCounts = is_array($metadata['promotion_counts'] ?? null) ? $metadata['promotion_counts'] : []; - $balanceCounts = is_array($metadata['balance_counts'] ?? null) - ? $metadata['balance_counts'] - : []; $transferTotals = $this->transferTotalsForClosedYear($year->name, $newYearName !== '' ? $newYearName : null); + $invoiceBalanceTotals = $this->positiveInvoiceBalanceTotals($year->name); $warnings = []; if (! $audit) { @@ -207,14 +205,13 @@ class SchoolYearClosureService 'students_graduated' => (int) ($promotionCounts['graduate'] ?? 0), 'students_transferred' => (int) ($promotionCounts['transfer'] ?? 0), 'students_withdrawn' => (int) ($promotionCounts['withdraw'] ?? 0), - 'parents_with_unpaid_balances' => (int) ($balanceCounts['transferred_parents'] ?? $transferTotals['parents_with_unpaid_balances']), - 'total_unpaid_balance_transferred' => round((float) ($balanceCounts['transferred_amount'] ?? $transferTotals['total_unpaid_balance_transferred']), 2), - 'parents_with_credit_balances' => (int) ($balanceCounts['credit_parents'] ?? $transferTotals['parents_with_credit_balances']), - 'total_credit_carried_or_reported' => round((float) ($balanceCounts['credit_amount'] ?? $transferTotals['total_credit_carried_or_reported']), 2), - 'total_credit_transferred' => round((float) ($balanceCounts['credit_amount'] ?? $transferTotals['total_credit_carried_or_reported']), 2), + 'parents_with_unpaid_balances' => $invoiceBalanceTotals['parents_with_unpaid_balances'], + 'total_unpaid_balance_transferred' => $invoiceBalanceTotals['total_unpaid_balance'], + 'parents_with_credit_balances' => $transferTotals['parents_with_credit_balances'], + 'total_credit_carried_or_reported' => $transferTotals['total_credit_carried_or_reported'], + 'total_credit_transferred' => $transferTotals['total_credit_carried_or_reported'], 'net_balance_impact' => round( - (float) ($balanceCounts['transferred_amount'] ?? $transferTotals['total_unpaid_balance_transferred']) - - (float) ($balanceCounts['credit_amount'] ?? $transferTotals['total_credit_carried_or_reported']), + $invoiceBalanceTotals['total_unpaid_balance'] - $transferTotals['total_credit_carried_or_reported'], 2 ), ], @@ -458,7 +455,8 @@ class SchoolYearClosureService return $this->buildParentBalanceRows( $year->name, - $toSchoolYear ?: ($this->resolver->suggestNextName($year->name) ?? $year->name) + $toSchoolYear ?: ($this->resolver->suggestNextName($year->name) ?? $year->name), + false ); } @@ -680,6 +678,8 @@ class SchoolYearClosureService $action = 'graduate'; } elseif (in_array($decisionText, ['retained', 'repeat', 'repeated', 'not promoted'], true) || ($score !== null && $score < 60)) { $action = 'repeat'; + } elseif ($this->isPassingSeventeenYearOld($student, $decisionText, $score)) { + $action = 'graduate'; } elseif (($progression['is_terminal'] ?? false) === true) { $action = 'graduate'; } elseif ($decisionText !== '' || $score !== null) { @@ -694,6 +694,9 @@ class SchoolYearClosureService } elseif ($action === 'promote') { $targetLevelId = $progression['next_level_id'] ?? null; $targetLevelName = $progression['next_level_name'] ?? null; + if ($this->isKindergartenAgeReadyForGradeOne($student, $currentClass, $section)) { + [$targetLevelId, $targetLevelName] = $this->resolveGradeOneTarget(); + } if ($targetLevelId === null) { $action = 'hold'; $warnings[] = 'No next grade mapping exists for this student.'; @@ -739,11 +742,61 @@ class SchoolYearClosureService ]; } - private function buildParentBalanceRows(string $fromSchoolYear, string $toSchoolYear): array + private function isPassingSeventeenYearOld(object $student, string $decisionText, ?float $score): bool { - $invoices = $this->eligibleCarryoverInvoiceQuery($fromSchoolYear) - ->whereRaw('ABS(COALESCE(balance, 0)) > 0.01') - ->get(['id', 'parent_id', 'balance']); + $age = (int) ($student->age ?? 0); + if ($age !== 17) { + return false; + } + + if ($score !== null) { + return $score >= 60; + } + + return str_contains($decisionText, 'promot') + || str_contains($decisionText, 'pass'); + } + + private function isKindergartenAgeReadyForGradeOne(object $student, ?object $currentClass, ?object $section): bool + { + $age = (int) ($student->age ?? 0); + if ($age + 1 < 6) { + return false; + } + + $className = strtolower(trim((string) ($currentClass->class_name ?? ''))); + $sectionName = strtolower(trim((string) ($section->class_section_name ?? ''))); + + return in_array($className, ['kg', 'kindergarten'], true) + || in_array($sectionName, ['kg', 'kindergarten'], true); + } + + private function resolveGradeOneTarget(): array + { + $target = DB::table('classes') + ->whereIn(DB::raw('LOWER(class_name)'), ['1', 'grade 1', 'class 1']) + ->orderByRaw("CASE WHEN class_name = '1' THEN 0 ELSE 1 END") + ->orderBy('id') + ->first(); + + return $target + ? [(int) $target->id, (string) $target->class_name] + : [null, null]; + } + + private function buildParentBalanceRows(string $fromSchoolYear, string $toSchoolYear, bool $includeCreditOnlyRows = true): array + { + $invoiceQuery = $includeCreditOnlyRows + ? $this->eligibleCarryoverInvoiceQuery($fromSchoolYear) + : DB::table('invoices')->where('school_year', $fromSchoolYear); + + if ($includeCreditOnlyRows) { + $invoiceQuery->whereRaw('ABS(COALESCE(balance, 0)) > 0.01'); + } else { + $invoiceQuery->whereRaw('COALESCE(balance, 0) > 0.01'); + } + + $invoices = $invoiceQuery->get(['id', 'parent_id', 'balance']); $balancesByParent = []; foreach ($invoices as $invoice) { @@ -801,6 +854,18 @@ class SchoolYearClosureService ->first() : null; + if (! $includeCreditOnlyRows && $positive < 0.01) { + if ($credit > 0.01) { + $summary['parents_with_credit_balance']++; + $summary['total_credit_balance'] += $credit; + } + if (abs($net) > 0.01) { + $summary['parents_with_net_non_zero_balance']++; + } + + continue; + } + $row = [ 'parent_id' => $parentId, 'parent_name' => $this->resolveParentName($parentId), @@ -808,9 +873,15 @@ class SchoolYearClosureService 'unpaid_invoice_count' => count($balanceRow['positive_invoice_ids']), 'positive_unpaid_balance' => $positive, 'old_unpaid_balance' => $positive, + 'old_unpaid_amount' => $positive, 'credit_balance' => $credit, 'net_balance' => $net, 'amount_to_transfer' => $positive, + 'old_school_year' => $fromSchoolYear, + 'new_school_year' => $toSchoolYear, + 'new_old_balance_invoice_id' => $existing?->new_invoice_id ?? $existing?->old_balance_invoice_id ?? null, + 'transfer_date' => optional($existing?->created_at)->toDateTimeString(), + 'created_by' => $existing?->created_by, 'transfer_status' => $existing?->status ?? ($positive > 0.01 ? 'pending' : 'credit_reported'), 'existing_transfer_id' => $existing?->id, 'existing_transfer_conflict' => $existing !== null, @@ -845,6 +916,9 @@ class SchoolYearClosureService $summary['parents_with_non_zero_balance'] = $summary['parents_with_net_non_zero_balance']; $summary['total_old_unpaid_balance'] = $summary['total_positive_unpaid_balance']; $summary['net_balance_to_transfer'] = $summary['net_balance_impact']; + $summary['parent_count'] = $summary['parents_with_positive_balance']; + $summary['total_transferred'] = $summary['total_positive_unpaid_balance']; + $summary['total_credit'] = $summary['total_credit_balance']; return [ 'from_school_year' => $fromSchoolYear, @@ -858,7 +932,19 @@ class SchoolYearClosureService { return DB::table('invoices') ->where('school_year', $schoolYear) - ->whereNotIn(DB::raw("LOWER(COALESCE(status, ''))"), ['void', 'voided', 'cancelled', 'canceled', 'draft', 'pending']); + ->whereNotIn(DB::raw("LOWER(COALESCE(status, ''))"), [ + 'void', + 'voided', + 'cancelled', + 'canceled', + 'draft', + 'pending', + 'paid', + 'full', + 'full payment', + 'complete', + 'completed', + ]); } private function resolveTargetSection(?int $targetLevelId, string $schoolYear): array @@ -1252,10 +1338,18 @@ class SchoolYearClosureService return $totals; } + $columns = ['amount']; + if (Schema::hasColumn('parent_balance_transfers', 'source_summary_json')) { + $columns[] = 'source_summary_json'; + } + if (Schema::hasColumn('parent_balance_transfers', 'source_summary')) { + $columns[] = 'source_summary'; + } + $rows = DB::table('parent_balance_transfers') ->where('from_school_year', $fromSchoolYear) ->when($toSchoolYear !== null && trim($toSchoolYear) !== '', fn ($query) => $query->where('to_school_year', $toSchoolYear)) - ->get(['amount', 'source_summary_json', 'source_summary']); + ->get($columns); foreach ($rows as $row) { $amount = round((float) ($row->amount ?? 0), 2); @@ -1283,6 +1377,21 @@ class SchoolYearClosureService return $totals; } + private function positiveInvoiceBalanceTotals(string $schoolYear): array + { + $rows = DB::table('invoices') + ->where('school_year', $schoolYear) + ->whereRaw('COALESCE(balance, 0) > 0.01') + ->select('parent_id', DB::raw('SUM(COALESCE(balance, 0)) as total_balance')) + ->groupBy('parent_id') + ->get(); + + return [ + 'parents_with_unpaid_balances' => $rows->count(), + 'total_unpaid_balance' => round((float) $rows->sum('total_balance'), 2), + ]; + } + private function decodeTransferSourceSummary(object $row): array { $raw = $row->source_summary_json ?? $row->source_summary ?? null; diff --git a/routes/api.php b/routes/api.php index 9971e2e5..b849cce1 100644 --- a/routes/api.php +++ b/routes/api.php @@ -676,8 +676,10 @@ Route::prefix('v1')->group(function () { Route::get('teacher/class-progress-history', [ClassProgressController::class, 'index']); // Legacy teacher progress view page Route::get('teacher/class-progress-view', [ClassProgressController::class, 'index']); - // Legacy alias: same handler as GET /api/v1/competition-scores + // Legacy aliases: same handlers as /api/v1/competition-scores. Route::get('teacher/competition-scores', [CompetitionScoresController::class, 'index']); + Route::get('teacher/competition-scores/{id}', [CompetitionScoresController::class, 'edit'])->whereNumber('id'); + Route::post('teacher/competition-scores/{id}', [CompetitionScoresController::class, 'save'])->whereNumber('id'); Route::get('dashboard/route', [DashboardController::class, 'route']); Route::get('emergency-contacts', [AdministratorEmergencyContactController::class, 'index']); @@ -861,6 +863,7 @@ Route::prefix('v1')->group(function () { Route::middleware('parent.access')->prefix('parents')->group(function () { Route::get('attendance', [ParentApiController::class, 'attendance']); Route::get('invoices', [ParentApiController::class, 'invoices']); + Route::get('statements', [ParentApiController::class, 'statement']); Route::get('enrollments', [ParentApiController::class, 'enrollments']); Route::post('enrollments', [ParentApiController::class, 'updateEnrollments']); Route::get('progress', [ParentApiController::class, 'progress']); diff --git a/storage/uploads/exams/finals/final1.pdf b/storage/uploads/exams/finals/final1.pdf new file mode 100644 index 00000000..6fcd80f8 --- /dev/null +++ b/storage/uploads/exams/finals/final1.pdf @@ -0,0 +1 @@ +PDFDATA \ No newline at end of file diff --git a/tests/Feature/Api/V1/Administrator/AdministratorTeacherSubmissionReportApiTest.php b/tests/Feature/Api/V1/Administrator/AdministratorTeacherSubmissionReportApiTest.php index d6274900..a89c6f54 100644 --- a/tests/Feature/Api/V1/Administrator/AdministratorTeacherSubmissionReportApiTest.php +++ b/tests/Feature/Api/V1/Administrator/AdministratorTeacherSubmissionReportApiTest.php @@ -9,7 +9,9 @@ use App\Models\TeacherSubmissionNotificationHistory; use App\Models\User; use App\Services\Administrator\AdministratorSharedService; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; use Laravel\Sanctum\Sanctum; use Tests\TestCase; @@ -290,6 +292,57 @@ class AdministratorTeacherSubmissionReportApiTest extends TestCase $this->assertSame('Main: Legacy Teacher', $response->json('rows.0.teachers.0.label')); } + public function test_teacher_submission_report_handles_student_class_without_semester_column(): void + { + Schema::table('student_class', function (Blueprint $table) { + $table->dropColumn('semester'); + }); + + $admin = $this->seedAdminUser('admin.report.student-class-legacy@test.com'); + Sanctum::actingAs($admin, [], 'api'); + + User::factory()->create([ + 'id' => 220, + 'firstname' => 'StudentClass', + 'lastname' => 'Legacy', + 'email' => 'student.class.legacy.teacher@test.com', + ]); + + DB::table('classSection')->insert([ + [ + 'class_section_id' => 22, + 'class_section_name' => 'Grade 9A', + 'class_id' => 9, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ], + ]); + + DB::table('teacher_class')->insert([ + [ + 'class_section_id' => 22, + 'teacher_id' => 220, + 'position' => 'main', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + ], + ]); + + DB::table('student_class')->insert([ + 'student_id' => 1, + 'class_section_id' => 22, + 'school_year' => '2025-2026', + ]); + + $response = $this->getJson('/api/v1/administrator/teacher-submissions?school_year=2025-2026'); + + $response->assertOk() + ->assertJsonCount(1, 'rows'); + + $this->assertSame('Grade 9A', $response->json('rows.0.class_section')); + $this->assertSame(1, $response->json('rows.0.student_count')); + } + public function test_teacher_submission_report_limits_history_to_three_entries(): void { $admin = $this->seedAdminUser('admin.report.two@test.com', 901, 'Admin', 'Two'); diff --git a/tests/Feature/Api/V1/ClassProgress/ClassProgressControllerTest.php b/tests/Feature/Api/V1/ClassProgress/ClassProgressControllerTest.php index d6630fed..ad6c4b2e 100644 --- a/tests/Feature/Api/V1/ClassProgress/ClassProgressControllerTest.php +++ b/tests/Feature/Api/V1/ClassProgress/ClassProgressControllerTest.php @@ -73,6 +73,23 @@ class ClassProgressControllerTest extends TestCase 'created_at' => now(), 'updated_at' => now(), ]); + DB::table('classSection')->insert([ + 'id' => 8, + 'class_id' => 8, + 'class_section_id' => 808, + 'class_section_name' => '8-A', + 'semester' => 'Fall', + 'school_year' => '2024-2025', + ]); + DB::table('teacher_class')->insert([ + 'class_section_id' => 808, + 'teacher_id' => $user->id, + 'position' => 'main', + 'semester' => 'Fall', + 'school_year' => '2024-2025', + 'created_at' => now(), + 'updated_at' => now(), + ]); DB::table('subject_curriculum_items')->insert([ 'class_id' => 9, 'school_year' => null, @@ -83,6 +100,16 @@ class ClassProgressControllerTest extends TestCase 'created_at' => now(), 'updated_at' => now(), ]); + DB::table('subject_curriculum_items')->insert([ + 'class_id' => 9, + 'school_year' => null, + 'subject' => 'Quran/Arabic', + 'unit_number' => 2, + 'unit_title' => 'Surah Review', + 'chapter_name' => 'Al-Fatiha', + 'created_at' => now(), + 'updated_at' => now(), + ]); Sanctum::actingAs($user); @@ -93,8 +120,12 @@ class ClassProgressControllerTest extends TestCase $response->assertJsonPath('data.defaults.school_year', '2025-2026'); $response->assertJsonPath('data.defaults.semester', 'Fall'); $response->assertJsonPath('data.defaults.class_section_id', 909); + $this->assertCount(1, $response->json('data.classes')); + $response->assertJsonPath('data.classes.0.class_section_id', 909); $response->assertJsonPath('data.curriculum.islamic.0.chapter_name', 'Intro'); $response->assertJsonPath('data.unit_options.islamic.0.label', 'Unit 1 - Faith'); + $response->assertJsonPath('data.curriculum.quran.0.chapter_name', 'Al-Fatiha'); + $response->assertJsonPath('data.unit_options.quran.0.label', 'Unit 2 - Surah Review'); } public function test_index_returns_paginated_reports(): void @@ -115,10 +146,184 @@ class ClassProgressControllerTest extends TestCase $this->assertCount(2, $response->json('data.items')); } + public function test_teacher_history_is_scoped_to_assigned_class_sections(): void + { + $teacher = $this->createUser(); + $otherTeacher = $this->createUser('other-history-teacher@example.com'); + $this->assignRole($teacher->id, 'teacher'); + $this->seedClassSection(); + DB::table('classSection')->insert([ + 'id' => 2, + 'class_id' => 2, + 'class_section_id' => 202, + 'class_section_name' => '2-A', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + $this->assignTeacher($teacher->id, 1); + $this->assignTeacher($teacher->id, 202); + + $assignedOwnReport = ClassProgressReport::factory()->create([ + 'teacher_id' => $teacher->id, + 'class_section_id' => 101, + 'school_year' => '2025-2026', + 'week_start' => '2025-09-07', + ]); + $assignedSectionReport = ClassProgressReport::factory()->create([ + 'teacher_id' => $otherTeacher->id, + 'class_section_id' => 101, + 'school_year' => '2025-2026', + 'week_start' => '2025-09-14', + ]); + $otherSectionReport = ClassProgressReport::factory()->create([ + 'teacher_id' => $teacher->id, + 'class_section_id' => 202, + 'school_year' => '2025-2026', + 'week_start' => '2025-09-21', + ]); + + Sanctum::actingAs($teacher); + + $response = $this->getJson('/api/v1/teacher/class-progress-history?school_year=2025-2026&class_section_id=1'); + + $response->assertOk(); + $ids = collect($response->json('data.items'))->pluck('id')->all(); + + $this->assertContains($assignedOwnReport->id, $ids); + $this->assertContains($assignedSectionReport->id, $ids); + $this->assertNotContains($otherSectionReport->id, $ids); + + $detailResponse = $this->getJson('/api/v1/class-progress/'.$assignedSectionReport->id); + + $detailResponse->assertOk(); + $detailResponse->assertJsonPath('data.report.id', $assignedSectionReport->id); + + $unfilteredResponse = $this + ->withSession(['class_section_id' => 202]) + ->getJson('/api/v1/teacher/class-progress-history?school_year=2025-2026'); + + $unfilteredResponse->assertOk(); + $unfilteredIds = collect($unfilteredResponse->json('data.items'))->pluck('id')->all(); + + $this->assertNotContains($assignedOwnReport->id, $unfilteredIds); + $this->assertNotContains($assignedSectionReport->id, $unfilteredIds); + $this->assertContains($otherSectionReport->id, $unfilteredIds); + } + + public function test_teacher_history_accepts_legacy_progress_rows_saved_with_section_pk(): void + { + $teacher = $this->createUser(); + $otherTeacher = $this->createUser('legacy-pk-history-teacher@example.com'); + $this->assignRole($teacher->id, 'teacher_assistant'); + $this->seedClassSection(); + $this->assignTeacher($teacher->id, 101); + + $legacyPkReport = ClassProgressReport::factory()->create([ + 'teacher_id' => $otherTeacher->id, + 'class_section_id' => 1, + 'school_year' => '2025-2026', + 'week_start' => '2025-09-28', + ]); + + Sanctum::actingAs($teacher); + + $response = $this->getJson('/api/v1/teacher/class-progress-history?school_year=2025-2026&class_section_id=101'); + + $response->assertOk(); + $ids = collect($response->json('data.items'))->pluck('id')->all(); + + $this->assertContains($legacyPkReport->id, $ids); + } + + public function test_teacher_history_without_section_uses_logged_in_teacher_assignment(): void + { + $teacher = $this->createUser(); + $otherTeacher = $this->createUser('assignment-history-teacher@example.com'); + $this->assignRole($teacher->id, 'teacher'); + $this->seedClassSection(); + DB::table('classSection')->insert([ + 'id' => 2, + 'class_id' => 2, + 'class_section_id' => 202, + 'class_section_name' => '2-A', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + $this->assignTeacher($teacher->id, 1); + + $sameSectionReport = ClassProgressReport::factory()->create([ + 'teacher_id' => $otherTeacher->id, + 'class_section_id' => 101, + 'school_year' => '2025-2026', + 'week_start' => '2025-10-05', + ]); + $wrongSectionReport = ClassProgressReport::factory()->create([ + 'teacher_id' => $otherTeacher->id, + 'class_section_id' => 202, + 'school_year' => '2025-2026', + 'week_start' => '2025-10-12', + ]); + + Sanctum::actingAs($teacher); + + $response = $this->getJson('/api/v1/teacher/class-progress-history?school_year=2025-2026'); + + $response->assertOk(); + $ids = collect($response->json('data.items'))->pluck('id')->all(); + + $this->assertContains($sameSectionReport->id, $ids); + $this->assertNotContains($wrongSectionReport->id, $ids); + } + + public function test_teacher_history_without_section_does_not_422_for_multiple_assignments(): void + { + $teacher = $this->createUser(); + $otherTeacher = $this->createUser('multi-assignment-history-teacher@example.com'); + $this->assignRole($teacher->id, 'teacher'); + $this->seedClassSection(); + DB::table('classSection')->insert([ + 'id' => 2, + 'class_id' => 2, + 'class_section_id' => 202, + 'class_section_name' => '2-A', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + $this->assignTeacher($teacher->id, 1); + $this->assignTeacher($teacher->id, 202); + + $primarySectionReport = ClassProgressReport::factory()->create([ + 'teacher_id' => $otherTeacher->id, + 'class_section_id' => 101, + 'school_year' => '2025-2026', + 'week_start' => '2025-10-19', + ]); + $otherSectionReport = ClassProgressReport::factory()->create([ + 'teacher_id' => $otherTeacher->id, + 'class_section_id' => 202, + 'school_year' => '2025-2026', + 'week_start' => '2025-10-26', + ]); + + Sanctum::actingAs($teacher); + + $response = $this->getJson('/api/v1/teacher/class-progress-history?school_year=2025-2026&per_page=100&sort_by=week_start&sort_dir=desc'); + + $response->assertOk(); + $ids = collect($response->json('data.items'))->pluck('id')->all(); + + $this->assertContains($primarySectionReport->id, $ids); + $this->assertNotContains($otherSectionReport->id, $ids); + } + public function test_admin_grouped_index_uses_school_year_name_and_includes_legacy_rows(): void { $admin = $this->createUser('admin@example.com'); $this->assignRole($admin->id, 'administrator'); + DB::table('configuration')->updateOrInsert( + ['config_key' => 'semester'], + ['config_value' => 'Fall'] + ); $this->seedClassSection(); DB::table('classSection')->where('class_section_id', 101)->update([ 'semester' => '', @@ -286,6 +491,74 @@ class ClassProgressControllerTest extends TestCase $this->assertDatabaseCount('class_progress_reports', 2); } + public function test_teacher_store_uses_logged_in_teacher_assignment_for_section(): void + { + Storage::fake('public'); + $user = $this->createUser(); + $this->assignRole($user->id, 'teacher'); + $this->seedClassSection(); + DB::table('classSection')->insert([ + 'id' => 2, + 'class_id' => 2, + 'class_section_id' => 202, + 'class_section_name' => '2-A', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + $this->assignTeacher($user->id, 101); + Sanctum::actingAs($user); + + $payload = [ + 'class_section_id' => 202, + 'week_start' => '2025-09-07', + 'week_end' => '2025-09-13', + 'covered_islamic' => 'Lesson A', + 'covered_quran' => 'Lesson B', + 'unit_islamic' => ['Unit 1'], + 'chapter_islamic' => ['Chapter A'], + 'school_year' => '2025-2026', + ]; + + $response = $this->postJson('/api/v1/teacher/class-progress-submit', $payload); + + $response->assertStatus(201); + $this->assertDatabaseHas('class_progress_reports', [ + 'teacher_id' => $user->id, + 'class_section_id' => 101, + 'subject' => 'Islamic Studies', + ]); + $this->assertDatabaseMissing('class_progress_reports', [ + 'teacher_id' => $user->id, + 'class_section_id' => 202, + ]); + } + + public function test_teacher_store_without_section_uses_logged_in_teacher_assignment(): void + { + Storage::fake('public'); + $user = $this->createUser(); + $this->assignRole($user->id, 'teacher'); + $this->seedClassSection(); + $this->assignTeacher($user->id, 101); + Sanctum::actingAs($user); + + $response = $this->postJson('/api/v1/teacher/class-progress-submit', [ + 'week_start' => '2025-09-07', + 'week_end' => '2025-09-13', + 'covered_islamic' => 'Lesson A', + 'unit_islamic' => ['Unit 1'], + 'chapter_islamic' => ['Chapter A'], + 'school_year' => '2025-2026', + ]); + + $response->assertStatus(201); + $this->assertDatabaseHas('class_progress_reports', [ + 'teacher_id' => $user->id, + 'class_section_id' => 101, + 'subject' => 'Islamic Studies', + ]); + } + public function test_show_returns_weekly_reports(): void { $user = $this->createUser(); diff --git a/tests/Feature/Api/V1/CompetitionScores/CompetitionScoresControllerTest.php b/tests/Feature/Api/V1/CompetitionScores/CompetitionScoresControllerTest.php index 19ce8150..bc4c8d52 100644 --- a/tests/Feature/Api/V1/CompetitionScores/CompetitionScoresControllerTest.php +++ b/tests/Feature/Api/V1/CompetitionScores/CompetitionScoresControllerTest.php @@ -45,6 +45,21 @@ class CompetitionScoresControllerTest extends TestCase $this->assertSame(20, $response->json('questionCount')); } + public function test_teacher_edit_alias_returns_students_and_scores(): void + { + $this->seedCompetitionData(); + + $user = $this->createUser(); + Sanctum::actingAs($user); + + $response = $this->getJson('/api/v1/teacher/competition-scores/1?school_year=2025-2026'); + + $response->assertOk(); + $this->assertNotEmpty($response->json('students')); + $this->assertSame(5, $response->json('scoreMap.1')); + $this->assertSame(20, $response->json('questionCount')); + } + public function test_save_updates_scores(): void { $this->seedCompetitionData(); @@ -72,6 +87,33 @@ class CompetitionScoresControllerTest extends TestCase ]); } + public function test_teacher_save_alias_updates_scores(): void + { + $this->seedCompetitionData(); + + $user = $this->createUser(); + Sanctum::actingAs($user); + + $response = $this->postJson('/api/v1/teacher/competition-scores/1', [ + 'class_section_id' => 101, + 'scores' => [ + 1 => 12, + ], + ]); + + $response->assertOk(); + $response->assertJson([ + 'ok' => true, + ]); + + $this->assertDatabaseHas('competition_scores', [ + 'competition_id' => 1, + 'student_id' => 1, + 'class_section_id' => 101, + 'score' => 12, + ]); + } + private function seedCompetitionData(): void { DB::table('configuration')->updateOrInsert( diff --git a/tests/Feature/Api/V1/Finance/PaymentManualControllerTest.php b/tests/Feature/Api/V1/Finance/PaymentManualControllerTest.php index 8c8f45f3..3955ede9 100644 --- a/tests/Feature/Api/V1/Finance/PaymentManualControllerTest.php +++ b/tests/Feature/Api/V1/Finance/PaymentManualControllerTest.php @@ -27,6 +27,112 @@ class PaymentManualControllerTest extends TestCase $this->assertSame(10, $response->json('parent.id')); } + public function test_search_returns_parent_data_by_selected_name(): void + { + $this->seedUsers(); + $this->seedConfig(); + $this->seedStudent(); + $this->seedInvoice(); + Sanctum::actingAs(User::query()->findOrFail(1)); + + $response = $this->getJson('/api/v1/finance/manual-pay/search?search_term=Parent+User&school_year=2025-2026'); + + $response->assertOk(); + $response->assertJson(['ok' => true]); + $this->assertSame(10, $response->json('parent.id')); + $this->assertNotEmpty($response->json('students')); + $this->assertNotEmpty($response->json('invoices')); + } + + public function test_search_lists_multiple_parent_matches_alphabetically(): void + { + $this->seedUsers(); + $this->seedConfig(); + Sanctum::actingAs(User::query()->findOrFail(1)); + + DB::table('users')->insert([ + [ + 'id' => 11, + 'school_id' => 1, + 'firstname' => 'Zara', + 'lastname' => 'Akter', + 'cellphone' => '5555555556', + 'email' => 'zara.akter@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' => 12, + 'school_id' => 1, + 'firstname' => 'Mousumi', + 'lastname' => 'Akter', + 'cellphone' => '5555555557', + 'email' => 'mousumi.akter@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' => 13, + 'school_id' => 1, + 'firstname' => 'Aisha', + 'lastname' => 'Akter', + 'cellphone' => '5555555558', + 'email' => 'aisha.akter@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' => 11, 'role_id' => 2], + ['user_id' => 12, 'role_id' => 2], + ['user_id' => 13, 'role_id' => 2], + ]); + + $response = $this->getJson('/api/v1/finance/manual-pay/search?search_term=akter&school_year=2025-2026'); + + $response->assertOk(); + $this->assertSame([ + 'Aisha Akter', + 'Mousumi Akter', + 'Zara Akter', + ], array_map( + fn (array $parent) => $parent['firstname'].' '.$parent['lastname'], + $response->json('parent_matches') + )); + $this->assertSame(13, $response->json('parent.id')); + } + public function test_suggest_returns_items(): void { $this->seedUsers(); diff --git a/tests/Feature/Api/V1/Parents/ParentControllerTest.php b/tests/Feature/Api/V1/Parents/ParentControllerTest.php index e6961c33..b649df80 100644 --- a/tests/Feature/Api/V1/Parents/ParentControllerTest.php +++ b/tests/Feature/Api/V1/Parents/ParentControllerTest.php @@ -103,6 +103,52 @@ class ParentControllerTest extends TestCase $this->assertDatabaseHas('emergency_contacts', ['parent_id' => $parent->id, 'emergency_contact_name' => 'John Parent']); } + public function test_statement_returns_parent_account_and_invoice_lines(): void + { + $this->seedConfig(); + $parent = $this->seedParent(); + + DB::table('parent_accounts')->insert([ + 'parent_id' => $parent->id, + 'school_year' => '2025-2026', + 'opening_balance' => 25, + 'current_balance' => 125, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('invoices')->insert([ + 'parent_id' => $parent->id, + 'invoice_number' => 'INV-2025-001', + 'total_amount' => 150, + 'paid_amount' => 50, + 'balance' => 100, + 'has_discount' => 0, + 'issue_date' => now(), + 'due_date' => now()->addWeek(), + 'status' => 'unpaid', + 'description' => 'Tuition invoice', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + Sanctum::actingAs($parent); + $response = $this->getJson('/api/v1/parents/statements?school_year=2025-2026'); + + $response->assertOk() + ->assertJsonPath('ok', true) + ->assertJsonPath('data.parent_id', $parent->id) + ->assertJsonPath('data.parent_name', 'Parent User') + ->assertJsonPath('data.school_year', '2025-2026') + ->assertJsonPath('data.opening_balance', 25) + ->assertJsonPath('data.current_balance', 125) + ->assertJsonPath('data.lines.0.label', 'Tuition invoice') + ->assertJsonPath('data.lines.0.amount', 100) + ->assertJsonPath('data.lines.0.source_school_year', '2025-2026'); + } + private function seedParent(): User { $roleId = DB::table('roles')->insertGetId([ diff --git a/tests/Feature/Api/V1/Reports/FilesControllerTest.php b/tests/Feature/Api/V1/Reports/FilesControllerTest.php index 6c9edda9..3a3677b9 100644 --- a/tests/Feature/Api/V1/Reports/FilesControllerTest.php +++ b/tests/Feature/Api/V1/Reports/FilesControllerTest.php @@ -5,6 +5,7 @@ namespace Tests\Feature\Api\V1\Reports; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; use Laravel\Sanctum\Sanctum; use Tests\TestCase; @@ -72,6 +73,93 @@ class FilesControllerTest extends TestCase $response->assertHeader('Content-Disposition', 'inline; filename="1a_midterm_v2.pdf"'); } + public function test_exam_draft_final_streams_when_requested_by_draft_id(): void + { + $user = $this->seedUser(); + Sanctum::actingAs($user); + + DB::table('classSection')->insert([ + 'id' => 1, + 'class_section_id' => 1, + 'class_section_name' => '1A', + 'class_id' => 1, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + DB::table('exam_drafts')->insert([ + 'id' => 1, + 'teacher_id' => 1, + 'class_section_id' => 1, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'exam_type' => 'Final Exam', + 'draft_title' => 'Final', + 'teacher_file' => 'draft1.pdf', + 'final_file' => 'final1.pdf', + 'version' => 3, + 'status' => 'finalized', + ]); + + $dir = storage_path('uploads/exams/finals'); + if (! is_dir($dir)) { + mkdir($dir, 0777, true); + } + $path = $dir.DIRECTORY_SEPARATOR.'final1.pdf'; + file_put_contents($path, 'PDFDATA'); + + $response = $this->get('/api/v1/files/exams/final/1'); + + $response->assertOk(); + $response->assertHeader('Content-Disposition', 'inline; filename="1a_final_exam_v3.pdf"'); + } + + public function test_exam_draft_final_ignores_numeric_final_pdf_placeholder(): void + { + $user = $this->seedUser(); + Sanctum::actingAs($user); + + DB::table('classSection')->insert([ + 'id' => 1, + 'class_section_id' => 1, + 'class_section_name' => '1A', + 'class_id' => 1, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + $payload = [ + 'id' => 1, + 'teacher_id' => 1, + 'class_section_id' => 1, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'exam_type' => 'Final Exam', + 'draft_title' => 'Final', + 'teacher_file' => 'draft1.pdf', + 'final_file' => 'final1.pdf', + 'version' => 3, + 'status' => 'finalized', + ]; + if (Schema::hasColumn('exam_drafts', 'final_pdf_file')) { + $payload['final_pdf_file'] = '1'; + } + + DB::table('exam_drafts')->insert($payload); + + $dir = storage_path('uploads/exams/finals'); + if (! is_dir($dir)) { + mkdir($dir, 0777, true); + } + $path = $dir.DIRECTORY_SEPARATOR.'final1.pdf'; + file_put_contents($path, 'PDFDATA'); + + $response = $this->get('/api/v1/files/exams/final/1'); + + $response->assertOk(); + $response->assertHeader('Content-Disposition', 'inline; filename="1a_final_exam_v3.pdf"'); + } + private function seedUser(): User { DB::table('users')->insert([ diff --git a/tests/Feature/Api/V1/SchoolYears/SchoolYearControllerTest.php b/tests/Feature/Api/V1/SchoolYears/SchoolYearControllerTest.php index d377e974..87b9deb7 100644 --- a/tests/Feature/Api/V1/SchoolYears/SchoolYearControllerTest.php +++ b/tests/Feature/Api/V1/SchoolYears/SchoolYearControllerTest.php @@ -152,28 +152,49 @@ class SchoolYearControllerTest extends TestCase $this->assertSame([502], $row['credit_invoice_ids']); } - public function test_credit_only_parent_is_reported_as_credit_not_unpaid_carryover(): void + public function test_parent_balance_report_uses_only_positive_invoice_balances(): void { $this->seedClosureData(); $this->seedParent(12, 'Credit', 'Only', 'credit@example.com'); + $this->seedParent(13, 'Discount', 'Paid', 'discount-paid@example.com'); DB::table('invoices')->insert([ - 'id' => 503, - 'parent_id' => 12, - 'invoice_number' => 'INV-503-CREDIT', - 'total_amount' => 0, - 'balance' => -50, - 'paid_amount' => 50, - 'has_discount' => 0, - 'issue_date' => '2026-05-11', - 'due_date' => '2026-06-10', - 'status' => 'credit', - 'description' => 'Credit only', - 'school_year' => '2025-2026', - 'created_at' => now(), - 'updated_at' => now(), - 'updated_by' => 1, - 'semester' => 'Fall', + [ + 'id' => 503, + 'parent_id' => 12, + 'invoice_number' => 'INV-503-CREDIT', + 'total_amount' => 0, + 'balance' => -50, + 'paid_amount' => 50, + 'has_discount' => 0, + 'issue_date' => '2026-05-11', + 'due_date' => '2026-06-10', + 'status' => 'credit', + 'description' => 'Credit only', + 'school_year' => '2025-2026', + 'created_at' => now(), + 'updated_at' => now(), + 'updated_by' => 1, + 'semester' => 'Fall', + ], + [ + 'id' => 505, + 'parent_id' => 13, + 'invoice_number' => 'INV-505-DISCOUNT-PAID', + 'total_amount' => 500, + 'balance' => 0, + 'paid_amount' => 500, + 'has_discount' => 1, + 'issue_date' => '2026-05-11', + 'due_date' => '2026-06-10', + 'status' => 'paid', + 'description' => 'Discount paid invoice', + 'school_year' => '2025-2026', + 'created_at' => now(), + 'updated_at' => now(), + 'updated_by' => 1, + 'semester' => 'Fall', + ], ]); $this->actingAs(User::query()->findOrFail(1), 'api'); @@ -182,18 +203,53 @@ class SchoolYearControllerTest extends TestCase $response->assertOk() ->assertJsonPath('data.summary.parents_with_positive_balance', 1) - ->assertJsonPath('data.summary.parents_with_credit_balance', 1) + ->assertJsonPath('data.summary.parents_with_credit_balance', 0) ->assertJsonPath('data.summary.total_positive_unpaid_balance', 200) - ->assertJsonPath('data.summary.total_credit_balance', 50); + ->assertJsonPath('data.summary.total_credit_balance', 0) + ->assertJsonPath('data.summary.parent_count', 1) + ->assertJsonPath('data.summary.total_transferred', 200); - $creditRow = collect($response->json('data.rows')) - ->first(fn (array $row): bool => (int) $row['parent_id'] === 12); + $rows = collect($response->json('data.rows')); - $this->assertNotNull($creditRow); - $this->assertSame(0.0, (float) $creditRow['positive_unpaid_balance']); - $this->assertSame(50.0, (float) $creditRow['credit_balance']); - $this->assertSame(0.0, (float) $creditRow['amount_to_transfer']); - $this->assertSame([503], $creditRow['credit_invoice_ids']); + $this->assertTrue($rows->contains(fn (array $row): bool => (int) $row['parent_id'] === 10)); + $this->assertSame(200.0, (float) $rows->first(fn (array $row): bool => (int) $row['parent_id'] === 10)['old_unpaid_amount']); + $this->assertFalse($rows->contains(fn (array $row): bool => (int) $row['parent_id'] === 12)); + $this->assertFalse($rows->contains(fn (array $row): bool => (int) $row['parent_id'] === 13)); + } + + public function test_closing_report_unpaid_balance_totals_are_recomputed_from_positive_invoice_balances(): void + { + $this->seedClosureData(); + + DB::table('school_years')->where('id', 1)->update([ + 'status' => 'closed', + 'closed_at' => now(), + 'closed_by' => 1, + ]); + + DB::table('audit_logs')->insert([ + 'user_id' => 1, + 'action' => 'school_year_closed', + 'table_name' => 'school_years', + 'record_id' => 1, + 'old_value' => null, + 'new_value' => null, + 'metadata' => json_encode([ + 'new_school_year' => '2026-2027', + 'balance_counts' => [ + 'transferred_parents' => 48, + 'transferred_amount' => 9999, + ], + ]), + 'created_at' => now(), + ]); + + $this->actingAs(User::query()->findOrFail(1), 'api'); + + $this->getJson('/api/v1/school-years/reports/closing?school_year=2025-2026') + ->assertOk() + ->assertJsonPath('data.totals.parents_with_unpaid_balances', 1) + ->assertJsonPath('data.totals.total_unpaid_balance_transferred', 200); } public function test_pending_or_draft_invoice_blocks_close_but_is_not_transferred(): void @@ -567,6 +623,139 @@ class SchoolYearControllerTest extends TestCase ]); } + public function test_passing_age_17_student_graduates_and_is_not_enrolled_next_year(): void + { + $this->seedClosureData(); + + DB::table('students')->where('id', 100)->update(['age' => 17]); + + $this->actingAs(User::query()->findOrFail(1), 'api'); + + $preview = $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, + ]); + + $preview->assertOk() + ->assertJsonPath('data.promotion_preview.summary.promote', 0) + ->assertJsonPath('data.promotion_preview.summary.graduate', 1) + ->assertJsonPath('data.promotion_preview.rows.0.promotion_action', 'graduate'); + + $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(); + + $this->assertDatabaseMissing('enrollments', [ + 'student_id' => 100, + 'school_year' => '2026-2027', + ]); + + $this->assertDatabaseMissing('student_class', [ + 'student_id' => 100, + 'school_year' => '2026-2027', + ]); + } + + public function test_kindergarten_student_age_six_next_year_promotes_to_grade_one_not_arabic(): void + { + $this->seedClosureData(); + + DB::table('classes')->insert([ + ['id' => 1, 'class_name' => '1', 'schedule' => 'Sunday 9:00', 'school_year' => '2026-2027', 'semester' => 'Fall', 'capacity' => 30], + ['id' => 13, 'class_name' => 'KG', 'schedule' => 'Sunday 9:00', 'school_year' => '2025-2026', 'semester' => 'Fall', 'capacity' => 30], + ['id' => 14, 'class_name' => 'Arabic', 'schedule' => 'Sunday 11:00', 'school_year' => '2026-2027', 'semester' => 'Fall', 'capacity' => 30], + ]); + + DB::table('classSection')->insert([ + [ + 'class_section_id' => 1, + 'class_section_name' => 'KG', + 'class_id' => 13, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ], + [ + 'class_section_id' => 10, + 'class_section_name' => '1A', + 'class_id' => 1, + 'semester' => 'Fall', + 'school_year' => '2026-2027', + ], + [ + 'class_section_id' => 140, + 'class_section_name' => 'Arabic A', + 'class_id' => 14, + 'semester' => 'Fall', + 'school_year' => '2026-2027', + ], + ]); + + DB::table('students')->where('id', 100)->update(['age' => 5]); + DB::table('student_class')->where('student_id', 100)->where('school_year', '2025-2026')->update([ + 'class_section_id' => 1, + 'updated_at' => now(), + ]); + DB::table('enrollments')->where('student_id', 100)->where('school_year', '2025-2026')->update([ + 'class_section_id' => 1, + 'updated_at' => now(), + ]); + + $this->actingAs(User::query()->findOrFail(1), 'api'); + + $preview = $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, + ]); + + $preview->assertOk() + ->assertJsonPath('data.promotion_preview.rows.0.promotion_action', 'promote') + ->assertJsonPath('data.promotion_preview.rows.0.current_grade', 'KG') + ->assertJsonPath('data.promotion_preview.rows.0.target_grade', '1') + ->assertJsonPath('data.promotion_preview.rows.0.target_class_section_id', 10); + + $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(); + + $this->assertDatabaseHas('enrollments', [ + 'student_id' => 100, + 'school_year' => '2026-2027', + 'class_section_id' => 10, + ]); + + $this->assertDatabaseHas('student_class', [ + 'student_id' => 100, + 'school_year' => '2026-2027', + 'class_section_id' => 10, + ]); + + $this->assertDatabaseMissing('enrollments', [ + 'student_id' => 100, + 'school_year' => '2026-2027', + 'class_section_id' => 140, + ]); + } + public function test_closed_school_year_blocks_legacy_invoice_generation(): void { $this->seedClosureData(); diff --git a/tests/Feature/Api/V1/Teachers/TeacherControllerTest.php b/tests/Feature/Api/V1/Teachers/TeacherControllerTest.php index f71bf372..d27da5e1 100644 --- a/tests/Feature/Api/V1/Teachers/TeacherControllerTest.php +++ b/tests/Feature/Api/V1/Teachers/TeacherControllerTest.php @@ -28,6 +28,7 @@ class TeacherControllerTest extends TestCase $response->assertJson(['ok' => true]); $this->assertNotEmpty($response->json('students')); $this->assertSame($classSectionId, $response->json('active_class_section_id')); + $this->assertSame($classSectionId, session('class_section_id')); } public function test_switch_class_returns_active_id(): void @@ -44,6 +45,7 @@ class TeacherControllerTest extends TestCase $response->assertOk(); $response->assertJsonPath('active_class_section_id', $classSectionId); + $this->assertSame($classSectionId, session('class_section_id')); } public function test_absence_submit_creates_staff_attendance(): void diff --git a/tests/Unit/Services/ClassProgress/ClassProgressQueryServiceTest.php b/tests/Unit/Services/ClassProgress/ClassProgressQueryServiceTest.php index 4d11829d..960c92e0 100644 --- a/tests/Unit/Services/ClassProgress/ClassProgressQueryServiceTest.php +++ b/tests/Unit/Services/ClassProgress/ClassProgressQueryServiceTest.php @@ -25,6 +25,23 @@ class ClassProgressQueryServiceTest extends TestCase 'teacher_id' => $user->id, 'class_section_id' => 202, ]); + DB::table('classSection')->insert([ + 'id' => 1, + 'class_id' => 1, + 'class_section_id' => 101, + 'class_section_name' => '1-A', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + DB::table('teacher_class')->insert([ + 'class_section_id' => 1, + 'teacher_id' => $user->id, + 'position' => 'main', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'created_at' => now(), + 'updated_at' => now(), + ]); $service = new ClassProgressQueryService(new ClassProgressRuleService); $results = $service->listReports($user, ['class_section_id' => 101]); @@ -32,6 +49,78 @@ class ClassProgressQueryServiceTest extends TestCase $this->assertSame(1, $results->total()); } + public function test_list_reports_filters_by_equivalent_legacy_section_ids(): void + { + $user = $this->createUser(); + DB::table('classSection')->insert([ + 'id' => 1, + 'class_id' => 1, + 'class_section_id' => 101, + 'class_section_name' => '1-A', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + DB::table('teacher_class')->insert([ + 'class_section_id' => 101, + 'teacher_id' => $user->id, + 'position' => 'main', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'created_at' => now(), + 'updated_at' => now(), + ]); + $report = ClassProgressReport::factory()->create([ + 'teacher_id' => 999, + 'class_section_id' => 1, + 'school_year' => '2025-2026', + ]); + + $service = new ClassProgressQueryService(new ClassProgressRuleService); + $results = $service->listReports($user, ['class_section_id' => 101]); + + $this->assertSame(1, $results->total()); + $this->assertSame($report->id, $results->items()[0]->id); + } + + public function test_teacher_assignments_prefer_section_code_over_colliding_primary_key(): void + { + $user = $this->createUser(); + DB::table('classSection')->insert([ + [ + 'id' => 1, + 'class_id' => 1, + 'class_section_id' => 10, + 'class_section_name' => '1', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ], + [ + 'id' => 10, + 'class_id' => 3, + 'class_section_id' => 31, + 'class_section_name' => '3-A', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ], + ]); + DB::table('teacher_class')->insert([ + 'class_section_id' => 10, + 'teacher_id' => $user->id, + 'position' => 'main', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $service = new ClassProgressQueryService(new ClassProgressRuleService); + $assignments = $service->teacherAssignments($user, '2025-2026', 'Fall'); + + $this->assertCount(1, $assignments); + $this->assertSame(10, $assignments[0]['class_section_id']); + $this->assertSame('1', $assignments[0]['class_section_name']); + } + private function createUser(): User { DB::table('users')->insert([ diff --git a/tests/Unit/Services/Invoices/InvoicePaymentServiceTest.php b/tests/Unit/Services/Invoices/InvoicePaymentServiceTest.php index cc678175..00346a99 100644 --- a/tests/Unit/Services/Invoices/InvoicePaymentServiceTest.php +++ b/tests/Unit/Services/Invoices/InvoicePaymentServiceTest.php @@ -60,4 +60,39 @@ class InvoicePaymentServiceTest extends TestCase $this->assertSame(5.0, $data['invoices'][0]['refund_amount']); $this->assertSame(60.0, $data['invoices'][0]['last_paid_amount']); } + + public function test_parent_invoice_summary_applies_full_discount_to_balance(): void + { + DB::table('invoices')->insert([ + [ + 'id' => 1, + 'parent_id' => 10, + 'invoice_number' => 'INV-DISCOUNT', + 'total_amount' => 370, + 'balance' => 370, + 'paid_amount' => 0, + 'has_discount' => 1, + 'issue_date' => '2025-01-10', + 'status' => 'Paid', + 'school_year' => '2025-2026', + ], + ]); + + DB::table('discount_usages')->insert([ + 'id' => 1, + 'voucher_id' => 1, + 'parent_id' => 10, + 'invoice_id' => 1, + 'discount_amount' => 370, + 'description' => 'Full scholarship', + 'school_year' => '2025-2026', + ]); + + $service = new InvoicePaymentService(new InvoiceConfigService); + $data = $service->getParentInvoiceSummary(10, '2025-2026'); + + $this->assertSame(370.0, $data['invoices'][0]['discount']); + $this->assertSame(0.0, $data['invoices'][0]['balance']); + $this->assertSame('Paid', $data['invoices'][0]['status']); + } } diff --git a/tests/Unit/Services/Parents/ParentEnrollmentServiceTest.php b/tests/Unit/Services/Parents/ParentEnrollmentServiceTest.php index 07a467f0..259f489b 100644 --- a/tests/Unit/Services/Parents/ParentEnrollmentServiceTest.php +++ b/tests/Unit/Services/Parents/ParentEnrollmentServiceTest.php @@ -50,6 +50,67 @@ class ParentEnrollmentServiceTest extends TestCase $this->assertSame('enrolled', $result['students'][0]['enrollment_status']); } + public function test_withdrawal_request_creates_pending_zero_amount_refund(): void + { + $this->seedConfig(); + $parentId = $this->seedParent(); + + $studentId = DB::table('students')->insertGetId([ + 'school_id' => 'S-201', + 'firstname' => 'Omar', + 'lastname' => 'Parent', + 'dob' => '2014-09-01', + 'age' => 11, + 'gender' => 'Male', + 'photo_consent' => 1, + 'parent_id' => $parentId, + 'year_of_registration' => '2025', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + ]); + + DB::table('enrollments')->insert([ + 'student_id' => $studentId, + 'parent_id' => $parentId, + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'enrollment_date' => '2025-09-01', + 'enrollment_status' => 'enrolled', + 'admission_status' => 'accepted', + 'is_withdrawn' => 0, + ]); + + $invoiceId = DB::table('invoices')->insertGetId([ + 'parent_id' => $parentId, + 'invoice_number' => 'INV-201', + 'total_amount' => 100, + 'paid_amount' => 100, + 'balance' => 0, + 'issue_date' => now(), + 'due_date' => now()->addWeek(), + 'status' => 'paid', + 'description' => 'Tuition', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $service = new ParentEnrollmentService(new ParentConfigService); + $result = $service->updateEnrollment($parentId, [], [$studentId]); + + $this->assertSame([$studentId], $result['withdrawn']); + $this->assertDatabaseHas('refunds', [ + 'parent_id' => $parentId, + 'invoice_id' => $invoiceId, + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'status' => 'Pending', + 'refund_amount' => 0, + 'refund_paid_amount' => 0, + ]); + } + private function seedParent(): int { return DB::table('users')->insertGetId([ diff --git a/tests/Unit/Services/Parents/ParentInvoiceServiceTest.php b/tests/Unit/Services/Parents/ParentInvoiceServiceTest.php index 59961f00..4ab97440 100644 --- a/tests/Unit/Services/Parents/ParentInvoiceServiceTest.php +++ b/tests/Unit/Services/Parents/ParentInvoiceServiceTest.php @@ -41,6 +41,188 @@ class ParentInvoiceServiceTest extends TestCase $this->assertSame('INV-1', $rows[0]['invoice_number']); } + public function test_list_invoices_derives_payment_totals_from_payments_table(): void + { + $parentId = $this->seedParent(); + + $invoiceId = DB::table('invoices')->insertGetId([ + 'parent_id' => $parentId, + 'invoice_number' => 'INV-PAY', + 'total_amount' => 100, + 'paid_amount' => 0, + 'balance' => 100, + 'issue_date' => '2025-09-01', + 'due_date' => '2025-10-01', + 'status' => 'Unpaid', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('payments')->insert([ + [ + 'parent_id' => $parentId, + 'invoice_id' => $invoiceId, + 'total_amount' => 100, + 'paid_amount' => 40, + 'balance' => 60, + 'number_of_installments' => 1, + 'payment_method' => 'Check', + 'payment_date' => '2025-09-15', + 'transaction_id' => 'CHK-1', + 'status' => 'Paid', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'parent_id' => $parentId, + 'invoice_id' => $invoiceId, + 'total_amount' => 100, + 'paid_amount' => 25, + 'balance' => 35, + 'number_of_installments' => 1, + 'payment_method' => 'Online', + 'payment_date' => '2025-09-16', + 'transaction_id' => 'PENDING-1', + 'status' => 'Pending', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'created_at' => now(), + 'updated_at' => now(), + ], + ]); + + $service = new ParentInvoiceService; + $rows = $service->listInvoices($parentId, '2025-2026'); + + $this->assertCount(1, $rows); + $this->assertSame(40.0, $rows[0]['paid_amount']); + $this->assertSame(60.0, $rows[0]['balance']); + $this->assertSame('Partially Paid', $rows[0]['status']); + $this->assertCount(1, $rows[0]['payments']); + $this->assertSame('CHK-1', $rows[0]['payments'][0]['transaction_id']); + } + + public function test_list_invoices_applies_discounts_to_balance(): void + { + $parentId = $this->seedParent(); + + $invoiceId = DB::table('invoices')->insertGetId([ + 'parent_id' => $parentId, + 'invoice_number' => 'INV-DISCOUNT', + 'total_amount' => 380, + 'paid_amount' => 10, + 'balance' => 370, + 'has_discount' => 1, + 'issue_date' => '2025-09-06', + 'due_date' => '2025-09-21', + 'status' => 'Paid', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('payments')->insert([ + 'parent_id' => $parentId, + 'invoice_id' => $invoiceId, + 'total_amount' => 380, + 'paid_amount' => 10, + 'balance' => 370, + 'number_of_installments' => 1, + 'payment_method' => 'Cash', + 'payment_date' => '2025-09-10', + 'status' => 'Paid', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('discount_usages')->insert([ + 'voucher_id' => 1, + 'parent_id' => $parentId, + 'invoice_id' => $invoiceId, + 'discount_amount' => 370, + 'description' => 'Tuition discount', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + ]); + + $service = new ParentInvoiceService; + $rows = $service->listInvoices($parentId, '2025-2026'); + + $this->assertSame(10.0, $rows[0]['paid_amount']); + $this->assertSame(370.0, $rows[0]['discount']); + $this->assertSame(0.0, $rows[0]['balance']); + $this->assertSame('Paid', $rows[0]['status']); + } + + public function test_statement_derives_current_balance_from_computed_invoice_lines(): void + { + $parentId = $this->seedParent(); + + $invoiceId = DB::table('invoices')->insertGetId([ + 'parent_id' => $parentId, + 'invoice_number' => 'INV-STMT-DISCOUNT', + 'total_amount' => 380, + 'paid_amount' => 10, + 'balance' => 370, + 'has_discount' => 1, + 'issue_date' => '2025-09-06', + 'due_date' => '2025-09-21', + 'status' => 'Paid', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('parent_accounts')->insert([ + 'parent_id' => $parentId, + 'school_year' => '2025-2026', + 'opening_balance' => 0, + 'current_balance' => 370, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('payments')->insert([ + 'parent_id' => $parentId, + 'invoice_id' => $invoiceId, + 'total_amount' => 380, + 'paid_amount' => 10, + 'balance' => 370, + 'number_of_installments' => 1, + 'payment_method' => 'Cash', + 'payment_date' => '2025-09-10', + 'status' => 'Paid', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('discount_usages')->insert([ + 'voucher_id' => 1, + 'parent_id' => $parentId, + 'invoice_id' => $invoiceId, + 'discount_amount' => 370, + 'description' => 'Tuition discount', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + ]); + + $service = new ParentInvoiceService; + $statement = $service->statement($parentId, '2025-2026'); + + $this->assertSame(0.0, $statement['lines'][0]['amount']); + $this->assertSame(0.0, $statement['current_balance']); + } + private function seedParent(string $email = 'parent@example.com'): int { return DB::table('users')->insertGetId([ diff --git a/tests/Unit/Services/Payments/PaymentManualServiceTest.php b/tests/Unit/Services/Payments/PaymentManualServiceTest.php index 3f80961c..c7179882 100644 --- a/tests/Unit/Services/Payments/PaymentManualServiceTest.php +++ b/tests/Unit/Services/Payments/PaymentManualServiceTest.php @@ -58,4 +58,82 @@ class PaymentManualServiceTest extends TestCase $this->assertCount(1, $results); $this->assertSame('parent@example.com', $results[0]['value']); } + + public function test_suggest_returns_matches_in_display_name_order(): void + { + DB::table('users')->insert([ + [ + 'id' => 10, + 'school_id' => 1, + 'firstname' => 'Zara', + 'lastname' => 'Akter', + 'cellphone' => '555-555-5555', + 'email' => 'zara.akter@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' => 11, + 'school_id' => 1, + 'firstname' => 'Aisha', + 'lastname' => 'Akter', + 'cellphone' => '555-555-5556', + 'email' => 'aisha.akter@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' => 12, + 'school_id' => 1, + 'firstname' => 'Mousumi', + 'lastname' => 'Akter', + 'cellphone' => '555-555-5557', + 'email' => 'mousumi.akter@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', + ], + ]); + + $invoiceService = Mockery::mock(DiscountInvoiceService::class); + $enrollmentService = Mockery::mock(PaymentEnrollmentEventService::class); + + $service = new PaymentManualService($invoiceService, $enrollmentService); + $results = $service->suggest('akter'); + + $this->assertSame([ + 'Aisha Akter', + 'Mousumi Akter', + 'Zara Akter', + ], array_column($results, 'label')); + } }