206 lines
8.8 KiB
PHP
206 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;
|
|
}
|
|
}
|