Files
alrahma_sunday_school_api/app/Services/Promotions/Placement/SectionPlacementPreviewService.php
T
2026-06-09 02:32:58 -04:00

205 lines
8.8 KiB
PHP

<?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;
}
}