fix financial and certificates

This commit is contained in:
root
2026-06-05 01:51:08 -04:00
parent d28d11e2e5
commit ad968eaff7
94 changed files with 9654 additions and 214 deletions
@@ -0,0 +1,647 @@
<?php
namespace App\Services\Administrator\Trophy;
use App\Models\Configuration;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
/**
* Ports the legacy CodeIgniter trophy projection + winner reports to
* Laravel JSON payloads for the SPA/admin API.
*/
class TrophyReportService
{
public function projection(?string $schoolYear = null, ?float $percentile = null): array
{
$selectedYear = $this->resolveSchoolYear($schoolYear);
$selectedPercentile = $this->sanitizePercentile($percentile);
$sections = $this->sectionMap($this->studentRows($selectedYear));
$classResults = [];
foreach ($sections as $sectionId => $section) {
$students = $this->sortByScore($section['students'], 'fall_score');
$scores = array_column($students, 'fall_score');
$calc = $this->calculateThreshold($scores, $selectedPercentile);
$threshold = $calc['threshold'];
$students = array_map(function (array $student) use ($threshold): array {
$student['projected_trophy'] = $threshold !== null
&& $student['fall_score'] !== null
&& $student['fall_score'] >= $threshold;
return $student;
}, $students);
$scoredCount = count(array_filter(
array_column($students, 'fall_score'),
static fn ($score): bool => $score !== null
));
[$boys, $girls] = $this->splitByBinaryGender($students);
$trophyBoys = array_values(array_filter($boys, static fn (array $student): bool => ! empty($student['projected_trophy'])));
$trophyGirls = array_values(array_filter($girls, static fn (array $student): bool => ! empty($student['projected_trophy'])));
$studentCount = count($students);
$classResults[] = [
'section_id' => $sectionId,
'section_name' => $section['name'],
'students' => $students,
'threshold' => $threshold,
'trophy_count' => $calc['winners'],
'student_count' => $studentCount,
'scored_count' => $scoredCount,
'method' => $calc['method'],
'boys' => count($boys),
'girls' => count($girls),
'trophy_boys' => count($trophyBoys),
'trophy_girls' => count($trophyGirls),
'pct_boys' => $studentCount > 0 ? round(count($boys) / $studentCount * 100) : 0,
'pct_girls' => $studentCount > 0 ? round(count($girls) / $studentCount * 100) : 0,
'pct_trophy_boys' => count($boys) > 0 ? round(count($trophyBoys) / count($boys) * 100) : 0,
'pct_trophy_girls' => count($girls) > 0 ? round(count($trophyGirls) / count($girls) * 100) : 0,
'pct_trophy_total' => $studentCount > 0 ? round($calc['winners'] / $studentCount * 100) : 0,
];
}
return [
'selected_year' => $selectedYear,
'selected_percentile' => $selectedPercentile,
'years' => $this->schoolYears(),
'class_results' => $classResults,
'summary' => $this->projectionSummary($classResults),
];
}
public function winners(?string $schoolYear = null, ?float $percentile = null): array
{
$selectedYear = $this->resolveSchoolYear($schoolYear);
$selectedPercentile = $this->sanitizePercentile($percentile);
$sections = $this->sectionMap($this->studentRows($selectedYear));
$classResults = [];
foreach ($sections as $sectionId => $section) {
$students = $this->sortByScore($section['students'], 'fall_score');
$calc = $this->calculateThreshold(array_column($students, 'fall_score'), $selectedPercentile);
$threshold = $calc['threshold'];
$winners = array_values(array_filter($students, static function (array $student) use ($threshold): bool {
return $threshold !== null
&& $student['fall_score'] !== null
&& $student['fall_score'] >= $threshold;
}));
if ($winners === []) {
continue;
}
[$boys, $girls] = $this->splitByBinaryGender($students);
[$winnerBoys, $winnerGirls] = $this->splitByBinaryGender($winners);
$studentCount = count($students);
$classResults[] = [
'section_id' => $sectionId,
'section_name' => $section['name'],
'threshold' => $threshold,
'winners' => $winners,
'student_count' => $studentCount,
'boys' => count($boys),
'girls' => count($girls),
'trophy_boys' => count($winnerBoys),
'trophy_girls' => count($winnerGirls),
'pct_boys' => $studentCount > 0 ? round(count($boys) / $studentCount * 100) : 0,
'pct_girls' => $studentCount > 0 ? round(count($girls) / $studentCount * 100) : 0,
'pct_trophy_boys' => count($boys) > 0 ? round(count($winnerBoys) / count($boys) * 100) : 0,
'pct_trophy_girls' => count($girls) > 0 ? round(count($winnerGirls) / count($girls) * 100) : 0,
'pct_trophy_total' => $studentCount > 0 ? round(count($winners) / $studentCount * 100) : 0,
];
}
return [
'selected_year' => $selectedYear,
'selected_percentile' => $selectedPercentile,
'years' => $this->schoolYears(),
'class_results' => $classResults,
'summary' => $this->winnersSummary($classResults),
];
}
public function final(?string $schoolYear = null, ?float $percentile = null): array
{
$selectedYear = $this->resolveSchoolYear($schoolYear);
$selectedPercentile = $this->sanitizePercentile($percentile);
$sections = $this->sectionMap($this->studentRows($selectedYear));
$classResults = [];
$allStudents = [];
$passStudents = [];
$winnerStudents = [];
$winnerStickers = [];
foreach ($sections as $sectionId => $section) {
$students = $section['students'];
$fallCalc = $this->calculateThreshold(array_column($students, 'fall_score'), $selectedPercentile);
$yearCalc = $this->calculateThreshold(array_column($students, 'year_score'), $selectedPercentile);
$fallThreshold = $fallCalc['threshold'];
$yearThreshold = $yearCalc['threshold'];
$annotated = array_map(function (array $student) use ($fallThreshold, $yearThreshold): array {
$predicted = $fallThreshold !== null
&& $student['fall_score'] !== null
&& $student['fall_score'] >= $fallThreshold;
$actual = $yearThreshold !== null
&& $student['year_score'] !== null
&& $student['year_score'] >= $yearThreshold;
$status = match (true) {
$predicted && $actual => 'confirmed',
! $predicted && $actual => 'surprise',
$predicted && ! $actual => 'missed',
default => 'none',
};
return $student + compact('predicted', 'actual', 'status');
}, $students);
$annotated = $this->sortByScore($annotated, 'year_score');
foreach ($annotated as $student) {
$allStudents[] = $student;
if ($student['year_score'] !== null && $student['year_score'] >= 60.0) {
$passStudents[] = $student;
}
if (! empty($student['actual'])) {
$winnerStudents[] = $student;
$winnerStickers[] = [
'name' => $student['name'],
'section' => $section['name'],
];
}
}
$confirmed = count(array_filter($annotated, static fn (array $student): bool => $student['status'] === 'confirmed'));
$surprises = count(array_filter($annotated, static fn (array $student): bool => $student['status'] === 'surprise'));
$missed = count(array_filter($annotated, static fn (array $student): bool => $student['status'] === 'missed'));
$predictedCount = count(array_filter($annotated, static fn (array $student): bool => ! empty($student['predicted'])));
$actualCount = count(array_filter($annotated, static fn (array $student): bool => ! empty($student['actual'])));
$studentCount = count($annotated);
$classResults[] = [
'section_id' => $sectionId,
'section_name' => $section['name'],
'students' => $annotated,
'student_count' => $studentCount,
'fall_threshold' => $fallThreshold,
'year_threshold' => $yearThreshold,
'predicted_count' => $predictedCount,
'actual_count' => $actualCount,
'confirmed' => $confirmed,
'surprises' => $surprises,
'missed' => $missed,
'accuracy' => $predictedCount > 0 ? round($confirmed / $predictedCount * 100) : ($actualCount === 0 ? 100 : 0),
];
}
return [
'selected_year' => $selectedYear,
'selected_percentile' => $selectedPercentile,
'years' => $this->schoolYears(),
'class_results' => $classResults,
'summary' => $this->finalSummary($classResults),
'winner_gender_summary' => $this->genderBreakdown($winnerStudents),
'all_gender_summary' => $this->genderBreakdown($allStudents),
'pass_gender_summary' => $this->genderBreakdown($passStudents),
'winner_stickers' => array_values(array_filter($winnerStickers, static fn (array $sticker): bool => trim((string) ($sticker['name'] ?? '')) !== '')),
];
}
/**
* @param array<int, float|int|string|null> $scores
* @return array{threshold: float|null, winners: int, method: string}
*/
public function calculateThreshold(array $scores, float $percentile = 75.0): array
{
$scores = array_values(array_filter(
$scores,
static fn ($value): bool => is_numeric($value) && $value !== null
));
$scores = array_map('floatval', $scores);
sort($scores);
$count = count($scores);
if ($count === 0) {
return ['threshold' => null, 'winners' => 0, 'method' => 'empty'];
}
$minWinners = 3;
$maxWinners = max($minWinners, (int) floor($count * (1 - $percentile / 100)));
$threshold = $this->empiricalPercentile($scores, $percentile);
$winners = $this->countAtOrAbove($scores, $threshold);
if ($winners < $minWinners) {
$target = min($minWinners, $count);
$descending = array_reverse($scores);
$threshold = $descending[$target - 1];
$winners = $this->countAtOrAbove($scores, $threshold);
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'min3_reduced'];
}
if ($winners <= $maxWinners) {
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'empirical_percentile'];
}
$result = $this->capByRank($scores, $maxWinners);
if ($result['winners'] < $minWinners) {
$target = min($minWinners, $count);
$descending = array_reverse($scores);
$threshold = $descending[$target - 1];
$winners = $this->countAtOrAbove($scores, $threshold);
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'min3_after_cap'];
}
return $result;
}
private function resolveSchoolYear(?string $schoolYear): string
{
$selected = trim((string) $schoolYear);
if ($selected !== '') {
return $selected;
}
return (string) (Configuration::getConfig('school_year') ?? '');
}
private function sanitizePercentile(?float $percentile): float
{
$value = $percentile ?? 75.0;
return max(1.0, min(99.0, $value));
}
/**
* @return list<string>
*/
private function schoolYears(): array
{
return DB::table('student_class')
->select('school_year')
->distinct()
->orderBy('school_year', 'DESC')
->pluck('school_year')
->filter(static fn ($value): bool => $value !== null && trim((string) $value) !== '')
->map(static fn ($value): string => (string) $value)
->values()
->all();
}
/**
* @return \Illuminate\Support\Collection<int, array<string, mixed>>
*/
private function studentRows(string $schoolYear): Collection
{
$fallScores = DB::table('semester_scores')
->select(
'student_id',
'class_section_id',
DB::raw('MAX(semester_score) AS fall_score')
)
->where('school_year', $schoolYear)
->whereRaw('LOWER(semester) = ?', ['fall'])
->groupBy('student_id', 'class_section_id');
$springScores = DB::table('semester_scores')
->select(
'student_id',
'class_section_id',
DB::raw('MAX(semester_score) AS spring_score')
)
->where('school_year', $schoolYear)
->whereRaw('LOWER(semester) = ?', ['spring'])
->groupBy('student_id', 'class_section_id');
return DB::table('student_class as sc')
->select(
'sc.student_id',
'sc.class_section_id',
'cs.class_section_name',
's.firstname',
's.lastname',
's.school_id',
's.gender',
DB::raw('MAX(sf.fall_score) AS fall_score'),
DB::raw('MAX(sp.spring_score) AS spring_score')
)
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id')
->leftJoin('students as s', 's.id', '=', 'sc.student_id')
->leftJoinSub($fallScores, 'sf', function ($join): void {
$join->on('sf.student_id', '=', 'sc.student_id')
->on('sf.class_section_id', '=', 'sc.class_section_id');
})
->leftJoinSub($springScores, 'sp', function ($join): void {
$join->on('sp.student_id', '=', 'sc.student_id')
->on('sp.class_section_id', '=', 'sc.class_section_id');
})
->where('sc.school_year', $schoolYear)
->groupBy(
'sc.student_id',
'sc.class_section_id',
'cs.class_section_name',
's.firstname',
's.lastname',
's.school_id',
's.gender'
)
->orderBy('cs.class_section_name', 'ASC')
->orderBy('s.firstname', 'ASC')
->orderBy('s.lastname', 'ASC')
->get()
->map(function (object $row): array {
$student = (array) $row;
$fall = is_numeric($student['fall_score'] ?? null) ? round((float) $student['fall_score'], 1) : null;
$spring = is_numeric($student['spring_score'] ?? null) ? round((float) $student['spring_score'], 1) : null;
$year = ($fall !== null && $spring !== null)
? round(($fall + $spring) / 2, 1)
: ($fall ?? $spring);
$firstname = trim((string) ($student['firstname'] ?? ''));
$lastname = trim((string) ($student['lastname'] ?? ''));
$name = trim($firstname.' '.$lastname);
return [
'student_id' => (int) ($student['student_id'] ?? 0),
'class_section_id' => (int) ($student['class_section_id'] ?? 0),
'section_name' => (string) ($student['class_section_name'] ?? ''),
'name' => $name !== '' ? $name : 'Student #'.($student['student_id'] ?? ''),
'firstname' => $firstname !== '' ? $firstname : null,
'lastname' => $lastname !== '' ? $lastname : null,
'school_id' => $student['school_id'] ?? null,
'gender' => (string) ($student['gender'] ?? ''),
'fall_score' => $fall,
'spring_score' => $spring,
'year_score' => $year,
];
});
}
/**
* @param \Illuminate\Support\Collection<int, array<string, mixed>> $rows
* @return array<int, array{name: string, students: list<array<string, mixed>>}>
*/
private function sectionMap(Collection $rows): array
{
$sections = [];
foreach ($rows as $row) {
$sectionId = (int) ($row['class_section_id'] ?? 0);
$name = trim((string) ($row['section_name'] ?? '')) ?: 'Section '.$sectionId;
if (! isset($sections[$sectionId])) {
$sections[$sectionId] = [
'name' => $name,
'students' => [],
];
}
unset($row['section_name']);
$sections[$sectionId]['students'][] = $row;
}
return $sections;
}
/**
* @param list<array<string, mixed>> $students
* @return list<array<string, mixed>>
*/
private function sortByScore(array $students, string $scoreKey): array
{
usort($students, static function (array $left, array $right) use ($scoreKey): int {
$leftScore = $left[$scoreKey] ?? null;
$rightScore = $right[$scoreKey] ?? null;
if ($leftScore === null && $rightScore === null) {
return strcmp((string) ($left['name'] ?? ''), (string) ($right['name'] ?? ''));
}
if ($leftScore === null) {
return 1;
}
if ($rightScore === null) {
return -1;
}
return $rightScore <=> $leftScore ?: strcmp((string) ($left['name'] ?? ''), (string) ($right['name'] ?? ''));
});
return $students;
}
/**
* @param list<array<string, mixed>> $students
* @return array{0: list<array<string, mixed>>, 1: list<array<string, mixed>>}
*/
private function splitByBinaryGender(array $students): array
{
$boys = array_values(array_filter($students, fn (array $student): bool => $this->genderKey($student['gender'] ?? '') === 'boys'));
$girls = array_values(array_filter($students, fn (array $student): bool => $this->genderKey($student['gender'] ?? '') === 'girls'));
return [$boys, $girls];
}
/**
* @param list<array<string, mixed>> $classResults
* @return array<string, int|float>
*/
private function projectionSummary(array $classResults): array
{
$totalStudents = array_sum(array_column($classResults, 'student_count'));
$totalScored = array_sum(array_column($classResults, 'scored_count'));
$totalTrophies = array_sum(array_column($classResults, 'trophy_count'));
$totalBoys = array_sum(array_column($classResults, 'boys'));
$totalGirls = array_sum(array_column($classResults, 'girls'));
$totalTrophyBoys = array_sum(array_column($classResults, 'trophy_boys'));
$totalTrophyGirls = array_sum(array_column($classResults, 'trophy_girls'));
return [
'classes' => count($classResults),
'students' => $totalStudents,
'scored' => $totalScored,
'trophies' => $totalTrophies,
'boys' => $totalBoys,
'girls' => $totalGirls,
'trophy_boys' => $totalTrophyBoys,
'trophy_girls' => $totalTrophyGirls,
'pct_scored' => $totalStudents > 0 ? round($totalScored / $totalStudents * 100) : 0,
'pct_boys' => $totalStudents > 0 ? round($totalBoys / $totalStudents * 100) : 0,
'pct_girls' => $totalStudents > 0 ? round($totalGirls / $totalStudents * 100) : 0,
'pct_trophies' => $totalStudents > 0 ? round($totalTrophies / $totalStudents * 100) : 0,
'pct_trophy_boys' => $totalBoys > 0 ? round($totalTrophyBoys / $totalBoys * 100) : 0,
'pct_trophy_girls' => $totalGirls > 0 ? round($totalTrophyGirls / $totalGirls * 100) : 0,
];
}
/**
* @param list<array<string, mixed>> $classResults
* @return array<string, int|float>
*/
private function winnersSummary(array $classResults): array
{
$totalWinners = array_sum(array_map(static fn (array $classResult): int => count($classResult['winners'] ?? []), $classResults));
$totalStudents = array_sum(array_column($classResults, 'student_count'));
$totalBoys = array_sum(array_column($classResults, 'boys'));
$totalGirls = array_sum(array_column($classResults, 'girls'));
$totalTrophyBoys = array_sum(array_column($classResults, 'trophy_boys'));
$totalTrophyGirls = array_sum(array_column($classResults, 'trophy_girls'));
return [
'classes' => count($classResults),
'students' => $totalStudents,
'winners' => $totalWinners,
'boys' => $totalBoys,
'girls' => $totalGirls,
'trophy_boys' => $totalTrophyBoys,
'trophy_girls' => $totalTrophyGirls,
'pct_winners' => $totalStudents > 0 ? round($totalWinners / $totalStudents * 100) : 0,
'pct_boys' => $totalStudents > 0 ? round($totalBoys / $totalStudents * 100) : 0,
'pct_girls' => $totalStudents > 0 ? round($totalGirls / $totalStudents * 100) : 0,
'pct_trophy_boys' => $totalBoys > 0 ? round($totalTrophyBoys / $totalBoys * 100) : 0,
'pct_trophy_girls' => $totalGirls > 0 ? round($totalTrophyGirls / $totalGirls * 100) : 0,
];
}
/**
* @param list<array<string, mixed>> $classResults
* @return array<string, int|float>
*/
private function finalSummary(array $classResults): array
{
$totalStudents = array_sum(array_column($classResults, 'student_count'));
$totalPredicted = array_sum(array_column($classResults, 'predicted_count'));
$totalActual = array_sum(array_column($classResults, 'actual_count'));
$totalConfirmed = array_sum(array_column($classResults, 'confirmed'));
$totalSurprises = array_sum(array_column($classResults, 'surprises'));
$totalMissed = array_sum(array_column($classResults, 'missed'));
$overallAccuracy = $totalPredicted > 0 ? round($totalConfirmed / $totalPredicted * 100) : ($totalActual === 0 ? 100 : 0);
return [
'classes' => count($classResults),
'students' => $totalStudents,
'predicted' => $totalPredicted,
'actual' => $totalActual,
'confirmed' => $totalConfirmed,
'surprises' => $totalSurprises,
'missed' => $totalMissed,
'accuracy' => $overallAccuracy,
'pct_predicted' => $totalStudents > 0 ? round($totalPredicted / $totalStudents * 100) : 0,
'pct_actual' => $totalStudents > 0 ? round($totalActual / $totalStudents * 100) : 0,
'pct_confirmed_of_predicted' => $totalPredicted > 0 ? round($totalConfirmed / $totalPredicted * 100) : 0,
];
}
/**
* @param list<array<string, mixed>> $students
* @return array<string, int|float>
*/
private function genderBreakdown(array $students): array
{
$stats = ['boys' => 0, 'girls' => 0, 'other' => 0];
foreach ($students as $student) {
$stats[$this->genderKey($student['gender'] ?? '')]++;
}
$total = array_sum($stats);
return [
'total' => $total,
'boys' => $stats['boys'],
'girls' => $stats['girls'],
'other' => $stats['other'],
'pct_boys' => $total > 0 ? round(($stats['boys'] / $total) * 100, 1) : 0.0,
'pct_girls' => $total > 0 ? round(($stats['girls'] / $total) * 100, 1) : 0.0,
'pct_other' => $total > 0 ? round(($stats['other'] / $total) * 100, 1) : 0.0,
];
}
private function genderKey(?string $gender): string
{
$value = strtolower(trim((string) $gender));
return match (true) {
in_array($value, ['male', 'm', 'boy', 'boys'], true) => 'boys',
in_array($value, ['female', 'f', 'girl', 'girls'], true) => 'girls',
default => 'other',
};
}
/**
* @param list<float> $sortedScores
* @return array{threshold: float, winners: int, method: string}
*/
private function capByRank(array $sortedScores, int $max): array
{
$descending = array_reverse($sortedScores);
$threshold = $descending[$max - 1];
$winners = $this->countAtOrAbove($sortedScores, $threshold);
if ($winners <= $max) {
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'capped_25pct'];
}
$uniqueCandidates = array_values(array_unique(array_filter(
$sortedScores,
static fn (float $score): bool => $score > $threshold
)));
sort($uniqueCandidates);
foreach ($uniqueCandidates as $candidate) {
$winnerCount = $this->countAtOrAbove($sortedScores, $candidate);
if ($winnerCount <= $max) {
return ['threshold' => $candidate, 'winners' => $winnerCount, 'method' => 'capped_25pct'];
}
}
return ['threshold' => $sortedScores[0], 'winners' => count($sortedScores), 'method' => 'all_equal'];
}
/**
* @param list<float> $sortedScores
*/
private function empiricalPercentile(array $sortedScores, float $percentile): float
{
$count = count($sortedScores);
if ($count === 0) {
return 0.0;
}
$index = ($percentile / 100.0) * ($count - 1);
$lower = (int) floor($index);
$upper = (int) ceil($index);
if ($lower === $upper) {
return $sortedScores[$lower];
}
return $sortedScores[$lower] + ($index - $lower) * ($sortedScores[$upper] - $sortedScores[$lower]);
}
/**
* @param list<float> $scores
*/
private function countAtOrAbove(array $scores, float $threshold): int
{
return count(array_filter($scores, static fn (float $score): bool => $score >= $threshold));
}
}
@@ -0,0 +1,682 @@
<?php
declare(strict_types=1);
namespace App\Services\Certificates;
use App\Models\CertificateRecord;
use App\Models\Configuration;
use App\Models\StudentDecision;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class CertificateAdminService
{
private bool $hasVerificationTokenColumn;
public function __construct()
{
$this->hasVerificationTokenColumn = Schema::hasTable('certificate_records')
&& Schema::hasColumn('certificate_records', 'verification_token');
}
public function dashboard(?string $schoolYear = null): array
{
$selectedYear = $this->resolveSchoolYear($schoolYear);
$allEnrolled = DB::table('student_class as sc')
->select(
's.id as student_id',
's.firstname',
's.lastname',
'sc.class_section_id',
'cs.class_section_name'
)
->join('students as s', 's.id', '=', 'sc.student_id')
->join('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id')
->where('s.is_active', 1)
->where('sc.school_year', $selectedYear)
->orderBy('s.firstname', 'ASC')
->orderBy('s.lastname', 'ASC')
->get()
->map(fn ($row) => (array) $row)
->all();
$studentIds = array_values(array_unique(array_map(
static fn (array $row): int => (int) ($row['student_id'] ?? 0),
$allEnrolled
)));
$studentIds = array_values(array_filter($studentIds, static fn (int $id): bool => $id > 0));
$decisionsByStudent = [];
if ($studentIds !== []) {
$decisionRows = StudentDecision::query()
->whereIn('student_id', $studentIds)
->where('school_year', $selectedYear)
->get();
foreach ($decisionRows as $decisionRow) {
$sid = (int) ($decisionRow->student_id ?? 0);
if ($sid <= 0) {
continue;
}
$decision = trim((string) ($decisionRow->decision ?? ''));
$source = trim((string) ($decisionRow->source ?? ''));
if ($source === '') {
$source = $decision === '' ? 'pending' : 'manual';
}
$decisionsByStudent[$sid][] = [
'decision' => $decision,
'source' => $source,
'notes' => (string) ($decisionRow->notes ?? ''),
'year_score' => is_numeric($decisionRow->year_score) ? round((float) $decisionRow->year_score, 2) : null,
];
}
}
$certsByStudent = [];
if ($studentIds !== []) {
$certRows = DB::table('certificate_records')
->select('student_id', 'certificate_number', 'issued_at')
->whereIn('student_id', $studentIds)
->where('school_year', $selectedYear)
->orderByDesc('issued_at')
->get();
foreach ($certRows as $record) {
$sid = (int) ($record->student_id ?? 0);
if ($sid > 0 && ! isset($certsByStudent[$sid])) {
$certsByStudent[$sid] = (string) ($record->certificate_number ?? '');
}
}
}
$statsPerClass = [];
$studentsByClass = [];
foreach ($allEnrolled as $row) {
$sid = (int) ($row['student_id'] ?? 0);
$classSectionId = (int) ($row['class_section_id'] ?? 0);
$classSectionName = (string) ($row['class_section_name'] ?? ('Section '.$classSectionId));
if (! isset($statsPerClass[$classSectionId])) {
$statsPerClass[$classSectionId] = [
'name' => $classSectionName,
'total' => 0,
'pass' => 0,
'cert' => 0,
];
}
$statsPerClass[$classSectionId]['total']++;
$studentDecisionRows = $decisionsByStudent[$sid] ?? [];
$decisionState = $this->normalizeDecisionState($studentDecisionRows);
if ($decisionState['eligible']) {
$statsPerClass[$classSectionId]['pass']++;
}
$certificateNumber = $certsByStudent[$sid] ?? null;
if ($certificateNumber !== null && $certificateNumber !== '') {
$statsPerClass[$classSectionId]['cert']++;
}
$studentsByClass[$classSectionId][] = [
'student_id' => $sid,
'firstname' => (string) ($row['firstname'] ?? ''),
'lastname' => (string) ($row['lastname'] ?? ''),
'class_section_id' => $classSectionId,
'class_section_name' => $classSectionName,
'year_score' => $decisionState['year_score'],
'eligible' => $decisionState['eligible'],
'decision_state' => $decisionState['status'],
'decision_labels' => $decisionState['display_decisions'],
'decision_rows' => $studentDecisionRows,
'certificate_number' => $certificateNumber,
];
}
$gradeGroups = $this->buildGradeGroups($statsPerClass, $studentsByClass);
return [
'school_year' => $selectedYear,
'cert_date' => date('m/d/Y'),
'grade_groups' => array_values($gradeGroups),
'stats_per_class' => $statsPerClass,
'default_group_key' => array_key_first($gradeGroups),
];
}
public function auditLog(?string $schoolYear = null): array
{
$selectedYear = trim((string) ($schoolYear ?? ''));
$recordsQuery = DB::table('certificate_records as cr')
->select(
'cr.*',
'u.firstname as admin_firstname',
'u.lastname as admin_lastname'
)
->leftJoin('users as u', 'u.id', '=', 'cr.issued_by')
->orderByDesc('cr.issued_at');
if ($selectedYear !== '') {
$recordsQuery->where('cr.school_year', $selectedYear);
}
$records = $recordsQuery->get()->map(fn ($row) => (array) $row)->all();
$yearSummary = DB::table('certificate_records')
->select('school_year', DB::raw('COUNT(*) as total'))
->groupBy('school_year')
->orderBy('school_year', 'DESC')
->get()
->map(fn ($row) => (array) $row)
->all();
return [
'school_year' => $selectedYear,
'records' => $records,
'year_summary' => $yearSummary,
];
}
/**
* @param list<int> $studentIds
* @return array{students: list<array<string, mixed>>, cert_date_display: string, school_year: string}
*/
public function issueCertificates(
array $studentIds,
string $certDate,
?int $classSectionId,
?string $schoolYear,
?int $issuedBy
): array {
$studentIds = array_values(array_filter(array_map('intval', $studentIds), static fn (int $id): bool => $id > 0));
if ($studentIds === []) {
throw new \InvalidArgumentException('Please select at least one student.');
}
$selectedYear = $this->resolveSchoolYear($schoolYear);
$certDateDisplay = $this->normalizeDisplayCertDate($certDate);
$certDateStorage = $this->parseCertDate($certDateDisplay);
$students = $this->fetchStudentsForIssuance($studentIds, $classSectionId, $selectedYear);
if ($students === []) {
throw new \RuntimeException('No valid students found.');
}
$rosterMap = [];
foreach ($students as $student) {
$rosterMap[(int) $student['id']] = $student;
}
$existingByStudent = DB::table('certificate_records')
->whereIn('student_id', array_keys($rosterMap))
->where('school_year', $selectedYear)
->orderByDesc('issued_at')
->get()
->map(fn ($row) => (array) $row)
->groupBy('student_id')
->map(fn ($rows) => $rows[0])
->all();
$baseCount = (int) DB::table('certificate_records')
->where('school_year', $selectedYear)
->count();
$nextOffset = 0;
DB::beginTransaction();
try {
foreach ($students as &$student) {
$sid = (int) ($student['id'] ?? 0);
$existing = $existingByStudent[$sid] ?? null;
if (is_array($existing)) {
$student['cert_number'] = (string) ($existing['certificate_number'] ?? '');
$student['verify_token'] = $this->ensureVerificationToken($existing);
} else {
$nextOffset++;
$certificateNumber = $this->formatCertificateNumber($selectedYear, $baseCount + $nextOffset);
$verificationToken = $this->hasVerificationTokenColumn ? $this->generateVerificationToken() : null;
$grade = $this->formatGrade((string) ($student['grade'] ?? ''));
$studentName = trim((string) ($student['firstname'] ?? '').' '.(string) ($student['lastname'] ?? ''));
$payload = [
'certificate_number' => $certificateNumber,
'student_id' => $sid,
'student_name' => $studentName,
'grade' => $grade,
'cert_date' => $certDateStorage,
'school_year' => $selectedYear,
'class_section_id' => $classSectionId,
'issued_by' => $issuedBy,
'issued_at' => now(),
'created_at' => now(),
'updated_at' => now(),
];
if ($this->hasVerificationTokenColumn) {
$payload['verification_token'] = $verificationToken;
}
DB::table('certificate_records')->insert($payload);
$student['cert_number'] = $certificateNumber;
$student['verify_token'] = $verificationToken;
}
$student['grade'] = $this->formatGrade((string) ($student['grade'] ?? ''));
$student['verify_url'] = ! empty($student['verify_token'])
? url('/api/certificates/verify/'.rawurlencode((string) $student['verify_token']))
: null;
}
unset($student);
DB::commit();
} catch (\Throwable $e) {
DB::rollBack();
throw $e;
}
return [
'students' => $students,
'cert_date_display' => $certDateDisplay,
'school_year' => $selectedYear,
];
}
/**
* @return array{student: array<string, mixed>, cert_date_display: string}|null
*/
public function reprintPayload(string $certificateNumber): ?array
{
$record = DB::table('certificate_records')
->where('certificate_number', strtoupper(trim($certificateNumber)))
->first();
if (! $record) {
return null;
}
$row = (array) $record;
$studentName = trim((string) ($row['student_name'] ?? ''));
[$firstname, $lastname] = $this->splitStudentName($studentName);
$verifyToken = $this->ensureVerificationToken($row);
return [
'student' => [
'id' => (int) ($row['student_id'] ?? 0),
'firstname' => $firstname,
'lastname' => $lastname,
'grade' => (string) ($row['grade'] ?? ''),
'cert_number' => (string) ($row['certificate_number'] ?? ''),
'verify_token' => $verifyToken,
'verify_url' => $verifyToken !== '' ? url('/api/certificates/verify/'.rawurlencode($verifyToken)) : null,
],
'cert_date_display' => $this->displayDateFromStorage($row['cert_date'] ?? null),
];
}
/**
* @return array<string, mixed>|null
*/
public function verifyPayload(string $tokenOrNumber): ?array
{
$lookup = strtoupper(trim($tokenOrNumber));
$query = DB::table('certificate_records as cr')
->select(
'cr.certificate_number',
'cr.student_name',
'cr.grade',
'cr.cert_date',
'cr.school_year',
'cr.issued_at',
'u.firstname as admin_firstname',
'u.lastname as admin_lastname'
)
->leftJoin('users as u', 'u.id', '=', 'cr.issued_by');
if ($this->hasVerificationTokenColumn) {
$query->where(function ($builder) use ($tokenOrNumber, $lookup): void {
$builder->where('cr.verification_token', trim($tokenOrNumber))
->orWhere('cr.certificate_number', $lookup);
});
} else {
$query->where('cr.certificate_number', $lookup);
}
$record = $query->first();
if (! $record) {
return null;
}
return (array) $record;
}
private function resolveSchoolYear(?string $schoolYear): string
{
$selectedYear = trim((string) ($schoolYear ?? ''));
if ($selectedYear !== '') {
return $selectedYear;
}
return trim((string) (Configuration::getConfig('school_year') ?? ''));
}
/**
* @param array<int, array{name: string, total: int, pass: int, cert: int}> $statsPerClass
* @param array<int, list<array<string, mixed>>> $studentsByClass
* @return array<string, array<string, mixed>>
*/
private function buildGradeGroups(array $statsPerClass, array $studentsByClass): array
{
$gradeGroups = [];
foreach ($statsPerClass as $classSectionId => $stats) {
[$label, $sortKey, $slug] = $this->classGroupMeta((string) ($stats['name'] ?? ''));
if (! isset($gradeGroups[$sortKey])) {
$gradeGroups[$sortKey] = [
'key' => $sortKey,
'label' => $label,
'slug' => $slug,
'total' => 0,
'pass' => 0,
'cert' => 0,
'sections' => [],
];
}
$total = (int) ($stats['total'] ?? 0);
$pass = (int) ($stats['pass'] ?? 0);
$cert = (int) ($stats['cert'] ?? 0);
$gradeGroups[$sortKey]['total'] += $total;
$gradeGroups[$sortKey]['pass'] += $pass;
$gradeGroups[$sortKey]['cert'] += $cert;
$gradeGroups[$sortKey]['sections'][] = [
'section_id' => (int) $classSectionId,
'section_name' => (string) ($stats['name'] ?? ''),
'student_count' => $total,
'pass_count' => $pass,
'cert_count' => $cert,
'remaining_count' => max(0, $pass - $cert),
'students' => $studentsByClass[$classSectionId] ?? [],
];
}
ksort($gradeGroups);
foreach ($gradeGroups as &$group) {
$group['fully_done'] = $group['pass'] > 0 && $group['cert'] >= $group['pass'];
$group['has_pass'] = $group['pass'] > 0;
}
unset($group);
return $gradeGroups;
}
/**
* @param list<array<string, mixed>> $studentDecisionRows
* @return array{eligible: bool, status: string, display_decisions: list<string>, year_score: float|null}
*/
private function normalizeDecisionState(array $studentDecisionRows): array
{
if ($studentDecisionRows === []) {
return [
'eligible' => false,
'status' => 'pending',
'display_decisions' => [],
'year_score' => null,
];
}
$allDecisions = array_map(
static fn (array $row): string => trim((string) ($row['decision'] ?? '')),
$studentDecisionRows
);
$hasPending = in_array('', $allDecisions, true);
$unique = array_values(array_unique(array_filter(
$allDecisions,
static fn (string $decision): bool => $decision !== ''
)));
$eligible = ! $hasPending && $unique === ['Pass'];
$displayDecisions = $eligible
? ['Pass']
: array_values(array_filter($unique, static fn (string $decision): bool => $decision !== 'Pass'));
$yearScore = null;
foreach ($studentDecisionRows as $row) {
if (array_key_exists('year_score', $row) && $row['year_score'] !== null && $row['year_score'] !== '') {
$yearScore = round((float) $row['year_score'], 2);
break;
}
}
return [
'eligible' => $eligible,
'status' => ($hasPending || $displayDecisions === []) ? 'pending' : ($eligible ? 'pass' : 'decision'),
'display_decisions' => $displayDecisions,
'year_score' => $yearScore,
];
}
/**
* @param list<int> $studentIds
* @return list<array<string, mixed>>
*/
private function fetchStudentsForIssuance(array $studentIds, ?int $classSectionId, string $schoolYear): array
{
$dashboard = $this->dashboard($schoolYear);
$eligibleMap = [];
foreach ($dashboard['grade_groups'] as $group) {
foreach (($group['sections'] ?? []) as $section) {
foreach (($section['students'] ?? []) as $student) {
$sid = (int) ($student['student_id'] ?? 0);
if ($sid > 0) {
$eligibleMap[$sid] = $student;
}
}
}
}
$filtered = [];
foreach ($studentIds as $studentId) {
$student = $eligibleMap[$studentId] ?? null;
if (! is_array($student) || empty($student['eligible'])) {
continue;
}
if ($classSectionId !== null && (int) ($student['class_section_id'] ?? 0) !== $classSectionId) {
continue;
}
$filtered[] = [
'id' => $studentId,
'firstname' => (string) ($student['firstname'] ?? ''),
'lastname' => (string) ($student['lastname'] ?? ''),
'grade' => (string) ($student['class_section_name'] ?? ''),
];
}
return $filtered;
}
private function formatGrade(string $raw): string
{
$clean = trim($raw);
$lower = strtolower($clean);
if (preg_match('/^grade\s*(\d+)/i', $clean, $matches)) {
return 'Grade '.(int) $matches[1];
}
if ($lower === 'youth') {
return 'Youth';
}
if ($lower === 'kg' || $lower === 'kindergarten') {
return 'Kindergarten';
}
if (preg_match('/^(\d+)([- ][A-Za-z0-9]+)?$/', $clean, $matches)) {
return 'Grade '.(int) $matches[1];
}
return $clean;
}
private function normalizeDisplayCertDate(string $certDate): string
{
$clean = trim($certDate);
if ($clean === '') {
return date('m/d/Y');
}
if (preg_match('#^\d{4}-\d{2}-\d{2}$#', $clean) === 1) {
$timestamp = strtotime($clean);
if ($timestamp !== false) {
return date('m/d/Y', $timestamp);
}
}
if (preg_match('#^\d{2}/\d{2}/\d{4}$#', $clean) === 1) {
return $clean;
}
return date('m/d/Y');
}
private function parseCertDate(string $certDate): ?string
{
if (preg_match('#^(\d{2})/(\d{2})/(\d{4})$#', $certDate, $matches) === 1) {
return $matches[3].'-'.$matches[1].'-'.$matches[2];
}
if (preg_match('#^\d{4}-\d{2}-\d{2}$#', $certDate) === 1) {
return $certDate;
}
return null;
}
private function displayDateFromStorage(mixed $value): string
{
if ($value === null || trim((string) $value) === '') {
return date('m/d/Y');
}
$timestamp = strtotime((string) $value);
return $timestamp !== false ? date('m/d/Y', $timestamp) : date('m/d/Y');
}
private function formatCertificateNumber(string $schoolYear, int $sequence): string
{
return 'ARSS-'.$schoolYear.'-'.str_pad((string) $sequence, 4, '0', STR_PAD_LEFT);
}
private function generateVerificationToken(): string
{
if (! $this->hasVerificationTokenColumn) {
return '';
}
do {
$token = bin2hex(random_bytes(16));
$exists = DB::table('certificate_records')
->where('verification_token', $token)
->exists();
} while ($exists);
return $token;
}
/**
* @param array<string, mixed> $record
*/
private function ensureVerificationToken(array $record): string
{
if (! $this->hasVerificationTokenColumn) {
return '';
}
$token = trim((string) ($record['verification_token'] ?? ''));
if ($token !== '') {
return $token;
}
$recordId = (int) ($record['id'] ?? 0);
if ($recordId <= 0) {
return '';
}
$token = $this->generateVerificationToken();
DB::table('certificate_records')
->where('id', $recordId)
->update([
'verification_token' => $token,
'updated_at' => now(),
]);
return $token;
}
/**
* @return array{0: string, 1: string, 2: string}
*/
private function classGroupMeta(string $name): array
{
$lower = strtolower(trim($name));
if (preg_match('/grade\s*(\d+)/i', $name, $matches) === 1) {
$grade = (int) $matches[1];
return [
'Grade '.$grade,
'1_'.str_pad((string) $grade, 3, '0', STR_PAD_LEFT),
'grade'.$grade,
];
}
if (str_contains($lower, 'kg') || str_contains($lower, 'kindergarten')) {
return ['KG', '0_kg', 'kg'];
}
if (str_contains($lower, 'arabic')) {
return ['Arabic', '2_arabic', 'arabic'];
}
if (str_contains($lower, 'youth')) {
return ['Youth', '5_youth', 'youth'];
}
return [
$name,
'3_'.$lower,
(string) preg_replace('/[^a-z0-9]+/', '-', $lower),
];
}
/**
* @return array{0: string, 1: string}
*/
private function splitStudentName(string $studentName): array
{
$parts = preg_split('/\s+/', trim($studentName), 2) ?: [];
return [
$parts[0] ?? '',
$parts[1] ?? '',
];
}
}
@@ -16,7 +16,7 @@ class CertificatePdfService
}
/**
* @param array<array{firstname: string, lastname: string, grade: string}> $students
* @param array<array<string, mixed>> $students
*/
public function generate(array $students, string $certDate): string
{
@@ -41,7 +41,22 @@ class CertificatePdfService
$pdf->AddPage();
$name = $student['firstname'] . ' ' . $student['lastname'];
$grade = $this->formatGrade((string) ($student['grade'] ?? ''));
$this->drawCertificate($pdf, $W, $H, $name, $grade, $certDate, $edwardianFont, $garamondBold, $ebGaramond);
$certNumber = (string) ($student['cert_number'] ?? '');
$verifyUrl = is_string($student['verify_url'] ?? null) ? $student['verify_url'] : null;
$this->drawCertificate(
$pdf,
$W,
$H,
$name,
$grade,
$certDate,
$certNumber,
$verifyUrl,
$edwardianFont,
$garamondBold,
$ebGaramond
);
}
return (string) $pdf->Output('', 'S');
@@ -54,28 +69,37 @@ class CertificatePdfService
string $name,
string $grade,
string $certDate,
string $certNumber,
?string $verifyUrl,
string $edwardianFont,
string $garamondBold,
string $ebGaramond
): void {
$pdf->Image($this->imgDir . 'title.png', 126, 0, 600);
$pdf->Image($this->imgDir . 'background.png', 280, 176, 291, 340);
$pdf->Image($this->imgDir . 'signature.png', 140, 399, 90, 90);
$pdf->Image($this->imgDir . 'signature.png', 140, 410, 90, 80);
$pdf->SetFont('times', 'B', 24);
$pdf->SetTextColor(0, 0, 0);
$pdf->SetXY(0, 151);
$pdf->Cell($W, 24, 'Presented to:', 0, 0, 'C');
if (! empty($verifyUrl)) {
$style = [
'border' => false,
'padding' => 0,
'fgcolor' => [0, 0, 0],
'bgcolor' => false,
];
$pdf->write2DBarcode($verifyUrl, 'QRCODE,L', 120, 162, 42, 42, $style, 'N');
}
$pdf->SetFont($edwardianFont, '', 38);
$pdf->SetDrawColor(0, 0, 0);
$pdf->setTextRenderingMode(0.4, true, false);
$pdf->SetXY(0, 219);
$pdf->Cell($W, 38, $name, 0, 0, 'C');
$pdf->setTextRenderingMode(0, true, false);
$nameX = ($W - $pdf->GetStringWidth($name)) / 2;
$this->drawGradientText($pdf, $edwardianFont, 38, $name, $nameX, 221.5);
$pdf->SetFont('times', '', 20);
$pdf->SetXY(0, 243);
$pdf->SetXY(0, 236);
$pdf->Cell(600, 20, '___________________________________', 0, 0, 'R');
$pdf->SetFont($ebGaramond, '', 20);
@@ -94,7 +118,7 @@ class CertificatePdfService
$pdf->SetXY(0, 375);
$pdf->Cell($W, 20, 'Al Rahma Sunday School', 0, 0, 'C');
$this->drawGradientText($pdf, $edwardianFont, 26, $certDate, 598, 453);
$this->drawGradientText($pdf, $edwardianFont, 26, $certDate, 586, 456);
$pdf->SetFont('times', '', 20);
$pdf->SetXY(0, 463);
@@ -106,6 +130,14 @@ class CertificatePdfService
$pdf->Cell(200, 20, '_________________', 0, 0, 'L');
$pdf->SetXY(106, 492);
$pdf->Cell(168, 20, 'Signature', 0, 0, 'C');
if ($certNumber !== '') {
$pdf->SetFont('helvetica', '', 8);
$pdf->SetTextColor(150, 150, 150);
$pdf->SetXY(70.86, $H - 39.68);
$pdf->Cell(200, 12, 'Certificate No. ' . $certNumber, 0, 0, 'L');
$pdf->SetTextColor(0, 0, 0);
}
}
private function drawGradientText(\TCPDF $pdf, string $fontName, float $fontSize, string $text, float $x, float $y): void
@@ -3,7 +3,6 @@
namespace App\Services\Expenses;
use App\Models\Expense;
use Illuminate\Support\Facades\DB;
class ExpenseQueryService
{
@@ -12,7 +11,7 @@ class ExpenseQueryService
return $this->baseQuery()
->orderByDesc('expenses.created_at')
->get()
->map(fn ($row) => (array) $row)
->map(fn ($row) => $row->toArray())
->all();
}
@@ -22,7 +21,7 @@ class ExpenseQueryService
->where('expenses.id', $id)
->first();
return $row ? (array) $row : null;
return $row ? $row->toArray() : null;
}
private function baseQuery()
@@ -0,0 +1,41 @@
<?php
namespace App\Services\Finance;
use App\Models\FinanceNotificationLog;
use App\Models\FinanceReceiptSequence;
use Illuminate\Support\Facades\DB;
class FinanceNotificationLogService
{
public function log(array $payload, ?int $actorId, string $status = 'sent'): array
{
$log = FinanceNotificationLog::query()->create(array_merge($payload, [
'status' => $status,
'sent_at' => $status === 'sent' ? now() : null,
'failed_at' => $status === 'failed' ? now() : null,
'created_by' => $actorId,
]));
return $log->toArray();
}
public function list(array $filters): array
{
$q = FinanceNotificationLog::query();
foreach (['status','notification_type','parent_id'] as $field) if (!empty($filters[$field])) $q->where($field, $filters[$field]);
if (!empty($filters['date_from'])) $q->whereDate('created_at', '>=', $filters['date_from']);
if (!empty($filters['date_to'])) $q->whereDate('created_at', '<=', $filters['date_to']);
return ['rows' => $q->orderByDesc('id')->paginate((int)($filters['per_page'] ?? 25))];
}
public function nextReceiptNumber(string $schoolYear): string
{
return DB::transaction(function () use ($schoolYear) {
$seq = FinanceReceiptSequence::query()->lockForUpdate()->firstOrCreate(['school_year' => $schoolYear], ['prefix' => 'R', 'next_number' => 1]);
$number = $seq->prefix . '-' . $schoolYear . '-' . str_pad((string) $seq->next_number, 6, '0', STR_PAD_LEFT);
$seq->next_number = $seq->next_number + 1;
$seq->save();
return $number;
});
}
}
@@ -0,0 +1,136 @@
<?php
namespace App\Services\Finance;
use App\Models\InvoiceInstallment;
use App\Models\InvoiceInstallmentPlan;
use App\Models\PaymentInstallmentAllocation;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class InstallmentPlanService
{
public function list(array $filters): array
{
$q = InvoiceInstallmentPlan::query();
foreach (['invoice_id','parent_id','school_year','status'] as $field) {
if (!empty($filters[$field])) $q->where($field, $filters[$field]);
}
return ['rows' => $q->orderByDesc('id')->paginate((int)($filters['per_page'] ?? 25))];
}
public function createForInvoice(int $invoiceId, array $payload, ?int $actorId): array
{
return DB::transaction(function () use ($invoiceId, $payload, $actorId) {
$invoice = DB::table('invoices')->where('id', $invoiceId)->first();
abort_if(!$invoice, 404, 'Invoice not found.');
$total = (float) ($payload['total_amount'] ?? ($invoice->balance ?? $invoice->total_amount ?? 0));
$count = (int) $payload['number_of_installments'];
$plan = InvoiceInstallmentPlan::query()->create([
'invoice_id' => $invoiceId,
'parent_id' => $invoice->parent_id,
'school_year' => $invoice->school_year ?? null,
'plan_name' => $payload['plan_name'] ?? ('Invoice #' . $invoiceId . ' installment plan'),
'total_amount' => $total,
'number_of_installments' => $count,
'status' => 'draft',
'created_by' => $actorId,
]);
$firstDue = Carbon::parse($payload['first_due_date']);
$interval = (int) ($payload['interval_months'] ?? 1);
$base = floor(($total / $count) * 100) / 100;
$allocated = 0.0;
for ($i = 1; $i <= $count; $i++) {
$amount = $i === $count ? round($total - $allocated, 2) : $base;
$allocated += $amount;
InvoiceInstallment::query()->create([
'installment_plan_id' => $plan->id,
'invoice_id' => $invoiceId,
'sequence' => $i,
'due_date' => $firstDue->copy()->addMonths(($i - 1) * $interval)->toDateString(),
'amount_due' => $amount,
'amount_paid' => 0,
'balance' => $amount,
'status' => 'pending',
]);
}
return $this->show($plan->id);
});
}
public function show(int $id): array
{
$plan = InvoiceInstallmentPlan::query()->findOrFail($id)->toArray();
$plan['installments'] = InvoiceInstallment::query()->where('installment_plan_id', $id)->orderBy('sequence')->get()->toArray();
return $plan;
}
public function activate(int $id, ?int $actorId): array
{
$plan = InvoiceInstallmentPlan::query()->findOrFail($id);
$plan->fill(['status' => 'active', 'approved_by' => $actorId])->save();
InvoiceInstallment::query()->where('installment_plan_id', $id)->where('status', 'pending')->update(['status' => 'due']);
return $this->show($id);
}
public function cancel(int $id): array
{
InvoiceInstallmentPlan::query()->whereKey($id)->update(['status' => 'cancelled']);
InvoiceInstallment::query()->where('installment_plan_id', $id)->whereNotIn('status', ['paid'])->update(['status' => 'cancelled']);
return $this->show($id);
}
public function allocatePayment(int $paymentId, array $payload): array
{
return DB::transaction(function () use ($paymentId, $payload) {
$payment = DB::table('payments')->where('id', $paymentId)->first();
abort_if(!$payment, 404, 'Payment not found.');
$allocations = $payload['installments'] ?? null;
if (!$allocations) {
$remaining = (float) ($payment->paid_amount ?? 0);
$installments = InvoiceInstallment::query()->where('invoice_id', $payment->invoice_id)->where('balance', '>', 0)->orderBy('due_date')->lockForUpdate()->get();
$allocations = [];
foreach ($installments as $inst) {
if ($remaining <= 0) break;
$amount = min($remaining, (float) $inst->balance);
$allocations[] = ['installment_id' => $inst->id, 'amount' => $amount];
$remaining -= $amount;
}
}
$created = [];
foreach ($allocations as $a) {
$inst = InvoiceInstallment::query()->lockForUpdate()->findOrFail($a['installment_id']);
$amount = min((float) $a['amount'], (float) $inst->balance);
if ($amount <= 0) continue;
$created[] = PaymentInstallmentAllocation::query()->create([
'payment_id' => $paymentId,
'invoice_id' => $inst->invoice_id,
'installment_id' => $inst->id,
'amount' => $amount,
])->toArray();
$inst->amount_paid = (float) $inst->amount_paid + $amount;
$inst->balance = max(0, (float) $inst->amount_due - (float) $inst->amount_paid);
$inst->status = $inst->balance <= 0 ? 'paid' : 'partially_paid';
$inst->paid_at = $inst->balance <= 0 ? now() : null;
$inst->save();
}
return ['allocations' => $created];
});
}
public function due(bool $overdue = false): array
{
$q = InvoiceInstallment::query()->where('balance', '>', 0);
$overdue ? $q->whereDate('due_date', '<', now()->toDateString()) : $q->whereDate('due_date', '>=', now()->toDateString());
return ['rows' => $q->orderBy('due_date')->get()->toArray()];
}
public function markOverdue(): int
{
return InvoiceInstallment::query()->where('balance', '>', 0)->whereDate('due_date', '<', now()->toDateString())->whereNotIn('status', ['paid','cancelled','waived'])->update(['status' => 'overdue']);
}
}
@@ -0,0 +1,239 @@
<?php
namespace App\Services\Finance;
use App\Models\Configuration;
use App\Models\FinanceFollowUpNote;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class ParentPaymentFollowUpService
{
public function report(array $filters): array
{
$schoolYear = $filters['school_year'] ?? (Configuration::getConfig('school_year') ?? date('Y'));
$minimumBalance = (float) ($filters['minimum_balance'] ?? 0);
$includePaid = filter_var($filters['include_paid_invoices'] ?? false, FILTER_VALIDATE_BOOLEAN);
$invoiceRows = $this->invoiceRows($schoolYear, $filters);
$paidByInvoice = $this->paymentTotalsByInvoice($schoolYear);
$discountByInvoice = $this->discountTotalsByInvoice();
$refundByInvoice = $this->refundTotalsByInvoice();
$latestNotes = $this->latestNotesByParent($schoolYear);
$students = $this->studentsByParent();
$parents = [];
foreach ($invoiceRows as $invoice) {
$invoiceId = (int) ($invoice->id ?? 0);
$parentId = (int) ($invoice->parent_id ?? 0);
if ($parentId <= 0) {
continue;
}
$total = (float) ($invoice->total_amount ?? 0);
$paid = (float) ($paidByInvoice[$invoiceId] ?? 0);
$discounted = (float) ($discountByInvoice[$invoiceId] ?? 0);
$refunded = (float) ($refundByInvoice[$invoiceId] ?? 0);
$open = max(0, $total - $paid - $discounted - $refunded);
if (!$includePaid && $open <= 0) {
continue;
}
if (!isset($parents[$parentId])) {
$parents[$parentId] = [
'parent_id' => $parentId,
'parent_name' => $invoice->parent_name ?? null,
'parent_email' => $invoice->parent_email ?? null,
'parent_phone' => $invoice->parent_phone ?? null,
'student_names' => $students[$parentId] ?? [],
'school_year' => $schoolYear,
'invoice_count' => 0,
'open_invoice_count' => 0,
'total_invoiced' => 0.0,
'total_paid' => 0.0,
'total_discounted' => 0.0,
'total_refunded' => 0.0,
'open_balance' => 0.0,
'oldest_unpaid_invoice_date' => null,
'days_overdue' => 0,
'aging_bucket' => 'current',
'last_payment_date' => null,
'last_payment_amount' => 0.0,
'last_contacted_at' => null,
'last_contacted_by' => null,
'follow_up_status' => 'not_contacted',
'next_follow_up_date' => null,
'promise_to_pay_date' => null,
'promise_to_pay_amount' => null,
'escalation_level' => 0,
'notes_count' => 0,
'risk_flags' => [],
];
}
$row =& $parents[$parentId];
$row['invoice_count']++;
$row['total_invoiced'] += $total;
$row['total_paid'] += $paid;
$row['total_discounted'] += $discounted;
$row['total_refunded'] += $refunded;
$row['open_balance'] += $open;
if ($open > 0) {
$row['open_invoice_count']++;
$due = $invoice->due_date ?? $invoice->issue_date ?? $invoice->created_at ?? null;
if ($due && ($row['oldest_unpaid_invoice_date'] === null || strtotime((string) $due) < strtotime((string) $row['oldest_unpaid_invoice_date']))) {
$row['oldest_unpaid_invoice_date'] = substr((string) $due, 0, 10);
}
}
unset($row);
}
foreach ($parents as $parentId => &$row) {
$note = $latestNotes[$parentId] ?? null;
if ($note) {
$row['last_contacted_at'] = $note->created_at ? (string) $note->created_at : null;
$row['last_contacted_by'] = $note->created_by ?? null;
$row['follow_up_status'] = $note->status ?? 'not_contacted';
$row['next_follow_up_date'] = $note->next_follow_up_date ?? null;
$row['promise_to_pay_date'] = $note->promise_to_pay_date ?? null;
$row['promise_to_pay_amount'] = $note->promise_to_pay_amount !== null ? (float) $note->promise_to_pay_amount : null;
$row['escalation_level'] = (int) ($note->escalation_level ?? 0);
}
$row['notes_count'] = FinanceFollowUpNote::query()->where('parent_id', $parentId)->when($schoolYear, fn($q) => $q->where('school_year', $schoolYear))->count();
$row['days_overdue'] = $this->daysOverdue($row['oldest_unpaid_invoice_date']);
$row['aging_bucket'] = $this->agingBucket($row['days_overdue']);
$row['risk_flags'] = $this->riskFlags($row);
}
unset($row);
$rows = array_values(array_filter($parents, function (array $row) use ($filters, $minimumBalance) {
if ($row['open_balance'] < $minimumBalance) return false;
if (!empty($filters['aging_bucket']) && $row['aging_bucket'] !== $filters['aging_bucket']) return false;
if (!empty($filters['follow_up_status']) && $row['follow_up_status'] !== $filters['follow_up_status']) return false;
if (!empty($filters['next_follow_up_due_before']) && (empty($row['next_follow_up_date']) || strtotime($row['next_follow_up_date']) > strtotime($filters['next_follow_up_due_before']))) return false;
if (!empty($filters['promise_to_pay_due_before']) && (empty($row['promise_to_pay_date']) || strtotime($row['promise_to_pay_date']) > strtotime($filters['promise_to_pay_due_before']))) return false;
return true;
}));
usort($rows, fn($a, $b) => [$b['open_balance'], $b['days_overdue']] <=> [$a['open_balance'], $a['days_overdue']]);
return ['schoolYear' => $schoolYear, 'generatedAt' => now()->toISOString(), 'results' => $rows];
}
public function storeNote(int $parentId, array $payload, ?int $actorId): array
{
$note = FinanceFollowUpNote::query()->create([
'parent_id' => $parentId,
'invoice_id' => $payload['invoice_id'] ?? null,
'school_year' => $payload['school_year'] ?? null,
'status' => $payload['status'] ?? 'contacted',
'contact_method' => $payload['contact_method'] ?? null,
'promise_to_pay_date' => $payload['promise_to_pay_date'] ?? null,
'promise_to_pay_amount' => $payload['promise_to_pay_amount'] ?? null,
'next_follow_up_date' => $payload['next_follow_up_date'] ?? null,
'escalation_level' => $payload['escalation_level'] ?? 0,
'note' => $payload['note'] ?? null,
'created_by' => $actorId,
]);
return $note->toArray();
}
public function csvRows(array $report): array
{
$rows = [['Parent ID','Parent','Email','Phone','Students','School Year','Invoices','Open Invoices','Total Invoiced','Total Paid','Open Balance','Oldest Unpaid','Days Overdue','Aging','Status','Next Follow-Up','Promise Date','Promise Amount','Risk Flags']];
foreach ($report['results'] as $r) {
$rows[] = [
$r['parent_id'], $r['parent_name'], $r['parent_email'], $r['parent_phone'], implode('; ', $r['student_names'] ?? []), $r['school_year'], $r['invoice_count'], $r['open_invoice_count'], $r['total_invoiced'], $r['total_paid'], $r['open_balance'], $r['oldest_unpaid_invoice_date'], $r['days_overdue'], $r['aging_bucket'], $r['follow_up_status'], $r['next_follow_up_date'], $r['promise_to_pay_date'], $r['promise_to_pay_amount'], implode('; ', $r['risk_flags'] ?? []),
];
}
return $rows;
}
private function invoiceRows(string $schoolYear, array $filters)
{
$q = DB::table('invoices')->where('invoices.school_year', $schoolYear);
if (!empty($filters['parent_id'])) $q->where('invoices.parent_id', $filters['parent_id']);
if (!empty($filters['date_from'])) $q->whereDate('invoices.created_at', '>=', $filters['date_from']);
if (!empty($filters['date_to'])) $q->whereDate('invoices.created_at', '<=', $filters['date_to']);
if (Schema::hasTable('users')) {
$name = $this->nameExpression('users');
$q->leftJoin('users', 'users.id', '=', 'invoices.parent_id')
->addSelect('invoices.*')
->addSelect(DB::raw($name . ' as parent_name'))
->addSelect('users.email as parent_email');
if (Schema::hasColumn('users', 'phone')) $q->addSelect('users.phone as parent_phone');
else $q->addSelect(DB::raw('NULL as parent_phone'));
} else {
$q->select('invoices.*', DB::raw('NULL as parent_name'), DB::raw('NULL as parent_email'), DB::raw('NULL as parent_phone'));
}
return $q->get();
}
private function paymentTotalsByInvoice(string $schoolYear): array
{
if (!Schema::hasTable('payments')) return [];
return DB::table('payments')->select('invoice_id', DB::raw('COALESCE(SUM(paid_amount),0) total'), DB::raw('MAX(payment_date) last_payment_date'))
->where('school_year', $schoolYear)->whereNotIn('status', ['void','voided','failed','cancelled','canceled','reversed'])
->groupBy('invoice_id')->pluck('total', 'invoice_id')->map(fn($v) => (float) $v)->all();
}
private function discountTotalsByInvoice(): array
{
if (!Schema::hasTable('discount_usages')) return [];
$col = Schema::hasColumn('discount_usages', 'discount_amount') ? 'discount_amount' : 'amount';
return DB::table('discount_usages')->select('invoice_id', DB::raw('COALESCE(SUM('.$col.'),0) total'))->groupBy('invoice_id')->pluck('total','invoice_id')->map(fn($v)=>(float)$v)->all();
}
private function refundTotalsByInvoice(): array
{
if (!Schema::hasTable('refunds')) return [];
$col = Schema::hasColumn('refunds', 'refund_amount') ? 'refund_amount' : (Schema::hasColumn('refunds', 'amount') ? 'amount' : null);
if (!$col) return [];
return DB::table('refunds')->select('invoice_id', DB::raw('COALESCE(SUM('.$col.'),0) total'))->whereNotIn('status', ['void','voided','cancelled','canceled'])->groupBy('invoice_id')->pluck('total','invoice_id')->map(fn($v)=>(float)$v)->all();
}
private function latestNotesByParent(string $schoolYear): array
{
return FinanceFollowUpNote::query()->where('school_year', $schoolYear)->orderByDesc('created_at')->get()->unique('parent_id')->keyBy('parent_id')->all();
}
private function studentsByParent(): array
{
if (!Schema::hasTable('students') || !Schema::hasColumn('students', 'parent_id')) return [];
$name = $this->nameExpression('students');
return DB::table('students')->select('parent_id', DB::raw($name.' as student_name'))->whereNotNull('parent_id')->get()->groupBy('parent_id')->map(fn($rows) => $rows->pluck('student_name')->filter()->values()->all())->all();
}
private function nameExpression(string $table): string
{
if (Schema::hasColumn($table, 'name')) return $table.'.name';
if (Schema::hasColumn($table, 'full_name')) return $table.'.full_name';
$driver = DB::connection()->getDriverName();
$concat = $driver === 'sqlite' ? '%s || \' \' || %s' : "CONCAT(%s, ' ', %s)";
if (Schema::hasColumn($table, 'firstname') && Schema::hasColumn($table, 'lastname')) return sprintf($concat, $table.'.firstname', $table.'.lastname');
if (Schema::hasColumn($table, 'first_name') && Schema::hasColumn($table, 'last_name')) return sprintf($concat, $table.'.first_name', $table.'.last_name');
return "CAST($table.id AS CHAR)";
}
private function daysOverdue(?string $date): int
{
if (!$date) return 0;
return max(0, now()->startOfDay()->diffInDays(\Carbon\Carbon::parse($date)->startOfDay(), false) * -1);
}
private function agingBucket(int $days): string
{
return match (true) { $days <= 0 => 'current', $days <= 30 => '1-30 days overdue', $days <= 60 => '31-60 days overdue', $days <= 90 => '61-90 days overdue', default => '90+ days overdue' };
}
private function riskFlags(array $row): array
{
$flags = [];
if ($row['days_overdue'] > 90) $flags[] = '90+ days overdue';
if ($row['open_balance'] > 1000) $flags[] = 'high balance';
if ($row['promise_to_pay_date'] && strtotime($row['promise_to_pay_date']) < strtotime(date('Y-m-d'))) $flags[] = 'missed promise-to-pay';
if (!$row['parent_email'] && !$row['parent_phone']) $flags[] = 'missing contact info';
return $flags;
}
}
@@ -0,0 +1,180 @@
<?php
namespace App\Services\Finance;
use App\Models\FinanceBalanceCarryforward;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class PriorYearBalanceCarryforwardService
{
public function preview(array $filters): array
{
$from = $filters['from_school_year'];
$to = $filters['to_school_year'];
$minimum = (float) ($filters['minimum_balance'] ?? 0);
$parentId = $filters['parent_id'] ?? null;
$payments = $this->paymentTotals($from);
$discounts = $this->discountTotals();
$refunds = $this->refundTotals();
$q = DB::table('invoices')->where('school_year', $from);
if ($parentId) $q->where('parent_id', $parentId);
$invoices = $q->get();
$byParent = [];
foreach ($invoices as $invoice) {
$invoiceId = (int) $invoice->id;
$balance = max(0, (float) ($invoice->total_amount ?? 0) - (float) ($payments[$invoiceId] ?? 0) - (float) ($discounts[$invoiceId] ?? 0) - (float) ($refunds[$invoiceId] ?? 0));
if ($balance <= 0) continue;
$pid = (int) $invoice->parent_id;
if (!isset($byParent[$pid])) {
$existing = FinanceBalanceCarryforward::query()->where('parent_id', $pid)->where('from_school_year', $from)->where('to_school_year', $to)->first();
$byParent[$pid] = [
'parent_id' => $pid,
'from_school_year' => $from,
'to_school_year' => $to,
'source_invoice_ids' => [],
'source_balance_amount' => 0.0,
'proposed_carryforward_amount' => 0.0,
'existing_carryforward_id' => $existing?->id,
'warnings' => [],
];
if ($existing) $byParent[$pid]['warnings'][] = 'source invoice already carried forward';
}
$byParent[$pid]['source_invoice_ids'][] = $invoiceId;
$byParent[$pid]['source_balance_amount'] += $balance;
$byParent[$pid]['proposed_carryforward_amount'] += $balance;
}
$rows = array_values(array_filter($byParent, fn($r) => $r['source_balance_amount'] >= $minimum));
usort($rows, fn($a, $b) => $b['source_balance_amount'] <=> $a['source_balance_amount']);
return ['fromSchoolYear' => $from, 'toSchoolYear' => $to, 'generatedAt' => now()->toISOString(), 'rows' => $rows];
}
public function storeDrafts(array $filters, ?int $actorId): array
{
$preview = $this->preview($filters);
$created = [];
foreach ($preview['rows'] as $row) {
if (!empty($row['existing_carryforward_id'])) continue;
$created[] = FinanceBalanceCarryforward::query()->create([
'parent_id' => $row['parent_id'],
'from_school_year' => $row['from_school_year'],
'to_school_year' => $row['to_school_year'],
'source_invoice_ids_json' => $row['source_invoice_ids'],
'source_balance_amount' => $row['source_balance_amount'],
'carryforward_amount' => $row['proposed_carryforward_amount'],
'remaining_amount' => $row['proposed_carryforward_amount'],
'status' => 'draft',
'reason' => $filters['reason'] ?? null,
'created_by' => $actorId,
'metadata_json' => ['warnings' => $row['warnings']],
])->toArray();
}
return ['created' => $created, 'skipped' => count($preview['rows']) - count($created)];
}
public function approve(int $id, ?int $actorId): array
{
$cf = FinanceBalanceCarryforward::query()->findOrFail($id);
$cf->fill(['status' => 'approved', 'approved_by' => $actorId, 'approved_at' => now()])->save();
return $cf->fresh()->toArray();
}
public function waive(int $id, float $amount, ?string $reason, ?int $actorId): array
{
$cf = FinanceBalanceCarryforward::query()->findOrFail($id);
$waived = min((float) $cf->remaining_amount, $amount);
$cf->waived_amount = (float) $cf->waived_amount + $waived;
$cf->remaining_amount = max(0, (float) $cf->remaining_amount - $waived);
$cf->status = $cf->remaining_amount <= 0 ? 'waived' : $cf->status;
$cf->reason = $reason ?: $cf->reason;
$cf->approved_by = $cf->approved_by ?: $actorId;
$cf->save();
return $cf->fresh()->toArray();
}
public function adjust(int $id, float $amount, ?string $reason): array
{
$cf = FinanceBalanceCarryforward::query()->findOrFail($id);
$cf->adjusted_amount = $amount;
$cf->remaining_amount = max(0, (float) $cf->carryforward_amount - (float) $cf->waived_amount + $amount);
$cf->reason = $reason ?: $cf->reason;
$cf->save();
return $cf->fresh()->toArray();
}
public function postToNewYear(int $id, ?int $actorId): array
{
return DB::transaction(function () use ($id, $actorId) {
$cf = FinanceBalanceCarryforward::query()->lockForUpdate()->findOrFail($id);
if (!in_array($cf->status, ['approved','draft','pending_review'], true)) {
return $cf->toArray() + ['warning' => 'Carryforward is not in a postable status.'];
}
$amount = max(0, (float) $cf->carryforward_amount - (float) $cf->waived_amount + (float) $cf->adjusted_amount);
$invoiceId = null;
if (Schema::hasTable('invoices')) {
$invoiceId = DB::table('invoices')->insertGetId([
'parent_id' => $cf->parent_id,
'invoice_number' => 'CF-' . $cf->to_school_year . '-' . $cf->id,
'total_amount' => $amount,
'balance' => $amount,
'paid_amount' => 0,
'school_year' => $cf->to_school_year,
'issue_date' => now()->toDateString(),
'status' => $amount > 0 ? 'unpaid' : 'paid',
'description' => 'Previous year balance from ' . $cf->from_school_year,
'created_at' => now(),
'updated_at' => now(),
'updated_by' => $actorId,
]);
}
$cf->posted_invoice_id = $invoiceId;
$cf->status = 'posted_to_new_year';
$cf->remaining_amount = $amount;
$cf->save();
return $cf->fresh()->toArray();
});
}
public function report(array $filters): array
{
$q = FinanceBalanceCarryforward::query();
foreach (['from_school_year','to_school_year','parent_id','status'] as $field) {
if (!empty($filters[$field])) $q->where($field, $filters[$field]);
}
return ['generatedAt' => now()->toISOString(), 'rows' => $q->orderByDesc('remaining_amount')->get()->toArray()];
}
public function csvRows(array $report): array
{
$rows = [['ID','Parent ID','From Year','To Year','Source Invoices','Source Balance','Carryforward','Waived','Adjusted','Remaining','Status','Posted Invoice']];
foreach ($report['rows'] as $r) {
$rows[] = [$r['id'],$r['parent_id'],$r['from_school_year'],$r['to_school_year'],json_encode($r['source_invoice_ids_json'] ?? []),$r['source_balance_amount'],$r['carryforward_amount'],$r['waived_amount'],$r['adjusted_amount'],$r['remaining_amount'],$r['status'],$r['posted_invoice_id'] ?? null];
}
return $rows;
}
private function paymentTotals(string $schoolYear): array
{
if (!Schema::hasTable('payments')) return [];
return DB::table('payments')->where('school_year', $schoolYear)->whereNotIn('status', ['void','voided','failed','cancelled','canceled','reversed'])->select('invoice_id', DB::raw('COALESCE(SUM(paid_amount),0) total'))->groupBy('invoice_id')->pluck('total','invoice_id')->map(fn($v)=>(float)$v)->all();
}
private function discountTotals(): array
{
if (!Schema::hasTable('discount_usages')) return [];
$col = Schema::hasColumn('discount_usages','discount_amount') ? 'discount_amount' : 'amount';
return DB::table('discount_usages')->select('invoice_id', DB::raw('COALESCE(SUM('.$col.'),0) total'))->groupBy('invoice_id')->pluck('total','invoice_id')->map(fn($v)=>(float)$v)->all();
}
private function refundTotals(): array
{
if (!Schema::hasTable('refunds')) return [];
$col = Schema::hasColumn('refunds','refund_amount') ? 'refund_amount' : (Schema::hasColumn('refunds','amount') ? 'amount' : null);
if (!$col) return [];
return DB::table('refunds')->whereNotIn('status', ['void','voided','cancelled','canceled'])->select('invoice_id', DB::raw('COALESCE(SUM('.$col.'),0) total'))->groupBy('invoice_id')->pluck('total','invoice_id')->map(fn($v)=>(float)$v)->all();
}
}
@@ -0,0 +1,509 @@
<?php
namespace App\Services\Finance;
use App\Models\Configuration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class StakeholderFinancialAnalysisService
{
private array $excludedPaymentStatuses = [
'void', 'voided', 'failed', 'refunded', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled',
];
private array $excludedChargeStatuses = ['void', 'voided', 'cancelled', 'canceled'];
public function analyze(?string $dateFrom, ?string $dateTo, ?string $schoolYear, ?string $compareSchoolYear = null): array
{
$schoolYear = $schoolYear ?: (Configuration::getConfig('school_year') ?? date('Y'));
[$rangeFrom, $rangeTo] = $this->resolveDateRange($schoolYear, $dateFrom, $dateTo);
$current = $this->periodMetrics($schoolYear, $rangeFrom, $rangeTo);
$comparison = null;
if (!empty($compareSchoolYear)) {
[$compareFrom, $compareTo] = $this->resolveDateRange($compareSchoolYear, null, null);
$comparison = $this->periodMetrics($compareSchoolYear, $compareFrom, $compareTo);
}
return [
'schoolYear' => $schoolYear,
'dateFrom' => $rangeFrom,
'dateTo' => $rangeTo,
'generatedAt' => now()->toISOString(),
'audience' => 'stakeholders',
'status' => 'read_only_report',
'executiveSummary' => $this->executiveSummary($current, $comparison),
'keyMetrics' => $current['keyMetrics'],
'cashFlow' => $current['cashFlow'],
'receivables' => $current['receivables'],
'expenseAnalysis' => $current['expenseAnalysis'],
'revenueAnalysis' => $current['revenueAnalysis'],
'paymentMethodMix' => $current['paymentMethodMix'],
'monthlyTrend' => $current['monthlyTrend'],
'riskFlags' => $current['riskFlags'],
'comparison' => $comparison ? [
'schoolYear' => $compareSchoolYear,
'keyMetrics' => $comparison['keyMetrics'],
'deltas' => $this->deltas($current['keyMetrics'], $comparison['keyMetrics']),
] : null,
'notes' => [
'This report is additive and read-only. It does not mutate invoices, payments, refunds, expenses, reimbursements, or legacy PayPal records.',
'Revenue is based on invoice and charge records; cash collection is based on non-voided payment records.',
'Ratios are directional management indicators, not audited financial statements.',
],
];
}
public function csvRows(array $analysis): array
{
$rows = [];
$rows[] = ['Section', 'Metric', 'Value'];
foreach (($analysis['keyMetrics'] ?? []) as $key => $value) {
$rows[] = ['Key Metrics', $key, is_scalar($value) ? $value : json_encode($value)];
}
foreach (($analysis['cashFlow'] ?? []) as $key => $value) {
$rows[] = ['Cash Flow', $key, is_scalar($value) ? $value : json_encode($value)];
}
foreach (($analysis['receivables'] ?? []) as $key => $value) {
if ($key === 'agingBuckets') {
foreach ($value as $bucket => $amount) {
$rows[] = ['Receivables Aging', $bucket, $amount];
}
continue;
}
$rows[] = ['Receivables', $key, is_scalar($value) ? $value : json_encode($value)];
}
foreach (($analysis['expenseAnalysis']['byCategory'] ?? []) as $category => $amount) {
$rows[] = ['Expense By Category', $category, $amount];
}
foreach (($analysis['paymentMethodMix'] ?? []) as $method => $amount) {
$rows[] = ['Payment Method Mix', $method, $amount];
}
foreach (($analysis['monthlyTrend'] ?? []) as $month => $data) {
$rows[] = ['Monthly Trend', $month . ' charges', $data['charges'] ?? 0];
$rows[] = ['Monthly Trend', $month . ' collections', $data['collections'] ?? 0];
$rows[] = ['Monthly Trend', $month . ' expenses', $data['expenses'] ?? 0];
$rows[] = ['Monthly Trend', $month . ' netCash', $data['netCash'] ?? 0];
}
foreach (($analysis['riskFlags'] ?? []) as $flag) {
$rows[] = ['Risk Flag', $flag['level'] ?? '', $flag['message'] ?? ''];
}
return $rows;
}
private function periodMetrics(string $schoolYear, ?string $dateFrom, ?string $dateTo): array
{
$invoiceTotals = $this->invoiceTotals($schoolYear, $dateFrom, $dateTo);
$payments = $this->paymentTotals($schoolYear, $dateFrom, $dateTo);
$refunds = $this->refundTotals($schoolYear, $dateFrom, $dateTo);
$discounts = $this->discountTotals($schoolYear, $dateFrom, $dateTo);
$additionalCharges = $this->additionalChargeTotals($schoolYear, $dateFrom, $dateTo);
$eventCharges = $this->eventChargeTotals($schoolYear, $dateFrom, $dateTo);
$expenses = $this->expenseTotals($schoolYear, $dateFrom, $dateTo);
$reimbursements = $this->reimbursementTotals($schoolYear, $dateFrom, $dateTo);
$grossRevenue = $invoiceTotals['total'] + $additionalCharges['total'] + $eventCharges['total'];
$netRevenue = max(0.0, $grossRevenue - $discounts['total'] - $refunds['total']);
$cashCollected = $payments['total'];
$cashOut = $expenses['total'] + $reimbursements['total'] + $refunds['total'];
$netCash = $cashCollected - $cashOut;
$receivables = max(0.0, $netRevenue - $cashCollected);
$collectionRate = $netRevenue > 0 ? ($cashCollected / $netRevenue) * 100 : 0.0;
$expenseRatio = $cashCollected > 0 ? (($expenses['total'] + $reimbursements['total']) / $cashCollected) * 100 : 0.0;
$operatingMargin = $netRevenue > 0 ? (($netRevenue - $expenses['total'] - $reimbursements['total']) / $netRevenue) * 100 : 0.0;
$aging = $this->receivableAging($schoolYear, $dateFrom, $dateTo);
$keyMetrics = [
'grossRevenue' => $this->money($grossRevenue),
'netRevenue' => $this->money($netRevenue),
'cashCollected' => $this->money($cashCollected),
'refunds' => $this->money($refunds['total']),
'discounts' => $this->money($discounts['total']),
'expenses' => $this->money($expenses['total']),
'reimbursements' => $this->money($reimbursements['total']),
'netCash' => $this->money($netCash),
'receivables' => $this->money($receivables),
'collectionRatePct' => $this->percent($collectionRate),
'expenseRatioPct' => $this->percent($expenseRatio),
'operatingMarginPct' => $this->percent($operatingMargin),
'invoiceCount' => $invoiceTotals['count'],
'payingFamilyCount' => $payments['familyCount'],
];
return [
'keyMetrics' => $keyMetrics,
'cashFlow' => [
'cashIn' => $this->money($cashCollected),
'cashOut' => $this->money($cashOut),
'netCash' => $this->money($netCash),
'cashOutBreakdown' => [
'expenses' => $this->money($expenses['total']),
'reimbursements' => $this->money($reimbursements['total']),
'refunds' => $this->money($refunds['total']),
],
],
'receivables' => [
'totalOpenReceivables' => $this->money($aging['total']),
'agingBuckets' => array_map(fn ($v) => $this->money($v), $aging['buckets']),
'largestOpenInvoices' => $aging['largestOpenInvoices'],
],
'expenseAnalysis' => [
'totalExpenses' => $this->money($expenses['total']),
'byCategory' => array_map(fn ($v) => $this->money($v), $expenses['byCategory']),
],
'revenueAnalysis' => [
'invoiceCharges' => $this->money($invoiceTotals['total']),
'additionalCharges' => $this->money($additionalCharges['total']),
'eventCharges' => $this->money($eventCharges['total']),
'discounts' => $this->money($discounts['total']),
'refunds' => $this->money($refunds['total']),
'netRevenue' => $this->money($netRevenue),
],
'paymentMethodMix' => array_map(fn ($v) => $this->money($v), $payments['byMethod']),
'monthlyTrend' => $this->monthlyTrend($schoolYear, $dateFrom, $dateTo),
'riskFlags' => $this->riskFlags($collectionRate, $expenseRatio, $aging['buckets'], $netCash),
];
}
private function invoiceTotals(string $schoolYear, ?string $dateFrom, ?string $dateTo): array
{
if (!Schema::hasTable('invoices')) {
return ['total' => 0.0, 'count' => 0];
}
$q = DB::table('invoices')->where('school_year', $schoolYear);
$this->applyDateRange($q, $this->dateColumn('invoices', ['issue_date', 'created_at']), $dateFrom, $dateTo);
$row = (array) $q->selectRaw('COALESCE(SUM(total_amount),0) as total, COUNT(*) as count')->first();
return ['total' => (float) ($row['total'] ?? 0), 'count' => (int) ($row['count'] ?? 0)];
}
private function paymentTotals(string $schoolYear, ?string $dateFrom, ?string $dateTo): array
{
if (!Schema::hasTable('payments')) {
return ['total' => 0.0, 'familyCount' => 0, 'byMethod' => []];
}
$base = DB::table('payments')->where('school_year', $schoolYear);
$this->excludeStatuses($base, 'payments', $this->excludedPaymentStatuses);
$this->applyDateRange($base, $this->dateColumn('payments', ['payment_date', 'created_at']), $dateFrom, $dateTo);
$total = (float) ((array) (clone $base)->selectRaw('COALESCE(SUM(paid_amount),0) as total')->first())['total'];
$familyCount = (int) ((array) (clone $base)->selectRaw('COUNT(DISTINCT parent_id) as count')->first())['count'];
$methodRows = (clone $base)
->selectRaw("COALESCE(NULLIF(payment_method,''), 'unknown') as method, COALESCE(SUM(paid_amount),0) as total")
->groupBy('method')
->orderByDesc('total')
->get();
$byMethod = [];
foreach ($methodRows as $row) {
$arr = (array) $row;
$byMethod[(string) ($arr['method'] ?? 'unknown')] = (float) ($arr['total'] ?? 0);
}
return ['total' => $total, 'familyCount' => $familyCount, 'byMethod' => $byMethod];
}
private function refundTotals(string $schoolYear, ?string $dateFrom, ?string $dateTo): array
{
if (!Schema::hasTable('refunds')) {
return ['total' => 0.0];
}
$q = DB::table('refunds')->where('school_year', $schoolYear);
if (Schema::hasColumn('refunds', 'status')) {
$q->whereIn('status', ['paid', 'partially_paid', 'approved', 'Paid', 'Partially Paid', 'Approved']);
}
$this->applyDateRange($q, $this->dateColumn('refunds', ['refunded_at', 'approved_at', 'created_at']), $dateFrom, $dateTo);
$amountColumn = Schema::hasColumn('refunds', 'refund_paid_amount') ? 'refund_paid_amount' : 'refund_amount';
$row = (array) $q->selectRaw('COALESCE(SUM(' . $amountColumn . '),0) as total')->first();
return ['total' => (float) ($row['total'] ?? 0)];
}
private function discountTotals(string $schoolYear, ?string $dateFrom, ?string $dateTo): array
{
if (!Schema::hasTable('discount_usages')) {
return ['total' => 0.0];
}
$q = DB::table('discount_usages as du');
if (Schema::hasTable('invoices')) {
$q->leftJoin('invoices as i', 'i.id', '=', 'du.invoice_id')->where('i.school_year', $schoolYear);
}
$this->excludeStatuses($q, 'discount_usages', $this->excludedChargeStatuses, 'du.status');
$this->applyDateRange($q, Schema::hasColumn('discount_usages', 'created_at') ? 'du.created_at' : null, $dateFrom, $dateTo);
$amountColumn = Schema::hasColumn('discount_usages', 'discount_amount') ? 'discount_amount' : 'amount';
$row = (array) $q->selectRaw('COALESCE(SUM(du.' . $amountColumn . '),0) as total')->first();
return ['total' => (float) ($row['total'] ?? 0)];
}
private function additionalChargeTotals(string $schoolYear, ?string $dateFrom, ?string $dateTo): array
{
if (!Schema::hasTable('additional_charges')) {
return ['total' => 0.0];
}
$q = DB::table('additional_charges')->where('school_year', $schoolYear);
$this->excludeStatuses($q, 'additional_charges', $this->excludedChargeStatuses);
$this->applyDateRange($q, $this->dateColumn('additional_charges', ['due_date', 'created_at']), $dateFrom, $dateTo);
$row = (array) $q->selectRaw('COALESCE(SUM(amount),0) as total')->first();
return ['total' => (float) ($row['total'] ?? 0)];
}
private function eventChargeTotals(string $schoolYear, ?string $dateFrom, ?string $dateTo): array
{
$table = Schema::hasTable('event_charges') ? 'event_charges' : (Schema::hasTable('event_charge') ? 'event_charge' : null);
if ($table === null) {
return ['total' => 0.0];
}
$q = DB::table($table);
if (Schema::hasColumn($table, 'school_year')) {
$q->where('school_year', $schoolYear);
}
$this->excludeStatuses($q, $table, $this->excludedChargeStatuses);
$amountColumn = Schema::hasColumn($table, 'amount') ? 'amount' : (Schema::hasColumn($table, 'charge_amount') ? 'charge_amount' : null);
if ($amountColumn === null) {
return ['total' => 0.0];
}
$this->applyDateRange($q, $this->dateColumn($table, ['charge_date', 'created_at']), $dateFrom, $dateTo);
$row = (array) $q->selectRaw('COALESCE(SUM(' . $amountColumn . '),0) as total')->first();
return ['total' => (float) ($row['total'] ?? 0)];
}
private function expenseTotals(string $schoolYear, ?string $dateFrom, ?string $dateTo): array
{
if (!Schema::hasTable('expenses')) {
return ['total' => 0.0, 'byCategory' => []];
}
$q = DB::table('expenses')->where('school_year', $schoolYear);
$this->excludeStatuses($q, 'expenses', ['void', 'voided', 'rejected', 'cancelled', 'canceled']);
$this->applyDateRange($q, $this->dateColumn('expenses', ['expense_date', 'date', 'created_at']), $dateFrom, $dateTo);
$amountColumn = Schema::hasColumn('expenses', 'amount') ? 'amount' : 'total_amount';
$total = (float) ((array) (clone $q)->selectRaw('COALESCE(SUM(' . $amountColumn . '),0) as total')->first())['total'];
$categoryColumn = Schema::hasColumn('expenses', 'category') ? 'category' : (Schema::hasColumn('expenses', 'expense_category') ? 'expense_category' : null);
$byCategory = [];
if ($categoryColumn) {
foreach ((clone $q)->selectRaw("COALESCE(NULLIF($categoryColumn,''), 'uncategorized') as category, COALESCE(SUM($amountColumn),0) as total")->groupBy('category')->orderByDesc('total')->get() as $row) {
$arr = (array) $row;
$byCategory[(string) ($arr['category'] ?? 'uncategorized')] = (float) ($arr['total'] ?? 0);
}
}
return ['total' => $total, 'byCategory' => $byCategory];
}
private function reimbursementTotals(string $schoolYear, ?string $dateFrom, ?string $dateTo): array
{
if (!Schema::hasTable('reimbursements')) {
return ['total' => 0.0];
}
$q = DB::table('reimbursements')->where('school_year', $schoolYear);
if (Schema::hasColumn('reimbursements', 'status')) {
$q->whereNotIn('status', ['void', 'voided', 'rejected', 'cancelled', 'canceled']);
}
$this->applyDateRange($q, $this->dateColumn('reimbursements', ['reimbursed_at', 'created_at']), $dateFrom, $dateTo);
$amountColumn = Schema::hasColumn('reimbursements', 'amount') ? 'amount' : (Schema::hasColumn('reimbursements', 'total_amount') ? 'total_amount' : 'reimbursement_amount');
$row = (array) $q->selectRaw('COALESCE(SUM(' . $amountColumn . '),0) as total')->first();
return ['total' => (float) ($row['total'] ?? 0)];
}
private function receivableAging(string $schoolYear, ?string $dateFrom, ?string $dateTo): array
{
$buckets = ['current' => 0.0, '1_30_days' => 0.0, '31_60_days' => 0.0, '61_90_days' => 0.0, 'over_90_days' => 0.0];
$largest = [];
if (!Schema::hasTable('invoices')) {
return ['total' => 0.0, 'buckets' => $buckets, 'largestOpenInvoices' => []];
}
$q = DB::table('invoices')->where('school_year', $schoolYear);
$this->applyDateRange($q, $this->dateColumn('invoices', ['issue_date', 'created_at']), $dateFrom, $dateTo);
$rows = $q->select(['id', 'invoice_number', 'parent_id', 'total_amount', 'paid_amount', 'balance', 'due_date', 'created_at'])->get();
$today = now()->startOfDay();
$total = 0.0;
foreach ($rows as $row) {
$arr = (array) $row;
$balance = (float) ($arr['balance'] ?? 0);
if ($balance <= 0) {
$totalAmount = (float) ($arr['total_amount'] ?? 0);
$paidAmount = (float) ($arr['paid_amount'] ?? 0);
$balance = max(0.0, $totalAmount - $paidAmount);
}
if ($balance <= 0) {
continue;
}
$total += $balance;
$dueRaw = $arr['due_date'] ?? $arr['created_at'] ?? null;
$days = 0;
try {
if (!empty($dueRaw)) {
$days = max(0, (int) \Illuminate\Support\Carbon::parse($dueRaw)->startOfDay()->diffInDays($today, false));
}
} catch (\Throwable $e) {
$days = 0;
}
$bucket = $days <= 0 ? 'current' : ($days <= 30 ? '1_30_days' : ($days <= 60 ? '31_60_days' : ($days <= 90 ? '61_90_days' : 'over_90_days')));
$buckets[$bucket] += $balance;
$largest[] = [
'invoiceId' => (int) ($arr['id'] ?? 0),
'invoiceNumber' => (string) ($arr['invoice_number'] ?? ''),
'parentId' => (int) ($arr['parent_id'] ?? 0),
'balance' => $this->money($balance),
'daysPastDue' => $days,
];
}
usort($largest, fn ($a, $b) => (float) $b['balance'] <=> (float) $a['balance']);
return ['total' => $total, 'buckets' => $buckets, 'largestOpenInvoices' => array_slice($largest, 0, 10)];
}
private function monthlyTrend(string $schoolYear, ?string $dateFrom, ?string $dateTo): array
{
$months = [];
$invoiceMonth = $this->monthlySum('invoices', 'total_amount', $schoolYear, $this->dateColumn('invoices', ['issue_date', 'created_at']), $dateFrom, $dateTo);
$paymentMonth = $this->monthlySum('payments', 'paid_amount', $schoolYear, $this->dateColumn('payments', ['payment_date', 'created_at']), $dateFrom, $dateTo, $this->excludedPaymentStatuses);
$expenseColumn = Schema::hasTable('expenses') && Schema::hasColumn('expenses', 'amount') ? 'amount' : 'total_amount';
$expenseMonth = $this->monthlySum('expenses', $expenseColumn, $schoolYear, $this->dateColumn('expenses', ['expense_date', 'date', 'created_at']), $dateFrom, $dateTo, ['void', 'voided', 'rejected', 'cancelled', 'canceled']);
foreach (array_unique(array_merge(array_keys($invoiceMonth), array_keys($paymentMonth), array_keys($expenseMonth))) as $month) {
$charges = $invoiceMonth[$month] ?? 0.0;
$collections = $paymentMonth[$month] ?? 0.0;
$expenses = $expenseMonth[$month] ?? 0.0;
$months[$month] = [
'charges' => $this->money($charges),
'collections' => $this->money($collections),
'expenses' => $this->money($expenses),
'netCash' => $this->money($collections - $expenses),
];
}
ksort($months);
return $months;
}
private function monthlySum(string $table, string $amountColumn, string $schoolYear, ?string $dateColumn, ?string $dateFrom, ?string $dateTo, array $excludedStatuses = []): array
{
if (!Schema::hasTable($table) || !Schema::hasColumn($table, $amountColumn) || $dateColumn === null) {
return [];
}
$q = DB::table($table);
if (Schema::hasColumn($table, 'school_year')) {
$q->where('school_year', $schoolYear);
}
$this->excludeStatuses($q, $table, $excludedStatuses);
$this->applyDateRange($q, $dateColumn, $dateFrom, $dateTo);
$driver = DB::connection()->getDriverName();
$monthExpr = $driver === 'sqlite'
? "strftime('%Y-%m', $dateColumn)"
: "DATE_FORMAT($dateColumn, '%Y-%m')";
$rows = $q->selectRaw($monthExpr . ' as month, COALESCE(SUM(' . $amountColumn . '),0) as total')
->groupBy('month')
->orderBy('month')
->get();
$out = [];
foreach ($rows as $row) {
$arr = (array) $row;
if (!empty($arr['month'])) {
$out[(string) $arr['month']] = (float) ($arr['total'] ?? 0);
}
}
return $out;
}
private function executiveSummary(array $current, ?array $comparison): array
{
$summary = [
'headline' => 'Stakeholder financial analysis generated from existing Laravel finance tables.',
'collectionRatePct' => $current['keyMetrics']['collectionRatePct'] ?? '0.00',
'netCash' => $current['keyMetrics']['netCash'] ?? '0.00',
'receivables' => $current['keyMetrics']['receivables'] ?? '0.00',
];
if ($comparison) {
$summary['comparison'] = $this->deltas($current['keyMetrics'], $comparison['keyMetrics']);
}
return $summary;
}
private function riskFlags(float $collectionRate, float $expenseRatio, array $agingBuckets, float $netCash): array
{
$flags = [];
if ($collectionRate < 85.0) {
$flags[] = ['level' => 'warning', 'message' => 'Collection rate is below 85%. Review receivables and follow-up cadence.'];
}
if (($agingBuckets['over_90_days'] ?? 0.0) > 0) {
$flags[] = ['level' => 'warning', 'message' => 'There are receivables more than 90 days past due.'];
}
if ($expenseRatio > 80.0) {
$flags[] = ['level' => 'watch', 'message' => 'Expenses and reimbursements exceed 80% of collections.'];
}
if ($netCash < 0) {
$flags[] = ['level' => 'critical', 'message' => 'Net cash for the selected period is negative.'];
}
if (empty($flags)) {
$flags[] = ['level' => 'ok', 'message' => 'No automatic risk flags were triggered for the selected period.'];
}
return $flags;
}
private function deltas(array $current, array $previous): array
{
$keys = ['grossRevenue', 'netRevenue', 'cashCollected', 'expenses', 'netCash', 'receivables'];
$out = [];
foreach ($keys as $key) {
$cur = (float) ($current[$key] ?? 0);
$prev = (float) ($previous[$key] ?? 0);
$out[$key] = [
'current' => $this->money($cur),
'previous' => $this->money($prev),
'change' => $this->money($cur - $prev),
'changePct' => $prev != 0.0 ? $this->percent((($cur - $prev) / abs($prev)) * 100) : null,
];
}
return $out;
}
private function resolveDateRange(string $schoolYear, ?string $dateFrom, ?string $dateTo): array
{
if ((!$dateFrom || !$dateTo) && preg_match('/^(\d{4})\D?(\d{4})$/', $schoolYear, $m)) {
$dateFrom = $dateFrom ?: $m[1] . '-01-01';
$dateTo = $dateTo ?: $m[2] . '-12-31';
}
return [$dateFrom, $dateTo];
}
private function applyDateRange($query, ?string $column, ?string $dateFrom, ?string $dateTo): void
{
if ($column === null) {
return;
}
if (!empty($dateFrom)) {
$query->whereRaw('DATE(' . $column . ') >= ?', [$dateFrom]);
}
if (!empty($dateTo)) {
$query->whereRaw('DATE(' . $column . ') <= ?', [$dateTo]);
}
}
private function excludeStatuses($query, string $table, array $statuses, ?string $qualifiedColumn = null): void
{
if (empty($statuses) || !Schema::hasColumn($table, 'status')) {
return;
}
$column = $qualifiedColumn ?: 'status';
$query->where(function ($q) use ($column, $statuses) {
$q->whereNotIn($column, $statuses)->orWhereNull($column);
});
}
private function dateColumn(string $table, array $candidates): ?string
{
if (!Schema::hasTable($table)) {
return null;
}
foreach ($candidates as $column) {
if (Schema::hasColumn($table, $column)) {
return $column;
}
}
return null;
}
private function money(float $value): string
{
return number_format(round($value, 2), 2, '.', '');
}
private function percent(float $value): string
{
return number_format(round($value, 2), 2, '.', '');
}
}
@@ -0,0 +1,39 @@
<?php
namespace App\Services\Grading\Display;
use App\Models\SemesterScore;
use App\Models\SemesterScoreSnapshot;
class GradeCalculationDisplayResolver
{
public function resolve(SemesterScore $score): array
{
$mode = (string) ($score->calculation_mode ?: 'legacy');
$version = (string) ($score->calculation_policy_version ?: 'legacy_v1');
if ($mode === 'strong' && $score->snapshot_id) {
$snapshot = SemesterScoreSnapshot::query()->find($score->snapshot_id);
if ($snapshot) {
return [
'mode' => 'strong',
'policy_version' => $version,
'label' => 'Strong Calculation',
'score' => $score,
'snapshot' => $snapshot,
'inputs' => $snapshot->input_json,
'calculation' => $snapshot->calculation_json,
];
}
}
return [
'mode' => 'legacy',
'policy_version' => $version ?: 'legacy_v1',
'label' => 'Legacy Calculation',
'score' => $score,
'snapshot' => null,
'legacy_note' => 'This score is displayed from stored legacy semester_scores values. Legacy averages ignored blank scores, PTAP used legacy dynamic weighting, and attendance includes one-absence grace.',
];
}
}
@@ -5,9 +5,19 @@ namespace App\Services\Grading;
use App\Models\GradingLock;
use App\Models\ClassSection;
use RuntimeException;
use App\Services\Grading\Policy\GradingPolicyResolver;
use App\Services\Grading\Validation\GradebookFinalizationValidator;
class GradingLockService
{
public function __construct(
private ?GradingPolicyResolver $policyResolver = null,
private ?GradebookFinalizationValidator $finalizationValidator = null
) {
$this->policyResolver = $this->policyResolver ?? new GradingPolicyResolver();
$this->finalizationValidator = $this->finalizationValidator ?? new GradebookFinalizationValidator();
}
public function toggle(int $classSectionId, string $semester, string $schoolYear, ?int $userId): bool
{
if ($classSectionId <= 0 || $semester === '' || $schoolYear === '') {
@@ -25,6 +35,8 @@ class GradingLockService
return false;
}
$this->assertCanLock($classSectionId, $semester, $schoolYear);
if ($existing) {
$existing->update([
'is_locked' => 1,
@@ -83,6 +95,8 @@ class GradingLockService
$count = 0;
foreach ($sectionIds as $sid) {
$this->assertCanLock($sid, $semester, $schoolYear);
if (!empty($existingBySection[$sid])) {
if ($existingBySection[$sid]->is_locked) {
continue;
@@ -115,4 +129,18 @@ class GradingLockService
return $count;
}
private function assertCanLock(int $classSectionId, string $semester, string $schoolYear): void
{
$mode = $this->policyResolver->calculationMode($classSectionId, $semester, $schoolYear);
if ($mode !== GradingPolicyResolver::STRONG_MODE) {
return;
}
$result = $this->finalizationValidator->validateClassSectionBeforeLock($classSectionId, $semester, $schoolYear);
if (!$result['valid']) {
$count = count($result['errors']);
throw new RuntimeException("Cannot lock class section {$classSectionId}; strong grading validation failed with {$count} error(s).");
}
}
}
+11 -2
View File
@@ -12,6 +12,7 @@ use App\Models\ScoreComment;
use App\Models\SemesterScore;
use App\Models\Student;
use App\Models\ClassSection;
use App\Services\Grading\Validation\ScoreValueValidator;
use App\Services\Scores\SemesterScoreService;
use RuntimeException;
@@ -72,18 +73,26 @@ class GradingScoreService
$scores = $payload['scores'] ?? [];
$comments = $payload['comments'] ?? [];
$validator = new ScoreValueValidator();
foreach ($scoreIds as $i => $id) {
$normalizedScore = $validator->normalizeNullable($scores[$i] ?? null, ScoreValueValidator::DEFAULT_MAX_POINTS, ucfirst($type) . ' score');
$model->newQuery()->whereKey($id)->update([
'score' => $scores[$i] ?? null,
'score' => $normalizedScore,
'max_points' => ScoreValueValidator::DEFAULT_MAX_POINTS,
'status' => $validator->inferStatus($normalizedScore, false),
'comment' => $comments[$i] ?? null,
'updated_at' => now(),
]);
}
} elseif (in_array($type, ['midterm', 'final', 'test'], true)) {
$score = $payload['score'] ?? null;
$validator = new ScoreValueValidator();
$normalizedScore = $validator->normalizeNullable($score, ScoreValueValidator::DEFAULT_MAX_POINTS, ucfirst($type) . ' score');
$data = [
'score' => $score,
'score' => $normalizedScore,
'max_points' => ScoreValueValidator::DEFAULT_MAX_POINTS,
'status' => $validator->inferStatus($normalizedScore, false),
'updated_at' => now(),
];
@@ -0,0 +1,36 @@
<?php
namespace App\Services\Grading\Policy;
use App\Models\Configuration;
class GradingPolicyResolver
{
public const LEGACY_MODE = 'legacy';
public const STRONG_MODE = 'strong';
public const LEGACY_VERSION = 'legacy_v1';
public const STRONG_VERSION = 'strong_v1';
public function calculationMode(?int $classSectionId = null, ?string $semester = null, ?string $schoolYear = null): string
{
$enabled = strtolower((string) (Configuration::getConfig('strong_grading_enabled') ?? ''));
if (!in_array($enabled, ['1', 'true', 'yes', 'on'], true)) {
return self::LEGACY_MODE;
}
$allowedSections = trim((string) (Configuration::getConfig('strong_grading_class_sections') ?? ''));
if ($allowedSections === '' || $allowedSections === '*') {
return self::STRONG_MODE;
}
$ids = array_filter(array_map('trim', explode(',', $allowedSections)), fn ($v) => $v !== '');
return $classSectionId !== null && in_array((string) $classSectionId, $ids, true)
? self::STRONG_MODE
: self::LEGACY_MODE;
}
public function policyVersion(string $mode): string
{
return $mode === self::STRONG_MODE ? self::STRONG_VERSION : self::LEGACY_VERSION;
}
}
@@ -0,0 +1,29 @@
<?php
namespace App\Services\Grading\Snapshots;
use App\Models\SemesterScore;
use App\Models\SemesterScoreSnapshot;
class SemesterScoreSnapshotService
{
public function createForScore(SemesterScore $score, array $input, array $calculation, ?int $actorId = null): SemesterScoreSnapshot
{
return SemesterScoreSnapshot::query()->create([
'student_id' => $score->student_id,
'school_id' => $score->school_id,
'class_section_id' => $score->class_section_id,
'semester' => $score->semester,
'school_year' => $score->school_year,
'grading_profile_id' => null,
'grading_profile_version' => $score->calculation_policy_version ?: 'strong_v1',
'calculation_mode' => $score->calculation_mode ?: 'strong',
'calculation_policy_version' => $score->calculation_policy_version ?: 'strong_v1',
'input_json' => $input,
'calculation_json' => $calculation,
'semester_score' => $score->semester_score,
'calculated_by' => $actorId,
'calculated_at' => now(),
]);
}
}
@@ -0,0 +1,60 @@
<?php
namespace App\Services\Grading;
use Illuminate\Support\Facades\DB;
class StrongCategoryAverageCalculator
{
public function calculate(string $table, int $studentId, string $semester, string $schoolYear, ?int $classSectionId = null): array
{
$query = DB::table($table)
->where('student_id', $studentId)
->where('semester', $semester)
->where('school_year', $schoolYear);
if ($classSectionId !== null) {
$query->where('class_section_id', $classSectionId);
}
$rows = $query->get();
$included = [];
$pending = [];
$excluded = [];
foreach ($rows as $row) {
$status = (string) ($row->status ?? 'pending');
$score = $row->score ?? null;
$maxPoints = isset($row->max_points) && is_numeric($row->max_points) && (float) $row->max_points > 0
? (float) $row->max_points
: 100.0;
if ($status === 'pending' || $status === '') {
$pending[] = $row;
continue;
}
if (in_array($status, ['excused', 'not_assigned'], true)) {
$excluded[] = $row;
continue;
}
if ($status === 'missing') {
$included[] = 0.0;
continue;
}
if ($status === 'scored' && $score !== null && is_numeric($score)) {
$included[] = ((float) $score / $maxPoints) * 100;
}
}
return [
'average' => count($included) > 0 ? round(array_sum($included) / count($included), 2) : null,
'included_count' => count($included),
'excluded_count' => count($excluded),
'pending_count' => count($pending),
'has_pending' => count($pending) > 0,
];
}
}
@@ -0,0 +1,99 @@
<?php
namespace App\Services\Grading\Validation;
use Illuminate\Support\Facades\DB;
class GradebookFinalizationValidator
{
/**
* Strong-mode validation only. Legacy mode deliberately remains displayable/lockable
* so historical records are not reinterpreted by the new policy.
*/
public function validateClassSectionBeforeLock(int $classSectionId, string $semester, string $schoolYear): array
{
$errors = [];
foreach ($this->scoreTables() as $table => $meta) {
if (!DB::getSchemaBuilder()->hasTable($table)) {
continue;
}
$query = DB::table($table)
->where('class_section_id', $classSectionId)
->where('semester', $semester)
->where('school_year', $schoolYear);
if (DB::getSchemaBuilder()->hasColumn($table, 'status')) {
$pending = (clone $query)->where(function ($q) {
$q->whereNull('status')->orWhere('status', 'pending');
})->get();
foreach ($pending as $row) {
$errors[] = $this->error($table, $meta['category'], $row, 'pending_score', 'Score must be marked scored, missing, excused, or not_assigned before strong-mode lock.');
}
}
if (DB::getSchemaBuilder()->hasColumn($table, 'max_points')) {
$invalidMax = (clone $query)->where(function ($q) {
$q->whereNull('max_points')->orWhere('max_points', '<=', 0);
})->get();
foreach ($invalidMax as $row) {
$errors[] = $this->error($table, $meta['category'], $row, 'invalid_max_points', 'Max points must be greater than 0.');
}
$invalidScores = (clone $query)
->whereNotNull('score')
->where(function ($q) {
$q->where('score', '<', 0)->orWhereRaw('score > max_points');
})
->get();
foreach ($invalidScores as $row) {
$errors[] = $this->error($table, $meta['category'], $row, 'score_out_of_range', 'Score must be between 0 and max_points.');
}
} else {
$invalidScores = (clone $query)
->whereNotNull('score')
->where(function ($q) {
$q->where('score', '<', 0)->orWhere('score', '>', 100);
})
->get();
foreach ($invalidScores as $row) {
$errors[] = $this->error($table, $meta['category'], $row, 'score_out_of_range', 'Score must be between 0 and 100.');
}
}
}
return [
'valid' => empty($errors),
'errors' => $errors,
];
}
private function scoreTables(): array
{
return [
'homework' => ['category' => 'homework'],
'quiz' => ['category' => 'quiz'],
'project' => ['category' => 'project'],
'participation' => ['category' => 'participation'],
'midterm_exam' => ['category' => 'midterm_exam'],
'final_exam' => ['category' => 'final_exam'],
];
}
private function error(string $table, string $category, object $row, string $code, string $message): array
{
return [
'table' => $table,
'category' => $category,
'student_id' => isset($row->student_id) ? (int) $row->student_id : null,
'item_index' => $row->homework_index ?? $row->quiz_index ?? $row->project_index ?? null,
'code' => $code,
'message' => $message,
];
}
}
@@ -0,0 +1,48 @@
<?php
namespace App\Services\Grading\Validation;
use InvalidArgumentException;
class ScoreValueValidator
{
public const DEFAULT_MAX_POINTS = 100.0;
public function normalizeNullable(mixed $value, float|int|null $maxPoints = self::DEFAULT_MAX_POINTS, string $label = 'Score'): ?float
{
if ($value === null || (is_string($value) && trim($value) === '')) {
return null;
}
$max = $this->normalizeMaxPoints($maxPoints, $label);
if (!is_numeric($value)) {
throw new InvalidArgumentException("{$label} must be numeric.");
}
$score = (float) $value;
if ($score < 0 || $score > $max) {
throw new InvalidArgumentException("{$label} must be between 0 and {$max}.");
}
return $score;
}
public function normalizeMaxPoints(float|int|null $maxPoints = self::DEFAULT_MAX_POINTS, string $label = 'Score'): float
{
$max = $maxPoints === null ? self::DEFAULT_MAX_POINTS : (float) $maxPoints;
if ($max <= 0) {
throw new InvalidArgumentException("{$label} max points must be greater than 0.");
}
return $max;
}
public function inferStatus(?float $score, bool $missingAllowed = false): string
{
if ($score !== null) {
return 'scored';
}
return $missingAllowed ? 'excused' : 'pending';
}
}
@@ -34,6 +34,11 @@ class InvoicePdfService
return $this->renderPdfInvoice($data);
}
public function previewData(int $invoiceId): array
{
return $this->prepareInvoiceData($invoiceId);
}
private function prepareInvoiceData(int $invoiceId): array
{
$invoice = Invoice::query()->find($invoiceId);
+50 -7
View File
@@ -43,7 +43,11 @@ class NavBuilderService
$this->sortTreeAlpha($tree);
$flatAlpha = $all;
usort($flatAlpha, fn ($a, $b) => strnatcasecmp($this->labelKey($a['label'] ?? ''), $this->labelKey($b['label'] ?? '')));
usort($flatAlpha, function ($a, $b) {
$result = strnatcasecmp($this->labelKey($a['label'] ?? ''), $this->labelKey($b['label'] ?? ''));
return $result !== 0 ? $result : ((int) ($a['id'] ?? 0) <=> (int) ($b['id'] ?? 0));
});
$roleAssignments = $this->buildRoleAssignments();
$flattened = $this->flattenTreeForResponse($tree, $roleAssignments);
@@ -109,6 +113,8 @@ class NavBuilderService
'nav_item_id' => $navItemId,
]);
}
$this->normalizeSortOrdersAlphabetically();
});
$this->navbarService->clearCache();
@@ -118,7 +124,16 @@ class NavBuilderService
public function delete(int $id): bool
{
$deleted = (bool) NavItem::query()->whereKey($id)->delete();
$deleted = false;
DB::transaction(function () use ($id, &$deleted): void {
$deleted = (bool) NavItem::query()->whereKey($id)->delete();
if ($deleted) {
$this->normalizeSortOrdersAlphabetically();
}
});
$this->navbarService->clearCache();
return $deleted;
@@ -126,15 +141,39 @@ class NavBuilderService
public function reorder(array $orders): void
{
DB::transaction(function () use ($orders): void {
foreach ($orders as $id => $order) {
NavItem::query()->whereKey((int) $id)->update(['sort_order' => (int) $order]);
}
DB::transaction(function (): void {
$this->normalizeSortOrdersAlphabetically();
});
$this->navbarService->clearCache();
}
private function normalizeSortOrdersAlphabetically(): void
{
$rows = NavItem::query()
->select(['id', 'menu_parent_id', 'label'])
->orderBy('menu_parent_id', 'asc')
->orderBy('label', 'asc')
->orderBy('id', 'asc')
->get()
->groupBy(fn ($row) => $row->menu_parent_id === null ? 'root' : (string) $row->menu_parent_id);
foreach ($rows as $siblings) {
$ordered = $siblings->values()->all();
usort($ordered, function ($a, $b) {
$result = strnatcasecmp($this->labelKey((string) ($a->label ?? '')), $this->labelKey((string) ($b->label ?? '')));
return $result !== 0 ? $result : ((int) $a->id <=> (int) $b->id);
});
foreach ($ordered as $index => $item) {
NavItem::query()
->whereKey((int) $item->id)
->update(['sort_order' => ($index + 1) * 10]);
}
}
}
private function buildRoleAssignments(): array
{
$rows = RoleNavItem::query()
@@ -205,7 +244,11 @@ class NavBuilderService
private function sortTreeAlpha(array &$nodes): void
{
usort($nodes, fn ($a, $b) => strnatcasecmp($this->labelKey($a['label'] ?? ''), $this->labelKey($b['label'] ?? '')));
usort($nodes, function ($a, $b) {
$result = strnatcasecmp($this->labelKey($a['label'] ?? ''), $this->labelKey($b['label'] ?? ''));
return $result !== 0 ? $result : ((int) ($a['id'] ?? 0) <=> (int) ($b['id'] ?? 0));
});
foreach ($nodes as &$node) {
if (!empty($node['children'])) {
$this->sortTreeAlpha($node['children']);
+30 -1
View File
@@ -21,7 +21,9 @@ class NavbarService
$rows = NavItem::query()
->where('is_enabled', 1)
->orderBy('sort_order', 'asc')
->orderBy('menu_parent_id', 'asc')
->orderBy('label', 'asc')
->orderBy('id', 'asc')
->get()
->toArray();
@@ -44,6 +46,8 @@ class NavbarService
}
unset($node);
$this->sortTreeAlpha($tree);
return $tree;
});
}
@@ -52,4 +56,29 @@ class NavbarService
{
Cache::flush();
}
private function labelKey(string $s): string
{
$s = trim(preg_replace('/\s+/', ' ', $s));
$s = preg_replace('/^[^[:alnum:]]+/u', '', $s);
$s = preg_replace('/^(?i)(the|an|a)\s+/', '', $s);
return mb_strtolower($s, 'UTF-8');
}
private function sortTreeAlpha(array &$nodes): void
{
usort($nodes, function ($a, $b) {
$result = strnatcasecmp($this->labelKey($a['label'] ?? ''), $this->labelKey($b['label'] ?? ''));
return $result !== 0 ? $result : ((int) ($a['id'] ?? 0) <=> (int) ($b['id'] ?? 0));
});
foreach ($nodes as &$node) {
if (!empty($node['children'])) {
$this->sortTreeAlpha($node['children']);
}
}
unset($node);
}
}
@@ -0,0 +1,106 @@
<?php
namespace App\Services\Promotions\Placement;
class BalancedSectionPlacementService
{
public const ALGORITHM = 'score_band_balanced_v1';
public function assign(array $students, int $capacity): array
{
$total = count($students);
if ($total === 0) {
return [
'section_count' => 0,
'target_sizes' => [],
'sections' => [],
'assignments' => [],
];
}
$sectionCount = $total <= $capacity ? 1 : (int) ceil($total / $capacity);
$targetSizes = $this->targetSizes($total, $sectionCount);
$sections = [];
for ($i = 1; $i <= $sectionCount; $i++) {
$sections[$i] = [
'section_index' => $i,
'target_size' => $targetSizes[$i],
'total' => 0,
'bands' => ['A' => 0, 'B' => 0, 'C' => 0, 'D' => 0],
'students' => [],
];
}
$grouped = ['A' => [], 'B' => [], 'C' => [], 'D' => []];
foreach ($students as $student) {
$band = $student['score_band'] ?? 'D';
$grouped[$band][] = $student;
}
foreach (array_keys($grouped) as $band) {
usort($grouped[$band], function (array $a, array $b): int {
$scoreA = $a['final_score'] ?? $a['placement_score'] ?? -1;
$scoreB = $b['final_score'] ?? $b['placement_score'] ?? -1;
if ($scoreA !== $scoreB) {
return $scoreB <=> $scoreA;
}
$name = strcmp((string) ($a['student_name'] ?? ''), (string) ($b['student_name'] ?? ''));
return $name !== 0 ? $name : ((int) $a['student_id'] <=> (int) $b['student_id']);
});
}
$assignments = [];
$order = 1;
foreach (['A', 'B', 'C', 'D'] as $band) {
foreach ($grouped[$band] as $student) {
$sectionIndex = $this->selectSection($sections, $band);
$sections[$sectionIndex]['total']++;
$sections[$sectionIndex]['bands'][$band]++;
$sections[$sectionIndex]['students'][] = (int) $student['student_id'];
$student['planned_section_index'] = $sectionIndex;
$student['assignment_order'] = $order++;
$assignments[] = $student;
}
}
return [
'section_count' => $sectionCount,
'target_sizes' => $targetSizes,
'sections' => array_values($sections),
'assignments' => $assignments,
];
}
private function targetSizes(int $total, int $sectionCount): array
{
$base = intdiv($total, $sectionCount);
$remainder = $total % $sectionCount;
$sizes = [];
for ($i = 1; $i <= $sectionCount; $i++) {
$sizes[$i] = $base + ($i <= $remainder ? 1 : 0);
}
return $sizes;
}
private function selectSection(array $sections, string $band): int
{
$eligible = array_filter($sections, fn ($s) => $s['total'] < $s['target_size']);
if (empty($eligible)) {
$eligible = $sections;
}
uasort($eligible, function (array $a, array $b) use ($band): int {
$cmp = $a['total'] <=> $b['total'];
if ($cmp !== 0) {
return $cmp;
}
$cmp = ($a['bands'][$band] ?? 0) <=> ($b['bands'][$band] ?? 0);
if ($cmp !== 0) {
return $cmp;
}
return $a['section_index'] <=> $b['section_index'];
});
return (int) array_key_first($eligible);
}
}
@@ -0,0 +1,182 @@
<?php
namespace App\Services\Promotions\Placement;
use App\Models\StudentPromotionRecord;
use Illuminate\Support\Facades\DB;
class PlacementPoolBuilder
{
public function __construct(private ScoreBandClassifier $bands)
{
}
public function build(string $fromSchoolYear, string $toSchoolYear, ?int $toGradeLevelId = null): array
{
$pool = [];
$exceptions = $this->exceptionBuckets($fromSchoolYear, $toSchoolYear);
$returning = StudentPromotionRecord::query()
->from('student_promotion_records as r')
->join('students as s', 's.id', '=', 'r.student_id')
->leftJoin('student_decisions as d', function ($join) use ($fromSchoolYear) {
$join->on('d.student_id', '=', 'r.student_id')
->where('d.school_year', '=', $fromSchoolYear);
})
->where('r.current_school_year', $fromSchoolYear)
->where('r.next_school_year', $toSchoolYear)
->where('r.enrollment_status', StudentPromotionRecord::ENROLLMENT_COMPLETED)
->where('r.promotion_status', StudentPromotionRecord::STATUS_PROMOTED_AND_ENROLLED)
->when($toGradeLevelId !== null, fn ($q) => $q->where('r.promoted_level_id', $toGradeLevelId))
->selectRaw('r.*, s.firstname, s.lastname, s.is_active, d.id as decision_id, d.decision, d.year_score')
->orderBy('s.lastname')
->orderBy('s.firstname')
->orderBy('s.id')
->get();
foreach ($returning as $row) {
$studentId = (int) $row->student_id;
if ((int) ($row->is_active ?? 1) === 0) {
$exceptions['withdrawn_or_inactive_student'][] = $studentId;
continue;
}
if ($this->alreadyPlaced($studentId, $toSchoolYear)) {
$exceptions['duplicate_target_year_placement'][] = $studentId;
continue;
}
if (!$this->isPromotedDecision((string) ($row->decision ?? ''))) {
$bucket = $row->decision === null ? 'missing_student_decision' : 'decision_conflicts_with_enrollment_request';
$exceptions[$bucket][] = $studentId;
continue;
}
$score = $row->year_score !== null ? (float) $row->year_score : ($row->final_average !== null ? (float) $row->final_average : null);
if ($score === null) {
$exceptions['missing_student_decision'][] = $studentId;
continue;
}
try {
$band = $this->bands->fromScore($score, true);
} catch (\RuntimeException) {
$exceptions['failed_retained'][] = $studentId;
continue;
}
$pool[] = [
'student_id' => $studentId,
'student_type' => 'returning',
'student_name' => trim((string) $row->lastname . ', ' . (string) $row->firstname),
'source_decision_id' => $row->decision_id ? (int) $row->decision_id : null,
'source_enrollment_id' => $row->enrollment_id ? (int) $row->enrollment_id : null,
'final_score' => $score,
'placement_score' => null,
'score_band' => $band,
'placement_band_reason' => 'returning_student_final_score',
];
}
$newStudents = DB::table('enrollments as e')
->join('students as s', 's.id', '=', 'e.student_id')
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'e.class_section_id')
->where('e.school_year', $toSchoolYear)
->where('s.is_new', 1)
->where('e.admission_status', 'accepted')
->whereIn('e.enrollment_status', ['enrolled', 'payment pending'])
->when($toGradeLevelId !== null, function ($q) use ($toGradeLevelId) {
$q->where(function ($inner) use ($toGradeLevelId) {
$inner->whereNull('e.class_section_id')
->orWhere('cs.class_id', $toGradeLevelId);
});
})
->selectRaw('e.id as enrollment_id, e.student_id, s.firstname, s.lastname, s.is_active')
->orderBy('s.lastname')
->orderBy('s.firstname')
->orderBy('s.id')
->get();
foreach ($newStudents as $row) {
$studentId = (int) $row->student_id;
if ((int) ($row->is_active ?? 1) === 0) {
$exceptions['withdrawn_or_inactive_student'][] = $studentId;
continue;
}
if ($this->alreadyPlaced($studentId, $toSchoolYear)) {
$exceptions['duplicate_target_year_placement'][] = $studentId;
continue;
}
$pool[] = [
'student_id' => $studentId,
'student_type' => 'new',
'student_name' => trim((string) $row->lastname . ', ' . (string) $row->firstname),
'source_decision_id' => null,
'source_enrollment_id' => (int) $row->enrollment_id,
'final_score' => null,
'placement_score' => null,
'score_band' => 'D',
'placement_band_reason' => 'new_student_default_band',
];
}
return [
'pool' => $pool,
'exceptions' => array_map(fn ($ids) => array_values(array_unique($ids)), $exceptions),
];
}
private function exceptionBuckets(string $fromSchoolYear, string $toSchoolYear): array
{
$buckets = [
'passed_but_parent_enrollment_missing' => [],
'passed_and_parent_enrollment_completed' => [],
'failed_retained' => [],
'missing_student_decision' => [],
'pending_student_decision' => [],
'decision_conflicts_with_enrollment_request' => [],
'withdrawn_or_inactive_student' => [],
'duplicate_target_year_placement' => [],
];
$rows = DB::table('student_decisions as d')
->leftJoin('student_promotion_records as r', function ($join) use ($toSchoolYear) {
$join->on('r.student_id', '=', 'd.student_id')
->where('r.next_school_year', '=', $toSchoolYear);
})
->where('d.school_year', $fromSchoolYear)
->select('d.student_id', 'd.decision', 'r.enrollment_status')
->get();
foreach ($rows as $row) {
$decision = strtolower(trim((string) $row->decision));
$studentId = (int) $row->student_id;
if ($this->isPromotedDecision($decision)) {
if ($row->enrollment_status === StudentPromotionRecord::ENROLLMENT_COMPLETED) {
$buckets['passed_and_parent_enrollment_completed'][] = $studentId;
} else {
$buckets['passed_but_parent_enrollment_missing'][] = $studentId;
}
} elseif (in_array($decision, ['failed', 'fail', 'retained', 'repeat', 'repeated_level'], true)) {
$buckets['failed_retained'][] = $studentId;
} elseif ($decision === '' || $decision === 'pending') {
$buckets['pending_student_decision'][] = $studentId;
}
}
return $buckets;
}
private function alreadyPlaced(int $studentId, string $schoolYear): bool
{
return DB::table('student_class')
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->where('is_event_only', 0)
->exists();
}
private function isPromotedDecision(string $decision): bool
{
$normalized = strtolower(trim($decision));
return in_array($normalized, ['passed', 'pass', 'promoted', 'promote', 'eligible_to_continue'], true);
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\Services\Promotions\Placement;
use App\Models\Configuration;
use Illuminate\Support\Facades\Log;
use RuntimeException;
class PromotionSectionCapacityService
{
public const CONFIG_KEY = 'promotion.section_capacity';
public const DEFAULT_CAPACITY = 20;
public function capacity(): array
{
$raw = Configuration::getConfigValueByKey(self::CONFIG_KEY);
$usedDefault = false;
if ($raw === null || trim((string) $raw) === '') {
$usedDefault = true;
Log::warning('Missing promotion section capacity configuration; using fallback.', [
'config_key' => self::CONFIG_KEY,
'fallback' => self::DEFAULT_CAPACITY,
]);
$value = self::DEFAULT_CAPACITY;
} elseif (!ctype_digit((string) $raw)) {
throw new RuntimeException('promotion.section_capacity must be a positive integer.');
} else {
$value = (int) $raw;
}
if ($value <= 0) {
throw new RuntimeException('promotion.section_capacity must be greater than zero.');
}
return [
'key' => self::CONFIG_KEY,
'value' => $value,
'used_default' => $usedDefault,
'warnings' => $usedDefault ? ['promotion.section_capacity missing; fallback capacity 20 used.'] : [],
];
}
}
@@ -0,0 +1,29 @@
<?php
namespace App\Services\Promotions\Placement;
use RuntimeException;
class ScoreBandClassifier
{
public function fromScore(?float $score, bool $allowNull = false): ?string
{
if ($score === null) {
return $allowNull ? null : 'D';
}
if ($score >= 90.0 && $score <= 100.0) {
return 'A';
}
if ($score >= 80.0) {
return 'B';
}
if ($score >= 70.0) {
return 'C';
}
if ($score >= 60.0) {
return 'D';
}
throw new RuntimeException('Scores below 60 are not eligible for automatic placement.');
}
}
@@ -0,0 +1,204 @@
<?php
namespace App\Services\Promotions\Placement;
use App\Models\Configuration;
use App\Models\SectionPlacementBatch;
use App\Models\SectionPlacementBatchStudent;
use Illuminate\Support\Facades\DB;
use RuntimeException;
class SectionPlacementPreviewService
{
public function __construct(
private PromotionSectionCapacityService $capacityService,
private PlacementPoolBuilder $poolBuilder,
private BalancedSectionPlacementService $placementService
) {
}
public function createDraft(
string $fromSchoolYear,
string $toSchoolYear,
?int $fromGradeLevelId = null,
?int $toGradeLevelId = null,
?int $createdBy = null
): SectionPlacementBatch {
$capacityInfo = $this->capacityService->capacity();
$poolResult = $this->poolBuilder->build($fromSchoolYear, $toSchoolYear, $toGradeLevelId);
$placement = $this->placementService->assign($poolResult['pool'], (int) $capacityInfo['value']);
$snapshot = [
'capacity' => $capacityInfo,
'target_section_sizes' => $placement['target_sizes'],
'sections' => $placement['sections'],
'excluded_students' => $poolResult['exceptions'],
'warnings' => $capacityInfo['warnings'],
'new_students_defaulted_to_d_band' => array_values(array_map(
fn ($s) => $s['student_id'],
array_filter($placement['assignments'], fn ($s) => ($s['placement_band_reason'] ?? null) === 'new_student_default_band')
)),
];
return DB::transaction(function () use ($fromSchoolYear, $toSchoolYear, $fromGradeLevelId, $toGradeLevelId, $createdBy, $capacityInfo, $placement, $snapshot) {
$batch = SectionPlacementBatch::query()->create([
'from_school_year' => $fromSchoolYear,
'to_school_year' => $toSchoolYear,
'from_grade_level_id' => $fromGradeLevelId,
'to_grade_level_id' => $toGradeLevelId,
'section_capacity_used' => (int) $capacityInfo['value'],
'total_students' => count($placement['assignments']),
'section_count' => (int) $placement['section_count'],
'algorithm' => BalancedSectionPlacementService::ALGORITHM,
'configuration_snapshot_json' => json_encode([
PromotionSectionCapacityService::CONFIG_KEY => (int) $capacityInfo['value'],
'placement.algorithm' => BalancedSectionPlacementService::ALGORITHM,
]),
'preview_snapshot_json' => json_encode($snapshot),
'created_by' => $createdBy,
'status' => SectionPlacementBatch::STATUS_DRAFT,
]);
foreach ($placement['assignments'] as $assignment) {
SectionPlacementBatchStudent::query()->create([
'batch_id' => $batch->getKey(),
'student_id' => $assignment['student_id'],
'student_type' => $assignment['student_type'],
'source_decision_id' => $assignment['source_decision_id'],
'source_enrollment_id' => $assignment['source_enrollment_id'],
'final_score' => $assignment['final_score'],
'placement_score' => $assignment['placement_score'],
'score_band' => $assignment['score_band'],
'placement_band_reason' => $assignment['placement_band_reason'],
'planned_section_index' => $assignment['planned_section_index'],
'assignment_order' => $assignment['assignment_order'],
'was_override' => false,
'created_at' => now(),
]);
}
return $batch->refresh();
});
}
public function previewPayload(SectionPlacementBatch $batch): array
{
return [
'batch' => $batch->toArray(),
'preview' => $batch->preview_snapshot_json ? json_decode((string) $batch->preview_snapshot_json, true) : null,
'students' => $batch->students()->orderBy('planned_section_index')->orderBy('assignment_order')->get()->toArray(),
];
}
public function finalize(int $batchId, ?int $userId = null): SectionPlacementBatch
{
$batch = SectionPlacementBatch::query()->findOrFail($batchId);
if ($batch->status !== SectionPlacementBatch::STATUS_DRAFT) {
throw new RuntimeException('Only draft placement batches can be finalized.');
}
if ((int) $batch->total_students !== $batch->students()->count()) {
throw new RuntimeException('Draft batch student count does not match batch total.');
}
return DB::transaction(function () use ($batch, $userId) {
$semester = Configuration::getConfigValueByKey('semester') ?: 'Fall';
$sectionMap = $this->ensureSections($batch, $semester);
$seen = [];
$students = $batch->students()->orderBy('assignment_order')->get();
foreach ($students as $student) {
$studentId = (int) $student->student_id;
if (isset($seen[$studentId])) {
throw new RuntimeException('Student appears more than once in placement batch.');
}
$seen[$studentId] = true;
$duplicate = DB::table('student_class')
->where('student_id', $studentId)
->where('school_year', $batch->to_school_year)
->where('is_event_only', 0)
->exists();
if ($duplicate) {
throw new RuntimeException('Student ' . $studentId . ' already has a target-year class placement.');
}
$sectionId = $sectionMap[(int) $student->planned_section_index] ?? null;
if ($sectionId === null) {
throw new RuntimeException('Missing target section for planned section ' . $student->planned_section_index . '.');
}
DB::table('student_class')->insert([
'student_id' => $studentId,
'class_section_id' => $sectionId,
'is_event_only' => 0,
'semester' => $semester,
'school_year' => $batch->to_school_year,
'description' => 'Finalized from section placement batch #' . $batch->getKey(),
'created_at' => now(),
'updated_at' => now(),
'updated_by' => $userId,
]);
$student->assigned_section_id = $sectionId;
$student->save();
}
$batch->status = SectionPlacementBatch::STATUS_FINALIZED;
$batch->finalized_by = $userId;
$batch->finalized_at = now();
$batch->save();
return $batch->refresh();
});
}
private function ensureSections(SectionPlacementBatch $batch, string $semester): array
{
if (!$batch->to_grade_level_id) {
throw new RuntimeException('to_grade_level_id is required to finalize placement sections.');
}
$map = [];
$classId = (int) $batch->to_grade_level_id;
$sectionCount = (int) $batch->section_count;
$maxSectionId = (int) DB::table('classSection')->max('class_section_id');
$letters = range('A', 'Z');
for ($i = 1; $i <= $sectionCount; $i++) {
$name = $this->sectionName($classId, $letters[$i - 1] ?? (string) $i, $batch->to_school_year);
$existing = DB::table('classSection')
->where('class_id', $classId)
->where('class_section_name', $name)
->where(function ($q) use ($batch) {
$q->where('school_year', $batch->to_school_year)->orWhereNull('school_year');
})
->value('class_section_id');
if ($existing) {
$map[$i] = (int) $existing;
continue;
}
$sectionId = ++$maxSectionId;
DB::table('classSection')->insert([
'class_id' => $classId,
'class_section_id' => $sectionId,
'class_section_name' => $name,
'semester' => $semester,
'school_year' => $batch->to_school_year,
'created_at' => now(),
'updated_at' => now(),
]);
$map[$i] = $sectionId;
}
return $map;
}
private function sectionName(int $classId, string $suffix, string $schoolYear): string
{
$className = DB::table('classes')->where('id', $classId)->value('class_name');
$base = $className ? (string) $className : ('Class ' . $classId);
return $base . '-' . $suffix . ' ' . $schoolYear;
}
}
@@ -281,50 +281,59 @@ class PromotionEligibilityService
*/
private function loadAcademicResult(int $studentId, string $schoolYear): array
{
$rows = DB::table('semester_scores')
->select('semester', 'semester_score')
$decision = DB::table('student_decisions')
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->get();
->orderByDesc('updated_at')
->orderByDesc('id')
->first();
$fall = null;
$spring = null;
foreach ($rows as $row) {
$semester = strtolower((string) ($row->semester ?? ''));
$score = isset($row->semester_score) ? (float) $row->semester_score : null;
if ($semester === 'fall') {
$fall = $score;
} elseif ($semester === 'spring') {
$spring = $score;
}
}
$threshold = $this->passThreshold();
if ($fall === null && $spring === null) {
if (!$decision) {
return [
'passed' => null,
'final_average' => null,
'notes' => 'Missing fall and spring scores',
'has_data' => false,
];
}
if ($fall === null || $spring === null) {
$existing = $fall ?? $spring;
return [
'passed' => null,
'final_average' => $existing,
'notes' => 'Awaiting both fall and spring scores',
'notes' => 'Missing student_decisions record for promotion eligibility',
'has_data' => false,
];
}
$rawDecision = strtolower(trim((string) ($decision->decision ?? '')));
$score = $decision->year_score !== null ? round((float) $decision->year_score, 2) : null;
if (in_array($rawDecision, ['passed', 'pass', 'promoted', 'promote', 'eligible_to_continue'], true)) {
return [
'passed' => true,
'final_average' => $score,
'notes' => $score !== null
? sprintf('student_decisions marked promoted with score %.2f', $score)
: 'student_decisions marked promoted without a year_score',
'has_data' => true,
];
}
if (in_array($rawDecision, ['failed', 'fail', 'retained', 'repeat', 'repeated_level'], true)) {
return [
'passed' => false,
'final_average' => $score,
'notes' => 'student_decisions marked failed/retained',
'has_data' => true,
];
}
if ($rawDecision === '' || $rawDecision === 'pending' || $rawDecision === 'manual review' || $rawDecision === 'manual_review') {
return [
'passed' => null,
'final_average' => $score,
'notes' => 'student_decisions is pending or requires manual review',
'has_data' => false,
];
}
$avg = round(($fall + $spring) / 2.0, 2);
return [
'passed' => $avg >= $threshold,
'final_average' => $avg,
'notes' => sprintf('Final average %.2f (threshold %.2f)', $avg, $threshold),
'has_data' => true,
'passed' => null,
'final_average' => $score,
'notes' => 'student_decisions has unrecognized decision: ' . $rawDecision,
'has_data' => false,
];
}
+4 -1
View File
@@ -63,7 +63,10 @@ class AttendanceCalculator implements ScoreCalculatorInterface
return ['attendance_score' => 100.0];
}
$attendanceRatio = ($totalDays + 1 - $absences) / $totalDays;
// School policy: one absence is excused by grace before attendance is reduced.
$graceAbsences = 1;
$effectiveAbsences = max(0, (int) $absences - $graceAbsences);
$attendanceRatio = ($totalDays - $effectiveAbsences) / $totalDays;
$score = min(100, max(0, $attendanceRatio * 100));
return ['attendance_score' => round($score, 2)];
+7 -2
View File
@@ -7,6 +7,7 @@ use App\Models\MissingScoreOverride;
use App\Models\Student;
use App\Models\TeacherClass;
use App\Models\ClassSection;
use App\Services\Grading\Validation\ScoreValueValidator;
use Illuminate\Support\Facades\DB;
class ExamScoreService
@@ -131,8 +132,10 @@ class ExamScoreService
if (!$student) {
continue;
}
$rawScore = $data['score'] ?? null;
$normalizedScore = is_numeric($rawScore) ? (float) $rawScore : null;
$rawScore = is_array($data) ? ($data['score'] ?? null) : $data;
$validator = new ScoreValueValidator();
$normalizedScore = $validator->normalizeNullable($rawScore, ScoreValueValidator::DEFAULT_MAX_POINTS, 'Exam score');
$missingAllowed = !empty($missingOk[$studentId]);
$existing = $builder
->where('student_id', $studentId)
@@ -147,6 +150,8 @@ class ExamScoreService
'class_section_id' => $classSectionId,
'updated_by' => $updatedBy,
'score' => $normalizedScore,
'max_points' => ScoreValueValidator::DEFAULT_MAX_POINTS,
'status' => $validator->inferStatus($normalizedScore, $missingAllowed),
'semester' => $semester,
'school_year' => $schoolYear,
'updated_at' => now(),
+6 -2
View File
@@ -9,6 +9,7 @@ use App\Models\Student;
use App\Models\StudentClass;
use App\Models\TeacherClass;
use App\Models\ClassSection;
use App\Services\Grading\Validation\ScoreValueValidator;
class HomeworkScoreService
{
@@ -248,8 +249,9 @@ class HomeworkScoreService
continue;
}
$rawScore = $score ?? null;
$isBlank = $rawScore === null || (is_string($rawScore) && trim($rawScore) === '');
$normalizedScore = (!$isBlank && is_numeric($rawScore)) ? (float) $rawScore : null;
$validator = new ScoreValueValidator();
$normalizedScore = $validator->normalizeNullable($rawScore, ScoreValueValidator::DEFAULT_MAX_POINTS, 'Homework score');
$missingAllowed = !empty($missingOk[$studentId][$index]);
$existing = Homework::query()
->where('student_id', $studentId)
@@ -266,6 +268,8 @@ class HomeworkScoreService
'updated_by' => $updatedBy,
'homework_index' => (int) $index,
'score' => $normalizedScore,
'max_points' => ScoreValueValidator::DEFAULT_MAX_POINTS,
'status' => $validator->inferStatus($normalizedScore, $missingAllowed),
'semester' => $semester,
'school_year' => $schoolYear,
'updated_at' => now(),
@@ -4,6 +4,7 @@ namespace App\Services\Scores;
use App\Models\GradingLock;
use App\Models\ClassSection;
use App\Services\Grading\Validation\ScoreValueValidator;
use App\Models\MissingScoreOverride;
use App\Models\Participation;
use App\Models\Student;
@@ -185,8 +186,9 @@ class ParticipationScoreService
}
$rawScore = $data['score'] ?? null;
$isBlank = $rawScore === null || (is_string($rawScore) && trim($rawScore) === '');
$normalizedScore = (!$isBlank && is_numeric($rawScore)) ? (float) $rawScore : null;
$validator = new ScoreValueValidator();
$normalizedScore = $validator->normalizeNullable($rawScore, ScoreValueValidator::DEFAULT_MAX_POINTS, 'Participation score');
$missingAllowed = !empty($missingOk[$studentId]);
$existing = Participation::query()
->where('student_id', $studentId)
@@ -201,6 +203,8 @@ class ParticipationScoreService
'class_section_id' => $classSectionId,
'updated_by' => $updatedBy,
'score' => $normalizedScore,
'max_points' => ScoreValueValidator::DEFAULT_MAX_POINTS,
'status' => $validator->inferStatus($normalizedScore, $missingAllowed),
'semester' => $semester,
'school_year' => $schoolYear,
'updated_at' => now(),
+6 -1
View File
@@ -9,6 +9,7 @@ use App\Models\Student;
use App\Models\StudentClass;
use App\Models\TeacherClass;
use App\Models\ClassSection;
use App\Services\Grading\Validation\ScoreValueValidator;
use Illuminate\Support\Facades\DB;
class ProjectScoreService
@@ -218,7 +219,9 @@ class ProjectScoreService
if (!is_numeric($index)) {
continue;
}
$normalizedScore = is_numeric($score) ? (float) $score : null;
$validator = new ScoreValueValidator();
$normalizedScore = $validator->normalizeNullable($score, ScoreValueValidator::DEFAULT_MAX_POINTS, 'Project score');
$missingAllowed = !empty($missingOk[$studentId][$index]);
$existing = Project::query()
->where('student_id', $studentId)
@@ -235,6 +238,8 @@ class ProjectScoreService
'updated_by' => $updatedBy,
'project_index' => (int) $index,
'score' => $normalizedScore,
'max_points' => ScoreValueValidator::DEFAULT_MAX_POINTS,
'status' => $validator->inferStatus($normalizedScore, $missingAllowed),
'semester' => $semester,
'school_year' => $schoolYear,
'updated_at' => now(),
+6 -2
View File
@@ -9,6 +9,7 @@ use App\Models\Student;
use App\Models\StudentClass;
use App\Models\TeacherClass;
use App\Models\ClassSection;
use App\Services\Grading\Validation\ScoreValueValidator;
class QuizScoreService
{
@@ -219,8 +220,9 @@ class QuizScoreService
if (!is_numeric($quizNumber)) {
continue;
}
$isBlank = $score === null || (is_string($score) && trim($score) === '');
$normalizedScore = (!$isBlank && is_numeric($score)) ? (float) $score : null;
$validator = new ScoreValueValidator();
$normalizedScore = $validator->normalizeNullable($score, ScoreValueValidator::DEFAULT_MAX_POINTS, 'Quiz score');
$missingAllowed = !empty($missingOk[$studentId][$quizNumber]);
$existing = Quiz::query()
->where('student_id', $studentId)
@@ -237,6 +239,8 @@ class QuizScoreService
'updated_by' => $updatedBy,
'quiz_index' => (int) $quizNumber,
'score' => $normalizedScore,
'max_points' => ScoreValueValidator::DEFAULT_MAX_POINTS,
'status' => $validator->inferStatus($normalizedScore, $missingAllowed),
'semester' => $semester,
'school_year' => $schoolYear,
'updated_at' => now(),
@@ -17,6 +17,7 @@ use App\Models\CalendarEvent;
use App\Models\Homework;
use App\Models\Project;
use RuntimeException;
use App\Services\Grading\Policy\GradingPolicyResolver;
class SemesterScoreService
{
@@ -26,6 +27,7 @@ class SemesterScoreService
private string $semester;
private string $schoolYear;
private ?int $actorId;
private GradingPolicyResolver $policyResolver;
public function __construct(
?AttendanceCalculator $attendanceCalculator = null,
@@ -36,6 +38,7 @@ class SemesterScoreService
$this->semester = (string) (Configuration::getConfig('semester') ?? '');
$this->schoolYear = (string) (Configuration::getConfig('school_year') ?? '');
$this->actorId = (int) (auth()->id() ?? 0) ?: null;
$this->policyResolver = new GradingPolicyResolver();
$attendanceCalculator = $attendanceCalculator ?? new AttendanceCalculator(
new AttendanceRecord(),
@@ -209,12 +212,16 @@ class SemesterScoreService
string $schoolYear,
array $scoreData
): void {
$calculationMode = $this->policyResolver->calculationMode($classSectionId, $semester, $schoolYear);
$payload = array_merge([
'student_id' => $studentId,
'school_id' => $schoolId,
'class_section_id' => $classSectionId,
'semester' => $semester,
'school_year' => $schoolYear,
'calculation_mode' => $calculationMode,
'calculation_policy_version' => $this->policyResolver->policyVersion($calculationMode),
'updated_at' => now(),
], $scoreData);