fix financial and certificates
This commit is contained in:
@@ -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).");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user