213 lines
8.2 KiB
PHP
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());
|
|
}
|
|
}
|
|
}
|