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,37 @@
<?php
namespace Tests\Unit\Grading;
use App\Services\Grading\Validation\ScoreValueValidator;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
class ScoreValueValidatorTest extends TestCase
{
public function test_blank_score_remains_null(): void
{
$validator = new ScoreValueValidator();
$this->assertNull($validator->normalizeNullable('', 100));
$this->assertNull($validator->normalizeNullable(null, 100));
}
public function test_score_must_be_inside_range(): void
{
$validator = new ScoreValueValidator();
$this->assertSame(95.0, $validator->normalizeNullable('95', 100));
$this->expectException(InvalidArgumentException::class);
$validator->normalizeNullable(101, 100);
}
public function test_status_inference_preserves_legacy_pending_behavior(): void
{
$validator = new ScoreValueValidator();
$this->assertSame('scored', $validator->inferStatus(80.0));
$this->assertSame('pending', $validator->inferStatus(null, false));
$this->assertSame('excused', $validator->inferStatus(null, true));
}
}
@@ -0,0 +1,219 @@
<?php
namespace Tests\Unit\Services;
use App\Models\CertificateRecord;
use App\Models\ClassSection;
use App\Models\Configuration;
use App\Models\Student;
use App\Models\StudentClass;
use App\Models\StudentDecision;
use App\Models\User;
use App\Services\Certificates\CertificateAdminService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class CertificateAdminServiceTest extends TestCase
{
use RefreshDatabase;
private CertificateAdminService $service;
private string $schoolYear = '2025-2026';
private ClassSection $section;
protected function setUp(): void
{
parent::setUp();
Configuration::query()->create([
'config_key' => 'school_year',
'config_value' => $this->schoolYear,
]);
$this->section = ClassSection::factory()->create([
'class_section_id' => 501,
'class_section_name' => 'Grade 5-A',
]);
$this->service = app(CertificateAdminService::class);
}
public function test_dashboard_groups_students_and_uses_saved_decisions_as_source_of_truth(): void
{
$passStudent = $this->enrollStudent('Amina', 'Pass', 'Female');
$repeatStudent = $this->enrollStudent('Bilal', 'Repeat', 'Male');
$pendingStudent = $this->enrollStudent('Celine', 'Pending', 'Female');
StudentDecision::query()->create([
'student_id' => $passStudent->id,
'school_year' => $this->schoolYear,
'class_section_name' => $this->section->class_section_name,
'year_score' => 95.50,
'decision' => 'Pass',
'source' => 'manual',
]);
StudentDecision::query()->create([
'student_id' => $repeatStudent->id,
'school_year' => $this->schoolYear,
'class_section_name' => $this->section->class_section_name,
'year_score' => 58.25,
'decision' => 'Repeat Class',
'source' => 'manual',
]);
CertificateRecord::query()->create([
'certificate_number' => 'ARSS-2025-2026-0001',
'verification_token' => 'existingtoken',
'student_id' => $passStudent->id,
'student_name' => 'Amina Pass',
'grade' => 'Grade 5',
'cert_date' => '2026-06-05',
'school_year' => $this->schoolYear,
'class_section_id' => $this->section->class_section_id,
'issued_at' => now(),
]);
$payload = $this->service->dashboard($this->schoolYear);
$this->assertSame($this->schoolYear, $payload['school_year']);
$this->assertCount(1, $payload['grade_groups']);
$this->assertSame('Grade 5', $payload['grade_groups'][0]['label']);
$this->assertTrue($payload['grade_groups'][0]['has_pass']);
$this->assertTrue($payload['grade_groups'][0]['fully_done']);
$section = $payload['grade_groups'][0]['sections'][0];
$this->assertSame(3, $section['student_count']);
$this->assertSame(1, $section['pass_count']);
$this->assertSame(1, $section['cert_count']);
$this->assertSame(0, $section['remaining_count']);
$students = collect($section['students'])->keyBy('student_id');
$this->assertTrue($students[$passStudent->id]['eligible']);
$this->assertSame(['Pass'], $students[$passStudent->id]['decision_labels']);
$this->assertSame('ARSS-2025-2026-0001', $students[$passStudent->id]['certificate_number']);
$this->assertFalse($students[$repeatStudent->id]['eligible']);
$this->assertSame(['Repeat Class'], $students[$repeatStudent->id]['decision_labels']);
$this->assertFalse($students[$pendingStudent->id]['eligible']);
$this->assertSame('pending', $students[$pendingStudent->id]['decision_state']);
}
public function test_issue_certificates_reuses_existing_record_and_creates_new_one_for_unissued_pass_students(): void
{
$admin = User::factory()->create();
$existingStudent = $this->enrollStudent('Adam', 'Existing', 'Male');
$newStudent = $this->enrollStudent('Dina', 'New', 'Female');
foreach ([$existingStudent, $newStudent] as $student) {
StudentDecision::query()->create([
'student_id' => $student->id,
'school_year' => $this->schoolYear,
'class_section_name' => $this->section->class_section_name,
'year_score' => 90.00,
'decision' => 'Pass',
'source' => 'manual',
]);
}
CertificateRecord::query()->create([
'certificate_number' => 'ARSS-2025-2026-0001',
'verification_token' => 'persistedtoken',
'student_id' => $existingStudent->id,
'student_name' => 'Adam Existing',
'grade' => 'Grade 5',
'cert_date' => '2026-06-05',
'school_year' => $this->schoolYear,
'class_section_id' => $this->section->class_section_id,
'issued_by' => $admin->id,
'issued_at' => now(),
]);
$payload = $this->service->issueCertificates(
[$existingStudent->id, $newStudent->id],
'06/05/2026',
$this->section->class_section_id,
$this->schoolYear,
$admin->id
);
$this->assertSame('06/05/2026', $payload['cert_date_display']);
$this->assertCount(2, $payload['students']);
$issued = collect($payload['students'])->keyBy('id');
$this->assertSame('ARSS-2025-2026-0001', $issued[$existingStudent->id]['cert_number']);
$this->assertNotSame('', $issued[$existingStudent->id]['verify_token']);
$this->assertSame('ARSS-2025-2026-0002', $issued[$newStudent->id]['cert_number']);
$this->assertNotSame('', $issued[$newStudent->id]['verify_token']);
$this->assertSame(2, CertificateRecord::query()->where('school_year', $this->schoolYear)->count());
$newRecord = CertificateRecord::query()
->where('student_id', $newStudent->id)
->where('school_year', $this->schoolYear)
->first();
$this->assertNotNull($newRecord);
$this->assertSame('Grade 5', $newRecord->grade);
$this->assertSame('2026-06-05', $newRecord->cert_date?->format('Y-m-d'));
$this->assertSame($admin->id, $newRecord->issued_by);
}
public function test_audit_log_returns_year_summary_and_filtered_records(): void
{
CertificateRecord::query()->create([
'certificate_number' => 'ARSS-2025-2026-0001',
'verification_token' => 'token-a',
'student_id' => 1,
'student_name' => 'Ali One',
'grade' => 'Grade 5',
'cert_date' => '2026-06-05',
'school_year' => '2025-2026',
'issued_at' => now(),
]);
CertificateRecord::query()->create([
'certificate_number' => 'ARSS-2024-2025-0001',
'verification_token' => 'token-b',
'student_id' => 2,
'student_name' => 'Maya Two',
'grade' => 'Grade 4',
'cert_date' => '2025-06-01',
'school_year' => '2024-2025',
'issued_at' => now()->subDay(),
]);
$payload = $this->service->auditLog('2025-2026');
$this->assertSame('2025-2026', $payload['school_year']);
$this->assertCount(1, $payload['records']);
$this->assertSame('ARSS-2025-2026-0001', $payload['records'][0]['certificate_number']);
$this->assertCount(2, $payload['year_summary']);
}
private function enrollStudent(string $firstname, string $lastname, string $gender): Student
{
$student = Student::factory()->create([
'firstname' => $firstname,
'lastname' => $lastname,
'gender' => $gender,
'school_year' => $this->schoolYear,
'is_active' => 1,
]);
StudentClass::query()->create([
'student_id' => $student->id,
'class_section_id' => $this->section->class_section_id,
'school_year' => $this->schoolYear,
'semester' => 'Spring',
'is_event_only' => 0,
]);
return $student;
}
}
@@ -21,7 +21,7 @@ class PromotionEligibilityServiceTest extends TestCase
$studentId = $this->seedStudent();
$this->seedClassChain();
$this->seedAssignment($studentId, 701, '2025-2026');
$this->seedScores($studentId, '2025-2026', 80.0, 75.0);
$this->seedDecision($studentId, '2025-2026', 77.5, 'promoted');
$service = $this->makeService();
$record = $service->evaluateStudent($studentId, '2025-2026', 1);
@@ -41,7 +41,7 @@ class PromotionEligibilityServiceTest extends TestCase
$studentId = $this->seedStudent();
$this->seedClassChain();
$this->seedAssignment($studentId, 701, '2025-2026');
$this->seedScores($studentId, '2025-2026', 50.0, 55.0);
$this->seedDecision($studentId, '2025-2026', 52.5, 'retained');
$service = $this->makeService();
$record = $service->evaluateStudent($studentId, '2025-2026', 1);
@@ -72,7 +72,7 @@ class PromotionEligibilityServiceTest extends TestCase
$studentId = $this->seedStudent();
$this->seedClassChain();
$this->seedAssignment($studentId, 901, '2025-2026');
$this->seedScores($studentId, '2025-2026', 90.0, 90.0);
$this->seedDecision($studentId, '2025-2026', 90.0, 'promoted');
// Mark Youth as terminal
\App\Models\LevelProgression::query()->where('current_level_name', 'Youth')->update(['is_terminal' => 1]);
@@ -90,7 +90,7 @@ class PromotionEligibilityServiceTest extends TestCase
$studentId = $this->seedStudent();
$this->seedClassChain();
$this->seedAssignment($studentId, 701, '2025-2026');
$this->seedScores($studentId, '2025-2026', 80.0, 80.0);
$this->seedDecision($studentId, '2025-2026', 80.0, 'promoted');
$service = $this->makeService();
$service->evaluateStudent($studentId, '2025-2026', 99);
@@ -159,11 +159,18 @@ class PromotionEligibilityServiceTest extends TestCase
]);
}
private function seedScores(int $studentId, string $schoolYear, float $fall, float $spring): void
private function seedDecision(int $studentId, string $schoolYear, float $score, string $decision): void
{
DB::table('semester_scores')->insert([
['student_id' => $studentId, 'class_section_id' => 0, 'school_year' => $schoolYear, 'semester' => 'fall', 'semester_score' => $fall],
['student_id' => $studentId, 'class_section_id' => 0, 'school_year' => $schoolYear, 'semester' => 'spring', 'semester_score' => $spring],
DB::table('student_decisions')->insert([
'student_id' => $studentId,
'school_year' => $schoolYear,
'class_section_name' => '3A',
'year_score' => $score,
'decision' => $decision,
'source' => 'manual',
'notes' => 'Seeded test decision',
'created_at' => now(),
'updated_at' => now(),
]);
}
}
}
@@ -0,0 +1,174 @@
<?php
namespace Tests\Unit\Services;
use App\Models\ClassSection;
use App\Models\Configuration;
use App\Models\SemesterScore;
use App\Models\Student;
use App\Models\StudentClass;
use App\Services\Administrator\Trophy\TrophyReportService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class TrophyReportServiceTest extends TestCase
{
use RefreshDatabase;
protected TrophyReportService $service;
protected string $schoolYear = '2025-2026';
protected ClassSection $section;
protected function setUp(): void
{
parent::setUp();
$this->service = app(TrophyReportService::class);
Configuration::query()->create([
'config_key' => 'school_year',
'config_value' => $this->schoolYear,
]);
$this->section = ClassSection::factory()->create([
'class_section_id' => 401,
'class_section_name' => 'Grade 4A',
]);
}
public function test_calculate_threshold_enforces_minimum_of_three_winners(): void
{
$result = $this->service->calculateThreshold([50, 60, 70, 80, 90], 95.0);
$this->assertSame('min3_reduced', $result['method']);
$this->assertSame(3, $result['winners']);
$this->assertSame(70.0, $result['threshold']);
}
public function test_projection_marks_students_without_fall_scores_as_not_projected(): void
{
$first = $this->enrollStudent('Amina', 'Top', 'Female', 'S-1', 98.0);
$second = $this->enrollStudent('Bilal', 'Strong', 'Male', 'S-2', 95.0);
$third = $this->enrollStudent('Celine', 'Good', 'Female', 'S-3', 90.0);
$fourth = $this->enrollStudent('David', 'Missing', 'Male', 'S-4', null);
$payload = $this->service->projection($this->schoolYear, 75.0);
$this->assertSame($this->schoolYear, $payload['selected_year']);
$this->assertSame(75.0, $payload['selected_percentile']);
$this->assertSame([$this->schoolYear], $payload['years']);
$this->assertCount(1, $payload['class_results']);
$classResult = $payload['class_results'][0];
$this->assertSame(4, $classResult['student_count']);
$this->assertSame(3, $classResult['scored_count']);
$this->assertSame(3, $classResult['trophy_count']);
$this->assertSame(90.0, $classResult['threshold']);
$this->assertSame(4, $payload['summary']['students']);
$this->assertSame(3, $payload['summary']['trophies']);
$students = collect($classResult['students'])->keyBy('student_id');
$this->assertTrue($students[$first->id]['projected_trophy']);
$this->assertTrue($students[$second->id]['projected_trophy']);
$this->assertTrue($students[$third->id]['projected_trophy']);
$this->assertFalse($students[$fourth->id]['projected_trophy']);
$this->assertNull($students[$fourth->id]['fall_score']);
}
public function test_final_report_classifies_confirmed_surprise_and_missed_winners(): void
{
$this->enrollStudent('Adam', 'Confirmed', 'Male', 'S-10', 99.0, 97.0);
$this->enrollStudent('Basma', 'Confirmed', 'Female', 'S-11', 95.0, 93.0);
$missed = $this->enrollStudent('Cyrus', 'Missed', 'Male', 'S-12', 92.0, 68.0);
$surprise = $this->enrollStudent('Dina', 'Surprise', 'Female', 'S-13', 88.0, 100.0);
$this->enrollStudent('Evan', 'None', 'Male', 'S-14', 70.0, 50.0);
$payload = $this->service->final($this->schoolYear, 75.0);
$this->assertCount(1, $payload['class_results']);
$this->assertSame(3, $payload['summary']['predicted']);
$this->assertSame(3, $payload['summary']['actual']);
$this->assertSame(2, $payload['summary']['confirmed']);
$this->assertSame(1, $payload['summary']['surprises']);
$this->assertSame(1, $payload['summary']['missed']);
$this->assertSame(67.0, $payload['summary']['accuracy']);
$this->assertSame(3, $payload['winner_gender_summary']['total']);
$this->assertSame(1, $payload['winner_gender_summary']['boys']);
$this->assertSame(2, $payload['winner_gender_summary']['girls']);
$this->assertCount(3, $payload['winner_stickers']);
$classResult = $payload['class_results'][0];
$this->assertSame(92.0, $classResult['fall_threshold']);
$this->assertSame(94.0, $classResult['year_threshold']);
$this->assertSame(2, $classResult['confirmed']);
$this->assertSame(1, $classResult['surprises']);
$this->assertSame(1, $classResult['missed']);
$this->assertSame(67.0, $classResult['accuracy']);
$students = collect($classResult['students'])->keyBy('student_id');
$this->assertSame('missed', $students[$missed->id]['status']);
$this->assertTrue($students[$missed->id]['predicted']);
$this->assertFalse($students[$missed->id]['actual']);
$this->assertSame('surprise', $students[$surprise->id]['status']);
$this->assertFalse($students[$surprise->id]['predicted']);
$this->assertTrue($students[$surprise->id]['actual']);
}
private function enrollStudent(
string $firstname,
string $lastname,
string $gender,
string $schoolId,
?float $fallScore,
?float $springScore = null
): Student {
$student = Student::factory()->create([
'firstname' => $firstname,
'lastname' => $lastname,
'gender' => $gender,
'school_id' => $schoolId,
'school_year' => $this->schoolYear,
'is_active' => 1,
]);
StudentClass::query()->create([
'student_id' => $student->id,
'school_id' => null,
'class_section_id' => $this->section->class_section_id,
'school_year' => $this->schoolYear,
'semester' => 'Fall',
'is_event_only' => 0,
]);
if ($fallScore !== null) {
SemesterScore::query()->create([
'student_id' => $student->id,
'school_id' => $schoolId,
'class_section_id' => $this->section->class_section_id,
'semester_score' => $fallScore,
'semester' => 'Fall',
'school_year' => $this->schoolYear,
]);
}
if ($springScore !== null) {
SemesterScore::query()->create([
'student_id' => $student->id,
'school_id' => $schoolId,
'class_section_id' => $this->section->class_section_id,
'semester_score' => $springScore,
'semester' => 'Spring',
'school_year' => $this->schoolYear,
]);
}
return $student;
}
}