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