This commit is contained in:
@@ -10,6 +10,7 @@ use App\Models\ClassSectionModel;
|
||||
use App\Models\EmergencyContactModel;
|
||||
use App\Models\EnrollmentModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
use App\Models\StudentSectionDistributionDraftModel;
|
||||
use App\Models\StudentAllergyModel;
|
||||
use App\Models\StudentMedicalConditionModel;
|
||||
use CodeIgniter\Database\Exceptions\DataException;
|
||||
@@ -866,10 +867,10 @@ class StudentController extends BaseController
|
||||
}
|
||||
|
||||
/**
|
||||
* POST admin endpoint: auto distribute students into lettered sections for a class
|
||||
* Input: class_id (int), students_per_section (int), school_year (optional)
|
||||
* Uses promotion_queue for the selected year (students who passed last year to this class).
|
||||
* Balances male/female per section and respects capacity.
|
||||
* POST admin endpoint: create draft balanced distribution rows for a class.
|
||||
*
|
||||
* Input: class_id/class_section_id, section_count, min_students_per_section,
|
||||
* max_students_per_section, school_year.
|
||||
*/
|
||||
public function autoDistributeSections()
|
||||
{
|
||||
@@ -884,7 +885,10 @@ class StudentController extends BaseController
|
||||
try {
|
||||
$classId = (int) $this->request->getPost('class_id');
|
||||
$classSectionId = (int) $this->request->getPost('class_section_id');
|
||||
$perSec = (int) $this->request->getPost('students_per_section');
|
||||
$sectionCount = (int) $this->request->getPost('section_count');
|
||||
$minPerSection = (int) $this->request->getPost('min_students_per_section');
|
||||
$maxRaw = trim((string) ($this->request->getPost('max_students_per_section') ?? ''));
|
||||
$maxPerSection = $maxRaw === '' ? null : (int) $maxRaw;
|
||||
$year = trim((string) ($this->request->getPost('school_year') ?? $this->schoolYear));
|
||||
|
||||
if ($classId <= 0 && $classSectionId > 0) {
|
||||
@@ -892,51 +896,27 @@ class StudentController extends BaseController
|
||||
$classId = (int) ($cid ?? 0);
|
||||
}
|
||||
|
||||
if ($classId <= 0 || $perSec <= 0) {
|
||||
$msg = 'Invalid class_id or students_per_section.';
|
||||
if ($classId <= 0 || $sectionCount <= 0 || $minPerSection <= 0 || ($maxPerSection !== null && $maxPerSection <= 0)) {
|
||||
$msg = 'Enter a valid class, section count, minimum size, and optional maximum size.';
|
||||
return $isAjax ? $json(['ok' => false, 'message' => $msg], 400) : redirect()->back()->with('error', $msg);
|
||||
}
|
||||
|
||||
$promo = new \App\Models\PromotionQueueModel();
|
||||
|
||||
// Candidates from promotion queue for this class/year and enrolled (or payment pending)
|
||||
$cands = $promo->select('promotion_queue.*, students.gender')
|
||||
->join('students', 'students.id = promotion_queue.student_id', 'left')
|
||||
->where('promotion_queue.to_class_id', $classId)
|
||||
->where('promotion_queue.school_year_to', $year)
|
||||
->whereIn('promotion_queue.status', ['queued','assigned'])
|
||||
->findAll();
|
||||
$cands = $this->distributionCandidates($classId, $year);
|
||||
|
||||
if (empty($cands)) {
|
||||
$msg = 'No students found in promotion queue for selected class/year.';
|
||||
return $isAjax ? $json(['ok' => false, 'message' => $msg], 200) : redirect()->back()->with('info', $msg);
|
||||
}
|
||||
|
||||
// Filter to those with enrollment for this year in acceptable statuses
|
||||
$studentIds = array_map(static fn($r) => (int)$r['student_id'], $cands);
|
||||
$enrolledIds = [];
|
||||
if (!empty($studentIds)) {
|
||||
$rows = $this->db->table('enrollments')
|
||||
->select('student_id')
|
||||
->whereIn('student_id', $studentIds)
|
||||
->where('school_year', $year)
|
||||
->whereIn('enrollment_status', ['payment pending', 'enrolled'])
|
||||
->groupBy('student_id')
|
||||
->get()->getResultArray();
|
||||
$enrolledIds = array_map(static fn($r) => (int)$r['student_id'], $rows);
|
||||
}
|
||||
|
||||
$cands = array_values(array_filter($cands, static function ($r) use ($enrolledIds) {
|
||||
return in_array((int)$r['student_id'], $enrolledIds, true);
|
||||
}));
|
||||
|
||||
if (empty($cands)) {
|
||||
$msg = 'No eligible enrolled students found to distribute.';
|
||||
$msg = 'No promoted students found to distribute for selected class/year.';
|
||||
return $isAjax ? $json(['ok' => false, 'message' => $msg], 200) : redirect()->back()->with('info', $msg);
|
||||
}
|
||||
|
||||
$total = count($cands);
|
||||
$sectionsNeeded = (int) ceil($total / $perSec);
|
||||
if ($sectionCount * $minPerSection > $total) {
|
||||
$msg = 'Insufficient students: ' . $sectionCount . ' sections require at least ' . ($sectionCount * $minPerSection) . ' students, but only ' . $total . ' are available.';
|
||||
return $isAjax ? $json(['ok' => false, 'message' => $msg], 400) : redirect()->back()->with('error', $msg);
|
||||
}
|
||||
if ($maxPerSection !== null && $total > $sectionCount * $maxPerSection) {
|
||||
$msg = 'Capacity exceeded: ' . $sectionCount . ' sections can hold at most ' . ($sectionCount * $maxPerSection) . ' students, but ' . $total . ' must be assigned.';
|
||||
return $isAjax ? $json(['ok' => false, 'message' => $msg], 400) : redirect()->back()->with('error', $msg);
|
||||
}
|
||||
|
||||
// Fetch lettered sections for this class
|
||||
$letters = $this->classSectionModel->getLetterSectionsByClassId($classId);
|
||||
@@ -945,109 +925,66 @@ class StudentController extends BaseController
|
||||
return $isAjax ? $json(['ok' => false, 'message' => $msg], 400) : redirect()->back()->with('error', $msg);
|
||||
}
|
||||
|
||||
if (count($letters) < $sectionsNeeded) {
|
||||
$msg = 'Not enough sections available. Needed: ' . $sectionsNeeded . ', available: ' . count($letters);
|
||||
if (count($letters) < $sectionCount) {
|
||||
$msg = 'Not enough sections available. Needed: ' . $sectionCount . ', available: ' . count($letters);
|
||||
return $isAjax ? $json(['ok' => false, 'message' => $msg], 400) : redirect()->back()->with('error', $msg);
|
||||
}
|
||||
|
||||
// Keep only required number of sections
|
||||
$letters = array_slice($letters, 0, $sectionsNeeded);
|
||||
$letters = array_slice($letters, 0, $sectionCount);
|
||||
$buckets = $this->buildBalancedDistribution($cands, $letters, $minPerSection, $maxPerSection);
|
||||
|
||||
// Prepare buckets
|
||||
$buckets = [];
|
||||
foreach ($letters as $idx => $sec) {
|
||||
$buckets[$idx] = [
|
||||
'class_section_id' => (int)$sec['class_section_id'],
|
||||
'assigned' => [],
|
||||
'male' => 0,
|
||||
'female' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
// Split by gender
|
||||
$males = [];
|
||||
$females = [];
|
||||
foreach ($cands as $r) {
|
||||
$g = (string)($r['gender'] ?? '');
|
||||
if (strcasecmp($g, 'Female') === 0) $females[] = $r; else $males[] = $r; // default non-female -> male bucket
|
||||
}
|
||||
|
||||
// Helper to pick next bucket with available capacity and least of a gender
|
||||
$pickBucket = function (string $gender) use (&$buckets, $perSec): ?int {
|
||||
$bestIdx = null;
|
||||
$bestCnt = PHP_INT_MAX;
|
||||
foreach ($buckets as $i => $b) {
|
||||
if (count($b['assigned']) >= $perSec) continue;
|
||||
$cnt = ($gender === 'female') ? $b['female'] : $b['male'];
|
||||
if ($cnt < $bestCnt) {
|
||||
$bestCnt = $cnt;
|
||||
$bestIdx = $i;
|
||||
}
|
||||
}
|
||||
return $bestIdx;
|
||||
};
|
||||
|
||||
// Assign males then females for balance
|
||||
foreach ($males as $r) {
|
||||
$bi = $pickBucket('male');
|
||||
if ($bi === null) break;
|
||||
$buckets[$bi]['assigned'][] = (int)$r['student_id'];
|
||||
$buckets[$bi]['male']++;
|
||||
}
|
||||
foreach ($females as $r) {
|
||||
$bi = $pickBucket('female');
|
||||
if ($bi === null) break;
|
||||
$buckets[$bi]['assigned'][] = (int)$r['student_id'];
|
||||
$buckets[$bi]['female']++;
|
||||
}
|
||||
|
||||
// Persist: set to_class_section_id on queue and upsert student_class
|
||||
$promoIdsBySid = [];
|
||||
foreach ($cands as $r) {
|
||||
$promoIdsBySid[(int)$r['student_id']] = (int)$r['id'];
|
||||
}
|
||||
|
||||
$studentClass = new StudentClassModel();
|
||||
$draftModel = new StudentSectionDistributionDraftModel();
|
||||
$promo = new \App\Models\PromotionQueueModel();
|
||||
$updatedBy = (int)(session()->get('user_id') ?? 0) ?: null;
|
||||
$now = utc_now();
|
||||
$batchKey = sha1($year . ':' . $classId . ':' . microtime(true));
|
||||
|
||||
$this->db->transStart();
|
||||
$studentIdsToReplace = array_values(array_unique(array_map(
|
||||
static fn(array $student): int => (int)($student['student_id'] ?? 0),
|
||||
$cands
|
||||
)));
|
||||
if (!empty($studentIdsToReplace)) {
|
||||
$draftModel->where('school_year', $year)
|
||||
->whereIn('student_id', $studentIdsToReplace)
|
||||
->where('status', 'pending')
|
||||
->delete();
|
||||
}
|
||||
foreach ($buckets as $b) {
|
||||
$secId = (int)$b['class_section_id'];
|
||||
foreach ($b['assigned'] as $sid) {
|
||||
// Update promotion queue
|
||||
if (isset($promoIdsBySid[$sid])) {
|
||||
$promo->update($promoIdsBySid[$sid], [
|
||||
foreach ($b['assigned'] as $student) {
|
||||
$sid = (int)$student['student_id'];
|
||||
$draftModel->insert([
|
||||
'student_id' => $sid,
|
||||
'class_id' => $classId,
|
||||
'class_section_id' => $secId,
|
||||
'school_year' => $year,
|
||||
'previous_school_year' => (string)($student['school_year_from'] ?? ''),
|
||||
'previous_final_score' => $student['previous_final_score'],
|
||||
'score_group' => $student['score_group'],
|
||||
'status' => 'pending',
|
||||
'batch_key' => $batchKey,
|
||||
'created_by' => $updatedBy,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
if ((int)($student['promotion_queue_id'] ?? 0) > 0) {
|
||||
$promo->update((int)$student['promotion_queue_id'], [
|
||||
'to_class_section_id' => $secId,
|
||||
'status' => 'assigned',
|
||||
'updated_by' => $updatedBy,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
// Upsert student_class
|
||||
$exists = $studentClass->where('student_id', $sid)
|
||||
->where('school_year', $year)
|
||||
->where('semester', (string)$this->semester)
|
||||
->first();
|
||||
|
||||
$payload = [
|
||||
'student_id' => $sid,
|
||||
'class_section_id' => $secId,
|
||||
'school_year' => $year,
|
||||
'semester' => (string)$this->semester,
|
||||
'updated_by' => $updatedBy,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
if ($exists) {
|
||||
$studentClass->update((int)$exists['id'], $payload);
|
||||
} else {
|
||||
$payload['created_at'] = $now;
|
||||
$studentClass->insert($payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
$this->db->transComplete();
|
||||
|
||||
if (!$this->db->transStatus()) {
|
||||
$msg = 'Distribution could not be saved. No official student class rows were changed.';
|
||||
return $isAjax ? $json(['ok' => false, 'message' => $msg], 500) : redirect()->back()->with('error', $msg);
|
||||
}
|
||||
|
||||
// Build a map for section id -> name (for friendly headers)
|
||||
$nameById = [];
|
||||
foreach ($letters as $secRow) {
|
||||
$nameById[(int)$secRow['class_section_id']] = (string)($secRow['class_section_name'] ?? '');
|
||||
@@ -1056,27 +993,323 @@ class StudentController extends BaseController
|
||||
$summary = [];
|
||||
foreach ($buckets as $b) {
|
||||
$secId = (int)$b['class_section_id'];
|
||||
$scores = array_map(static fn($s): float => (float)$s['previous_final_score'], $b['assigned']);
|
||||
$groups = ['90_100' => 0, '80_89' => 0, '70_79' => 0, '69_below' => 0];
|
||||
$male = 0;
|
||||
$female = 0;
|
||||
$studentNames = [];
|
||||
foreach ($b['assigned'] as $student) {
|
||||
$groups[$student['score_group']] = ($groups[$student['score_group']] ?? 0) + 1;
|
||||
$gender = strtolower((string)($student['gender'] ?? ''));
|
||||
if ($gender === 'female') $female++; else $male++;
|
||||
$studentName = trim((string)($student['student_name'] ?? ''));
|
||||
if ($studentName === '') {
|
||||
$studentName = 'Student #' . (int)($student['student_id'] ?? 0);
|
||||
}
|
||||
$studentNames[] = $studentName;
|
||||
}
|
||||
$summary[] = [
|
||||
'class_section_id' => $secId,
|
||||
'class_section_name' => $nameById[$secId] ?? (string)$secId,
|
||||
'total' => count($b['assigned']),
|
||||
'male' => $b['male'],
|
||||
'female' => $b['female'],
|
||||
'male' => $male,
|
||||
'female' => $female,
|
||||
'score_groups' => $groups,
|
||||
'average_score' => count($scores) > 0 ? round(array_sum($scores) / count($scores), 2) : null,
|
||||
'student_names' => $studentNames,
|
||||
];
|
||||
}
|
||||
|
||||
return $isAjax
|
||||
? $json(['ok' => true, 'message' => 'Auto distribution completed.', 'sections' => $summary])
|
||||
: redirect()->back()->with('success', 'Auto distribution completed.');
|
||||
? $json(['ok' => true, 'message' => 'Draft distribution saved. Students will move to student_class when they enroll.', 'sections' => $summary])
|
||||
: redirect()->back()->with('success', 'Draft distribution saved.');
|
||||
} catch (\Throwable $e) {
|
||||
$msg = 'Auto distribution failed: ' . $e->getMessage();
|
||||
return $isAjax ? $json(['ok' => false, 'message' => $msg], 500) : redirect()->back()->with('error', $msg);
|
||||
}
|
||||
}
|
||||
|
||||
private function distributionCandidates(int $classId, string $year): array
|
||||
{
|
||||
$rows = $this->db->table('promotion_queue pq')
|
||||
->select('pq.id AS promotion_queue_id, pq.student_id, pq.school_year_from, pq.to_class_id, students.firstname, students.lastname, students.gender, sd.year_score AS decision_score')
|
||||
->join('students', 'students.id = pq.student_id', 'left')
|
||||
->join('student_decisions sd', 'sd.student_id = pq.student_id AND sd.school_year = pq.school_year_from', 'left')
|
||||
->where('pq.to_class_id', $classId)
|
||||
->where('pq.school_year_to', $year)
|
||||
->whereIn('pq.status', ['queued', 'assigned'])
|
||||
->groupBy('pq.id')
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
if (empty($rows)) {
|
||||
return $this->decisionDistributionCandidates($classId, $year);
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$score = is_numeric($row['decision_score'] ?? null)
|
||||
? (float)$row['decision_score']
|
||||
: $this->previousAverageScore((int)$row['student_id'], (string)($row['school_year_from'] ?? ''));
|
||||
$score = $score === null ? 0.0 : max(0.0, min(100.0, $score));
|
||||
$row['previous_final_score'] = $score;
|
||||
$row['score_group'] = $this->scoreGroup($score);
|
||||
$row['student_name'] = $this->formatStudentName($row);
|
||||
$out[] = $row;
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
private function decisionDistributionCandidates(int $classId, string $targetSchoolYear): array
|
||||
{
|
||||
$previousSchoolYear = $this->previousSchoolYearName($targetSchoolYear);
|
||||
if ($previousSchoolYear === null || ! $this->db->tableExists('student_decisions')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = $this->db->table('student_decisions sd')
|
||||
->select('sd.id AS decision_id, sd.student_id, sd.class_section_name, sd.year_score, sd.decision, students.firstname, students.lastname, students.gender')
|
||||
->join('students', 'students.id = sd.student_id', 'left')
|
||||
->where('sd.school_year', $previousSchoolYear)
|
||||
->where('students.is_active', 1)
|
||||
->orderBy('sd.updated_at', 'DESC')
|
||||
->orderBy('sd.id', 'DESC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
$seen = [];
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$studentId = (int)($row['student_id'] ?? 0);
|
||||
if ($studentId <= 0 || isset($seen[$studentId])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$studentId] = true;
|
||||
|
||||
$targetClassId = $this->targetClassIdFromDecision(
|
||||
(string)($row['class_section_name'] ?? ''),
|
||||
(string)($row['decision'] ?? '')
|
||||
);
|
||||
if ($targetClassId !== $classId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$score = is_numeric($row['year_score'] ?? null) ? (float)$row['year_score'] : 0.0;
|
||||
$score = max(0.0, min(100.0, $score));
|
||||
$out[] = [
|
||||
'promotion_queue_id' => 0,
|
||||
'student_id' => $studentId,
|
||||
'school_year_from' => $previousSchoolYear,
|
||||
'to_class_id' => $classId,
|
||||
'student_name' => $this->formatStudentName($row),
|
||||
'gender' => (string)($row['gender'] ?? ''),
|
||||
'previous_final_score' => $score,
|
||||
'score_group' => $this->scoreGroup($score),
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
private function targetClassIdFromDecision(string $classSectionName, string $decision): ?int
|
||||
{
|
||||
$decision = strtolower(trim($decision));
|
||||
$baseName = strtoupper(trim(preg_replace('/-.+$/', '', $classSectionName) ?? ''));
|
||||
if ($baseName === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$targetBaseName = $baseName;
|
||||
if ($decision === 'pass') {
|
||||
if ($baseName === 'KG') {
|
||||
$targetBaseName = '1';
|
||||
} elseif (ctype_digit($baseName)) {
|
||||
$level = (int)$baseName;
|
||||
$targetBaseName = $level >= 9 ? 'YOUTH' : (string)($level + 1);
|
||||
} elseif ($baseName === 'YOUTH') {
|
||||
$targetBaseName = 'YOUTH';
|
||||
}
|
||||
}
|
||||
|
||||
$row = $this->classSectionModel
|
||||
->select('class_id')
|
||||
->where('UPPER(class_section_name)', $targetBaseName)
|
||||
->where("class_section_name NOT LIKE '%-%'", null, false)
|
||||
->first();
|
||||
|
||||
return $row ? (int)$row['class_id'] : null;
|
||||
}
|
||||
|
||||
private function formatStudentName(array $row): string
|
||||
{
|
||||
$name = trim(
|
||||
trim((string)($row['firstname'] ?? '')) . ' ' .
|
||||
trim((string)($row['lastname'] ?? ''))
|
||||
);
|
||||
|
||||
return $name !== '' ? $name : 'Student #' . (int)($row['student_id'] ?? 0);
|
||||
}
|
||||
|
||||
private function previousSchoolYearName(string $schoolYear): ?string
|
||||
{
|
||||
if (preg_match('/^(\d{4})-(\d{4})$/', trim($schoolYear), $matches) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ((int)$matches[1] - 1) . '-' . ((int)$matches[2] - 1);
|
||||
}
|
||||
|
||||
private function previousAverageScore(int $studentId, string $schoolYear): ?float
|
||||
{
|
||||
if ($studentId <= 0 || $schoolYear === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$row = $this->db->table('semester_scores')
|
||||
->select('AVG(semester_score) AS avg_score')
|
||||
->where('student_id', $studentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('semester_score IS NOT NULL', null, false)
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
return is_numeric($row['avg_score'] ?? null) ? (float)$row['avg_score'] : null;
|
||||
}
|
||||
|
||||
private function scoreGroup(float $score): string
|
||||
{
|
||||
if ($score >= 90) return '90_100';
|
||||
if ($score >= 80) return '80_89';
|
||||
if ($score >= 70) return '70_79';
|
||||
return '69_below';
|
||||
}
|
||||
|
||||
private function buildBalancedDistribution(array $students, array $sections, int $minPerSection, ?int $maxPerSection): array
|
||||
{
|
||||
$sectionCount = count($sections);
|
||||
$total = count($students);
|
||||
$baseSize = intdiv($total, $sectionCount);
|
||||
$remainder = $total % $sectionCount;
|
||||
$targetSizes = [];
|
||||
|
||||
foreach ($sections as $idx => $section) {
|
||||
$targetSizes[$idx] = $baseSize + ($idx < $remainder ? 1 : 0);
|
||||
}
|
||||
|
||||
$buckets = [];
|
||||
foreach ($sections as $idx => $section) {
|
||||
$buckets[$idx] = [
|
||||
'class_section_id' => (int)$section['class_section_id'],
|
||||
'assigned' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$groups = ['90_100' => [], '80_89' => [], '70_79' => [], '69_below' => []];
|
||||
foreach ($students as $student) {
|
||||
$groups[$student['score_group']][] = $student;
|
||||
}
|
||||
|
||||
$currentCounts = array_fill(0, $sectionCount, 0);
|
||||
$allocations = [];
|
||||
$groupIndex = 0;
|
||||
foreach ($groups as $groupName => $groupStudents) {
|
||||
usort($groupStudents, static fn($a, $b): int => ((float)$b['previous_final_score']) <=> ((float)$a['previous_final_score']));
|
||||
$groupTotal = count($groupStudents);
|
||||
$base = intdiv($groupTotal, $sectionCount);
|
||||
$extra = $groupTotal % $sectionCount;
|
||||
$allocations[$groupName] = array_fill(0, $sectionCount, $base);
|
||||
foreach ($currentCounts as $idx => $cnt) {
|
||||
$currentCounts[$idx] += $base;
|
||||
}
|
||||
|
||||
$order = range(0, $sectionCount - 1);
|
||||
$offset = $groupIndex % $sectionCount;
|
||||
$order = array_merge(array_slice($order, $offset), array_slice($order, 0, $offset));
|
||||
usort($order, static function ($a, $b) use ($targetSizes, $currentCounts) {
|
||||
$remainingA = $targetSizes[$a] - $currentCounts[$a];
|
||||
$remainingB = $targetSizes[$b] - $currentCounts[$b];
|
||||
return $remainingB <=> $remainingA;
|
||||
});
|
||||
|
||||
foreach ($order as $sectionIdx) {
|
||||
if ($extra <= 0) break;
|
||||
if ($currentCounts[$sectionIdx] >= $targetSizes[$sectionIdx]) continue;
|
||||
$allocations[$groupName][$sectionIdx]++;
|
||||
$currentCounts[$sectionIdx]++;
|
||||
$extra--;
|
||||
}
|
||||
$groups[$groupName] = $groupStudents;
|
||||
$groupIndex++;
|
||||
}
|
||||
|
||||
foreach ($groups as $groupName => $groupStudents) {
|
||||
$quotas = $allocations[$groupName];
|
||||
foreach ($groupStudents as $idx => $student) {
|
||||
$round = intdiv($idx, max(1, $sectionCount));
|
||||
$order = range(0, $sectionCount - 1);
|
||||
if ($round % 2 === 1) {
|
||||
$order = array_reverse($order);
|
||||
}
|
||||
foreach ($order as $sectionIdx) {
|
||||
if (($quotas[$sectionIdx] ?? 0) <= 0) continue;
|
||||
$buckets[$sectionIdx]['assigned'][] = $student;
|
||||
$quotas[$sectionIdx]--;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $this->balanceDistributionAverages($buckets, $minPerSection, $maxPerSection);
|
||||
}
|
||||
|
||||
private function balanceDistributionAverages(array $buckets, int $minPerSection, ?int $maxPerSection): array
|
||||
{
|
||||
for ($i = 0; $i < 50; $i++) {
|
||||
$averages = array_map(function ($bucket): float {
|
||||
if (empty($bucket['assigned'])) return 0.0;
|
||||
$scores = array_map(static fn($student): float => (float)$student['previous_final_score'], $bucket['assigned']);
|
||||
return array_sum($scores) / count($scores);
|
||||
}, $buckets);
|
||||
$highIdx = array_keys($averages, max($averages), true)[0];
|
||||
$lowIdx = array_keys($averages, min($averages), true)[0];
|
||||
if (($averages[$highIdx] - $averages[$lowIdx]) <= 1.0) {
|
||||
break;
|
||||
}
|
||||
|
||||
$best = null;
|
||||
foreach ($buckets[$highIdx]['assigned'] as $hiPos => $hiStudent) {
|
||||
foreach ($buckets[$lowIdx]['assigned'] as $loPos => $loStudent) {
|
||||
if ($hiStudent['score_group'] !== $loStudent['score_group']) continue;
|
||||
$trial = $buckets;
|
||||
$trial[$highIdx]['assigned'][$hiPos] = $loStudent;
|
||||
$trial[$lowIdx]['assigned'][$loPos] = $hiStudent;
|
||||
$trialAvg = array_map(function ($bucket): float {
|
||||
if (empty($bucket['assigned'])) return 0.0;
|
||||
$scores = array_map(static fn($student): float => (float)$student['previous_final_score'], $bucket['assigned']);
|
||||
return array_sum($scores) / count($scores);
|
||||
}, $trial);
|
||||
$newSpread = max($trialAvg) - min($trialAvg);
|
||||
if ($newSpread < ($averages[$highIdx] - $averages[$lowIdx])) {
|
||||
$best = [$hiPos, $loPos, $newSpread];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($best === null) {
|
||||
break;
|
||||
}
|
||||
[$hiPos, $loPos] = $best;
|
||||
$tmp = $buckets[$highIdx]['assigned'][$hiPos];
|
||||
$buckets[$highIdx]['assigned'][$hiPos] = $buckets[$lowIdx]['assigned'][$loPos];
|
||||
$buckets[$lowIdx]['assigned'][$loPos] = $tmp;
|
||||
}
|
||||
|
||||
return $buckets;
|
||||
}
|
||||
|
||||
/**
|
||||
* API: Return totals per base class (KG, 1..9, Youth) for promotion_queue in selected year
|
||||
* Only counts students with enrollment status 'payment pending' or 'enrolled'.
|
||||
* API: Return promoted-student totals per base class for the selected year.
|
||||
*/
|
||||
public function promotionTotalsApi()
|
||||
{
|
||||
@@ -1111,22 +1344,24 @@ class StudentController extends BaseController
|
||||
$out = [];
|
||||
foreach ($wanted as $r) {
|
||||
$classId = (int)$r['class_id'];
|
||||
// candidates from promotion_queue for this base class in the target year
|
||||
$cands = $this->db->table('promotion_queue pq')
|
||||
->select('pq.student_id')
|
||||
->join('enrollments e', 'e.student_id = pq.student_id AND e.school_year = ' . $this->db->escape($year), 'left')
|
||||
->where('pq.to_class_id', $classId)
|
||||
->where('pq.school_year_to', $year)
|
||||
->whereIn('pq.status', ['queued','assigned','applied'])
|
||||
->whereIn('e.enrollment_status', ['payment pending', 'enrolled'])
|
||||
->whereIn('pq.status', ['queued','assigned'])
|
||||
->groupBy('pq.student_id')
|
||||
->get()->getResultArray();
|
||||
$total = count($cands);
|
||||
if ($total === 0) {
|
||||
$total = count($this->decisionDistributionCandidates($classId, $year));
|
||||
}
|
||||
|
||||
$out[] = [
|
||||
'class_id' => $classId,
|
||||
'class_section_id' => (int)($r['class_section_id'] ?? 0),
|
||||
'class_section_name'=> (string)($r['class_section_name'] ?? ''),
|
||||
'total' => count($cands),
|
||||
'total' => $total,
|
||||
'sections' => $this->savedDistributionSections($classId, $year),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1136,6 +1371,51 @@ class StudentController extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
private function savedDistributionSections(int $classId, string $year): array
|
||||
{
|
||||
if (! $this->db->tableExists('student_section_distribution_drafts')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = $this->db->table('student_section_distribution_drafts d')
|
||||
->select('d.class_section_id, cs.class_section_name, students.firstname, students.lastname, d.student_id')
|
||||
->join('classSection cs', 'cs.class_section_id = d.class_section_id', 'left')
|
||||
->join('students', 'students.id = d.student_id', 'left')
|
||||
->where('d.class_id', $classId)
|
||||
->where('d.school_year', $year)
|
||||
->where('d.status', 'pending')
|
||||
->orderBy('cs.class_section_name', 'ASC')
|
||||
->orderBy('students.lastname', 'ASC')
|
||||
->orderBy('students.firstname', 'ASC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
$sections = [];
|
||||
foreach ($rows as $row) {
|
||||
$sectionId = (int)($row['class_section_id'] ?? 0);
|
||||
if ($sectionId <= 0) {
|
||||
continue;
|
||||
}
|
||||
if (!isset($sections[$sectionId])) {
|
||||
$sections[$sectionId] = [
|
||||
'class_section_id' => $sectionId,
|
||||
'class_section_name' => (string)($row['class_section_name'] ?? $sectionId),
|
||||
'total' => 0,
|
||||
'student_names' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$name = trim(trim((string)($row['firstname'] ?? '')) . ' ' . trim((string)($row['lastname'] ?? '')));
|
||||
if ($name === '') {
|
||||
$name = 'Student #' . (int)($row['student_id'] ?? 0);
|
||||
}
|
||||
$sections[$sectionId]['student_names'][] = $name;
|
||||
$sections[$sectionId]['total']++;
|
||||
}
|
||||
|
||||
return array_values($sections);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /students/update/{id}
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user