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 { 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 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()); } } }