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,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';
}
}