e9c10ae376
API CI/CD / Validate (composer + pint) (push) Failing after 1m11s
API CI/CD / Test (PHPUnit) (push) Failing after 2m30s
API CI/CD / Build frontend assets (push) Successful in 1m14s
API CI/CD / Security audit (push) Failing after 48s
API CI/CD / Deploy to shared hosting (PHP) (push) Has been skipped
484 lines
17 KiB
PHP
484 lines
17 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Families;
|
|
|
|
use App\Models\StudentClass;
|
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
|
use Illuminate\Database\Query\Builder as QueryBuilder;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Schema;
|
|
|
|
class ParentProfileAdminQueryService
|
|
{
|
|
public function __construct(private FamilyFinanceService $finance) {}
|
|
|
|
public function index(string $schoolYear, string $search = '', int $perPage = 25): LengthAwarePaginator
|
|
{
|
|
$familyCounts = DB::table('family_guardians')
|
|
->select('user_id', DB::raw('COUNT(DISTINCT family_id) AS families_count'))
|
|
->groupBy('user_id');
|
|
|
|
$familyStudentMap = DB::table('family_guardians as pfg')
|
|
->join('family_students as pfs', 'pfs.family_id', '=', 'pfg.family_id')
|
|
->joinSub($this->schoolYearStudentIds($schoolYear), 'pys', function ($join): void {
|
|
$join->on('pys.student_id', '=', 'pfs.student_id');
|
|
})
|
|
->selectRaw('pfg.user_id AS parent_id, pfs.student_id AS student_id');
|
|
|
|
$directStudentMap = DB::table('students as ps')
|
|
->joinSub($this->schoolYearStudentIds($schoolYear), 'dys', function ($join): void {
|
|
$join->on('dys.student_id', '=', 'ps.id');
|
|
})
|
|
->whereNotNull('ps.parent_id')
|
|
->selectRaw('ps.parent_id AS parent_id, ps.id AS student_id');
|
|
|
|
$parentStudentCounts = DB::query()
|
|
->fromSub(
|
|
$familyStudentMap->union($directStudentMap),
|
|
'parent_students'
|
|
)
|
|
->select(
|
|
'parent_id',
|
|
DB::raw('COUNT(DISTINCT student_id) AS selected_year_students_count')
|
|
)
|
|
->groupBy('parent_id');
|
|
|
|
$invoiceBalances = DB::table('invoices')
|
|
->select('parent_id', DB::raw('COALESCE(SUM(balance), 0) AS balance'))
|
|
->whereNotNull('parent_id');
|
|
|
|
$this->applySchoolYearFilter($invoiceBalances, 'school_year', $schoolYear);
|
|
|
|
$invoiceBalances->groupBy('parent_id');
|
|
|
|
$query = DB::table('users as u')
|
|
->leftJoinSub($familyCounts, 'fc', function ($join): void {
|
|
$join->on('fc.user_id', '=', 'u.id');
|
|
})
|
|
->leftJoinSub($parentStudentCounts, 'psc', function ($join): void {
|
|
$join->on('psc.parent_id', '=', 'u.id');
|
|
})
|
|
->leftJoinSub($invoiceBalances, 'ib', function ($join): void {
|
|
$join->on('ib.parent_id', '=', 'u.id');
|
|
})
|
|
->select(
|
|
'u.id',
|
|
'u.firstname',
|
|
'u.lastname',
|
|
'u.email',
|
|
'u.cellphone',
|
|
'u.status',
|
|
DB::raw('COALESCE(fc.families_count, 0) AS families_count'),
|
|
DB::raw('COALESCE(psc.selected_year_students_count, 0) AS selected_year_students_count'),
|
|
DB::raw('COALESCE(ib.balance, 0) AS balance')
|
|
)
|
|
->where($this->relevantParentConstraint($schoolYear))
|
|
->orderBy('u.lastname')
|
|
->orderBy('u.firstname');
|
|
|
|
$search = trim($search);
|
|
|
|
if ($search !== '') {
|
|
$needle = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $search).'%';
|
|
|
|
$query->where(function ($inner) use ($needle, $schoolYear): void {
|
|
$inner
|
|
->whereRaw("CONCAT_WS(' ', u.firstname, u.lastname) LIKE ?", [$needle])
|
|
->orWhere('u.email', 'like', $needle)
|
|
->orWhere('u.cellphone', 'like', $needle)
|
|
->orWhereExists(function ($exists) use ($needle, $schoolYear): void {
|
|
$exists->selectRaw('1')
|
|
->from('family_guardians as sfg')
|
|
->join('family_students as sfs', 'sfs.family_id', '=', 'sfg.family_id')
|
|
->join('students as ss', 'ss.id', '=', 'sfs.student_id')
|
|
->whereColumn('sfg.user_id', 'u.id')
|
|
->whereRaw("CONCAT_WS(' ', ss.firstname, ss.lastname) LIKE ?", [$needle]);
|
|
|
|
$this->constrainStudentToSchoolYear(
|
|
$exists,
|
|
'ss.id',
|
|
$schoolYear
|
|
);
|
|
})
|
|
->orWhereExists(function ($exists) use ($needle, $schoolYear): void {
|
|
$exists->selectRaw('1')
|
|
->from('students as direct_search_students')
|
|
->whereColumn('direct_search_students.parent_id', 'u.id')
|
|
->whereRaw(
|
|
"CONCAT_WS(' ', direct_search_students.firstname, direct_search_students.lastname) LIKE ?",
|
|
[$needle]
|
|
);
|
|
|
|
$this->constrainStudentToSchoolYear(
|
|
$exists,
|
|
'direct_search_students.id',
|
|
$schoolYear
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
return $query->paginate(max(1, min($perPage, 100)));
|
|
}
|
|
|
|
public function search(string $schoolYear, string $search, int $limit = 10): array
|
|
{
|
|
if (trim($search) === '') {
|
|
return [];
|
|
}
|
|
|
|
return $this->index($schoolYear, $search, $limit)
|
|
->getCollection()
|
|
->map(fn ($row) => $this->parentSummary((array) $row, $schoolYear))
|
|
->all();
|
|
}
|
|
|
|
public function show(int $parentId, string $schoolYear): ?array
|
|
{
|
|
$parent = DB::table('users')
|
|
->select(
|
|
'id',
|
|
'firstname',
|
|
'lastname',
|
|
'email',
|
|
'cellphone',
|
|
'address_street',
|
|
'city',
|
|
'state',
|
|
'zip',
|
|
'status'
|
|
)
|
|
->where('id', $parentId)
|
|
->first();
|
|
|
|
if (! $parent) {
|
|
return null;
|
|
}
|
|
|
|
$guardians = [
|
|
[
|
|
'user_id' => $parentId,
|
|
'firstname' => $parent->firstname,
|
|
'lastname' => $parent->lastname,
|
|
],
|
|
];
|
|
|
|
$finance = $this->finance->loadFinancialsForParents([$parentId], $schoolYear);
|
|
|
|
return [
|
|
'parent' => (array) $parent,
|
|
'families' => $this->familiesForParent($parentId, $schoolYear),
|
|
'students' => $this->studentsForParent($parentId, $schoolYear),
|
|
'emergency_contacts' => $this->emergencyContactsForParents($guardians),
|
|
'invoices' => $finance['invoices'],
|
|
'payments' => $finance['payments'],
|
|
'finance_summary' => $finance['summary'],
|
|
];
|
|
}
|
|
|
|
public function updateParent(int $parentId, array $payload): ?array
|
|
{
|
|
$allowed = array_intersect_key($payload, array_flip([
|
|
'firstname',
|
|
'lastname',
|
|
'email',
|
|
'cellphone',
|
|
'address_street',
|
|
'city',
|
|
'state',
|
|
'zip',
|
|
'status',
|
|
]));
|
|
|
|
if ($allowed !== []) {
|
|
$allowed['updated_at'] = now();
|
|
DB::table('users')->where('id', $parentId)->update($allowed);
|
|
}
|
|
|
|
$parent = DB::table('users')->where('id', $parentId)->first();
|
|
|
|
return $parent ? (array) $parent : null;
|
|
}
|
|
|
|
public function parentSummary(array $row, string $schoolYear): array
|
|
{
|
|
$balance = (float) ($row['balance'] ?? 0);
|
|
|
|
$primaryFamily = DB::table('family_guardians as fg')
|
|
->join('families as f', 'f.id', '=', 'fg.family_id')
|
|
->where('fg.user_id', (int) ($row['id'] ?? 0))
|
|
->orderByDesc('fg.is_primary')
|
|
->orderBy('f.household_name')
|
|
->select('f.id', 'f.household_name')
|
|
->first();
|
|
|
|
return [
|
|
'id' => (int) ($row['id'] ?? 0),
|
|
'firstname' => $row['firstname'] ?? null,
|
|
'lastname' => $row['lastname'] ?? null,
|
|
'email' => $row['email'] ?? null,
|
|
'cellphone' => $row['cellphone'] ?? null,
|
|
'is_active' => strtolower((string) ($row['status'] ?? '')) === 'active',
|
|
'families_count' => (int) ($row['families_count'] ?? 0),
|
|
'students_count' => (int) ($row['selected_year_students_count'] ?? 0),
|
|
'selected_year_students_count' => (int) ($row['selected_year_students_count'] ?? 0),
|
|
'balance' => $balance,
|
|
'positive_unpaid_balance' => max(0, $balance),
|
|
'credit_balance' => $balance < 0 ? abs($balance) : 0.0,
|
|
'primary_family' => $primaryFamily ? (array) $primaryFamily : null,
|
|
'school_year' => $schoolYear,
|
|
];
|
|
}
|
|
|
|
private function relevantParentConstraint(string $schoolYear): callable
|
|
{
|
|
$schoolYearId = $this->schoolYearId($schoolYear);
|
|
|
|
return function ($query) use ($schoolYear, $schoolYearId): void {
|
|
$query
|
|
->whereExists(function ($exists) use ($schoolYear): void {
|
|
$exists->selectRaw('1')
|
|
->from('family_guardians as rfg')
|
|
->join('family_students as rfs', 'rfs.family_id', '=', 'rfg.family_id')
|
|
->whereColumn('rfg.user_id', 'u.id');
|
|
|
|
$this->constrainStudentToSchoolYear(
|
|
$exists,
|
|
'rfs.student_id',
|
|
$schoolYear
|
|
);
|
|
})
|
|
->orWhereExists(function ($exists) use ($schoolYear): void {
|
|
$exists->selectRaw('1')
|
|
->from('students as direct_students')
|
|
->whereColumn('direct_students.parent_id', 'u.id');
|
|
|
|
$this->constrainStudentToSchoolYear(
|
|
$exists,
|
|
'direct_students.id',
|
|
$schoolYear
|
|
);
|
|
})
|
|
->orWhereExists(function ($exists) use ($schoolYear): void {
|
|
$exists->selectRaw('1')
|
|
->from('invoices as ri')
|
|
->whereColumn('ri.parent_id', 'u.id');
|
|
|
|
$this->applySchoolYearFilter(
|
|
$exists,
|
|
'ri.school_year',
|
|
$schoolYear
|
|
);
|
|
});
|
|
|
|
if (! Schema::hasTable('parent_accounts')) {
|
|
return;
|
|
}
|
|
|
|
if ($this->isAllSchoolYears($schoolYear)) {
|
|
$query->orWhereExists(function ($exists): void {
|
|
$exists->selectRaw('1')
|
|
->from('parent_accounts as pa')
|
|
->whereColumn('pa.parent_id', 'u.id');
|
|
});
|
|
} elseif (
|
|
$schoolYearId !== null
|
|
&& Schema::hasColumn('parent_accounts', 'school_year_id')
|
|
) {
|
|
$query->orWhereExists(function ($exists) use ($schoolYearId): void {
|
|
$exists->selectRaw('1')
|
|
->from('parent_accounts as pa')
|
|
->whereColumn('pa.parent_id', 'u.id')
|
|
->where('pa.school_year_id', $schoolYearId);
|
|
});
|
|
}
|
|
};
|
|
}
|
|
|
|
private function familiesForParent(int $parentId, string $schoolYear): array
|
|
{
|
|
return DB::table('family_guardians as fg')
|
|
->join('families as f', 'f.id', '=', 'fg.family_id')
|
|
->where('fg.user_id', $parentId)
|
|
->whereExists(function ($exists) use ($schoolYear): void {
|
|
$exists->selectRaw('1')
|
|
->from('family_students as fs')
|
|
->whereColumn('fs.family_id', 'f.id');
|
|
|
|
$this->constrainStudentToSchoolYear(
|
|
$exists,
|
|
'fs.student_id',
|
|
$schoolYear
|
|
);
|
|
})
|
|
->orderBy('f.household_name')
|
|
->select(
|
|
'f.*',
|
|
'fg.relation',
|
|
'fg.is_primary',
|
|
'fg.receive_emails',
|
|
'fg.receive_sms'
|
|
)
|
|
->get()
|
|
->map(fn ($row) => (array) $row)
|
|
->all();
|
|
}
|
|
|
|
private function studentsForParent(int $parentId, string $schoolYear): array
|
|
{
|
|
$rowsQuery = DB::table('students as s')
|
|
->select('s.id', 's.firstname', 's.lastname', 's.parent_id')
|
|
->where(function ($query) use ($parentId): void {
|
|
$query
|
|
->where('s.parent_id', $parentId)
|
|
->orWhereExists(function ($exists) use ($parentId): void {
|
|
$exists->selectRaw('1')
|
|
->from('family_guardians as fg')
|
|
->join('family_students as fs', 'fs.family_id', '=', 'fg.family_id')
|
|
->whereColumn('fs.student_id', 's.id')
|
|
->where('fg.user_id', $parentId);
|
|
});
|
|
});
|
|
|
|
$this->constrainStudentToSchoolYear(
|
|
$rowsQuery,
|
|
's.id',
|
|
$schoolYear
|
|
);
|
|
|
|
$rows = $rowsQuery
|
|
->orderBy('s.lastname')
|
|
->orderBy('s.firstname')
|
|
->get()
|
|
->map(fn ($row) => (array) $row)
|
|
->all();
|
|
|
|
foreach ($rows as &$row) {
|
|
$studentId = (int) ($row['id'] ?? 0);
|
|
|
|
$classSectionName = $studentId > 0
|
|
? (string) (StudentClass::getClassSectionsByStudentId($studentId, $schoolYear) ?? '')
|
|
: '';
|
|
|
|
$row['class_section_name'] = $classSectionName;
|
|
$row['grade'] = $classSectionName;
|
|
}
|
|
unset($row);
|
|
|
|
return $rows;
|
|
}
|
|
|
|
private function emergencyContactsForParents(array $guardians): array
|
|
{
|
|
$parentIds = array_values(array_filter(array_map(
|
|
static fn ($guardian) => (int) ($guardian['user_id'] ?? 0),
|
|
$guardians
|
|
)));
|
|
|
|
if ($parentIds === []) {
|
|
return [];
|
|
}
|
|
|
|
return DB::table('emergency_contacts')
|
|
->select(
|
|
'id',
|
|
'parent_id',
|
|
'emergency_contact_name',
|
|
'relation',
|
|
'cellphone',
|
|
'email',
|
|
'created_at',
|
|
'updated_at'
|
|
)
|
|
->whereIn('parent_id', $parentIds)
|
|
->orderByDesc('updated_at')
|
|
->get()
|
|
->map(fn ($row) => (array) $row)
|
|
->all();
|
|
}
|
|
|
|
private function schoolYearStudentIds(string $schoolYear): QueryBuilder
|
|
{
|
|
$studentClass = DB::table('student_class')
|
|
->select('student_id');
|
|
|
|
$enrollments = DB::table('enrollments')
|
|
->select('student_id');
|
|
|
|
if (! $this->isAllSchoolYears($schoolYear)) {
|
|
$studentClass->where('school_year', $schoolYear);
|
|
$enrollments->where('school_year', $schoolYear);
|
|
}
|
|
|
|
return $studentClass->union($enrollments);
|
|
}
|
|
|
|
private function constrainStudentToSchoolYear(
|
|
QueryBuilder $query,
|
|
string $studentIdColumn,
|
|
string $schoolYear
|
|
): void {
|
|
if ($this->isAllSchoolYears($schoolYear)) {
|
|
return;
|
|
}
|
|
|
|
$query->where(function ($yearQuery) use ($studentIdColumn, $schoolYear): void {
|
|
$yearQuery
|
|
->whereExists(function ($exists) use ($studentIdColumn, $schoolYear): void {
|
|
$exists->selectRaw('1')
|
|
->from('student_class as year_sc')
|
|
->whereColumn('year_sc.student_id', $studentIdColumn)
|
|
->where('year_sc.school_year', $schoolYear);
|
|
})
|
|
->orWhereExists(function ($exists) use ($studentIdColumn, $schoolYear): void {
|
|
$exists->selectRaw('1')
|
|
->from('enrollments as year_e')
|
|
->whereColumn('year_e.student_id', $studentIdColumn)
|
|
->where('year_e.school_year', $schoolYear);
|
|
});
|
|
});
|
|
}
|
|
|
|
|
|
private function schoolYearId(string $schoolYear): ?int
|
|
{
|
|
if ($this->isAllSchoolYears($schoolYear) || ! Schema::hasTable('school_years')) {
|
|
return null;
|
|
}
|
|
|
|
$schoolYear = trim($schoolYear);
|
|
|
|
foreach (['school_year', 'name', 'year', 'label'] as $column) {
|
|
if (! Schema::hasColumn('school_years', $column)) {
|
|
continue;
|
|
}
|
|
|
|
$id = DB::table('school_years')
|
|
->where($column, $schoolYear)
|
|
->value('id');
|
|
|
|
if ($id !== null) {
|
|
return (int) $id;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function applySchoolYearFilter(
|
|
QueryBuilder $query,
|
|
string $column,
|
|
string $schoolYear
|
|
): void {
|
|
if (! $this->isAllSchoolYears($schoolYear)) {
|
|
$query->where($column, $schoolYear);
|
|
}
|
|
}
|
|
|
|
private function isAllSchoolYears(string $schoolYear): bool
|
|
{
|
|
$schoolYear = trim($schoolYear);
|
|
|
|
return $schoolYear === '' || strtolower($schoolYear) === 'all';
|
|
}
|
|
} |