Files
alrahma_sunday_school_api/app/Services/Promotions/PromotionQueryService.php
T
2026-06-09 02:32:58 -04:00

224 lines
8.9 KiB
PHP

<?php
namespace App\Services\Promotions;
use App\Models\Student;
use App\Models\StudentPromotionRecord;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
/**
* Admin-side reads against `student_promotion_records` (plan sections 13
* and 16). Always returns hydrated student / parent context so the
* admin UI can render rows without N+1 queries.
*/
class PromotionQueryService
{
/**
* Filterable list of promotion records for admin views.
*
* Filters supported:
* - status: string|array
* - next_school_year: string
* - current_school_year: string
* - parent_id: int
* - student_id: int
* - search: string (matches student first/last name)
* - parent_action_required: bool (subset of parentActionableStatuses)
*
* Returns array<int, array<string, mixed>> when paginate=false,
* otherwise a Laravel paginator instance.
*
* @param array{
* status?: string|array<int,string>,
* next_school_year?: string|null,
* current_school_year?: string|null,
* parent_id?: int|null,
* student_id?: int|null,
* search?: string|null,
* parent_action_required?: bool,
* per_page?: int|null,
* } $filters
*
* @return array{
* data: array<int,array<string,mixed>>,
* total: int,
* page: int,
* per_page: int,
* total_pages: int,
* counts_by_status: array<string,int>
* }
*/
public function list(array $filters): array
{
$query = StudentPromotionRecord::query();
$this->applyFilters($query, $filters);
$perPage = isset($filters['per_page']) ? max(1, min(200, (int) $filters['per_page'])) : 50;
$page = isset($filters['page']) ? max(1, (int) $filters['page']) : 1;
/** @var LengthAwarePaginator $paginator */
$paginator = $query->orderByDesc('promotion_id')->paginate($perPage, ['*'], 'page', $page);
/** @var EloquentCollection<int,StudentPromotionRecord> $records */
$records = $paginator->getCollection();
$studentIds = $records->pluck('student_id')->unique()->values()->all();
$parentIds = $records->pluck('parent_id')->filter()->unique()->values()->all();
$studentsById = !empty($studentIds)
? Student::query()->whereIn('id', $studentIds)->get()->keyBy('id')
: new EloquentCollection();
$parentsById = !empty($parentIds)
? User::query()->whereIn('id', $parentIds)->get()->keyBy('id')
: new EloquentCollection();
$rows = $records->map(function (StudentPromotionRecord $record) use ($studentsById, $parentsById) {
return $this->presentAdminRow(
$record,
$studentsById->get($record->student_id),
$record->parent_id ? $parentsById->get($record->parent_id) : null
);
})->all();
return [
'data' => array_values($rows),
'total' => (int) $paginator->total(),
'page' => (int) $paginator->currentPage(),
'per_page' => (int) $paginator->perPage(),
'total_pages' => (int) $paginator->lastPage(),
'counts_by_status' => $this->countsByStatus($filters),
];
}
/**
* Returns the per-status count of promotion records that match the
* given filter set (ignoring `status` and pagination filters). Plan
* section 16's reports rely on these counts.
*
* @return array<string,int>
*/
public function countsByStatus(array $filters): array
{
$query = StudentPromotionRecord::query();
// Apply non-status filters only.
$filtersSansStatus = $filters;
unset($filtersSansStatus['status'], $filtersSansStatus['parent_action_required']);
$this->applyFilters($query, $filtersSansStatus);
$rows = $query
->select('promotion_status', DB::raw('count(*) as total'))
->groupBy('promotion_status')
->get();
$counts = array_fill_keys(StudentPromotionRecord::ALL_STATUSES, 0);
foreach ($rows as $row) {
$counts[(string) $row->promotion_status] = (int) $row->total;
}
return $counts;
}
/**
* Hydrates a single record with student + parent context for the
* admin detail view.
*/
public function detail(StudentPromotionRecord $record): array
{
$student = Student::query()->find($record->student_id);
$parent = $record->parent_id ? User::query()->find($record->parent_id) : null;
return $this->presentAdminRow($record, $student, $parent, true);
}
/**
* @param Builder<StudentPromotionRecord> $query
*/
private function applyFilters(Builder $query, array $filters): void
{
if (!empty($filters['status'])) {
$statuses = is_array($filters['status']) ? $filters['status'] : [$filters['status']];
$statuses = array_values(array_filter($statuses, static fn ($s) => is_string($s) && $s !== ''));
if (!empty($statuses)) {
$query->whereIn('promotion_status', $statuses);
}
}
if (!empty($filters['parent_action_required'])) {
$query->whereIn('promotion_status', StudentPromotionRecord::parentActionableStatuses());
}
if (!empty($filters['next_school_year'])) {
$query->where('next_school_year', $filters['next_school_year']);
}
if (!empty($filters['current_school_year'])) {
$query->where('current_school_year', $filters['current_school_year']);
}
if (!empty($filters['parent_id'])) {
$query->where('parent_id', (int) $filters['parent_id']);
}
if (!empty($filters['student_id'])) {
$query->where('student_id', (int) $filters['student_id']);
}
if (!empty($filters['search'])) {
$search = '%' . strtolower((string) $filters['search']) . '%';
$query->whereIn('student_id', function ($sub) use ($search) {
$sub->select('id')
->from('students')
->where(function ($w) use ($search) {
$w->whereRaw('LOWER(firstname) LIKE ?', [$search])
->orWhereRaw('LOWER(lastname) LIKE ?', [$search]);
});
});
}
}
private function presentAdminRow(
StudentPromotionRecord $record,
?Student $student,
?User $parent,
bool $detail = false
): array {
$row = [
'promotion_id' => (int) $record->getKey(),
'student_id' => (int) $record->student_id,
'student_name' => $student
? trim((string) $student->firstname . ' ' . (string) $student->lastname)
: null,
'school_id' => $student->school_id ?? null,
'parent_id' => $record->parent_id ? (int) $record->parent_id : null,
'parent_name' => $parent
? trim((string) $parent->firstname . ' ' . (string) $parent->lastname)
: null,
'parent_email' => $parent->email ?? null,
'current_school_year' => $record->current_school_year,
'next_school_year' => $record->next_school_year,
'current_level' => $record->current_level_name,
'promoted_level' => $record->promoted_level_name,
'promotion_status' => $record->promotion_status,
'enrollment_status' => $record->enrollment_status,
'enrollment_deadline' => $record->enrollment_deadline?->toDateString(),
'parent_notified_at' => $record->parent_notified_at?->toDateTimeString(),
'enrollment_started_at' => $record->enrollment_started_at?->toDateTimeString(),
'enrollment_completed_at' => $record->enrollment_completed_at?->toDateTimeString(),
'promotion_finalized_at' => $record->promotion_finalized_at?->toDateTimeString(),
'final_average' => $record->final_average !== null ? (float) $record->final_average : null,
'passed_current_level' => $record->passed_current_level,
'updated_by' => $record->updated_by ? (int) $record->updated_by : null,
'updated_at' => $record->updated_at?->toDateTimeString(),
];
if ($detail) {
$row['checklist'] = [
'info_confirmed' => (bool) $record->info_confirmed,
'documents_uploaded' => (bool) $record->documents_uploaded,
'agreement_accepted' => (bool) $record->agreement_accepted,
'payment_completed' => (bool) $record->payment_completed,
];
$row['eligibility_notes'] = $record->eligibility_notes;
$row['enrollment_id'] = $record->enrollment_id ? (int) $record->enrollment_id : null;
}
return $row;
}
}