normalizeInt($this->request->getGet('target_school_year_id')); $source = (new SchoolYearModel())->find($id); if ($targetId === null && (string) ($source['status'] ?? '') === SchoolYearStatus::ACTIVE) { $target = service('schoolYearManagement')->ensureNextDraftForClosing($id, $this->userId()); $targetId = (int) ($target['id'] ?? 0) ?: null; } $preview = service('schoolYearClosing')->preview($id, $targetId); $promotionTable = $this->promotionTablePayload($preview['promotion']['rows'] ?? []); $carryForwardTable = $this->carryForwardTablePayload($preview['carry_forward'] ?? []); $latestBatch = service('schoolYearClosing')->latestBatch($id); return view('school_years/closing_preview', [ 'preview' => $preview, 'promotionTable' => $promotionTable, 'carryForwardTable' => $carryForwardTable, 'latestBatch' => $latestBatch, 'missingCarryForwardInvoices' => $this->missingCarryForwardInvoiceCount($latestBatch), 'schoolYears' => (new SchoolYearModel())->orderBy('name', 'DESC')->findAll(), ]); } catch (Throwable $e) { return redirect()->to('/administrator/school-years')->with('error', $e->getMessage()); } } public function previewByName(string $name) { try { $year = (new SchoolYearModel()) ->where('name', rawurldecode($name)) ->first(); if ($year === null) { return redirect()->to('/administrator/school-years')->with('error', 'School year was not found.'); } return $this->preview((int) $year['id']); } catch (Throwable $e) { return redirect()->to('/administrator/school-years')->with('error', $e->getMessage()); } } public function start(int $id) { try { $targetId = $this->normalizeInt($this->request->getPost('target_school_year_id')); if ($targetId === null) { return redirect()->back()->with('error', 'Select a target school year.'); } service('schoolYearClosing')->start($id, $targetId, $this->userId()); return redirect()->to('/administrator/school-years/' . $id . '/closing/preview?close_flow=1')->with('success', 'End-year process started.'); } catch (Throwable $e) { return redirect()->back()->with('error', $e->getMessage()); } } public function execute(int $id) { try { service('schoolYearClosing')->execute($id, $this->userId()); return redirect()->to('/administrator/school-years/' . $id . '/closing/preview?close_flow=1')->with('success', 'Carry-forward confirmed.'); } catch (Throwable $e) { return redirect()->back()->with('error', $e->getMessage()); } } public function complete(int $id) { try { service('schoolYearClosing')->complete($id, $this->userId()); return redirect()->to('/administrator/school-years/' . $id . '/closing/preview?close_flow=1')->with('success', 'School year ended and closed.'); } catch (Throwable $e) { return redirect()->back()->with('error', $e->getMessage()); } } public function cancel(int $id) { try { service('schoolYearClosing')->cancel($id, $this->userId()); return redirect()->to('/administrator/school-years')->with('success', 'End-year process cancelled.'); } catch (Throwable $e) { return redirect()->back()->with('error', $e->getMessage()); } } private function normalizeInt(mixed $value): ?int { return is_numeric($value) && (int) $value > 0 ? (int) $value : null; } private function missingCarryForwardInvoiceCount(?array $batch): int { if ($batch === null || ! in_array((string) ($batch['status'] ?? ''), ['executed', 'completed'], true)) { return 0; } $db = \Config\Database::connect(); if (! $db->tableExists('school_year_closing_items')) { return 0; } return $db->table('school_year_closing_items') ->where('closing_batch_id', (int) $batch['id']) ->groupStart() ->where('target_invoice_id', null) ->orWhere('target_invoice_id', 0) ->groupEnd() ->countAllResults(); } private function promotionTablePayload(array $rows): array { $allowedSorts = ['student', 'school_id', 'class', 'year_score', 'decision', 'source', 'queue', 'target', 'status']; $sort = 'class'; $order = 'asc'; $perPage = 25; $allowedPerPage = [10, 25, 50, 100]; usort($rows, function (array $a, array $b) use ($sort, $order): int { $comparison = $this->comparePromotionRows($a, $b, $sort); if ($comparison === 0 && $sort !== 'class') { $comparison = $this->comparePromotionRows($a, $b, 'class'); } if ($comparison === 0 && $sort !== 'student') { $comparison = $this->comparePromotionRows($a, $b, 'student'); } if ($comparison === 0) { $comparison = ((int) ($a['student_id'] ?? 0)) <=> ((int) ($b['student_id'] ?? 0)); } return $order === 'desc' ? -$comparison : $comparison; }); $total = count($rows); return [ 'rows' => $rows, 'sort' => $sort, 'order' => $order, 'page' => 1, 'perPage' => $perPage, 'total' => $total, 'pageCount' => 1, 'allowedPerPage' => $allowedPerPage, 'from' => $total === 0 ? 0 : 1, 'to' => $total, ]; } private function comparePromotionRows(array $a, array $b, string $sort): int { $aValue = $this->promotionSortValue($a, $sort); $bValue = $this->promotionSortValue($b, $sort); if ($sort === 'year_score') { if ($aValue === null && $bValue === null) { return 0; } if ($aValue === null) { return 1; } if ($bValue === null) { return -1; } return $aValue <=> $bValue; } return strnatcasecmp((string) $aValue, (string) $bValue); } private function promotionSortValue(array $row, string $sort): mixed { return match ($sort) { 'student' => (string) ($row['student_name'] ?? ''), 'school_id' => (string) ($row['school_id'] ?? ''), 'class' => (string) ($row['class_section_name'] ?? ''), 'year_score' => is_numeric($row['year_score'] ?? null) ? (float) $row['year_score'] : null, 'decision' => (string) ($row['decision'] ?? ''), 'source' => (string) ($row['source'] ?? ''), 'queue' => (string) ($row['queue_status'] ?? ''), 'target' => trim((string) ($row['target_section'] ?? '') . ' ' . (string) ($row['target_class'] ?? '')), 'status' => (string) ($row['status'] ?? ''), default => '', }; } private function carryForwardTablePayload(array $rows): array { $allowedSorts = ['family', 'parent', 'source_balance', 'credits', 'adjustments', 'net']; $sort = (string) ($this->request->getGet('cf_sort') ?? 'family'); $sort = in_array($sort, $allowedSorts, true) ? $sort : 'family'; $order = strtolower((string) ($this->request->getGet('cf_order') ?? 'asc')) === 'desc' ? 'desc' : 'asc'; usort($rows, function (array $a, array $b) use ($sort, $order): int { $comparison = $this->compareCarryForwardRows($a, $b, $sort); if ($comparison === 0 && $sort !== 'family') { $comparison = $this->compareCarryForwardRows($a, $b, 'family'); } if ($comparison === 0) { $comparison = ((int) ($a['family_id'] ?? 0)) <=> ((int) ($b['family_id'] ?? 0)); } return $order === 'desc' ? -$comparison : $comparison; }); return [ 'rows' => $rows, 'sort' => $sort, 'order' => $order, ]; } private function compareCarryForwardRows(array $a, array $b, string $sort): int { $numericSorts = ['source_balance', 'credits', 'adjustments', 'net']; $aValue = $this->carryForwardSortValue($a, $sort); $bValue = $this->carryForwardSortValue($b, $sort); if (in_array($sort, $numericSorts, true)) { return (float) $aValue <=> (float) $bValue; } return strnatcasecmp((string) $aValue, (string) $bValue); } private function carryForwardSortValue(array $row, string $sort): mixed { return match ($sort) { 'family' => (string) ($row['family'] ?? ''), 'parent' => trim((string) ($row['parent'] ?? '') . ' ' . (string) ($row['parent_email'] ?? '')), 'source_balance' => (float) ($row['source_balance'] ?? 0), 'credits' => (float) ($row['credit_amount'] ?? 0), 'adjustments' => (float) ($row['adjustment_amount'] ?? 0), 'net' => (float) ($row['carry_forward_amount'] ?? 0), default => '', }; } private function userId(): ?int { $id = session('user_id') ?? session('id'); return is_numeric($id) ? (int) $id : null; } }