fix parent pages and teachers and admin
API CI/CD / Validate (composer + pint) (push) Successful in 3m8s
API CI/CD / Build frontend assets (push) Successful in 1m2s
API CI/CD / Test (PHPUnit) (push) Failing after 6m5s
API CI/CD / Security audit (push) Failing after 52s
API CI/CD / Deploy to shared hosting (PHP) (push) Has been skipped

This commit is contained in:
root
2026-07-09 13:56:22 -04:00
parent 21c9322127
commit 02ba2fe6b6
34 changed files with 2306 additions and 99 deletions
@@ -97,13 +97,22 @@ class ClassProgressController extends BaseApiController
return $auth; 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); return $this->respondError('Forbidden.', Response::HTTP_FORBIDDEN);
} }
$filters = $request->validated(); $filters = $request->validated();
$meta = $this->queryService->meta($filters['school_year'] ?? null, $filters['semester'] ?? null); $meta = $this->queryService->meta($filters['school_year'] ?? null, $filters['semester'] ?? null);
$filters['school_year'] = $filters['school_year'] ?? ($meta['school_year'] ?? 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)) { if ($this->isAdminProgressAliasRoute($request)) {
$filters['group_by_week'] = true; $filters['group_by_week'] = true;
} }
@@ -136,7 +145,12 @@ class ClassProgressController extends BaseApiController
try { try {
$filesBySubject = $request->filesBySubject(); $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) { } catch (\InvalidArgumentException $e) {
if ($e->getMessage() === 'CONFIRM_OVERWRITE') { if ($e->getMessage() === 'CONFIRM_OVERWRITE') {
return response()->json([ return response()->json([
@@ -278,6 +292,100 @@ class ClassProgressController extends BaseApiController
|| $request->is('api/v1/admin/progress'); || $request->is('api/v1/admin/progress');
} }
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>|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<string, mixed> $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<string> $roles * @param list<string> $roles
*/ */
@@ -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 public function enrollments(ParentEnrollmentRequest $request): JsonResponse
{ {
$guard = $this->parentIdOrUnauthorized(); $guard = $this->parentIdOrUnauthorized();
@@ -9,6 +9,8 @@ use App\Services\Files\ExamDraftDownloadNameService;
use App\Services\Files\FileServeService; use App\Services\Files\FileServeService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class FilesController extends BaseApiController class FilesController extends BaseApiController
{ {
@@ -71,6 +73,7 @@ class FilesController extends BaseApiController
public function examDraftFinal(FileNameRequest $request, string $name): Response|JsonResponse public function examDraftFinal(FileNameRequest $request, string $name): Response|JsonResponse
{ {
$name = $this->resolveFinalExamFileName($name);
$downloadName = $this->draftNameService->build($name, 'finals'); $downloadName = $this->draftNameService->build($name, 'finals');
return $this->serveFile( return $this->serveFile(
@@ -102,4 +105,37 @@ class FilesController extends BaseApiController
return $this->fileService->serveInline($baseDir, $name, $allowedExtensions, $request, $downloadName, $nosniff); 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;
}
} }
@@ -47,6 +47,8 @@ class TeacherController extends BaseApiController
$studentsBySection[(string) $sectionId] = TeacherStudentResource::collection($students); $studentsBySection[(string) $sectionId] = TeacherStudentResource::collection($students);
} }
$this->storeActiveClassInSession($data);
return response()->json([ return response()->json([
'ok' => true, 'ok' => true,
'teacher' => $teacher, 'teacher' => $teacher,
@@ -91,6 +93,8 @@ class TeacherController extends BaseApiController
], 422); ], 422);
} }
$this->storeActiveClassInSession($data);
return response()->json([ return response()->json([
'ok' => true, 'ok' => true,
'active_class_section_id' => $requested, 'active_class_section_id' => $requested,
@@ -162,4 +166,18 @@ class TeacherController extends BaseApiController
return $userId; return $userId;
} }
/**
* @param array<string, mixed> $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);
}
} }
@@ -12,7 +12,7 @@ class ClassProgressStoreRequest extends ClassProgressFormRequest
public function rules(): array public function rules(): array
{ {
$rules = [ $rules = [
'class_section_id' => ['required', 'integer', 'exists:classSection,class_section_id'], 'class_section_id' => ['nullable', 'integer'],
'school_year' => ['nullable', 'string', 'max:20'], 'school_year' => ['nullable', 'string', 'max:20'],
'semester' => ['nullable', 'string', 'max:20'], 'semester' => ['nullable', 'string', 'max:20'],
'week_start' => ['required', 'date_format:Y-m-d'], 'week_start' => ['required', 'date_format:Y-m-d'],
@@ -15,6 +15,7 @@ class InvoiceResource extends JsonResource
'invoice_number' => $this->resource['invoice_number'] ?? null, 'invoice_number' => $this->resource['invoice_number'] ?? null,
'total_amount' => (float) ($this->resource['total_amount'] ?? 0), 'total_amount' => (float) ($this->resource['total_amount'] ?? 0),
'paid_amount' => (float) ($this->resource['paid_amount'] ?? 0), 'paid_amount' => (float) ($this->resource['paid_amount'] ?? 0),
'discount' => (float) ($this->resource['discount'] ?? 0),
'balance' => (float) ($this->resource['balance'] ?? 0), 'balance' => (float) ($this->resource['balance'] ?? 0),
'status' => $this->resource['status'] ?? null, 'status' => $this->resource['status'] ?? null,
'school_year' => $this->resource['school_year'] ?? null, 'school_year' => $this->resource['school_year'] ?? null,
@@ -26,6 +27,19 @@ class InvoiceResource extends JsonResource
'refund_amount' => (float) ($this->resource['refund_amount'] ?? 0), 'refund_amount' => (float) ($this->resource['refund_amount'] ?? 0),
'last_paid_amount' => (float) ($this->resource['last_paid_amount'] ?? 0), 'last_paid_amount' => (float) ($this->resource['last_paid_amount'] ?? 0),
'last_payment_date' => $this->resource['last_payment_date'] ?? null, '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(),
]; ];
} }
} }
+44 -19
View File
@@ -8,13 +8,23 @@ class Payment extends BaseModel
{ {
protected $table = 'payments'; protected $table = 'payments';
/** protected $fillable = [
* legacy: useTimestamps = false because DB handles created_at/updated_at (defaults/triggers). 'parent_id',
* Keep OFF in Laravel too. 'invoice_id',
*/ 'total_amount',
public $timestamps = true; 'paid_amount',
'balance',
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']; 'number_of_installments',
'transaction_id',
'check_file',
'check_number',
'payment_method',
'payment_date',
'semester',
'school_year',
'status',
'updated_by',
];
protected $casts = [ protected $casts = [
'parent_id' => 'integer', 'parent_id' => 'integer',
@@ -26,10 +36,20 @@ class Payment extends BaseModel
'paid_amount' => 'decimal:2', 'paid_amount' => 'decimal:2',
'balance' => '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 */ /* Optional relationships */
public function invoice() public function invoice()
{ {
return $this->belongsTo(Invoice::class, 'invoice_id'); return $this->belongsTo(Invoice::class, 'invoice_id');
@@ -67,19 +87,18 @@ class Payment extends BaseModel
{ {
/** @var self|null $payment */ /** @var self|null $payment */
$payment = static::query()->find($paymentId); $payment = static::query()->find($paymentId);
if (! $payment) { if (! $payment) {
return false; return false;
} }
$newBalance = (float) $payment->balance - (float) $amountPaid; $newPaidAmount = round((float) $payment->paid_amount + $amountPaid, 2);
$newBalance = round((float) $payment->balance - $amountPaid, 2);
// keep precision stable
$paidAmount = (float) $payment->paid_amount + (float) $amountPaid;
$affected = static::query() $affected = static::query()
->whereKey($paymentId) ->whereKey($paymentId)
->update([ ->update([
'paid_amount' => $paidAmount, 'paid_amount' => $newPaidAmount,
'balance' => $newBalance, 'balance' => $newBalance,
]); ]);
@@ -119,26 +138,30 @@ class Payment extends BaseModel
} }
/** /**
* Get latest payment (date/amount/status) per invoice for given invoice IDs. * Get latest payment date/amount/status per invoice for given invoice IDs.
* Matches your legacy logic: statuses in ['Full','Paid','Partially Paid']. * Statuses included: Full, Paid, Partially Paid.
*/ */
public static function getPaymentsByInvoice($invoiceIds): array public static function getPaymentsByInvoice($invoiceIds): array
{ {
if (! is_array($invoiceIds)) { if (! is_array($invoiceIds)) {
$invoiceIds = [$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)) { if (empty($invoiceIds)) {
return []; return [];
} }
// Subquery: latest payment_date per invoice (Full/Partial only)
$latestSub = DB::table('payments') $latestSub = DB::table('payments')
->selectRaw('invoice_id, MAX(payment_date) AS max_date') ->selectRaw('invoice_id, MAX(payment_date) AS max_date')
->whereIn('invoice_id', $invoiceIds)
->whereIn('status', ['Full', 'Paid', 'Partially Paid']) ->whereIn('status', ['Full', 'Paid', 'Partially Paid'])
->groupBy('invoice_id'); ->groupBy('invoice_id');
// Main query: join payments to the latest per invoice
$rows = DB::table('payments as p') $rows = DB::table('payments as p')
->joinSub($latestSub, 'latest', function ($join) { ->joinSub($latestSub, 'latest', function ($join) {
$join->on('latest.invoice_id', '=', 'p.invoice_id') $join->on('latest.invoice_id', '=', 'p.invoice_id')
@@ -149,11 +172,13 @@ class Payment extends BaseModel
->select([ ->select([
'p.invoice_id', 'p.invoice_id',
DB::raw('p.paid_amount AS last_paid_amount'), 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.payment_date AS last_payment_date'),
DB::raw('p.status AS last_payment_status'), DB::raw('p.status AS last_payment_status'),
]) ])
->get(); ->get();
return $rows->map(fn ($r) => (array) $r)->all(); return $rows->map(fn ($row) => (array) $row)->all();
} }
} }
+65
View File
@@ -5,6 +5,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class TeacherClass extends BaseModel class TeacherClass extends BaseModel
{ {
@@ -162,6 +163,70 @@ class TeacherClass extends BaseModel
->all(); ->all();
} }
/**
* Distinct section identifiers this teacher can access, expanded across legacy
* `classSection.id` PKs and `classSection.class_section_id` business codes.
*
* @return list<int>
*/
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() */ /** legacy: getClassSectionsByTeacherId() */
public static function getClassSectionsByTeacherId(int $teacherId): array public static function getClassSectionsByTeacherId(int $teacherId): array
{ {
+1 -1
View File
@@ -51,7 +51,7 @@ class ClassProgressReportPolicy
{ {
return in_array( return in_array(
(int) $report->class_section_id, (int) $report->class_section_id,
TeacherClass::distinctSectionIdsForTeacher((int) $user->id), TeacherClass::distinctAccessibleSectionIdsForTeacher((int) $user->id),
true true
); );
} }
BIN
View File
Binary file not shown.
@@ -8,6 +8,7 @@ use App\Models\SemesterScore;
use App\Models\TeacherSubmissionNotificationHistory; use App\Models\TeacherSubmissionNotificationHistory;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class TeacherSubmissionReportService class TeacherSubmissionReportService
{ {
@@ -25,6 +26,11 @@ class TeacherSubmissionReportService
} catch (\Throwable) { } catch (\Throwable) {
$teacherClassSupportsSemester = false; $teacherClassSupportsSemester = false;
} }
try {
$studentClassSupportsSemester = Schema::hasColumn('student_class', 'semester');
} catch (\Throwable) {
$studentClassSupportsSemester = false;
}
$assignmentQuery = DB::table('teacher_class as tc') $assignmentQuery = DB::table('teacher_class as tc')
->select([ ->select([
@@ -99,10 +105,15 @@ class TeacherSubmissionReportService
continue; continue;
} }
$studentIds = DB::table('student_class') $studentQuery = DB::table('student_class')
->where('class_section_id', $classSectionId) ->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') ->pluck('student_id')
->map(fn ($id) => (int) $id) ->map(fn ($id) => (int) $id)
->filter() ->filter()
@@ -5,6 +5,7 @@ namespace App\Services\ClassProgress;
use App\Models\Configuration; use App\Models\Configuration;
use App\Models\SubjectCurriculum; use App\Models\SubjectCurriculum;
use App\Services\SemesterRangeService; use App\Services\SemesterRangeService;
use Illuminate\Database\Eloquent\Builder;
class ClassProgressMetaService class ClassProgressMetaService
{ {
@@ -146,7 +147,17 @@ class ClassProgressMetaService
$options = []; $options = [];
foreach ($this->subjectSections() as $slug => $section) { 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; return $options;
@@ -198,4 +209,26 @@ class ClassProgressMetaService
return $unitTitle; 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<string, mixed> $section
* @return list<string>
*/
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));
}
} }
@@ -362,10 +362,11 @@ class ClassProgressMutationService
return; return;
} }
$assigned = TeacherClass::query() $assigned = in_array(
->where('teacher_id', $user->id) $classSectionId,
->where('class_section_id', $classSectionId) TeacherClass::distinctAccessibleSectionIdsForTeacher((int) $user->id),
->exists(); true
);
if (! $assigned) { if (! $assigned) {
throw new \InvalidArgumentException('No class assignment found for this report.'); throw new \InvalidArgumentException('No class assignment found for this report.');
@@ -25,6 +25,11 @@ class ClassProgressQueryService
public function listReports(User $user, array $filters): LengthAwarePaginator 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() $query = ClassProgressReport::query()
->with(['classSection', 'teacher']) ->with(['classSection', 'teacher'])
->orderBy($filters['sort_by'] ?? 'week_start', $filters['sort_dir'] ?? 'desc'); ->orderBy($filters['sort_by'] ?? 'week_start', $filters['sort_dir'] ?? 'desc');
@@ -44,22 +49,19 @@ class ClassProgressQueryService
$query->whereRaw('1 = 0'); $query->whereRaw('1 = 0');
} }
} else { } else {
$relaxedSectionIds = TeacherClass::distinctSectionIdsForTeacher((int) $user->id); $relaxedSectionIds = TeacherClass::distinctAccessibleSectionIdsForTeacher((int) $user->id);
if (! empty($filters['class_section_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 // 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`). // (legacy DB often has `class_progress_reports` without matching current-year `teacher_class`).
if (! in_array($sid, $relaxedSectionIds, true)) { if (array_intersect($requestedSectionIds, $relaxedSectionIds) === []) {
$query->where('teacher_id', (int) $user->id); $query->whereRaw('1 = 0');
} }
} elseif ($relaxedSectionIds !== []) {
$query->whereIn('class_section_id', $relaxedSectionIds);
} else { } else {
$query->where(function ($q) use ($user, $relaxedSectionIds) { $query->where('teacher_id', (int) $user->id);
$q->where('teacher_id', (int) $user->id);
if ($relaxedSectionIds !== []) {
$q->orWhereIn('class_section_id', $relaxedSectionIds);
}
});
} }
} }
} elseif (! empty($filters['teacher_id'])) { } elseif (! empty($filters['teacher_id'])) {
@@ -67,7 +69,10 @@ class ClassProgressQueryService
} }
if (! empty($filters['class_section_id'])) { 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'])) { if (! empty($filters['subject'])) {
@@ -203,7 +208,12 @@ class ClassProgressQueryService
$schoolYear = (string) (config('school.school_year') ?? ''); $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 public function meta(?string $schoolYear = null, ?string $semester = null): array
@@ -258,6 +268,119 @@ class ClassProgressQueryService
return $options[$status] ?? 'Unknown'; 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<array<string, mixed>>
*/
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<int>
*/
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 private function isAdmin(User $user): bool
{ {
$roles = $user->roles() $roles = $user->roles()
@@ -339,7 +462,7 @@ class ClassProgressQueryService
return in_array( return in_array(
(int) $report->class_section_id, (int) $report->class_section_id,
TeacherClass::distinctSectionIdsForTeacher((int) $user->id), TeacherClass::distinctAccessibleSectionIdsForTeacher((int) $user->id),
true true
); );
} }
@@ -35,8 +35,49 @@ class InvoicePaymentService
} }
$invoiceIds = array_column($invoices, 'id'); $invoiceIds = array_column($invoices, 'id');
$payments = [];
$discounts = [];
$refunds = []; $refunds = [];
if (! empty($invoiceIds)) { 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') $refundResults = DB::table('refunds')
->selectRaw('invoice_id, COALESCE(SUM(refund_paid_amount),0) as refund_paid_amount') ->selectRaw('invoice_id, COALESCE(SUM(refund_paid_amount),0) as refund_paid_amount')
->whereIn('invoice_id', $invoiceIds) ->whereIn('invoice_id', $invoiceIds)
@@ -63,9 +104,20 @@ class InvoicePaymentService
} }
foreach ($invoices as &$invoice) { foreach ($invoices as &$invoice) {
$invoice['refund_amount'] = $refunds[$invoice['id']] ?? 0.0; $invoiceId = (int) ($invoice['id'] ?? 0);
$invoice['last_paid_amount'] = $lastPayments[$invoice['id']]['last_paid_amount'] ?? 0.0; $totalAmount = round((float) ($invoice['total_amount'] ?? 0), 2);
$invoice['last_payment_date'] = $lastPayments[$invoice['id']]['last_payment_date'] ?? null; $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); unset($invoice);
@@ -77,4 +129,17 @@ class InvoicePaymentService
'dueDate' => $this->config->getDueDate(), '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';
}
} }
@@ -218,6 +218,7 @@ class ParentEnrollmentService
'reason' => 'Withdrawal under review for student ID '.$studentId, 'reason' => 'Withdrawal under review for student ID '.$studentId,
'request' => 'new', 'request' => 'new',
'semester' => $semester, 'semester' => $semester,
'refund_amount' => 0.0,
'refund_paid_amount' => 0.0, 'refund_paid_amount' => 0.0,
]); ]);
} }
+311 -1
View File
@@ -3,6 +3,11 @@
namespace App\Services\Parents; namespace App\Services\Parents;
use App\Models\Invoice; 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 class ParentInvoiceService
{ {
@@ -14,6 +19,311 @@ class ParentInvoiceService
->orderByDesc('created_at') ->orderByDesc('created_at')
->get(); ->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<int> $invoiceIds
* @return array<int, list<array<string, mixed>>>
*/
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<int> $invoiceIds
* @return array{payments: array<int, float>, discounts: array<int, float>, refunds: array<int, float>}
*/
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<int|string> $transferIds
* @return array<int, string|null>
*/
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();
} }
} }
+29 -4
View File
@@ -34,6 +34,7 @@ class PaymentManualService
} }
$parentData = null; $parentData = null;
$parentMatches = [];
$students = []; $students = [];
$payments = []; $payments = [];
$invoices = []; $invoices = [];
@@ -47,6 +48,14 @@ class PaymentManualService
$builder->where(function ($q) use ($searchTerm, $searchClean, $searchFormatted) { $builder->where(function ($q) use ($searchTerm, $searchClean, $searchFormatted) {
$q->where('email', $searchTerm); $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 (! empty($searchClean)) {
if (strlen($searchClean) === 10) { if (strlen($searchClean) === 10) {
$formattedPhone = substr($searchClean, 0, 3).'-'.substr($searchClean, 3, 3).'-'.substr($searchClean, 6, 4); $formattedPhone = substr($searchClean, 0, 3).'-'.substr($searchClean, 3, 3).'-'.substr($searchClean, 6, 4);
@@ -62,11 +71,19 @@ class PaymentManualService
if (! empty($searchFormatted)) { if (! empty($searchFormatted)) {
$q->orWhere('cellphone', $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)) { if (! empty($parent)) {
unset($parent['password']);
$parentData = $parent; $parentData = $parent;
$parentId = (int) ($parent['id'] ?? 0); $parentId = (int) ($parent['id'] ?? 0);
@@ -100,6 +117,7 @@ class PaymentManualService
return [ return [
'parent' => $parentData, 'parent' => $parentData,
'parent_matches' => $parentMatches,
'students' => $students, 'students' => $students,
'payments' => $payments, 'payments' => $payments,
'invoices' => $invoices, 'invoices' => $invoices,
@@ -133,7 +151,7 @@ class PaymentManualService
$params[] = '%'.$digits.'%'; $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); $rows = DB::select($sql, $params);
@@ -156,6 +174,13 @@ class PaymentManualService
return $items; return $items;
} }
private function sanitizeParentRow(array $parent): array
{
unset($parent['password'], $parent['remember_token']);
return $parent;
}
public function recordPayment( public function recordPayment(
int $invoiceId, int $invoiceId,
float $amount, float $amount,
@@ -179,10 +179,8 @@ class SchoolYearClosureService
$promotionCounts = is_array($metadata['promotion_counts'] ?? null) $promotionCounts = is_array($metadata['promotion_counts'] ?? null)
? $metadata['promotion_counts'] ? $metadata['promotion_counts']
: []; : [];
$balanceCounts = is_array($metadata['balance_counts'] ?? null)
? $metadata['balance_counts']
: [];
$transferTotals = $this->transferTotalsForClosedYear($year->name, $newYearName !== '' ? $newYearName : null); $transferTotals = $this->transferTotalsForClosedYear($year->name, $newYearName !== '' ? $newYearName : null);
$invoiceBalanceTotals = $this->positiveInvoiceBalanceTotals($year->name);
$warnings = []; $warnings = [];
if (! $audit) { if (! $audit) {
@@ -207,14 +205,13 @@ class SchoolYearClosureService
'students_graduated' => (int) ($promotionCounts['graduate'] ?? 0), 'students_graduated' => (int) ($promotionCounts['graduate'] ?? 0),
'students_transferred' => (int) ($promotionCounts['transfer'] ?? 0), 'students_transferred' => (int) ($promotionCounts['transfer'] ?? 0),
'students_withdrawn' => (int) ($promotionCounts['withdraw'] ?? 0), 'students_withdrawn' => (int) ($promotionCounts['withdraw'] ?? 0),
'parents_with_unpaid_balances' => (int) ($balanceCounts['transferred_parents'] ?? $transferTotals['parents_with_unpaid_balances']), 'parents_with_unpaid_balances' => $invoiceBalanceTotals['parents_with_unpaid_balances'],
'total_unpaid_balance_transferred' => round((float) ($balanceCounts['transferred_amount'] ?? $transferTotals['total_unpaid_balance_transferred']), 2), 'total_unpaid_balance_transferred' => $invoiceBalanceTotals['total_unpaid_balance'],
'parents_with_credit_balances' => (int) ($balanceCounts['credit_parents'] ?? $transferTotals['parents_with_credit_balances']), 'parents_with_credit_balances' => $transferTotals['parents_with_credit_balances'],
'total_credit_carried_or_reported' => round((float) ($balanceCounts['credit_amount'] ?? $transferTotals['total_credit_carried_or_reported']), 2), 'total_credit_carried_or_reported' => $transferTotals['total_credit_carried_or_reported'],
'total_credit_transferred' => round((float) ($balanceCounts['credit_amount'] ?? $transferTotals['total_credit_carried_or_reported']), 2), 'total_credit_transferred' => $transferTotals['total_credit_carried_or_reported'],
'net_balance_impact' => round( 'net_balance_impact' => round(
(float) ($balanceCounts['transferred_amount'] ?? $transferTotals['total_unpaid_balance_transferred']) $invoiceBalanceTotals['total_unpaid_balance'] - $transferTotals['total_credit_carried_or_reported'],
- (float) ($balanceCounts['credit_amount'] ?? $transferTotals['total_credit_carried_or_reported']),
2 2
), ),
], ],
@@ -458,7 +455,8 @@ class SchoolYearClosureService
return $this->buildParentBalanceRows( return $this->buildParentBalanceRows(
$year->name, $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'; $action = 'graduate';
} elseif (in_array($decisionText, ['retained', 'repeat', 'repeated', 'not promoted'], true) || ($score !== null && $score < 60)) { } elseif (in_array($decisionText, ['retained', 'repeat', 'repeated', 'not promoted'], true) || ($score !== null && $score < 60)) {
$action = 'repeat'; $action = 'repeat';
} elseif ($this->isPassingSeventeenYearOld($student, $decisionText, $score)) {
$action = 'graduate';
} elseif (($progression['is_terminal'] ?? false) === true) { } elseif (($progression['is_terminal'] ?? false) === true) {
$action = 'graduate'; $action = 'graduate';
} elseif ($decisionText !== '' || $score !== null) { } elseif ($decisionText !== '' || $score !== null) {
@@ -694,6 +694,9 @@ class SchoolYearClosureService
} elseif ($action === 'promote') { } elseif ($action === 'promote') {
$targetLevelId = $progression['next_level_id'] ?? null; $targetLevelId = $progression['next_level_id'] ?? null;
$targetLevelName = $progression['next_level_name'] ?? null; $targetLevelName = $progression['next_level_name'] ?? null;
if ($this->isKindergartenAgeReadyForGradeOne($student, $currentClass, $section)) {
[$targetLevelId, $targetLevelName] = $this->resolveGradeOneTarget();
}
if ($targetLevelId === null) { if ($targetLevelId === null) {
$action = 'hold'; $action = 'hold';
$warnings[] = 'No next grade mapping exists for this student.'; $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) $age = (int) ($student->age ?? 0);
->whereRaw('ABS(COALESCE(balance, 0)) > 0.01') if ($age !== 17) {
->get(['id', 'parent_id', 'balance']); 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 = []; $balancesByParent = [];
foreach ($invoices as $invoice) { foreach ($invoices as $invoice) {
@@ -801,6 +854,18 @@ class SchoolYearClosureService
->first() ->first()
: null; : 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 = [ $row = [
'parent_id' => $parentId, 'parent_id' => $parentId,
'parent_name' => $this->resolveParentName($parentId), 'parent_name' => $this->resolveParentName($parentId),
@@ -808,9 +873,15 @@ class SchoolYearClosureService
'unpaid_invoice_count' => count($balanceRow['positive_invoice_ids']), 'unpaid_invoice_count' => count($balanceRow['positive_invoice_ids']),
'positive_unpaid_balance' => $positive, 'positive_unpaid_balance' => $positive,
'old_unpaid_balance' => $positive, 'old_unpaid_balance' => $positive,
'old_unpaid_amount' => $positive,
'credit_balance' => $credit, 'credit_balance' => $credit,
'net_balance' => $net, 'net_balance' => $net,
'amount_to_transfer' => $positive, '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'), 'transfer_status' => $existing?->status ?? ($positive > 0.01 ? 'pending' : 'credit_reported'),
'existing_transfer_id' => $existing?->id, 'existing_transfer_id' => $existing?->id,
'existing_transfer_conflict' => $existing !== null, '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['parents_with_non_zero_balance'] = $summary['parents_with_net_non_zero_balance'];
$summary['total_old_unpaid_balance'] = $summary['total_positive_unpaid_balance']; $summary['total_old_unpaid_balance'] = $summary['total_positive_unpaid_balance'];
$summary['net_balance_to_transfer'] = $summary['net_balance_impact']; $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 [ return [
'from_school_year' => $fromSchoolYear, 'from_school_year' => $fromSchoolYear,
@@ -858,7 +932,19 @@ class SchoolYearClosureService
{ {
return DB::table('invoices') return DB::table('invoices')
->where('school_year', $schoolYear) ->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 private function resolveTargetSection(?int $targetLevelId, string $schoolYear): array
@@ -1252,10 +1338,18 @@ class SchoolYearClosureService
return $totals; 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') $rows = DB::table('parent_balance_transfers')
->where('from_school_year', $fromSchoolYear) ->where('from_school_year', $fromSchoolYear)
->when($toSchoolYear !== null && trim($toSchoolYear) !== '', fn ($query) => $query->where('to_school_year', $toSchoolYear)) ->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) { foreach ($rows as $row) {
$amount = round((float) ($row->amount ?? 0), 2); $amount = round((float) ($row->amount ?? 0), 2);
@@ -1283,6 +1377,21 @@ class SchoolYearClosureService
return $totals; 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 private function decodeTransferSourceSummary(object $row): array
{ {
$raw = $row->source_summary_json ?? $row->source_summary ?? null; $raw = $row->source_summary_json ?? $row->source_summary ?? null;
+4 -1
View File
@@ -676,8 +676,10 @@ Route::prefix('v1')->group(function () {
Route::get('teacher/class-progress-history', [ClassProgressController::class, 'index']); Route::get('teacher/class-progress-history', [ClassProgressController::class, 'index']);
// Legacy teacher progress view page // Legacy teacher progress view page
Route::get('teacher/class-progress-view', [ClassProgressController::class, 'index']); 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', [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('dashboard/route', [DashboardController::class, 'route']);
Route::get('emergency-contacts', [AdministratorEmergencyContactController::class, 'index']); 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::middleware('parent.access')->prefix('parents')->group(function () {
Route::get('attendance', [ParentApiController::class, 'attendance']); Route::get('attendance', [ParentApiController::class, 'attendance']);
Route::get('invoices', [ParentApiController::class, 'invoices']); Route::get('invoices', [ParentApiController::class, 'invoices']);
Route::get('statements', [ParentApiController::class, 'statement']);
Route::get('enrollments', [ParentApiController::class, 'enrollments']); Route::get('enrollments', [ParentApiController::class, 'enrollments']);
Route::post('enrollments', [ParentApiController::class, 'updateEnrollments']); Route::post('enrollments', [ParentApiController::class, 'updateEnrollments']);
Route::get('progress', [ParentApiController::class, 'progress']); Route::get('progress', [ParentApiController::class, 'progress']);
+1
View File
@@ -0,0 +1 @@
PDFDATA
@@ -9,7 +9,9 @@ use App\Models\TeacherSubmissionNotificationHistory;
use App\Models\User; use App\Models\User;
use App\Services\Administrator\AdministratorSharedService; use App\Services\Administrator\AdministratorSharedService;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Laravel\Sanctum\Sanctum; use Laravel\Sanctum\Sanctum;
use Tests\TestCase; use Tests\TestCase;
@@ -290,6 +292,57 @@ class AdministratorTeacherSubmissionReportApiTest extends TestCase
$this->assertSame('Main: Legacy Teacher', $response->json('rows.0.teachers.0.label')); $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 public function test_teacher_submission_report_limits_history_to_three_entries(): void
{ {
$admin = $this->seedAdminUser('admin.report.two@test.com', 901, 'Admin', 'Two'); $admin = $this->seedAdminUser('admin.report.two@test.com', 901, 'Admin', 'Two');
@@ -73,6 +73,23 @@ class ClassProgressControllerTest extends TestCase
'created_at' => now(), 'created_at' => now(),
'updated_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([ DB::table('subject_curriculum_items')->insert([
'class_id' => 9, 'class_id' => 9,
'school_year' => null, 'school_year' => null,
@@ -83,6 +100,16 @@ class ClassProgressControllerTest extends TestCase
'created_at' => now(), 'created_at' => now(),
'updated_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); Sanctum::actingAs($user);
@@ -93,8 +120,12 @@ class ClassProgressControllerTest extends TestCase
$response->assertJsonPath('data.defaults.school_year', '2025-2026'); $response->assertJsonPath('data.defaults.school_year', '2025-2026');
$response->assertJsonPath('data.defaults.semester', 'Fall'); $response->assertJsonPath('data.defaults.semester', 'Fall');
$response->assertJsonPath('data.defaults.class_section_id', 909); $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.curriculum.islamic.0.chapter_name', 'Intro');
$response->assertJsonPath('data.unit_options.islamic.0.label', 'Unit 1 - Faith'); $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 public function test_index_returns_paginated_reports(): void
@@ -115,10 +146,184 @@ class ClassProgressControllerTest extends TestCase
$this->assertCount(2, $response->json('data.items')); $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 public function test_admin_grouped_index_uses_school_year_name_and_includes_legacy_rows(): void
{ {
$admin = $this->createUser('admin@example.com'); $admin = $this->createUser('admin@example.com');
$this->assignRole($admin->id, 'administrator'); $this->assignRole($admin->id, 'administrator');
DB::table('configuration')->updateOrInsert(
['config_key' => 'semester'],
['config_value' => 'Fall']
);
$this->seedClassSection(); $this->seedClassSection();
DB::table('classSection')->where('class_section_id', 101)->update([ DB::table('classSection')->where('class_section_id', 101)->update([
'semester' => '', 'semester' => '',
@@ -286,6 +491,74 @@ class ClassProgressControllerTest extends TestCase
$this->assertDatabaseCount('class_progress_reports', 2); $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 public function test_show_returns_weekly_reports(): void
{ {
$user = $this->createUser(); $user = $this->createUser();
@@ -45,6 +45,21 @@ class CompetitionScoresControllerTest extends TestCase
$this->assertSame(20, $response->json('questionCount')); $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 public function test_save_updates_scores(): void
{ {
$this->seedCompetitionData(); $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 private function seedCompetitionData(): void
{ {
DB::table('configuration')->updateOrInsert( DB::table('configuration')->updateOrInsert(
@@ -27,6 +27,112 @@ class PaymentManualControllerTest extends TestCase
$this->assertSame(10, $response->json('parent.id')); $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 public function test_suggest_returns_items(): void
{ {
$this->seedUsers(); $this->seedUsers();
@@ -103,6 +103,52 @@ class ParentControllerTest extends TestCase
$this->assertDatabaseHas('emergency_contacts', ['parent_id' => $parent->id, 'emergency_contact_name' => 'John Parent']); $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 private function seedParent(): User
{ {
$roleId = DB::table('roles')->insertGetId([ $roleId = DB::table('roles')->insertGetId([
@@ -5,6 +5,7 @@ namespace Tests\Feature\Api\V1\Reports;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Laravel\Sanctum\Sanctum; use Laravel\Sanctum\Sanctum;
use Tests\TestCase; use Tests\TestCase;
@@ -72,6 +73,93 @@ class FilesControllerTest extends TestCase
$response->assertHeader('Content-Disposition', 'inline; filename="1a_midterm_v2.pdf"'); $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 private function seedUser(): User
{ {
DB::table('users')->insert([ DB::table('users')->insert([
@@ -152,12 +152,14 @@ class SchoolYearControllerTest extends TestCase
$this->assertSame([502], $row['credit_invoice_ids']); $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->seedClosureData();
$this->seedParent(12, 'Credit', 'Only', 'credit@example.com'); $this->seedParent(12, 'Credit', 'Only', 'credit@example.com');
$this->seedParent(13, 'Discount', 'Paid', 'discount-paid@example.com');
DB::table('invoices')->insert([ DB::table('invoices')->insert([
[
'id' => 503, 'id' => 503,
'parent_id' => 12, 'parent_id' => 12,
'invoice_number' => 'INV-503-CREDIT', 'invoice_number' => 'INV-503-CREDIT',
@@ -174,6 +176,25 @@ class SchoolYearControllerTest extends TestCase
'updated_at' => now(), 'updated_at' => now(),
'updated_by' => 1, 'updated_by' => 1,
'semester' => 'Fall', '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'); $this->actingAs(User::query()->findOrFail(1), 'api');
@@ -182,18 +203,53 @@ class SchoolYearControllerTest extends TestCase
$response->assertOk() $response->assertOk()
->assertJsonPath('data.summary.parents_with_positive_balance', 1) ->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_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')) $rows = collect($response->json('data.rows'));
->first(fn (array $row): bool => (int) $row['parent_id'] === 12);
$this->assertNotNull($creditRow); $this->assertTrue($rows->contains(fn (array $row): bool => (int) $row['parent_id'] === 10));
$this->assertSame(0.0, (float) $creditRow['positive_unpaid_balance']); $this->assertSame(200.0, (float) $rows->first(fn (array $row): bool => (int) $row['parent_id'] === 10)['old_unpaid_amount']);
$this->assertSame(50.0, (float) $creditRow['credit_balance']); $this->assertFalse($rows->contains(fn (array $row): bool => (int) $row['parent_id'] === 12));
$this->assertSame(0.0, (float) $creditRow['amount_to_transfer']); $this->assertFalse($rows->contains(fn (array $row): bool => (int) $row['parent_id'] === 13));
$this->assertSame([503], $creditRow['credit_invoice_ids']); }
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 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 public function test_closed_school_year_blocks_legacy_invoice_generation(): void
{ {
$this->seedClosureData(); $this->seedClosureData();
@@ -28,6 +28,7 @@ class TeacherControllerTest extends TestCase
$response->assertJson(['ok' => true]); $response->assertJson(['ok' => true]);
$this->assertNotEmpty($response->json('students')); $this->assertNotEmpty($response->json('students'));
$this->assertSame($classSectionId, $response->json('active_class_section_id')); $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 public function test_switch_class_returns_active_id(): void
@@ -44,6 +45,7 @@ class TeacherControllerTest extends TestCase
$response->assertOk(); $response->assertOk();
$response->assertJsonPath('active_class_section_id', $classSectionId); $response->assertJsonPath('active_class_section_id', $classSectionId);
$this->assertSame($classSectionId, session('class_section_id'));
} }
public function test_absence_submit_creates_staff_attendance(): void public function test_absence_submit_creates_staff_attendance(): void
@@ -25,6 +25,23 @@ class ClassProgressQueryServiceTest extends TestCase
'teacher_id' => $user->id, 'teacher_id' => $user->id,
'class_section_id' => 202, '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); $service = new ClassProgressQueryService(new ClassProgressRuleService);
$results = $service->listReports($user, ['class_section_id' => 101]); $results = $service->listReports($user, ['class_section_id' => 101]);
@@ -32,6 +49,78 @@ class ClassProgressQueryServiceTest extends TestCase
$this->assertSame(1, $results->total()); $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 private function createUser(): User
{ {
DB::table('users')->insert([ DB::table('users')->insert([
@@ -60,4 +60,39 @@ class InvoicePaymentServiceTest extends TestCase
$this->assertSame(5.0, $data['invoices'][0]['refund_amount']); $this->assertSame(5.0, $data['invoices'][0]['refund_amount']);
$this->assertSame(60.0, $data['invoices'][0]['last_paid_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']);
}
} }
@@ -50,6 +50,67 @@ class ParentEnrollmentServiceTest extends TestCase
$this->assertSame('enrolled', $result['students'][0]['enrollment_status']); $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 private function seedParent(): int
{ {
return DB::table('users')->insertGetId([ return DB::table('users')->insertGetId([
@@ -41,6 +41,188 @@ class ParentInvoiceServiceTest extends TestCase
$this->assertSame('INV-1', $rows[0]['invoice_number']); $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 private function seedParent(string $email = 'parent@example.com'): int
{ {
return DB::table('users')->insertGetId([ return DB::table('users')->insertGetId([
@@ -58,4 +58,82 @@ class PaymentManualServiceTest extends TestCase
$this->assertCount(1, $results); $this->assertCount(1, $results);
$this->assertSame('parent@example.com', $results[0]['value']); $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'));
}
} }