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
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;