107 lines
3.6 KiB
PHP
107 lines
3.6 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Promotions\Placement;
|
|
|
|
class BalancedSectionPlacementService
|
|
{
|
|
public const ALGORITHM = 'score_band_balanced_v1';
|
|
|
|
public function assign(array $students, int $capacity): array
|
|
{
|
|
$total = count($students);
|
|
if ($total === 0) {
|
|
return [
|
|
'section_count' => 0,
|
|
'target_sizes' => [],
|
|
'sections' => [],
|
|
'assignments' => [],
|
|
];
|
|
}
|
|
|
|
$sectionCount = $total <= $capacity ? 1 : (int) ceil($total / $capacity);
|
|
$targetSizes = $this->targetSizes($total, $sectionCount);
|
|
$sections = [];
|
|
for ($i = 1; $i <= $sectionCount; $i++) {
|
|
$sections[$i] = [
|
|
'section_index' => $i,
|
|
'target_size' => $targetSizes[$i],
|
|
'total' => 0,
|
|
'bands' => ['A' => 0, 'B' => 0, 'C' => 0, 'D' => 0],
|
|
'students' => [],
|
|
];
|
|
}
|
|
|
|
$grouped = ['A' => [], 'B' => [], 'C' => [], 'D' => []];
|
|
foreach ($students as $student) {
|
|
$band = $student['score_band'] ?? 'D';
|
|
$grouped[$band][] = $student;
|
|
}
|
|
|
|
foreach (array_keys($grouped) as $band) {
|
|
usort($grouped[$band], function (array $a, array $b): int {
|
|
$scoreA = $a['final_score'] ?? $a['placement_score'] ?? -1;
|
|
$scoreB = $b['final_score'] ?? $b['placement_score'] ?? -1;
|
|
if ($scoreA !== $scoreB) {
|
|
return $scoreB <=> $scoreA;
|
|
}
|
|
$name = strcmp((string) ($a['student_name'] ?? ''), (string) ($b['student_name'] ?? ''));
|
|
return $name !== 0 ? $name : ((int) $a['student_id'] <=> (int) $b['student_id']);
|
|
});
|
|
}
|
|
|
|
$assignments = [];
|
|
$order = 1;
|
|
foreach (['A', 'B', 'C', 'D'] as $band) {
|
|
foreach ($grouped[$band] as $student) {
|
|
$sectionIndex = $this->selectSection($sections, $band);
|
|
$sections[$sectionIndex]['total']++;
|
|
$sections[$sectionIndex]['bands'][$band]++;
|
|
$sections[$sectionIndex]['students'][] = (int) $student['student_id'];
|
|
$student['planned_section_index'] = $sectionIndex;
|
|
$student['assignment_order'] = $order++;
|
|
$assignments[] = $student;
|
|
}
|
|
}
|
|
|
|
return [
|
|
'section_count' => $sectionCount,
|
|
'target_sizes' => $targetSizes,
|
|
'sections' => array_values($sections),
|
|
'assignments' => $assignments,
|
|
];
|
|
}
|
|
|
|
private function targetSizes(int $total, int $sectionCount): array
|
|
{
|
|
$base = intdiv($total, $sectionCount);
|
|
$remainder = $total % $sectionCount;
|
|
$sizes = [];
|
|
for ($i = 1; $i <= $sectionCount; $i++) {
|
|
$sizes[$i] = $base + ($i <= $remainder ? 1 : 0);
|
|
}
|
|
return $sizes;
|
|
}
|
|
|
|
private function selectSection(array $sections, string $band): int
|
|
{
|
|
$eligible = array_filter($sections, fn ($s) => $s['total'] < $s['target_size']);
|
|
if (empty($eligible)) {
|
|
$eligible = $sections;
|
|
}
|
|
|
|
uasort($eligible, function (array $a, array $b) use ($band): int {
|
|
$cmp = $a['total'] <=> $b['total'];
|
|
if ($cmp !== 0) {
|
|
return $cmp;
|
|
}
|
|
$cmp = ($a['bands'][$band] ?? 0) <=> ($b['bands'][$band] ?? 0);
|
|
if ($cmp !== 0) {
|
|
return $cmp;
|
|
}
|
|
return $a['section_index'] <=> $b['section_index'];
|
|
});
|
|
|
|
return (int) array_key_first($eligible);
|
|
}
|
|
}
|