Files
alrahma_sunday_school/app/Database/Migrations/2026-07-19-000500_FinancialReimbursementPoHardening.php
T
root 2be16553df
Tests / PHPUnit (push) Successful in 1m21s
fix test run issue
2026-07-18 23:18:49 -04:00

213 lines
8.2 KiB
PHP

<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class FinancialReimbursementPoHardening extends Migration
{
public function up()
{
$this->ensureReimbursementInvariants();
$this->ensurePurchaseOrderInvariants();
}
public function down()
{
if ($this->db->tableExists('reimbursements')) {
$this->dropIndexIfExists('reimbursements', 'uniq_reimbursement_active_expense');
if ($this->db->fieldExists('active_expense_id', 'reimbursements')) {
$this->forge->dropColumn('reimbursements', 'active_expense_id');
}
}
$this->dropIndexIfExists('reimbursement_batches', 'uniq_reimbursement_batch_year_sequence');
$this->dropIndexIfExists('reimbursement_batch_items', 'uniq_reimbursement_batch_item_active_expense');
}
private function ensureReimbursementInvariants(): void
{
if (!$this->db->tableExists('reimbursements')) {
return;
}
try {
$this->forge->modifyColumn('reimbursements', [
'status' => [
'type' => 'ENUM',
'constraint' => ['pending', 'approved', 'paid', 'rejected', 'reversed', 'Pending', 'Approved', 'Paid', 'Rejected', 'Reversed'],
'default' => 'pending',
'null' => false,
],
]);
} catch (\Throwable $e) {
throw new \RuntimeException('Could not normalize reimbursement statuses: ' . $e->getMessage(), 0, $e);
}
if (!$this->db->fieldExists('active_expense_id', 'reimbursements')) {
try {
$this->db->query(
"ALTER TABLE reimbursements
ADD active_expense_id INT
GENERATED ALWAYS AS (
CASE
WHEN expense_id IS NOT NULL
AND LOWER(status) NOT IN ('reversed','rejected','cancelled','canceled','voided')
THEN expense_id
ELSE NULL
END
) STORED"
);
} catch (\Throwable $e) {
throw new \RuntimeException('Could not add reimbursement active_expense_id generated column: ' . $e->getMessage(), 0, $e);
}
}
$this->createIndexIfMissing('reimbursements', 'uniq_reimbursement_active_expense', 'CREATE UNIQUE INDEX uniq_reimbursement_active_expense ON reimbursements (active_expense_id)');
$this->createIndexIfMissing('reimbursement_batches', 'uniq_reimbursement_batch_year_sequence', 'CREATE UNIQUE INDEX uniq_reimbursement_batch_year_sequence ON reimbursement_batches (school_year, yearly_batch_number)');
$this->createIndexIfMissing('reimbursement_batch_items', 'uniq_reimbursement_batch_item_active_expense', 'CREATE UNIQUE INDEX uniq_reimbursement_batch_item_active_expense ON reimbursement_batch_items (batch_id, expense_id)');
}
private function ensurePurchaseOrderInvariants(): void
{
$this->ensurePurchaseOrderSchoolYearColumns();
if (!$this->db->tableExists('purchase_order_items')) {
return;
}
try {
$this->forge->modifyColumn('purchase_order_items', [
'quantity' => [
'type' => 'INT',
'constraint' => 11,
'null' => false,
],
'received_qty' => [
'type' => 'INT',
'constraint' => 11,
'default' => 0,
'null' => false,
],
'unit_cost' => [
'type' => 'DECIMAL',
'constraint' => '10,2',
'default' => 0,
'null' => false,
],
]);
} catch (\Throwable $e) {
throw new \RuntimeException('Could not tighten purchase order item columns: ' . $e->getMessage(), 0, $e);
}
try {
$this->db->query(
'ALTER TABLE purchase_order_items
ADD CONSTRAINT chk_po_item_quantities
CHECK (quantity > 0 AND received_qty >= 0 AND received_qty <= quantity AND unit_cost >= 0)'
);
} catch (\Throwable $e) {
throw new \RuntimeException('Could not add purchase order quantity check: ' . $e->getMessage(), 0, $e);
}
}
private function ensurePurchaseOrderSchoolYearColumns(): void
{
foreach (['purchase_orders' => 'status', 'purchase_order_items' => 'purchase_order_id'] as $table => $after) {
if (!$this->db->tableExists($table)) {
continue;
}
if (!$this->db->fieldExists('school_year', $table)) {
$this->forge->addColumn($table, [
'school_year' => [
'type' => 'VARCHAR',
'constraint' => 9,
'null' => true,
'after' => $after,
],
]);
}
}
if ($this->db->tableExists('purchase_orders')) {
$currentYear = $this->currentSchoolYear();
$this->db->query(
'UPDATE purchase_orders SET school_year = ? WHERE school_year IS NULL OR TRIM(school_year) = ?',
[$currentYear, '']
);
$this->db->query('ALTER TABLE purchase_orders MODIFY school_year VARCHAR(9) NOT NULL');
}
if ($this->db->tableExists('purchase_orders') && $this->db->tableExists('purchase_order_items')) {
$this->db->query(
"UPDATE purchase_order_items poi
INNER JOIN purchase_orders po ON po.id = poi.purchase_order_id
SET poi.school_year = po.school_year
WHERE poi.school_year IS NULL OR TRIM(poi.school_year) = ''"
);
$this->db->query('ALTER TABLE purchase_order_items MODIFY school_year VARCHAR(9) NOT NULL');
}
}
private function currentSchoolYear(): string
{
if ($this->db->tableExists('school_years')) {
$row = $this->db->table('school_years')
->select('name')
->where('status', 'active')
->orderBy('id', 'DESC')
->get(1)
->getRowArray();
$name = trim((string)($row['name'] ?? ''));
if (preg_match('/^\d{4}-\d{4}$/', $name) === 1) {
return $name;
}
}
$year = (int)date('Y');
return $year . '-' . ($year + 1);
}
private function createIndexIfMissing(string $table, string $indexName, string $sql): void
{
if (!$this->db->tableExists($table)) {
return;
}
$indexes = $this->db->query('SHOW INDEX FROM ' . $this->db->escapeIdentifiers($table))->getResultArray();
foreach ($indexes as $index) {
if (($index['Key_name'] ?? '') === $indexName) {
return;
}
}
$this->db->query($sql);
$indexes = $this->db->query('SHOW INDEX FROM ' . $this->db->escapeIdentifiers($table))->getResultArray();
foreach ($indexes as $index) {
if (($index['Key_name'] ?? '') === $indexName) {
return;
}
}
throw new \RuntimeException('Required index ' . $indexName . ' was not created.');
}
private function dropIndexIfExists(string $table, string $indexName): void
{
if (!$this->db->tableExists($table)) {
return;
}
try {
$indexes = $this->db->query('SHOW INDEX FROM ' . $this->db->escapeIdentifiers($table))->getResultArray();
foreach ($indexes as $index) {
if (($index['Key_name'] ?? '') === $indexName) {
$this->db->query('DROP INDEX ' . $this->db->escapeIdentifiers($indexName) . ' ON ' . $this->db->escapeIdentifiers($table));
return;
}
}
} catch (\Throwable $e) {
log_message('warning', 'Could not drop index ' . $indexName . ': ' . $e->getMessage());
}
}
}