Files
alrahma_sunday_school/app/Database/Migrations/2026-08-21-000100_CreateWithdrawalRefundInventoryFoundation.php
T
root d906a915d6
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 47s
Tests / PHPUnit (push) Failing after 1m20s
add refund logic and fix books inventory logic
2026-08-22 13:44:25 -04:00

489 lines
26 KiB
PHP

<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreateWithdrawalRefundInventoryFoundation extends Migration
{
public function up(): void
{
$this->addSchoolYearPolicyColumns();
$this->addInventoryCatalogColumns();
$this->createInventoryItemYears();
$this->extendInventoryMovements();
$this->protectInventoryHistoryForeignKey();
$this->createBookClassAssignments();
$this->createStudentBookIssues();
$this->createWithdrawalCalculations();
$this->extendRefunds();
$this->seedActiveYearPolicy();
$this->seedActiveBookYears();
}
public function down(): void
{
$this->forge->dropTable('withdrawal_financial_calculations', true);
$this->forge->dropTable('student_book_issues', true);
$this->forge->dropTable('inventory_book_class_assignments', true);
if ($this->db->tableExists('inventory_movements')) {
foreach ([
'item_year_id', 'idempotency_key', 'reversal_of_movement_id', 'status',
'reversed_at', 'reversed_by', 'source_type', 'source_id',
] as $column) {
if ($this->db->fieldExists($column, 'inventory_movements')) {
$this->forge->dropColumn('inventory_movements', $column);
}
}
}
$this->forge->dropTable('inventory_item_years', true);
$this->restoreInventoryHistoryForeignKey();
if ($this->db->tableExists('inventory_items')) {
foreach (['author', 'is_active', 'retired_at', 'isbn_edition_key', 'sku_normalized'] as $column) {
if ($this->db->fieldExists($column, 'inventory_items')) {
$this->forge->dropColumn('inventory_items', $column);
}
}
}
if ($this->db->tableExists('school_years')) {
foreach (['total_instructional_weeks', 'annual_fee_includes_books', 'withdrawal_policy_version'] as $column) {
if ($this->db->fieldExists($column, 'school_years')) {
$this->forge->dropColumn('school_years', $column);
}
}
}
if ($this->db->tableExists('refunds') && $this->db->fieldExists('withdrawal_calculation_id', 'refunds')) {
$this->forge->dropColumn('refunds', 'withdrawal_calculation_id');
}
}
private function addSchoolYearPolicyColumns(): void
{
if (! $this->db->tableExists('school_years')) {
return;
}
$columns = [];
if (! $this->db->fieldExists('total_instructional_weeks', 'school_years')) {
$columns['total_instructional_weeks'] = [
'type' => 'SMALLINT', 'constraint' => 5, 'unsigned' => true, 'null' => true,
];
}
if (! $this->db->fieldExists('annual_fee_includes_books', 'school_years')) {
$columns['annual_fee_includes_books'] = [
'type' => 'TINYINT', 'constraint' => 1, 'default' => 1, 'null' => false,
];
}
if (! $this->db->fieldExists('withdrawal_policy_version', 'school_years')) {
$columns['withdrawal_policy_version'] = [
'type' => 'VARCHAR', 'constraint' => 40, 'default' => 'studied_weeks_v1', 'null' => false,
];
}
if ($columns !== []) {
$this->forge->addColumn('school_years', $columns);
}
}
private function addInventoryCatalogColumns(): void
{
if (! $this->db->tableExists('inventory_items')) {
return;
}
$columns = [];
if (! $this->db->fieldExists('author', 'inventory_items')) {
$columns['author'] = ['type' => 'VARCHAR', 'constraint' => 190, 'null' => true];
}
if (! $this->db->fieldExists('is_active', 'inventory_items')) {
$columns['is_active'] = ['type' => 'TINYINT', 'constraint' => 1, 'default' => 1, 'null' => false];
}
if (! $this->db->fieldExists('retired_at', 'inventory_items')) {
$columns['retired_at'] = ['type' => 'DATETIME', 'null' => true];
}
if (! $this->db->fieldExists('isbn_edition_key', 'inventory_items')) {
$columns['isbn_edition_key'] = ['type' => 'VARCHAR', 'constraint' => 190, 'null' => true];
}
if (! $this->db->fieldExists('sku_normalized', 'inventory_items')) {
$columns['sku_normalized'] = ['type' => 'VARCHAR', 'constraint' => 120, 'null' => true];
}
if ($columns !== []) {
$this->forge->addColumn('inventory_items', $columns);
}
// Existing legacy rows remain NULL until an admin reconciles/edits them,
// so installing this migration cannot fail because of historical duplicates.
$this->addIndexIfMissing('inventory_items', 'uq_inventory_book_isbn_edition', ['isbn_edition_key'], true);
$this->addIndexIfMissing('inventory_items', 'uq_inventory_sku_normalized', ['sku_normalized'], true);
}
private function createInventoryItemYears(): void
{
if ($this->db->tableExists('inventory_item_years')) {
return;
}
$this->forge->addField([
'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true],
'inventory_item_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
'school_year_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
'school_year' => ['type' => 'VARCHAR', 'constraint' => 16, 'null' => false],
'opening_quantity' => ['type' => 'INT', 'constraint' => 11, 'default' => 0, 'null' => false],
'charge_price_cents' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'default' => 0, 'null' => false],
'currency' => ['type' => 'CHAR', 'constraint' => 3, 'default' => 'USD', 'null' => false],
'price_confirmed' => ['type' => 'TINYINT', 'constraint' => 1, 'default' => 0, 'null' => false],
'system_closing_quantity' => ['type' => 'INT', 'constraint' => 11, 'null' => true],
'counted_closing_quantity' => ['type' => 'INT', 'constraint' => 11, 'null' => true],
'variance_quantity' => ['type' => 'INT', 'constraint' => 11, 'null' => true],
'status' => ['type' => 'VARCHAR', 'constraint' => 20, 'default' => 'open', 'null' => false],
'source_item_year_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
'closing_batch_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, '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->addUniqueKey(['inventory_item_id', 'school_year'], 'uq_inventory_item_year');
$this->forge->addKey(['school_year_id', 'status'], false, false, 'idx_inventory_item_year_status');
$this->forge->addKey('closing_batch_id');
$this->forge->createTable('inventory_item_years', true, ['ENGINE' => 'InnoDB']);
}
private function extendInventoryMovements(): void
{
if (! $this->db->tableExists('inventory_movements')) {
return;
}
$definitions = [
'item_year_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
'idempotency_key' => ['type' => 'VARCHAR', 'constraint' => 120, 'null' => true],
'reversal_of_movement_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
'status' => ['type' => 'VARCHAR', 'constraint' => 20, 'default' => 'posted', 'null' => false],
'reversed_at' => ['type' => 'DATETIME', 'null' => true],
'reversed_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
'source_type' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true],
'source_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
];
$columns = [];
foreach ($definitions as $name => $definition) {
if (! $this->db->fieldExists($name, 'inventory_movements')) {
$columns[$name] = $definition;
}
}
if ($columns !== []) {
$this->forge->addColumn('inventory_movements', $columns);
}
$this->addIndexIfMissing('inventory_movements', 'uq_inventory_movement_idempotency', ['idempotency_key'], true);
$this->addIndexIfMissing('inventory_movements', 'idx_inventory_movement_item_year_status', ['item_year_id', 'status']);
$this->addIndexIfMissing('inventory_movements', 'idx_inventory_movement_source', ['source_type', 'source_id']);
}
private function createBookClassAssignments(): void
{
if ($this->db->tableExists('inventory_book_class_assignments')) {
return;
}
$this->forge->addField([
'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true],
'inventory_item_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
'school_year' => ['type' => 'VARCHAR', 'constraint' => 16, 'null' => false],
'class_number' => ['type' => 'SMALLINT', 'constraint' => 5, 'unsigned' => true, 'null' => false],
'created_at' => ['type' => 'DATETIME', 'null' => true],
]);
$this->forge->addKey('id', true);
$this->forge->addUniqueKey(['inventory_item_id', 'school_year', 'class_number'], 'uq_book_year_class');
$this->forge->addKey(['school_year', 'class_number'], false, false, 'idx_book_class_year');
$this->forge->createTable('inventory_book_class_assignments', true, ['ENGINE' => 'InnoDB']);
}
private function protectInventoryHistoryForeignKey(): void
{
if (! $this->db->tableExists('inventory_movements') || ! $this->db->tableExists('inventory_items')) {
return;
}
try {
$rows = $this->db->query(
"SELECT CONSTRAINT_NAME FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'inventory_movements'
AND COLUMN_NAME = 'item_id' AND REFERENCED_TABLE_NAME = 'inventory_items'"
)->getResultArray();
foreach ($rows as $row) {
$name = str_replace('`', '', (string) ($row['CONSTRAINT_NAME'] ?? ''));
if ($name !== '') {
$this->db->query('ALTER TABLE inventory_movements DROP FOREIGN KEY `' . $name . '`');
}
}
$this->db->query(
'ALTER TABLE inventory_movements ADD CONSTRAINT fk_inventory_movements_item_restrict '
. 'FOREIGN KEY (item_id) REFERENCES inventory_items(id) ON DELETE RESTRICT ON UPDATE CASCADE'
);
} catch (\Throwable $e) {
log_message('warning', 'Unable to replace inventory movement cascade with RESTRICT: ' . $e->getMessage());
}
}
private function restoreInventoryHistoryForeignKey(): void
{
if (! $this->db->tableExists('inventory_movements') || ! $this->db->tableExists('inventory_items')) {
return;
}
try {
$rows = $this->db->query(
"SELECT CONSTRAINT_NAME FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'inventory_movements'
AND COLUMN_NAME = 'item_id' AND REFERENCED_TABLE_NAME = 'inventory_items'"
)->getResultArray();
foreach ($rows as $row) {
$name = str_replace('`', '', (string) ($row['CONSTRAINT_NAME'] ?? ''));
if ($name !== '') {
$this->db->query('ALTER TABLE inventory_movements DROP FOREIGN KEY `' . $name . '`');
}
}
$this->db->query(
'ALTER TABLE inventory_movements ADD CONSTRAINT fk_inventory_movements_item_cascade '
. 'FOREIGN KEY (item_id) REFERENCES inventory_items(id) ON DELETE CASCADE ON UPDATE CASCADE'
);
} catch (\Throwable $e) {
log_message('warning', 'Unable to restore inventory movement cascade during rollback: ' . $e->getMessage());
}
}
private function createStudentBookIssues(): void
{
if ($this->db->tableExists('student_book_issues')) {
return;
}
$this->forge->addField([
'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true],
'student_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
'enrollment_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
'parent_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
'inventory_item_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
'inventory_item_year_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
'school_year' => ['type' => 'VARCHAR', 'constraint' => 16, 'null' => false],
'class_section_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
'quantity' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'default' => 1, 'null' => false],
'unit_charge_price_cents' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
'total_charge_cents' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
'distribution_movement_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
'idempotency_key' => ['type' => 'VARCHAR', 'constraint' => 160, 'null' => false],
'status' => ['type' => 'VARCHAR', 'constraint' => 20, 'default' => 'issued', 'null' => false],
'issued_at' => ['type' => 'DATETIME', 'null' => false],
'issued_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
'reversed_at' => ['type' => 'DATETIME', 'null' => true],
'reversed_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
'reversal_reason' => ['type' => 'TEXT', 'null' => true],
'reversal_quantity' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
'reversal_movement_id' => ['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->addUniqueKey('idempotency_key', 'uq_student_book_issue_key');
$this->forge->addKey(['student_id', 'school_year', 'status'], false, false, 'idx_student_book_issue_student');
$this->forge->addKey(['inventory_item_year_id', 'status'], false, false, 'idx_student_book_issue_item_year');
$this->forge->addKey('distribution_movement_id');
$this->forge->createTable('student_book_issues', true, ['ENGINE' => 'InnoDB']);
}
private function createWithdrawalCalculations(): void
{
if ($this->db->tableExists('withdrawal_financial_calculations')) {
return;
}
$this->forge->addField([
'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true],
'version' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'default' => 1, 'null' => false],
'enrollment_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
'student_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
'parent_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
'invoice_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
'school_year' => ['type' => 'VARCHAR', 'constraint' => 16, 'null' => false],
'policy_version' => ['type' => 'VARCHAR', 'constraint' => 40, 'null' => false],
'annual_fee_includes_books' => ['type' => 'TINYINT', 'constraint' => 1, 'default' => 1, 'null' => false],
'school_year_start_date' => ['type' => 'DATE', 'null' => false],
'enrollment_date' => ['type' => 'DATE', 'null' => false],
'withdrawal_request_date' => ['type' => 'DATE', 'null' => false],
'total_instructional_weeks' => ['type' => 'SMALLINT', 'constraint' => 5, 'unsigned' => true, 'null' => false],
'total_chargeable_days' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
'studied_calendar_days' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
'studied_weeks' => ['type' => 'SMALLINT', 'constraint' => 5, 'unsigned' => true, 'null' => false],
'annual_fee_allocation_cents' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
'issued_book_charge_cents' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
'annual_instruction_cents' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
'earned_tuition_cents' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
'other_charge_cents' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'default' => 0, 'null' => false],
'retained_charge_cents' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
'original_invoice_charge_cents' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'default' => 0, 'null' => false],
'invoice_adjustment_cents' => ['type' => 'INT', 'constraint' => 11, 'default' => 0, 'null' => false],
'adjusted_invoice_charge_cents' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'default' => 0, 'null' => false],
'valid_payment_cents' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'default' => 0, 'null' => false],
'completed_payout_cents' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'default' => 0, 'null' => false],
'open_reservation_cents' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'default' => 0, 'null' => false],
'refundable_credit_cents' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'default' => 0, 'null' => false],
'new_refund_request_cents' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'default' => 0, 'null' => false],
'balance_due_cents' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'default' => 0, 'null' => false],
'book_evidence_json' => ['type' => 'LONGTEXT', 'null' => true],
'explanation_json' => ['type' => 'LONGTEXT', 'null' => false],
'calculation_hash' => ['type' => 'CHAR', 'constraint' => 64, 'null' => false],
'active_posted_key' => ['type' => 'VARCHAR', 'constraint' => 100, 'null' => true],
'books_discount_eligible' => ['type' => 'TINYINT', 'constraint' => 1, 'default' => 0, 'null' => false],
'status' => ['type' => 'VARCHAR', 'constraint' => 24, 'default' => 'preview', 'null' => false],
'superseded_by_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
'override_reason' => ['type' => 'TEXT', 'null' => true],
'overridden_at' => ['type' => 'DATETIME', 'null' => true],
'overridden_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
'calculated_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
'posted_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
'calculated_at' => ['type' => 'DATETIME', 'null' => false],
'posted_at' => ['type' => 'DATETIME', 'null' => true],
'created_at' => ['type' => 'DATETIME', 'null' => true],
'updated_at' => ['type' => 'DATETIME', 'null' => true],
]);
$this->forge->addKey('id', true);
$this->forge->addUniqueKey(['enrollment_id', 'version'], 'uq_withdrawal_calc_version');
$this->forge->addUniqueKey('active_posted_key', 'uq_withdrawal_calc_active_posted');
$this->forge->addKey('calculation_hash');
$this->forge->addKey(['enrollment_id', 'status'], false, false, 'idx_withdrawal_calc_status');
$this->forge->addKey(['invoice_id', 'status'], false, false, 'idx_withdrawal_calc_invoice');
$this->forge->createTable('withdrawal_financial_calculations', true, ['ENGINE' => 'InnoDB']);
}
private function extendRefunds(): void
{
if (! $this->db->tableExists('refunds') || $this->db->fieldExists('withdrawal_calculation_id', 'refunds')) {
return;
}
$this->forge->addColumn('refunds', [
'withdrawal_calculation_id' => [
'type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true, 'after' => 'invoice_id',
],
]);
$this->addIndexIfMissing('refunds', 'idx_refunds_withdrawal_calculation', ['withdrawal_calculation_id']);
}
private function seedActiveYearPolicy(): void
{
if (! $this->db->tableExists('school_years') || ! $this->db->tableExists('configuration')) {
return;
}
$row = $this->db->table('configuration')
->select('config_value')
->where('config_key', 'total_instructional_weeks')
->orderBy('id', 'DESC')
->get(1)
->getRowArray();
$weeks = filter_var($row['config_value'] ?? null, FILTER_VALIDATE_INT);
if ($weeks === false || $weeks <= 0) {
return;
}
$this->db->table('school_years')
->where('status', 'active')
->where('total_instructional_weeks IS NULL', null, false)
->update([
'total_instructional_weeks' => $weeks,
'annual_fee_includes_books' => 1,
'withdrawal_policy_version' => 'studied_weeks_v1',
]);
}
private function seedActiveBookYears(): void
{
if (! $this->db->tableExists('inventory_item_years') || ! $this->db->tableExists('inventory_items')) {
return;
}
$year = $this->db->table('school_years')
->select('id, name')
->where('status', 'active')
->orderBy('id', 'DESC')
->get(1)
->getRowArray();
if ($year === null || trim((string) ($year['name'] ?? '')) === '') {
return;
}
$books = $this->db->table('inventory_items')
->select('id, quantity')
->where('type', 'book')
->get()
->getResultArray();
$now = date('Y-m-d H:i:s');
foreach ($books as $book) {
$exists = $this->db->table('inventory_item_years')
->where('inventory_item_id', (int) $book['id'])
->where('school_year', (string) $year['name'])
->countAllResults();
if ($exists > 0) {
continue;
}
$movementRow = $this->db->table('inventory_movements')
->selectSum('qty_change', 'year_movement_total')
->where('item_id', (int) $book['id'])
->where('school_year', (string) $year['name'])
->get()->getRowArray();
// inventory_items.quantity is the legacy current physical balance.
// Reconstruct the opening so linking existing movements to this
// item-year does not count those movements a second time.
$openingQuantity = (int) ($book['quantity'] ?? 0) - (int) ($movementRow['year_movement_total'] ?? 0);
$this->db->table('inventory_item_years')->insert([
'inventory_item_id' => (int) $book['id'],
'school_year_id' => (int) $year['id'],
'school_year' => (string) $year['name'],
'opening_quantity' => $openingQuantity,
'charge_price_cents' => 0,
'price_confirmed' => 0,
'status' => 'open',
'created_at' => $now,
'updated_at' => $now,
]);
$itemYearId = (int) $this->db->insertID();
$this->db->table('inventory_movements')
->where('item_id', (int) $book['id'])
->where('school_year', (string) $year['name'])
->where('item_year_id', null)
->update(['item_year_id' => $itemYearId, 'updated_at' => $now]);
}
}
/** @param list<string> $columns */
private function addIndexIfMissing(string $table, string $index, array $columns, bool $unique = false): void
{
try {
$exists = $this->db->query(
'SELECT 1 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ? LIMIT 1',
[$table, $index]
)->getRowArray();
if ($exists !== null) {
return;
}
$columnSql = implode(', ', array_map(static fn (string $column): string => '`' . str_replace('`', '', $column) . '`', $columns));
$this->db->query(sprintf(
'CREATE %s INDEX `%s` ON `%s` (%s)',
$unique ? 'UNIQUE' : '',
str_replace('`', '', $index),
str_replace('`', '', $table),
$columnSql
));
} catch (\Throwable $e) {
log_message('warning', 'Unable to create inventory index {index}: {message}', [
'index' => $index,
'message' => $e->getMessage(),
]);
}
}
}