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
@@ -0,0 +1,258 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreateSchoolYears extends Migration
{
public function up(): void
{
if (! $this->db->tableExists('school_years')) {
$this->forge->addField([
'id' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => true,
'auto_increment' => true,
],
'name' => [
'type' => 'VARCHAR',
'constraint' => 9,
'null' => false,
],
'status' => [
'type' => 'VARCHAR',
'constraint' => 20,
'null' => false,
'default' => 'draft',
],
'starts_on' => [
'type' => 'DATE',
'null' => true,
],
'ends_on' => [
'type' => 'DATE',
'null' => true,
],
'description' => [
'type' => 'TEXT',
'null' => true,
],
'registration_starts_on' => [
'type' => 'DATE',
'null' => true,
],
'registration_ends_on' => [
'type' => 'DATE',
'null' => true,
],
'previous_school_year_id' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => true,
'null' => true,
],
'next_school_year_id' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => true,
'null' => true,
],
'activated_at' => [
'type' => 'DATETIME',
'null' => true,
],
'closing_started_at' => [
'type' => 'DATETIME',
'null' => true,
],
'closed_at' => [
'type' => 'DATETIME',
'null' => true,
],
'archived_at' => [
'type' => 'DATETIME',
'null' => true,
],
'created_by' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => true,
'null' => true,
],
'updated_by' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => true,
'null' => true,
],
'created_at' => [
'type' => 'DATETIME',
'null' => true,
],
'updated_at' => [
'type' => 'DATETIME',
'null' => true,
],
]);
$this->forge->addKey('id', true);
$this->forge->addKey('name', false, true);
$this->forge->addKey('status');
$this->forge->createTable('school_years');
} else {
$this->ensureSchoolYearColumns();
}
$this->createClosingTables();
$configuredYear = $this->configuredSchoolYear();
if ($configuredYear !== null && $this->isValidYearName($configuredYear)) {
$existing = $this->db->table('school_years')
->where('name', $configuredYear)
->get()
->getRowArray();
if ($existing === null) {
$this->db->table('school_years')->insert([
'name' => $configuredYear,
'status' => 'active',
'activated_at' => date('Y-m-d H:i:s'),
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
}
}
}
public function down(): void
{
$this->forge->dropTable('school_year_transition_logs', true);
$this->forge->dropTable('school_year_closing_items', true);
$this->forge->dropTable('school_year_closing_batches', true);
$this->forge->dropTable('school_years', true);
}
private function ensureSchoolYearColumns(): void
{
$fields = $this->db->getFieldNames('school_years');
$add = [];
$definitions = [
'description' => ['type' => 'TEXT', 'null' => true],
'registration_starts_on' => ['type' => 'DATE', 'null' => true],
'registration_ends_on' => ['type' => 'DATE', 'null' => true],
'previous_school_year_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
'next_school_year_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
'activated_at' => ['type' => 'DATETIME', 'null' => true],
'closing_started_at' => ['type' => 'DATETIME', 'null' => true],
'closed_at' => ['type' => 'DATETIME', 'null' => true],
'archived_at' => ['type' => 'DATETIME', 'null' => true],
'created_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
'updated_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
];
foreach ($definitions as $field => $definition) {
if (! in_array($field, $fields, true)) {
$add[$field] = $definition;
}
}
if ($add !== []) {
$this->forge->addColumn('school_years', $add);
}
}
private function createClosingTables(): void
{
if (! $this->db->tableExists('school_year_closing_batches')) {
$this->forge->addField([
'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true],
'source_school_year_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true],
'target_school_year_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
'status' => ['type' => 'VARCHAR', 'constraint' => 30, 'null' => false, 'default' => 'preview'],
'preview_hash' => ['type' => 'CHAR', 'constraint' => 64, 'null' => true],
'total_families' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false, 'default' => 0],
'total_positive_balance' => ['type' => 'DECIMAL', 'constraint' => '12,2', 'null' => false, 'default' => '0.00'],
'total_credit_balance' => ['type' => 'DECIMAL', 'constraint' => '12,2', 'null' => false, 'default' => '0.00'],
'started_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
'completed_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
'started_at' => ['type' => 'DATETIME', 'null' => true],
'completed_at' => ['type' => 'DATETIME', 'null' => true],
'failed_at' => ['type' => 'DATETIME', 'null' => true],
'failure_message' => ['type' => 'TEXT', 'null' => true],
'created_at' => ['type' => 'DATETIME', 'null' => true],
'updated_at' => ['type' => 'DATETIME', 'null' => true],
]);
$this->forge->addKey('id', true);
$this->forge->addKey(['source_school_year_id', 'status']);
$this->forge->createTable('school_year_closing_batches');
}
if (! $this->db->tableExists('school_year_closing_items')) {
$this->forge->addField([
'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true],
'closing_batch_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true],
'family_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true],
'source_balance' => ['type' => 'DECIMAL', 'constraint' => '12,2', 'null' => false, 'default' => '0.00'],
'credit_amount' => ['type' => 'DECIMAL', 'constraint' => '12,2', 'null' => false, 'default' => '0.00'],
'adjustment_amount' => ['type' => 'DECIMAL', 'constraint' => '12,2', 'null' => false, 'default' => '0.00'],
'carry_forward_amount' => ['type' => 'DECIMAL', 'constraint' => '12,2', 'null' => false, 'default' => '0.00'],
'target_invoice_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
'target_adjustment_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
'status' => ['type' => 'VARCHAR', 'constraint' => 30, 'null' => false, 'default' => 'pending'],
'error_message' => ['type' => 'TEXT', 'null' => true],
'created_at' => ['type' => 'DATETIME', 'null' => true],
'updated_at' => ['type' => 'DATETIME', 'null' => true],
]);
$this->forge->addKey('id', true);
$this->forge->addKey(['closing_batch_id', 'family_id'], false, true);
$this->forge->createTable('school_year_closing_items');
}
if (! $this->db->tableExists('school_year_transition_logs')) {
$this->forge->addField([
'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true],
'school_year_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true],
'from_status' => ['type' => 'VARCHAR', 'constraint' => 20, 'null' => true],
'to_status' => ['type' => 'VARCHAR', 'constraint' => 20, 'null' => true],
'action' => ['type' => 'VARCHAR', 'constraint' => 80, 'null' => false],
'performed_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
'metadata_json' => ['type' => 'TEXT', 'null' => true],
'created_at' => ['type' => 'DATETIME', 'null' => false],
]);
$this->forge->addKey('id', true);
$this->forge->addKey(['school_year_id', 'created_at']);
$this->forge->createTable('school_year_transition_logs');
}
}
private function configuredSchoolYear(): ?string
{
if (! $this->db->tableExists('configuration')) {
return null;
}
$row = $this->db->table('configuration')
->select('config_value')
->where('config_key', 'school_year')
->orderBy('id', 'DESC')
->get(1)
->getRowArray();
$value = trim((string) ($row['config_value'] ?? ''));
return $value !== '' ? $value : null;
}
private function isValidYearName(string $value): bool
{
if (! preg_match('/^(\d{4})-(\d{4})$/', $value, $matches)) {
return false;
}
return (int) $matches[2] === (int) $matches[1] + 1;
}
}
@@ -0,0 +1,111 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class AddSchoolYearsNavItem extends Migration
{
private string $url = 'administrator/school-years';
public function up(): void
{
if (! $this->db->tableExists('nav_items')) {
return;
}
$parentColumn = $this->parentColumn();
$existingQuery = $this->db->table('nav_items')
->where('url', $this->url)
->get();
$existing = $existingQuery !== false ? $existingQuery->getRowArray() : null;
if ($existing !== null) {
return;
}
$parentBuilder = $this->db->table('nav_items')
->where('label', 'Configuration');
if ($parentColumn !== null) {
$parentBuilder->where($parentColumn, null);
}
$parentQuery = $parentBuilder->get();
$parent = $parentQuery !== false ? $parentQuery->getRowArray() : null;
$insert = [
'label' => 'School Years',
'url' => $this->url,
'sort_order' => 2,
'is_enabled' => 1,
'created_at' => date('Y-m-d H:i:s'),
];
if ($parentColumn !== null) {
$insert[$parentColumn] = $parent['id'] ?? null;
}
$this->db->table('nav_items')->insert($insert);
$navItemId = (int) $this->db->insertID();
if (
$navItemId <= 0
|| ! $this->db->tableExists('role_nav_items')
|| ! $this->db->fieldExists('role', 'role_nav_items')
|| ! $this->db->fieldExists('nav_item_id', 'role_nav_items')
) {
return;
}
foreach (['administrator', 'principal', 'vice_principal'] as $role) {
$this->db->table('role_nav_items')->insert([
'role' => $role,
'nav_item_id' => $navItemId,
'created_at' => date('Y-m-d H:i:s'),
]);
}
}
public function down(): void
{
if (! $this->db->tableExists('nav_items')) {
return;
}
$query = $this->db->table('nav_items')
->where('url', $this->url)
->get();
$row = $query !== false ? $query->getRowArray() : null;
if ($row === null) {
return;
}
if ($this->db->tableExists('role_nav_items')) {
$this->db->table('role_nav_items')
->where('nav_item_id', (int) $row['id'])
->delete();
}
$this->db->table('nav_items')
->where('id', (int) $row['id'])
->delete();
}
private function parentColumn(): ?string
{
if ($this->db->fieldExists('parent_id', 'nav_items')) {
return 'parent_id';
}
if ($this->db->fieldExists('menu_parent_id', 'nav_items')) {
return 'menu_parent_id';
}
return null;
}
}