ADD SCHOOL YEAR MANAGEMENT
Tests / PHPUnit (push) Failing after 40s

This commit is contained in:
root
2026-07-12 02:21:39 -04:00
parent c7f67da9bf
commit e06ccc9cc0
36 changed files with 6722 additions and 327 deletions
@@ -730,18 +730,15 @@ class AdministratorController extends BaseController
->join('users u', 'u.id = tc.teacher_id', 'left')
->orderBy('cs.class_section_name', 'ASC');
$filteredQuery = clone $assignmentQuery;
// teacher_class assignments are scoped by school year only.
// The table has no semester column; semester filtering belongs on
// semester-specific records such as scores, comments, attendance,
// homework, and exam drafts.
if ($schoolYear !== '') {
$filteredQuery = $filteredQuery->where('tc.school_year', $schoolYear);
}
if (!empty($semesterCandidates)) {
$filteredQuery = $filteredQuery->whereIn('tc.semester', $semesterCandidates);
$assignmentQuery->where('tc.school_year', $schoolYear);
}
$assignmentRows = $filteredQuery->get()->getResultArray();
if (empty($assignmentRows) && ($schoolYear !== '' || $semester !== '')) {
$assignmentRows = $assignmentQuery->get()->getResultArray();
}
$assignmentRows = $assignmentQuery->get()->getResultArray();
$studentCounts = $this->studentClassModel->getStudentCountsBySection($schoolYear !== '' ? $schoolYear : null);
$sectionRows = $this->classSectionModel
@@ -873,14 +870,12 @@ class AdministratorController extends BaseController
continue;
}
$studentQuery = $this->studentClassModel
$studentEntries = $this->db->table('student_class')
->select('student_id')
->where('class_section_id', $classSectionId)
->where('school_year', $schoolYear);
if (!empty($semesterCandidates)) {
$studentQuery->whereIn('semester', $semesterCandidates);
}
$studentEntries = $studentQuery->findAll();
->where('school_year', $schoolYear)
->get()
->getResultArray();
if (empty($studentEntries)) {
$studentEntries = $this->studentClassModel
->select('student_id')
@@ -21,8 +21,10 @@ class ClassPreparationController extends BaseController
protected $schoolYear;
protected $semester;
protected $adjustmentModel;
/** cache for roster presence per term */
/** Cache for roster presence per school year. */
private array $rosterPresenceCache = [];
/** Cache table-column checks to avoid repeated schema queries. */
private array $columnExistsCache = [];
// Inside ClassPreparationController (class scope, not inside a method)
private array $allowedPrepCategories = [
'Grade Box',
@@ -57,7 +59,7 @@ class ClassPreparationController extends BaseController
$semParam = $this->request->getGet('semester');
$semester = (is_string($semParam) && $semParam !== '') ? (string)$semParam : (string)$this->semester;
$allowed = $this->allowedPrepCategories;
$limitToSemester = $this->hasRosterForSemester($schoolYear, $semester);
$hasRoster = $this->hasRosterForSchoolYear($schoolYear);
// 1) Get student count per class-section (distinct students, correct semester)
$scQ = $this->db->table('student_class sc')
@@ -65,13 +67,12 @@ class ClassPreparationController extends BaseController
->join('students s', 's.id = sc.student_id', 'inner')
->where('s.is_active', 1)
->where('sc.school_year', $schoolYear);
if ($limitToSemester && $semester !== '') {
$scQ->where('sc.semester', $semester);
}
$classSections = $scQ->groupBy('sc.class_section_id')->get()->getResultArray();
$classSections = $hasRoster
? $scQ->groupBy('sc.class_section_id')->get()->getResultArray()
: [];
// 2) Inventory availability — prefer good_qty when present, else condition='good' quantity.
$inventoryMap = $this->buildInventoryAvailability($schoolYear, $semester, $limitToSemester, $allowed);
$inventoryMap = $this->buildInventoryAvailability($schoolYear, $semester, $allowed);
// Seed totals with allowed categories for stable ordering
$requiredTotals = array_fill_keys($allowed, 0);
@@ -85,14 +86,10 @@ class ClassPreparationController extends BaseController
$className = $this->classSectionModel->getClassSectionNameBySectionId($classSectionId);
// Calculate required items (whitelist inside)
$baseItems = $this->calculatePrepItems($studentCount, $classLevel, $classSectionId);
$baseItems = $this->calculatePrepItems($studentCount, $classLevel, $classSectionId, $schoolYear);
// --- Apply adjustments (only Small/Large Table), clamp >= 0 ---
$rawAdjustments = $this->adjustmentModel
->where('class_section_id', $classSectionId)
->where('school_year', $schoolYear)
->where('adjustable', 1)
->findAll();
$rawAdjustments = $this->getAdjustments((string) $classSectionId, (string) $schoolYear);
$adjMap = ['Large Table' => 0, 'Small Table' => 0];
@@ -117,11 +114,7 @@ class ClassPreparationController extends BaseController
}
// 5) Compare with last snapshot; do not save here — only on print or explicit API
$oldSnap = $this->prepLogModel
->where('class_section_id', $classSectionId)
->where('school_year', $schoolYear)
->orderBy('created_at', 'DESC')
->first();
$oldSnap = $this->getLatestPrepSnapshot((string) $classSectionId, (string) $schoolYear);
$oldPrep = $oldSnap ? json_decode($oldSnap['prep_data'], true) : [];
$hasChanged = $this->hasPrepChanged($baseItems, $oldPrep);
@@ -175,7 +168,7 @@ class ClassPreparationController extends BaseController
return (int)($row['cnt'] ?? 0);
}
private function calculatePrepItems(int $students, int $classLevel, string $classSectionId): array
private function calculatePrepItems(int $students, int $classLevel, string $classSectionId, ?string $schoolYear = null): array
{
// 1) Stable keys: your whitelist, all zero to start
$allowed = $this->allowedPrepCategories;
@@ -192,7 +185,7 @@ class ClassPreparationController extends BaseController
// 3) Rules
$isLowerGrades = in_array($classLevel, [1, 2], true);
$teacherCount = $this->getTeacherCountForSection($classSectionId, $this->schoolYear);
$teacherCount = $this->getTeacherCountForSection($classSectionId, $schoolYear ?? $this->schoolYear);
foreach ($categories as $cat) {
$name = $cat['name']; // e.g., 'Small Table'
@@ -253,24 +246,35 @@ class ClassPreparationController extends BaseController
$adjustments = $data['adjustments'] ?? [];
foreach ($adjustments as $itemName => $adjustment) {
$row = $this->adjustmentModel
$adjustmentQuery = $this->adjustmentModel
->where('class_section_id', $sectionId)
->where('school_year', $schoolYear)
->where('item_name', $itemName)
->first();
->where('item_name', $itemName);
$adjustmentTable = $this->adjustmentModel->getTable();
if ($schoolYear !== '' && $this->tableHasColumn($adjustmentTable, 'school_year')) {
$adjustmentQuery->where('school_year', $schoolYear);
}
$row = $adjustmentQuery->first();
if ($row) {
// Update
$this->adjustmentModel->update($row['id'], ['adjustment' => (int)$adjustment, 'adjustable' => 1]);
} else {
// Insert
$this->adjustmentModel->insert([
$insertData = [
'class_section_id' => $sectionId,
'item_name' => $itemName,
'adjustment' => (int)$adjustment,
'school_year' => $schoolYear,
'adjustment' => (int) $adjustment,
'adjustable' => 1,
]);
];
if ($schoolYear !== '' && $this->tableHasColumn($adjustmentTable, 'school_year')) {
$insertData['school_year'] = $schoolYear;
}
$this->adjustmentModel->insert($insertData);
}
}
@@ -292,10 +296,6 @@ class ClassPreparationController extends BaseController
public function print($classSectionId, $schoolYear)
{
$semParam = $this->request->getGet('semester');
$semester = (is_string($semParam) && $semParam !== '') ? (string) $semParam : (string) $this->semester;
$limitToSemester = $this->hasRosterForSemester((string) $schoolYear, $semester);
// Friendly label (if you have this helper)
$className = $this->classSectionModel->getClassSectionNameBySectionId($classSectionId) ?? $classSectionId;
@@ -306,15 +306,12 @@ class ClassPreparationController extends BaseController
->where('s.is_active', 1)
->where('sc.class_section_id', $classSectionId)
->where('sc.school_year', $schoolYear);
if ($limitToSemester && $semester !== '') {
$studentQ->where('sc.semester', $semester);
}
$studentRow = $studentQ->get()->getRowArray();
$studentCount = (int)($studentRow['cnt'] ?? 0);
// Live calc + adjustments
$classLevel = $this->getClassLevelBySection((string)$classSectionId);
$items = $this->calculatePrepItems($studentCount, $classLevel, (string)$classSectionId);
$items = $this->calculatePrepItems($studentCount, $classLevel, (string)$classSectionId, (string)$schoolYear);
$rawAdjustments = $this->adjustmentModel
->where('class_section_id', $classSectionId)
@@ -328,13 +325,15 @@ class ClassPreparationController extends BaseController
}
// Record a snapshot at print time as the new baseline
$this->prepLogModel->insert([
'class_section_id' => (string)$classSectionId,
'class_section' => $className,
'school_year' => $schoolYear,
'prep_data' => json_encode($items),
'created_at' => utc_now(),
]);
$this->prepLogModel->insert(
$this->buildPrepLogData(
(string) $classSectionId,
(string) $className,
(string) $schoolYear,
$items,
utc_now()
)
);
// Return PRINT view (HTML) that auto-opens the print dialog
return view('class_prep/print', [
@@ -356,7 +355,7 @@ class ClassPreparationController extends BaseController
$semParam = $this->request->getGet('semester');
$semester = (is_string($semParam) && $semParam !== '') ? (string)$semParam : (string)$this->semester;
$allowed = $this->allowedPrepCategories;
$limitToSemester = $this->hasRosterForSemester($schoolYear, $semester);
$hasRoster = $this->hasRosterForSchoolYear($schoolYear);
// Student counts
$scQ = $this->db->table('student_class sc')
@@ -364,13 +363,12 @@ class ClassPreparationController extends BaseController
->join('students s', 's.id = sc.student_id', 'inner')
->where('s.is_active', 1)
->where('sc.school_year', $schoolYear);
if ($limitToSemester && $semester !== '') {
$scQ->where('sc.semester', $semester);
}
$classSections = $scQ->groupBy('sc.class_section_id')->get()->getResultArray();
$classSections = $hasRoster
? $scQ->groupBy('sc.class_section_id')->get()->getResultArray()
: [];
// Build inventory availability maps
$inventoryMap = $this->buildInventoryAvailability($schoolYear, $semester, $limitToSemester, $allowed);
$inventoryMap = $this->buildInventoryAvailability($schoolYear, $semester, $allowed);
$requiredTotals = array_fill_keys($allowed, 0);
$results = [];
@@ -381,12 +379,8 @@ class ClassPreparationController extends BaseController
$classLevel = $this->getClassLevelBySection($classSectionId);
$className = $this->classSectionModel->getClassSectionNameBySectionId($classSectionId);
$baseItems = $this->calculatePrepItems($studentCount, $classLevel, $classSectionId);
$rawAdjustments = $this->adjustmentModel
->where('class_section_id', $classSectionId)
->where('school_year', $schoolYear)
->where('adjustable', 1)
->findAll();
$baseItems = $this->calculatePrepItems($studentCount, $classLevel, $classSectionId, $schoolYear);
$rawAdjustments = $this->getAdjustments((string) $classSectionId, (string) $schoolYear);
$adjMap = ['Large Table' => 0, 'Small Table' => 0];
foreach ($rawAdjustments as $a) {
$item = $a['item_name'];
@@ -399,11 +393,7 @@ class ClassPreparationController extends BaseController
$requiredTotals[$cat] += (int)($baseItems[$cat] ?? 0);
}
$oldSnap = $this->prepLogModel
->where('class_section_id', $classSectionId)
->where('school_year', $schoolYear)
->orderBy('created_at', 'DESC')
->first();
$oldSnap = $this->getLatestPrepSnapshot((string) $classSectionId, (string) $schoolYear);
$oldPrep = $oldSnap ? json_decode($oldSnap['prep_data'], true) : [];
$hasChanged = $this->hasPrepChanged($baseItems, $oldPrep);
@@ -446,7 +436,6 @@ class ClassPreparationController extends BaseController
$schoolYear = (string)($this->request->getPost('school_year') ?? $this->schoolYear);
$semParam = $this->request->getPost('semester');
$semester = (is_string($semParam) && $semParam !== '') ? (string)$semParam : (string)$this->semester;
$limitToSemester = $this->hasRosterForSemester($schoolYear, $semester);
$ids = $this->request->getPost('class_section_ids') ?? [];
if (!is_array($ids)) $ids = $ids ? [$ids] : [];
$ids = array_values(array_unique(array_filter(array_map('strval', $ids))));
@@ -460,21 +449,14 @@ class ClassPreparationController extends BaseController
->where('s.is_active', 1)
->where('sc.class_section_id', $classSectionId)
->where('sc.school_year', $schoolYear);
if ($limitToSemester && $semester !== '') {
$studentQ->where('sc.semester', $semester);
}
$studentRow = $studentQ->get()->getRowArray();
$studentCount = (int)($studentRow['cnt'] ?? 0);
$classLevel = $this->getClassLevelBySection((string)$classSectionId);
$className = $this->classSectionModel->getClassSectionNameBySectionId($classSectionId) ?? $classSectionId;
$items = $this->calculatePrepItems($studentCount, $classLevel, (string)$classSectionId);
$items = $this->calculatePrepItems($studentCount, $classLevel, (string)$classSectionId, (string)$schoolYear);
$rawAdjustments = $this->adjustmentModel
->where('class_section_id', $classSectionId)
->where('school_year', $schoolYear)
->where('adjustable', 1)
->findAll();
$rawAdjustments = $this->getAdjustments((string) $classSectionId, (string) $schoolYear);
foreach ($rawAdjustments as $a) {
$item = $a['item_name'];
$delta = (int)$a['adjustment'];
@@ -482,13 +464,15 @@ class ClassPreparationController extends BaseController
}
try {
$this->prepLogModel->insert([
'class_section_id' => (string)$classSectionId,
'class_section' => $className,
'school_year' => $schoolYear,
'prep_data' => json_encode($items),
'created_at' => $now,
]);
$this->prepLogModel->insert(
$this->buildPrepLogData(
(string) $classSectionId,
(string) $className,
(string) $schoolYear,
$items,
$now
)
);
$count++;
} catch (\Throwable $e) {
// ignore and continue
@@ -528,12 +512,12 @@ class ClassPreparationController extends BaseController
}
/**
* Returns true if student_class has any rows for the given school year.
* The semester argument is ignored because assignments are tracked per year.
* Returns true when student_class contains at least one assignment
* for the selected school year.
*/
private function hasRosterForTerm(string $schoolYear, string $semester): bool
private function hasRosterForSchoolYear(string $schoolYear): bool
{
$year = trim((string)$schoolYear);
$year = trim($schoolYear);
if ($year === '') {
return false;
}
@@ -542,75 +526,154 @@ class ClassPreparationController extends BaseController
return $this->rosterPresenceCache[$year];
}
$cnt = $this->db->table('student_class')
$exists = $this->db->table('student_class')
->where('school_year', $year)
->countAllResults();
->limit(1)
->countAllResults() > 0;
$this->rosterPresenceCache[$year] = $cnt > 0;
return $this->rosterPresenceCache[$year];
$this->rosterPresenceCache[$year] = $exists;
return $exists;
}
private function hasRosterForSemester(string $schoolYear, string $semester): bool
private function tableHasColumn(string $table, string $column): bool
{
$year = trim((string)$schoolYear);
$sem = trim((string)$semester);
if ($year === '' || $sem === '') {
return false;
$key = $table . '.' . $column;
if (!array_key_exists($key, $this->columnExistsCache)) {
$this->columnExistsCache[$key] = $this->db->fieldExists($column, $table);
}
$key = sprintf('sem:%s:%s', $year, $sem);
if (array_key_exists($key, $this->rosterPresenceCache)) {
return $this->rosterPresenceCache[$key];
}
$cnt = $this->db->table('student_class')
->where('school_year', $year)
->where('semester', $sem)
->countAllResults();
$this->rosterPresenceCache[$key] = $cnt > 0;
return $this->rosterPresenceCache[$key];
return $this->columnExistsCache[$key];
}
private function buildInventoryAvailability(string $schoolYear, string $semester, bool $limitToSemester, array $allowed): array
private function getAdjustments(string $classSectionId, string $schoolYear): array
{
$query = $this->adjustmentModel
->where('class_section_id', $classSectionId)
->where('adjustable', 1);
$table = $this->adjustmentModel->getTable();
if ($schoolYear !== '' && $this->tableHasColumn($table, 'school_year')) {
$query->where('school_year', $schoolYear);
}
return $query->findAll();
}
private function getLatestPrepSnapshot(string $classSectionId, string $schoolYear): ?array
{
$query = $this->prepLogModel
->where('class_section_id', $classSectionId);
$table = $this->prepLogModel->getTable();
if ($schoolYear !== '' && $this->tableHasColumn($table, 'school_year')) {
$query->where('school_year', $schoolYear);
}
return $query
->orderBy('created_at', 'DESC')
->first();
}
private function buildPrepLogData(
string $classSectionId,
string $className,
string $schoolYear,
array $items,
string $createdAt
): array {
$data = [
'class_section_id' => $classSectionId,
'class_section' => $className,
'prep_data' => json_encode($items),
'created_at' => $createdAt,
];
$table = $this->prepLogModel->getTable();
if ($schoolYear !== '' && $this->tableHasColumn($table, 'school_year')) {
$data['school_year'] = $schoolYear;
}
return $data;
}
private function buildInventoryAvailability(string $schoolYear, string $semester, array $allowed): array
{
$inventoryMap = array_fill_keys($allowed, 0);
$hasSchoolYear = $this->tableHasColumn('inventory_items', 'school_year');
$hasSemester = $this->tableHasColumn('inventory_items', 'semester');
$hasName = $this->tableHasColumn('inventory_items', 'name');
$joinRows = $this->db->table('inventory_items ii')
->select('ic.name AS item_name, COALESCE(SUM(CASE WHEN ii.good_qty IS NOT NULL THEN ii.good_qty WHEN ii.`condition`="good" THEN ii.quantity ELSE 0 END),0) AS available')
->select('ic.name AS item_name, COALESCE(SUM(CASE WHEN ii.good_qty IS NOT NULL THEN ii.good_qty WHEN ii.`condition` = "good" THEN ii.quantity ELSE 0 END), 0) AS available', false)
->join('inventory_categories ic', 'ic.id = ii.category_id', 'left')
->where('ii.type', 'classroom')
->where('ii.school_year', $schoolYear)
->whereIn('ic.name', $allowed);
if ($limitToSemester && $semester !== '') {
if ($hasSchoolYear && $schoolYear !== '') {
$joinRows->where('ii.school_year', $schoolYear);
}
if ($hasSemester && $semester !== '') {
$joinRows->where('ii.semester', $semester);
}
$joinRows = $joinRows->groupBy('ic.name')->get()->getResultArray();
foreach ($joinRows as $r) {
$name = (string)($r['item_name'] ?? '');
if ($name !== '' && isset($inventoryMap[$name])) {
$inventoryMap[$name] = max($inventoryMap[$name], (int)($r['available'] ?? 0));
$joinRows = $joinRows
->groupBy('ic.name')
->get()
->getResultArray();
foreach ($joinRows as $row) {
$name = (string) ($row['item_name'] ?? '');
if ($name !== '' && array_key_exists($name, $inventoryMap)) {
$inventoryMap[$name] = max(
$inventoryMap[$name],
(int) ($row['available'] ?? 0)
);
}
}
$nameRows = $this->db->table('inventory_items')
->select('name AS item_name, COALESCE(SUM(CASE WHEN good_qty IS NOT NULL THEN good_qty WHEN `condition`="good" THEN quantity ELSE 0 END),0) AS available')
->where('type', 'classroom')
->where('school_year', $schoolYear)
->whereIn('name', $allowed);
if ($limitToSemester && $semester !== '') {
$nameRows->where('semester', $semester);
}
$nameRows = $nameRows->groupBy('name')->get()->getResultArray();
/*
* Some older schemas store the item name directly on inventory_items.
* Only run this fallback when that column actually exists.
*/
if ($hasName) {
$nameRows = $this->db->table('inventory_items')
->select('name AS item_name, COALESCE(SUM(CASE WHEN good_qty IS NOT NULL THEN good_qty WHEN `condition` = "good" THEN quantity ELSE 0 END), 0) AS available', false)
->where('type', 'classroom')
->whereIn('name', $allowed);
foreach ($nameRows as $r) {
$name = (string)($r['item_name'] ?? '');
if ($name !== '' && isset($inventoryMap[$name])) {
$inventoryMap[$name] = max($inventoryMap[$name], (int)($r['available'] ?? 0));
if ($hasSchoolYear && $schoolYear !== '') {
$nameRows->where('school_year', $schoolYear);
}
if ($hasSemester && $semester !== '') {
$nameRows->where('semester', $semester);
}
$nameRows = $nameRows
->groupBy('name')
->get()
->getResultArray();
foreach ($nameRows as $row) {
$name = (string) ($row['item_name'] ?? '');
if ($name !== '' && array_key_exists($name, $inventoryMap)) {
$inventoryMap[$name] = max(
$inventoryMap[$name],
(int) ($row['available'] ?? 0)
);
}
}
}
return $inventoryMap;
}
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Controllers\View;
/**
* Compatibility wrapper for older references.
*
* School-year lifecycle behavior lives in App\Controllers\Administrator,
* where status changes are exposed only as dedicated actions.
*/
class SchoolYearController extends \App\Controllers\Administrator\SchoolYearController
{
}