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;
}
if ($this->isLegacyTeacherProgressListRoute($request) && ! $this->hasAnyRole($auth, ['teacher', 'administrator', 'admin'])) {
if ($this->isLegacyTeacherProgressListRoute($request) && ! $this->hasAnyRole($auth, ['teacher', 'teacher_assistant', 'ta', 'administrator', 'admin'])) {
return $this->respondError('Forbidden.', Response::HTTP_FORBIDDEN);
}
$filters = $request->validated();
$meta = $this->queryService->meta($filters['school_year'] ?? null, $filters['semester'] ?? null);
$filters['school_year'] = $filters['school_year'] ?? ($meta['school_year'] ?? null);
if ($this->isLegacyTeacherProgressListRoute($request) && empty($filters['class_section_id'])) {
$resolvedClassSectionId = $this->resolveTeacherHistoryClassSectionId($auth, $filters);
if ($resolvedClassSectionId instanceof JsonResponse) {
return $resolvedClassSectionId;
}
if ($resolvedClassSectionId > 0) {
$filters['class_section_id'] = $resolvedClassSectionId;
}
}
if ($this->isAdminProgressAliasRoute($request)) {
$filters['group_by_week'] = true;
}
@@ -136,7 +145,12 @@ class ClassProgressController extends BaseApiController
try {
$filesBySubject = $request->filesBySubject();
$reports = $this->mutationService->createReports($auth, $request->validated(), $filesBySubject);
$payload = $this->resolveTeacherStorePayload($auth, $request->validated());
if ($payload instanceof JsonResponse) {
return $payload;
}
$reports = $this->mutationService->createReports($auth, $payload, $filesBySubject);
} catch (\InvalidArgumentException $e) {
if ($e->getMessage() === 'CONFIRM_OVERWRITE') {
return response()->json([
@@ -278,6 +292,100 @@ class ClassProgressController extends BaseApiController
|| $request->is('api/v1/admin/progress');
}
/**
* @param array<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
*/
@@ -80,6 +80,21 @@ class ParentController extends BaseApiController
]);
}
public function statement(ParentInvoiceRequest $request): JsonResponse
{
$guard = $this->parentIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$schoolYear = $request->validated()['school_year'] ?? null;
return response()->json([
'ok' => true,
'data' => $this->invoiceService->statement($guard, $schoolYear),
]);
}
public function enrollments(ParentEnrollmentRequest $request): JsonResponse
{
$guard = $this->parentIdOrUnauthorized();
@@ -9,6 +9,8 @@ use App\Services\Files\ExamDraftDownloadNameService;
use App\Services\Files\FileServeService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class FilesController extends BaseApiController
{
@@ -71,6 +73,7 @@ class FilesController extends BaseApiController
public function examDraftFinal(FileNameRequest $request, string $name): Response|JsonResponse
{
$name = $this->resolveFinalExamFileName($name);
$downloadName = $this->draftNameService->build($name, 'finals');
return $this->serveFile(
@@ -102,4 +105,37 @@ class FilesController extends BaseApiController
return $this->fileService->serveInline($baseDir, $name, $allowedExtensions, $request, $downloadName, $nosniff);
}
private function resolveFinalExamFileName(string $name): string
{
if (! ctype_digit($name) || ! Schema::hasTable('exam_drafts')) {
return $name;
}
$query = DB::table('exam_drafts')->where('id', (int) $name);
if (Schema::hasColumn('exam_drafts', 'final_pdf_file')) {
$query->select('final_file', 'final_pdf_file');
} else {
$query->select('final_file');
}
$row = $query->first();
if (! $row) {
return $name;
}
$pdfFile = trim((string) ($row->final_pdf_file ?? ''));
if ($this->looksLikeExamFileName($pdfFile)) {
return $pdfFile;
}
$finalFile = trim((string) ($row->final_file ?? ''));
return $this->looksLikeExamFileName($finalFile) ? $finalFile : $name;
}
private function looksLikeExamFileName(string $name): bool
{
return preg_match('/\.(doc|docx|pdf)\z/i', $name) === 1;
}
}
@@ -47,6 +47,8 @@ class TeacherController extends BaseApiController
$studentsBySection[(string) $sectionId] = TeacherStudentResource::collection($students);
}
$this->storeActiveClassInSession($data);
return response()->json([
'ok' => true,
'teacher' => $teacher,
@@ -91,6 +93,8 @@ class TeacherController extends BaseApiController
], 422);
}
$this->storeActiveClassInSession($data);
return response()->json([
'ok' => true,
'active_class_section_id' => $requested,
@@ -162,4 +166,18 @@ class TeacherController extends BaseApiController
return $userId;
}
/**
* @param array<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
{
$rules = [
'class_section_id' => ['required', 'integer', 'exists:classSection,class_section_id'],
'class_section_id' => ['nullable', 'integer'],
'school_year' => ['nullable', 'string', 'max:20'],
'semester' => ['nullable', 'string', 'max:20'],
'week_start' => ['required', 'date_format:Y-m-d'],
@@ -15,6 +15,7 @@ class InvoiceResource extends JsonResource
'invoice_number' => $this->resource['invoice_number'] ?? null,
'total_amount' => (float) ($this->resource['total_amount'] ?? 0),
'paid_amount' => (float) ($this->resource['paid_amount'] ?? 0),
'discount' => (float) ($this->resource['discount'] ?? 0),
'balance' => (float) ($this->resource['balance'] ?? 0),
'status' => $this->resource['status'] ?? null,
'school_year' => $this->resource['school_year'] ?? null,
@@ -26,6 +27,19 @@ class InvoiceResource extends JsonResource
'refund_amount' => (float) ($this->resource['refund_amount'] ?? 0),
'last_paid_amount' => (float) ($this->resource['last_paid_amount'] ?? 0),
'last_payment_date' => $this->resource['last_payment_date'] ?? null,
'payments' => collect($this->resource['payments'] ?? [])
->map(static fn ($payment) => [
'id' => (int) ($payment['id'] ?? 0),
'invoice_id' => (int) ($payment['invoice_id'] ?? 0),
'paid_amount' => (float) ($payment['paid_amount'] ?? 0),
'balance' => (float) ($payment['balance'] ?? 0),
'payment_method' => $payment['payment_method'] ?? null,
'payment_date' => $payment['payment_date'] ?? null,
'transaction_id' => $payment['transaction_id'] ?? null,
'status' => $payment['status'] ?? null,
])
->values()
->all(),
];
}
}
+47 -22
View File
@@ -8,13 +8,23 @@ class Payment extends BaseModel
{
protected $table = 'payments';
/**
* legacy: useTimestamps = false because DB handles created_at/updated_at (defaults/triggers).
* Keep OFF in Laravel too.
*/
public $timestamps = true;
protected $fillable = ['parent_id', 'invoice_id', 'total_amount', 'paid_amount', 'balance', 'number_of_installments', 'transaction_id', 'check_file', 'check_number', 'payment_method', 'payment_date', 'semester', 'school_year', 'status', 'updated_by', 'created_at', 'updated_at'];
protected $fillable = [
'parent_id',
'invoice_id',
'total_amount',
'paid_amount',
'balance',
'number_of_installments',
'transaction_id',
'check_file',
'check_number',
'payment_method',
'payment_date',
'semester',
'school_year',
'status',
'updated_by',
];
protected $casts = [
'parent_id' => 'integer',
@@ -26,10 +36,20 @@ class Payment extends BaseModel
'paid_amount' => 'decimal:2',
'balance' => 'decimal:2',
'payment_date' => 'datetime',
'payment_date' => 'date',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
protected $attributes = [
'paid_amount' => '0.00',
'balance' => '0.00',
'payment_method' => 'Unknown',
'status' => 'Pending',
];
/* Optional relationships */
public function invoice()
{
return $this->belongsTo(Invoice::class, 'invoice_id');
@@ -67,19 +87,18 @@ class Payment extends BaseModel
{
/** @var self|null $payment */
$payment = static::query()->find($paymentId);
if (! $payment) {
return false;
}
$newBalance = (float) $payment->balance - (float) $amountPaid;
// keep precision stable
$paidAmount = (float) $payment->paid_amount + (float) $amountPaid;
$newPaidAmount = round((float) $payment->paid_amount + $amountPaid, 2);
$newBalance = round((float) $payment->balance - $amountPaid, 2);
$affected = static::query()
->whereKey($paymentId)
->update([
'paid_amount' => $paidAmount,
'paid_amount' => $newPaidAmount,
'balance' => $newBalance,
]);
@@ -101,10 +120,10 @@ class Payment extends BaseModel
public static function generateNewTransactionId(): string
{
$currentYear = date('Y');
$prefix = $currentYear.'-';
$prefix = $currentYear . '-';
$latest = static::query()
->where('transaction_id', 'like', $prefix.'%')
->where('transaction_id', 'like', $prefix . '%')
->orderByDesc('transaction_id')
->first();
@@ -115,30 +134,34 @@ class Payment extends BaseModel
$newNumber = 1;
}
return $prefix.str_pad((string) $newNumber, 6, '0', STR_PAD_LEFT);
return $prefix . str_pad((string) $newNumber, 6, '0', STR_PAD_LEFT);
}
/**
* Get latest payment (date/amount/status) per invoice for given invoice IDs.
* Matches your legacy logic: statuses in ['Full','Paid','Partially Paid'].
* Get latest payment date/amount/status per invoice for given invoice IDs.
* Statuses included: Full, Paid, Partially Paid.
*/
public static function getPaymentsByInvoice($invoiceIds): array
{
if (! is_array($invoiceIds)) {
$invoiceIds = [$invoiceIds];
}
$invoiceIds = array_values(array_unique(array_map('intval', array_filter($invoiceIds))));
$invoiceIds = array_values(array_unique(array_map(
'intval',
array_filter($invoiceIds)
)));
if (empty($invoiceIds)) {
return [];
}
// Subquery: latest payment_date per invoice (Full/Partial only)
$latestSub = DB::table('payments')
->selectRaw('invoice_id, MAX(payment_date) AS max_date')
->whereIn('invoice_id', $invoiceIds)
->whereIn('status', ['Full', 'Paid', 'Partially Paid'])
->groupBy('invoice_id');
// Main query: join payments to the latest per invoice
$rows = DB::table('payments as p')
->joinSub($latestSub, 'latest', function ($join) {
$join->on('latest.invoice_id', '=', 'p.invoice_id')
@@ -149,11 +172,13 @@ class Payment extends BaseModel
->select([
'p.invoice_id',
DB::raw('p.paid_amount AS last_paid_amount'),
DB::raw('p.balance AS balance'),
DB::raw('p.balance AS balance_after_payment'),
DB::raw('p.payment_date AS last_payment_date'),
DB::raw('p.status AS last_payment_status'),
])
->get();
return $rows->map(fn ($r) => (array) $r)->all();
return $rows->map(fn ($row) => (array) $row)->all();
}
}
+65
View File
@@ -5,6 +5,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class TeacherClass extends BaseModel
{
@@ -162,6 +163,70 @@ class TeacherClass extends BaseModel
->all();
}
/**
* Distinct section identifiers this teacher can access, expanded across legacy
* `classSection.id` PKs and `classSection.class_section_id` business codes.
*
* @return list<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() */
public static function getClassSectionsByTeacherId(int $teacherId): array
{
+1 -1
View File
@@ -51,7 +51,7 @@ class ClassProgressReportPolicy
{
return in_array(
(int) $report->class_section_id,
TeacherClass::distinctSectionIdsForTeacher((int) $user->id),
TeacherClass::distinctAccessibleSectionIdsForTeacher((int) $user->id),
true
);
}
BIN
View File
Binary file not shown.
@@ -8,6 +8,7 @@ use App\Models\SemesterScore;
use App\Models\TeacherSubmissionNotificationHistory;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class TeacherSubmissionReportService
{
@@ -25,6 +26,11 @@ class TeacherSubmissionReportService
} catch (\Throwable) {
$teacherClassSupportsSemester = false;
}
try {
$studentClassSupportsSemester = Schema::hasColumn('student_class', 'semester');
} catch (\Throwable) {
$studentClassSupportsSemester = false;
}
$assignmentQuery = DB::table('teacher_class as tc')
->select([
@@ -99,10 +105,15 @@ class TeacherSubmissionReportService
continue;
}
$studentIds = DB::table('student_class')
$studentQuery = DB::table('student_class')
->where('class_section_id', $classSectionId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->where('school_year', $schoolYear);
if ($studentClassSupportsSemester && $semester !== '') {
$studentQuery->where('semester', $semester);
}
$studentIds = $studentQuery
->pluck('student_id')
->map(fn ($id) => (int) $id)
->filter()
@@ -5,6 +5,7 @@ namespace App\Services\ClassProgress;
use App\Models\Configuration;
use App\Models\SubjectCurriculum;
use App\Services\SemesterRangeService;
use Illuminate\Database\Eloquent\Builder;
class ClassProgressMetaService
{
@@ -146,7 +147,17 @@ class ClassProgressMetaService
$options = [];
foreach ($this->subjectSections() as $slug => $section) {
$options[$slug] = SubjectCurriculum::getOptionsForClass((int) $classId, (string) $slug, $schoolYear);
$subjects = $this->subjectLookupValues((string) $slug, is_array($section) ? $section : []);
$options[$slug] = SubjectCurriculum::query()
->forClass((int) $classId)
->where(function (Builder $query) use ($subjects) {
$query->whereIn('subject', $subjects);
})
->forSchoolYear($schoolYear)
->ordered()
->get()
->all();
}
return $options;
@@ -198,4 +209,26 @@ class ClassProgressMetaService
return $unitTitle;
}
/**
* Curriculum rows have existed with both short slugs (`quran`) and display
* subjects (`Quran/Arabic`). Query all configured aliases but keep the API
* response keyed by slug for the frontend.
*
* @param array<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;
}
$assigned = TeacherClass::query()
->where('teacher_id', $user->id)
->where('class_section_id', $classSectionId)
->exists();
$assigned = in_array(
$classSectionId,
TeacherClass::distinctAccessibleSectionIdsForTeacher((int) $user->id),
true
);
if (! $assigned) {
throw new \InvalidArgumentException('No class assignment found for this report.');
@@ -25,6 +25,11 @@ class ClassProgressQueryService
public function listReports(User $user, array $filters): LengthAwarePaginator
{
$filterSectionIds = [];
if (! empty($filters['class_section_id'])) {
$filterSectionIds = $this->equivalentClassSectionIds((int) $filters['class_section_id']);
}
$query = ClassProgressReport::query()
->with(['classSection', 'teacher'])
->orderBy($filters['sort_by'] ?? 'week_start', $filters['sort_dir'] ?? 'desc');
@@ -44,22 +49,19 @@ class ClassProgressQueryService
$query->whereRaw('1 = 0');
}
} else {
$relaxedSectionIds = TeacherClass::distinctSectionIdsForTeacher((int) $user->id);
$relaxedSectionIds = TeacherClass::distinctAccessibleSectionIdsForTeacher((int) $user->id);
if (! empty($filters['class_section_id'])) {
$sid = (int) $filters['class_section_id'];
$requestedSectionIds = $filterSectionIds !== [] ? $filterSectionIds : [(int) $filters['class_section_id']];
// Section filter: if this user has ever taught the section, show all rows for that section
// (legacy DB often has `class_progress_reports` without matching current-year `teacher_class`).
if (! in_array($sid, $relaxedSectionIds, true)) {
$query->where('teacher_id', (int) $user->id);
if (array_intersect($requestedSectionIds, $relaxedSectionIds) === []) {
$query->whereRaw('1 = 0');
}
} elseif ($relaxedSectionIds !== []) {
$query->whereIn('class_section_id', $relaxedSectionIds);
} else {
$query->where(function ($q) use ($user, $relaxedSectionIds) {
$q->where('teacher_id', (int) $user->id);
if ($relaxedSectionIds !== []) {
$q->orWhereIn('class_section_id', $relaxedSectionIds);
}
});
$query->where('teacher_id', (int) $user->id);
}
}
} elseif (! empty($filters['teacher_id'])) {
@@ -67,7 +69,10 @@ class ClassProgressQueryService
}
if (! empty($filters['class_section_id'])) {
$query->where('class_section_id', (int) $filters['class_section_id']);
$query->whereIn(
'class_section_id',
$filterSectionIds !== [] ? $filterSectionIds : [(int) $filters['class_section_id']]
);
}
if (! empty($filters['subject'])) {
@@ -203,7 +208,12 @@ class ClassProgressQueryService
$schoolYear = (string) (config('school.school_year') ?? '');
}
return TeacherClass::getClassAssignmentsByUserId($user->id, $schoolYear, $semester);
$assignments = $this->teacherAssignmentsForTerm($user, $schoolYear !== '' ? $schoolYear : null);
if ($assignments !== []) {
return $assignments;
}
return $this->teacherAssignmentsForTerm($user, null);
}
public function meta(?string $schoolYear = null, ?string $semester = null): array
@@ -258,6 +268,119 @@ class ClassProgressQueryService
return $options[$status] ?? 'Unknown';
}
/**
* Resolve the logged-in teacher's real assignment rows. The join accepts both
* legacy section identifiers used in this codebase.
*
* @return list<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
{
$roles = $user->roles()
@@ -339,7 +462,7 @@ class ClassProgressQueryService
return in_array(
(int) $report->class_section_id,
TeacherClass::distinctSectionIdsForTeacher((int) $user->id),
TeacherClass::distinctAccessibleSectionIdsForTeacher((int) $user->id),
true
);
}
@@ -35,8 +35,49 @@ class InvoicePaymentService
}
$invoiceIds = array_column($invoices, 'id');
$payments = [];
$discounts = [];
$refunds = [];
if (! empty($invoiceIds)) {
$paymentTotals = DB::table('payments')
->selectRaw('invoice_id, COALESCE(SUM(paid_amount),0) as paid_amount')
->whereIn('invoice_id', $invoiceIds)
->where(function ($query) {
$query->whereNotIn(DB::raw('LOWER(status)'), [
'pending',
'void',
'voided',
'cancelled',
'canceled',
'failed',
'rejected',
'refunded',
'chargeback',
'declined',
'reversed',
])->orWhereNull('status');
})
->groupBy('invoice_id')
->get()
->map(fn ($r) => (array) $r)
->all();
foreach ($paymentTotals as $row) {
$payments[$row['invoice_id']] = (float) ($row['paid_amount'] ?? 0);
}
$discountResults = DB::table('discount_usages')
->selectRaw('invoice_id, COALESCE(SUM(discount_amount),0) as discount_amount')
->whereIn('invoice_id', $invoiceIds)
->groupBy('invoice_id')
->get()
->map(fn ($r) => (array) $r)
->all();
foreach ($discountResults as $row) {
$discounts[$row['invoice_id']] = (float) ($row['discount_amount'] ?? 0);
}
$refundResults = DB::table('refunds')
->selectRaw('invoice_id, COALESCE(SUM(refund_paid_amount),0) as refund_paid_amount')
->whereIn('invoice_id', $invoiceIds)
@@ -63,9 +104,20 @@ class InvoicePaymentService
}
foreach ($invoices as &$invoice) {
$invoice['refund_amount'] = $refunds[$invoice['id']] ?? 0.0;
$invoice['last_paid_amount'] = $lastPayments[$invoice['id']]['last_paid_amount'] ?? 0.0;
$invoice['last_payment_date'] = $lastPayments[$invoice['id']]['last_payment_date'] ?? null;
$invoiceId = (int) ($invoice['id'] ?? 0);
$totalAmount = round((float) ($invoice['total_amount'] ?? 0), 2);
$paidAmount = round((float) ($payments[$invoiceId] ?? $invoice['paid_amount'] ?? 0), 2);
$discountAmount = round((float) ($discounts[$invoiceId] ?? $invoice['discount'] ?? 0), 2);
$refundAmount = round((float) ($refunds[$invoiceId] ?? 0), 2);
$balance = max(0.0, round($totalAmount - $paidAmount - $discountAmount - $refundAmount, 2));
$invoice['paid_amount'] = $paidAmount;
$invoice['discount'] = $discountAmount;
$invoice['refund_amount'] = $refundAmount;
$invoice['balance'] = $balance;
$invoice['status'] = $this->deriveInvoiceStatus($invoice['status'] ?? null, $paidAmount, $balance);
$invoice['last_paid_amount'] = $lastPayments[$invoiceId]['last_paid_amount'] ?? 0.0;
$invoice['last_payment_date'] = $lastPayments[$invoiceId]['last_payment_date'] ?? null;
}
unset($invoice);
@@ -77,4 +129,17 @@ class InvoicePaymentService
'dueDate' => $this->config->getDueDate(),
];
}
private function deriveInvoiceStatus(?string $storedStatus, float $paidAmount, float $balance): string
{
if ($balance <= 0.00001) {
return 'Paid';
}
if ($paidAmount > 0) {
return 'Partially Paid';
}
return $storedStatus ?: 'Unpaid';
}
}
@@ -218,6 +218,7 @@ class ParentEnrollmentService
'reason' => 'Withdrawal under review for student ID '.$studentId,
'request' => 'new',
'semester' => $semester,
'refund_amount' => 0.0,
'refund_paid_amount' => 0.0,
]);
}
+311 -1
View File
@@ -3,6 +3,11 @@
namespace App\Services\Parents;
use App\Models\Invoice;
use App\Models\ParentAccount;
use App\Models\ParentBalanceTransfer;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class ParentInvoiceService
{
@@ -14,6 +19,311 @@ class ParentInvoiceService
->orderByDesc('created_at')
->get();
return $rows->toArray();
$invoiceIds = $rows
->pluck('id')
->map(fn ($id) => (int) $id)
->filter(fn ($id) => $id > 0)
->values()
->all();
$paymentsByInvoice = $this->paymentsByInvoice($parentId, $invoiceIds);
$adjustments = $this->invoiceAdjustments($invoiceIds);
return $rows
->map(function (Invoice $invoice) use ($paymentsByInvoice, $adjustments): array {
$invoiceRow = $invoice->toArray();
$invoiceId = (int) $invoice->id;
$payments = $paymentsByInvoice[(int) $invoice->id] ?? [];
$paidAmount = round(array_sum(array_map(
static fn (array $payment): float => (float) ($payment['paid_amount'] ?? 0),
$payments
)), 2);
$discountAmount = round((float) ($adjustments['discounts'][$invoiceId] ?? 0), 2);
$refundAmount = round((float) ($adjustments['refunds'][$invoiceId] ?? 0), 2);
$totalAmount = round((float) ($invoice->total_amount ?? 0), 2);
$balance = max(0.0, round($totalAmount - $paidAmount - $discountAmount - $refundAmount, 2));
$invoiceRow['payments'] = $payments;
$invoiceRow['paid_amount'] = $paidAmount;
$invoiceRow['discount'] = $discountAmount;
$invoiceRow['refund_amount'] = $refundAmount;
$invoiceRow['balance'] = $balance;
$invoiceRow['last_paid_amount'] = (float) ($payments[0]['paid_amount'] ?? 0);
$invoiceRow['last_payment_date'] = $payments[0]['payment_date'] ?? null;
$invoiceRow['status'] = $this->deriveInvoiceStatus($invoice->status, $paidAmount, $balance);
return $invoiceRow;
})
->all();
}
public function statement(int $parentId, ?string $schoolYear = null): array
{
$parent = User::query()->find($parentId);
$account = $schoolYear
? ParentAccount::query()
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->first()
: null;
$columns = [
'id',
'invoice_number',
'total_amount',
'paid_amount',
'balance',
'status',
'description',
'school_year',
];
if (Schema::hasColumn('invoices', 'invoice_type')) {
$columns[] = 'invoice_type';
}
if (Schema::hasColumn('invoices', 'balance_transfer_id')) {
$columns[] = 'balance_transfer_id';
}
$invoices = Invoice::query()
->where('parent_id', $parentId)
->when($schoolYear, fn ($q) => $q->where('school_year', $schoolYear))
->orderBy('created_at')
->get($columns);
$transferSourceYears = $this->transferSourceYears($invoices->pluck('balance_transfer_id')->filter()->all());
$invoiceIds = $invoices
->pluck('id')
->map(fn ($id) => (int) $id)
->filter(fn ($id) => $id > 0)
->values()
->all();
$adjustments = $this->invoiceAdjustments($invoiceIds);
$lines = $invoices
->map(function (Invoice $invoice) use ($transferSourceYears, $adjustments): array {
$transferId = (int) ($invoice->balance_transfer_id ?? 0);
$invoiceId = (int) $invoice->id;
$totalAmount = round((float) ($invoice->total_amount ?? 0), 2);
$paidAmount = round((float) ($adjustments['payments'][$invoiceId] ?? $invoice->paid_amount ?? 0), 2);
$discountAmount = round((float) ($adjustments['discounts'][$invoiceId] ?? 0), 2);
$refundAmount = round((float) ($adjustments['refunds'][$invoiceId] ?? 0), 2);
$balance = max(0.0, round($totalAmount - $paidAmount - $discountAmount - $refundAmount, 2));
return [
'label' => $invoice->description ?: ($invoice->invoice_number ?: sprintf('Invoice #%d', $invoice->id)),
'amount' => $balance,
'source_school_year' => $transferId > 0
? ($transferSourceYears[$transferId] ?? $invoice->school_year)
: $invoice->school_year,
'invoice_id' => $invoice->id,
];
})
->values()
->all();
$openingBalance = round((float) ($account?->opening_balance ?? 0), 2);
$currentBalance = round($openingBalance + array_sum(array_map(
static fn (array $line): float => (float) ($line['amount'] ?? 0),
$lines
)), 2);
return [
'parent_id' => $parentId,
'parent_name' => $this->parentName($parent),
'school_year' => $schoolYear,
'lines' => $lines,
'opening_balance' => $openingBalance,
'current_balance' => round($currentBalance, 2),
];
}
private function parentName(?User $parent): ?string
{
if (! $parent) {
return null;
}
$parts = array_filter([
trim((string) ($parent->firstname ?? '')),
trim((string) ($parent->lastname ?? '')),
]);
if ($parts !== []) {
return implode(' ', $parts);
}
return $parent->name ?? $parent->email ?? null;
}
/**
* @param list<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;
$parentMatches = [];
$students = [];
$payments = [];
$invoices = [];
@@ -47,6 +48,14 @@ class PaymentManualService
$builder->where(function ($q) use ($searchTerm, $searchClean, $searchFormatted) {
$q->where('email', $searchTerm);
if (ctype_digit($searchTerm)) {
$q->orWhere('id', (int) $searchTerm);
}
$q->orWhereRaw("CONCAT_WS(' ', firstname, lastname) LIKE ?", ['%'.$searchTerm.'%'])
->orWhere('firstname', 'like', '%'.$searchTerm.'%')
->orWhere('lastname', 'like', '%'.$searchTerm.'%');
if (! empty($searchClean)) {
if (strlen($searchClean) === 10) {
$formattedPhone = substr($searchClean, 0, 3).'-'.substr($searchClean, 3, 3).'-'.substr($searchClean, 6, 4);
@@ -62,11 +71,19 @@ class PaymentManualService
if (! empty($searchFormatted)) {
$q->orWhere('cellphone', $searchFormatted);
}
});
})
->orderByRaw('LOWER(firstname)')
->orderByRaw('LOWER(lastname)')
->orderByRaw('LOWER(email)')
->limit(20);
$parent = (array) $builder->first();
$parentRows = $builder->get()
->map(fn ($row) => $this->sanitizeParentRow((array) $row))
->all();
$parentMatches = $parentRows;
$parent = $parentRows[0] ?? [];
if (! empty($parent)) {
unset($parent['password']);
$parentData = $parent;
$parentId = (int) ($parent['id'] ?? 0);
@@ -100,6 +117,7 @@ class PaymentManualService
return [
'parent' => $parentData,
'parent_matches' => $parentMatches,
'students' => $students,
'payments' => $payments,
'invoices' => $invoices,
@@ -133,7 +151,7 @@ class PaymentManualService
$params[] = '%'.$digits.'%';
}
$sql .= ') ORDER BY lastname, firstname LIMIT 20';
$sql .= ') ORDER BY LOWER(firstname), LOWER(lastname), LOWER(email) LIMIT 20';
$rows = DB::select($sql, $params);
@@ -156,6 +174,13 @@ class PaymentManualService
return $items;
}
private function sanitizeParentRow(array $parent): array
{
unset($parent['password'], $parent['remember_token']);
return $parent;
}
public function recordPayment(
int $invoiceId,
float $amount,
@@ -179,10 +179,8 @@ class SchoolYearClosureService
$promotionCounts = is_array($metadata['promotion_counts'] ?? null)
? $metadata['promotion_counts']
: [];
$balanceCounts = is_array($metadata['balance_counts'] ?? null)
? $metadata['balance_counts']
: [];
$transferTotals = $this->transferTotalsForClosedYear($year->name, $newYearName !== '' ? $newYearName : null);
$invoiceBalanceTotals = $this->positiveInvoiceBalanceTotals($year->name);
$warnings = [];
if (! $audit) {
@@ -207,14 +205,13 @@ class SchoolYearClosureService
'students_graduated' => (int) ($promotionCounts['graduate'] ?? 0),
'students_transferred' => (int) ($promotionCounts['transfer'] ?? 0),
'students_withdrawn' => (int) ($promotionCounts['withdraw'] ?? 0),
'parents_with_unpaid_balances' => (int) ($balanceCounts['transferred_parents'] ?? $transferTotals['parents_with_unpaid_balances']),
'total_unpaid_balance_transferred' => round((float) ($balanceCounts['transferred_amount'] ?? $transferTotals['total_unpaid_balance_transferred']), 2),
'parents_with_credit_balances' => (int) ($balanceCounts['credit_parents'] ?? $transferTotals['parents_with_credit_balances']),
'total_credit_carried_or_reported' => round((float) ($balanceCounts['credit_amount'] ?? $transferTotals['total_credit_carried_or_reported']), 2),
'total_credit_transferred' => round((float) ($balanceCounts['credit_amount'] ?? $transferTotals['total_credit_carried_or_reported']), 2),
'parents_with_unpaid_balances' => $invoiceBalanceTotals['parents_with_unpaid_balances'],
'total_unpaid_balance_transferred' => $invoiceBalanceTotals['total_unpaid_balance'],
'parents_with_credit_balances' => $transferTotals['parents_with_credit_balances'],
'total_credit_carried_or_reported' => $transferTotals['total_credit_carried_or_reported'],
'total_credit_transferred' => $transferTotals['total_credit_carried_or_reported'],
'net_balance_impact' => round(
(float) ($balanceCounts['transferred_amount'] ?? $transferTotals['total_unpaid_balance_transferred'])
- (float) ($balanceCounts['credit_amount'] ?? $transferTotals['total_credit_carried_or_reported']),
$invoiceBalanceTotals['total_unpaid_balance'] - $transferTotals['total_credit_carried_or_reported'],
2
),
],
@@ -458,7 +455,8 @@ class SchoolYearClosureService
return $this->buildParentBalanceRows(
$year->name,
$toSchoolYear ?: ($this->resolver->suggestNextName($year->name) ?? $year->name)
$toSchoolYear ?: ($this->resolver->suggestNextName($year->name) ?? $year->name),
false
);
}
@@ -680,6 +678,8 @@ class SchoolYearClosureService
$action = 'graduate';
} elseif (in_array($decisionText, ['retained', 'repeat', 'repeated', 'not promoted'], true) || ($score !== null && $score < 60)) {
$action = 'repeat';
} elseif ($this->isPassingSeventeenYearOld($student, $decisionText, $score)) {
$action = 'graduate';
} elseif (($progression['is_terminal'] ?? false) === true) {
$action = 'graduate';
} elseif ($decisionText !== '' || $score !== null) {
@@ -694,6 +694,9 @@ class SchoolYearClosureService
} elseif ($action === 'promote') {
$targetLevelId = $progression['next_level_id'] ?? null;
$targetLevelName = $progression['next_level_name'] ?? null;
if ($this->isKindergartenAgeReadyForGradeOne($student, $currentClass, $section)) {
[$targetLevelId, $targetLevelName] = $this->resolveGradeOneTarget();
}
if ($targetLevelId === null) {
$action = 'hold';
$warnings[] = 'No next grade mapping exists for this student.';
@@ -739,11 +742,61 @@ class SchoolYearClosureService
];
}
private function buildParentBalanceRows(string $fromSchoolYear, string $toSchoolYear): array
private function isPassingSeventeenYearOld(object $student, string $decisionText, ?float $score): bool
{
$invoices = $this->eligibleCarryoverInvoiceQuery($fromSchoolYear)
->whereRaw('ABS(COALESCE(balance, 0)) > 0.01')
->get(['id', 'parent_id', 'balance']);
$age = (int) ($student->age ?? 0);
if ($age !== 17) {
return false;
}
if ($score !== null) {
return $score >= 60;
}
return str_contains($decisionText, 'promot')
|| str_contains($decisionText, 'pass');
}
private function isKindergartenAgeReadyForGradeOne(object $student, ?object $currentClass, ?object $section): bool
{
$age = (int) ($student->age ?? 0);
if ($age + 1 < 6) {
return false;
}
$className = strtolower(trim((string) ($currentClass->class_name ?? '')));
$sectionName = strtolower(trim((string) ($section->class_section_name ?? '')));
return in_array($className, ['kg', 'kindergarten'], true)
|| in_array($sectionName, ['kg', 'kindergarten'], true);
}
private function resolveGradeOneTarget(): array
{
$target = DB::table('classes')
->whereIn(DB::raw('LOWER(class_name)'), ['1', 'grade 1', 'class 1'])
->orderByRaw("CASE WHEN class_name = '1' THEN 0 ELSE 1 END")
->orderBy('id')
->first();
return $target
? [(int) $target->id, (string) $target->class_name]
: [null, null];
}
private function buildParentBalanceRows(string $fromSchoolYear, string $toSchoolYear, bool $includeCreditOnlyRows = true): array
{
$invoiceQuery = $includeCreditOnlyRows
? $this->eligibleCarryoverInvoiceQuery($fromSchoolYear)
: DB::table('invoices')->where('school_year', $fromSchoolYear);
if ($includeCreditOnlyRows) {
$invoiceQuery->whereRaw('ABS(COALESCE(balance, 0)) > 0.01');
} else {
$invoiceQuery->whereRaw('COALESCE(balance, 0) > 0.01');
}
$invoices = $invoiceQuery->get(['id', 'parent_id', 'balance']);
$balancesByParent = [];
foreach ($invoices as $invoice) {
@@ -801,6 +854,18 @@ class SchoolYearClosureService
->first()
: null;
if (! $includeCreditOnlyRows && $positive < 0.01) {
if ($credit > 0.01) {
$summary['parents_with_credit_balance']++;
$summary['total_credit_balance'] += $credit;
}
if (abs($net) > 0.01) {
$summary['parents_with_net_non_zero_balance']++;
}
continue;
}
$row = [
'parent_id' => $parentId,
'parent_name' => $this->resolveParentName($parentId),
@@ -808,9 +873,15 @@ class SchoolYearClosureService
'unpaid_invoice_count' => count($balanceRow['positive_invoice_ids']),
'positive_unpaid_balance' => $positive,
'old_unpaid_balance' => $positive,
'old_unpaid_amount' => $positive,
'credit_balance' => $credit,
'net_balance' => $net,
'amount_to_transfer' => $positive,
'old_school_year' => $fromSchoolYear,
'new_school_year' => $toSchoolYear,
'new_old_balance_invoice_id' => $existing?->new_invoice_id ?? $existing?->old_balance_invoice_id ?? null,
'transfer_date' => optional($existing?->created_at)->toDateTimeString(),
'created_by' => $existing?->created_by,
'transfer_status' => $existing?->status ?? ($positive > 0.01 ? 'pending' : 'credit_reported'),
'existing_transfer_id' => $existing?->id,
'existing_transfer_conflict' => $existing !== null,
@@ -845,6 +916,9 @@ class SchoolYearClosureService
$summary['parents_with_non_zero_balance'] = $summary['parents_with_net_non_zero_balance'];
$summary['total_old_unpaid_balance'] = $summary['total_positive_unpaid_balance'];
$summary['net_balance_to_transfer'] = $summary['net_balance_impact'];
$summary['parent_count'] = $summary['parents_with_positive_balance'];
$summary['total_transferred'] = $summary['total_positive_unpaid_balance'];
$summary['total_credit'] = $summary['total_credit_balance'];
return [
'from_school_year' => $fromSchoolYear,
@@ -858,7 +932,19 @@ class SchoolYearClosureService
{
return DB::table('invoices')
->where('school_year', $schoolYear)
->whereNotIn(DB::raw("LOWER(COALESCE(status, ''))"), ['void', 'voided', 'cancelled', 'canceled', 'draft', 'pending']);
->whereNotIn(DB::raw("LOWER(COALESCE(status, ''))"), [
'void',
'voided',
'cancelled',
'canceled',
'draft',
'pending',
'paid',
'full',
'full payment',
'complete',
'completed',
]);
}
private function resolveTargetSection(?int $targetLevelId, string $schoolYear): array
@@ -1252,10 +1338,18 @@ class SchoolYearClosureService
return $totals;
}
$columns = ['amount'];
if (Schema::hasColumn('parent_balance_transfers', 'source_summary_json')) {
$columns[] = 'source_summary_json';
}
if (Schema::hasColumn('parent_balance_transfers', 'source_summary')) {
$columns[] = 'source_summary';
}
$rows = DB::table('parent_balance_transfers')
->where('from_school_year', $fromSchoolYear)
->when($toSchoolYear !== null && trim($toSchoolYear) !== '', fn ($query) => $query->where('to_school_year', $toSchoolYear))
->get(['amount', 'source_summary_json', 'source_summary']);
->get($columns);
foreach ($rows as $row) {
$amount = round((float) ($row->amount ?? 0), 2);
@@ -1283,6 +1377,21 @@ class SchoolYearClosureService
return $totals;
}
private function positiveInvoiceBalanceTotals(string $schoolYear): array
{
$rows = DB::table('invoices')
->where('school_year', $schoolYear)
->whereRaw('COALESCE(balance, 0) > 0.01')
->select('parent_id', DB::raw('SUM(COALESCE(balance, 0)) as total_balance'))
->groupBy('parent_id')
->get();
return [
'parents_with_unpaid_balances' => $rows->count(),
'total_unpaid_balance' => round((float) $rows->sum('total_balance'), 2),
];
}
private function decodeTransferSourceSummary(object $row): array
{
$raw = $row->source_summary_json ?? $row->source_summary ?? null;