diff --git a/db/migrations/20260502_zz_financial_documents_schema_sync.php b/db/migrations/20260502_zz_financial_documents_schema_sync.php new file mode 100644 index 0000000..be2145d --- /dev/null +++ b/db/migrations/20260502_zz_financial_documents_schema_sync.php @@ -0,0 +1,216 @@ + [ + 'transaction_no' => 'VARCHAR(50) DEFAULT NULL', + 'payment_type' => 'VARCHAR(100) DEFAULT NULL', + 'vat_amount' => 'DECIMAL(15,3) DEFAULT 0.000', + 'total_with_vat' => 'DECIMAL(15,3) DEFAULT 0.000', + 'terms_conditions' => 'TEXT NULL', + 'paid_amount' => 'DECIMAL(15,3) DEFAULT 0.000', + 'status' => "ENUM('paid','unpaid','partially_paid','refunded','cancelled') DEFAULT 'unpaid'", + 'register_session_id' => 'INT(11) DEFAULT NULL', + 'is_pos' => 'TINYINT(1) DEFAULT 0', + 'discount_amount' => 'DECIMAL(15,3) DEFAULT 0.000', + 'loyalty_points_earned' => 'DECIMAL(15,3) DEFAULT 0.000', + 'loyalty_points_redeemed' => 'DECIMAL(15,3) DEFAULT 0.000', + 'created_by' => 'INT(11) DEFAULT NULL', + 'outlet_id' => 'INT(11) DEFAULT NULL', + ], + 'purchases' => [ + 'payment_type' => 'VARCHAR(100) DEFAULT NULL', + 'total_amount' => 'DECIMAL(15,3) DEFAULT 0.000', + 'vat_amount' => 'DECIMAL(15,3) DEFAULT 0.000', + 'total_with_vat' => 'DECIMAL(15,3) DEFAULT 0.000', + 'terms_conditions' => 'TEXT NULL', + 'paid_amount' => 'DECIMAL(15,3) DEFAULT 0.000', + 'status' => "ENUM('paid','unpaid','partially_paid') DEFAULT 'unpaid'", + 'register_session_id' => 'INT(11) DEFAULT NULL', + 'due_date' => 'DATE DEFAULT NULL', + 'outlet_id' => 'INT(11) DEFAULT 1', + ], + 'lpos' => [ + 'delivery_date' => 'DATE DEFAULT NULL', + 'status' => "ENUM('pending','converted','cancelled') DEFAULT 'pending'", + 'total_amount' => 'DECIMAL(15,3) DEFAULT 0.000', + 'vat_amount' => 'DECIMAL(15,3) DEFAULT 0.000', + 'total_with_vat' => 'DECIMAL(15,3) DEFAULT 0.000', + 'terms_conditions' => 'TEXT NULL', + 'outlet_id' => 'INT(11) DEFAULT NULL', + ], + 'quotations' => [ + 'status' => "ENUM('pending','converted','expired','cancelled') DEFAULT 'pending'", + 'total_amount' => 'DECIMAL(15,3) DEFAULT 0.000', + 'vat_amount' => 'DECIMAL(15,3) DEFAULT 0.000', + 'total_with_vat' => 'DECIMAL(15,3) DEFAULT 0.000', + 'terms_conditions' => 'TEXT NULL', + 'outlet_id' => 'INT(11) DEFAULT NULL', + ], + ]; + + foreach ($columns as $table => $tableColumns) { + if (!financial_documents_schema_sync_20260502_table_exists($pdo, $table)) { + continue; + } + + foreach ($tableColumns as $column => $definition) { + if (financial_documents_schema_sync_20260502_column_exists($pdo, $table, $column)) { + continue; + } + + $statement = sprintf( + 'ALTER TABLE `%s` ADD COLUMN `%s` %s', + str_replace('`', '', $table), + str_replace('`', '', $column), + $definition + ); + + financial_documents_schema_sync_20260502_exec($pdo, $statement); + } + } + + foreach (['invoices', 'purchases', 'lpos', 'quotations'] as $table) { + financial_documents_schema_sync_20260502_backfill_total_with_vat($pdo, $table); + } + + foreach (['invoices', 'purchases'] as $table) { + financial_documents_schema_sync_20260502_backfill_paid_amount($pdo, $table); + } + + foreach (['purchases', 'lpos', 'quotations'] as $table) { + financial_documents_schema_sync_20260502_default_outlet_id($pdo, $table); + } + } + + function financial_documents_schema_sync_20260502_table_exists(PDO $pdo, string $table): bool + { + $stmt = $pdo->prepare( + 'SELECT 1 FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :table LIMIT 1' + ); + $stmt->execute(['table' => $table]); + + return (bool) $stmt->fetchColumn(); + } + + function financial_documents_schema_sync_20260502_column_exists(PDO $pdo, string $table, string $column): bool + { + $stmt = $pdo->prepare( + 'SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :table AND COLUMN_NAME = :column LIMIT 1' + ); + $stmt->execute([ + 'table' => $table, + 'column' => $column, + ]); + + return (bool) $stmt->fetchColumn(); + } + + function financial_documents_schema_sync_20260502_exec(PDO $pdo, string $statement): void + { + try { + $pdo->exec($statement); + } catch (PDOException $exception) { + if (financial_documents_schema_sync_20260502_is_ignorable($exception)) { + return; + } + + throw $exception; + } + } + + function financial_documents_schema_sync_20260502_is_ignorable(PDOException $exception): bool + { + $driverCode = isset($exception->errorInfo[1]) ? (int) $exception->errorInfo[1] : null; + $message = strtolower($exception->getMessage()); + $ignorableCodes = [1050, 1060, 1061, 1062, 1091, 1826]; + $ignorableSnippets = [ + 'already exists', + 'duplicate column name', + 'duplicate key name', + 'duplicate entry', + 'duplicate foreign key constraint name', + 'duplicate key on write or update', + 'errno: 121', + 'check that column/key exists', + ]; + + if ($driverCode !== null && in_array($driverCode, $ignorableCodes, true)) { + return true; + } + + foreach ($ignorableSnippets as $snippet) { + if (str_contains($message, $snippet)) { + return true; + } + } + + return false; + } + + function financial_documents_schema_sync_20260502_backfill_total_with_vat(PDO $pdo, string $table): void + { + if ( + !financial_documents_schema_sync_20260502_table_exists($pdo, $table) + || !financial_documents_schema_sync_20260502_column_exists($pdo, $table, 'total_amount') + || !financial_documents_schema_sync_20260502_column_exists($pdo, $table, 'vat_amount') + || !financial_documents_schema_sync_20260502_column_exists($pdo, $table, 'total_with_vat') + ) { + return; + } + + $sql = sprintf( + 'UPDATE `%1$s` SET `total_with_vat` = COALESCE(`total_amount`, 0) + COALESCE(`vat_amount`, 0) WHERE `total_with_vat` IS NULL OR (`total_with_vat` = 0 AND (COALESCE(`total_amount`, 0) <> 0 OR COALESCE(`vat_amount`, 0) <> 0))', + str_replace('`', '', $table) + ); + + $pdo->exec($sql); + } + + function financial_documents_schema_sync_20260502_backfill_paid_amount(PDO $pdo, string $table): void + { + if ( + !financial_documents_schema_sync_20260502_table_exists($pdo, $table) + || !financial_documents_schema_sync_20260502_column_exists($pdo, $table, 'status') + || !financial_documents_schema_sync_20260502_column_exists($pdo, $table, 'paid_amount') + || !financial_documents_schema_sync_20260502_column_exists($pdo, $table, 'total_with_vat') + ) { + return; + } + + $sql = sprintf( + 'UPDATE `%1$s` SET `paid_amount` = COALESCE(`total_with_vat`, 0) WHERE `status` = \'paid\' AND (`paid_amount` IS NULL OR `paid_amount` = 0)', + str_replace('`', '', $table) + ); + + $pdo->exec($sql); + } + + function financial_documents_schema_sync_20260502_default_outlet_id(PDO $pdo, string $table): void + { + if ( + !financial_documents_schema_sync_20260502_table_exists($pdo, $table) + || !financial_documents_schema_sync_20260502_column_exists($pdo, $table, 'outlet_id') + ) { + return; + } + + $sql = sprintf( + 'UPDATE `%1$s` SET `outlet_id` = 1 WHERE `outlet_id` IS NULL', + str_replace('`', '', $table) + ); + + $pdo->exec($sql); + } +} + +financial_documents_schema_sync_20260502_run(); + +return true; diff --git a/index.php b/index.php index c218780..3fad45c 100644 --- a/index.php +++ b/index.php @@ -82,6 +82,27 @@ if (!function_exists('db_table_exists')) { } } +if (!function_exists('db_column_exists')) { + function db_column_exists(string $tableName, string $columnName): bool { + static $cache = []; + + $cacheKey = strtolower($tableName . '.' . $columnName); + if (array_key_exists($cacheKey, $cache)) { + return $cache[$cacheKey]; + } + + try { + $stmt = db()->prepare("SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ? LIMIT 1"); + $stmt->execute([$tableName, $columnName]); + $cache[$cacheKey] = (bool)$stmt->fetchColumn(); + } catch (Throwable $e) { + $cache[$cacheKey] = false; + } + + return $cache[$cacheKey]; + } +} + if (!function_exists('runtime_debug_can_render_details')) { function runtime_debug_can_render_details(): bool { if (PHP_SAPI === 'cli') { @@ -454,15 +475,31 @@ function can(string $permission): bool { } function getPurchaseAlerts() { - if (!can('dashboard_view')) return []; + if (!can('dashboard_view') || !db_table_exists('purchases')) return []; + $db = db(); - $stmt = $db->query("SELECT p.id, p.due_date, p.total_with_vat, s.name as supplier_name - FROM purchases p - LEFT JOIN suppliers s ON p.supplier_id = s.id - WHERE p.status != 'paid' - AND p.due_date IS NOT NULL - AND p.due_date <= DATE_ADD(CURDATE(), INTERVAL 7 DAY) - ORDER BY p.due_date ASC"); + $hasSupplierJoin = db_table_exists('suppliers') && db_column_exists('purchases', 'supplier_id'); + $dueDateExpression = db_column_exists('purchases', 'due_date') ? 'p.due_date' : 'NULL'; + $statusPredicate = db_column_exists('purchases', 'status') ? "WHERE p.status != 'paid'" : 'WHERE 1=1'; + $dueDatePredicate = db_column_exists('purchases', 'due_date') + ? ' AND p.due_date IS NOT NULL AND p.due_date <= DATE_ADD(CURDATE(), INTERVAL 7 DAY)' + : ''; + $totalExpression = db_column_exists('purchases', 'total_with_vat') + ? 'p.total_with_vat' + : (db_column_exists('purchases', 'total_amount') ? 'COALESCE(p.total_amount, 0)' : '0'); + $supplierExpression = $hasSupplierJoin ? 's.name' : 'NULL'; + $joinClause = $hasSupplierJoin ? ' LEFT JOIN suppliers s ON p.supplier_id = s.id' : ''; + $orderBy = db_column_exists('purchases', 'due_date') ? ' ORDER BY p.due_date ASC' : ' ORDER BY p.id DESC'; + + $sql = "SELECT p.id, {$dueDateExpression} AS due_date, {$totalExpression} AS total_with_vat, {$supplierExpression} AS supplier_name" + . ' FROM purchases p' + . $joinClause + . ' ' + . $statusPredicate + . $dueDatePredicate + . $orderBy; + + $stmt = $db->query($sql); return $stmt->fetchAll(PDO::FETCH_ASSOC); } diff --git a/post_debug.log b/post_debug.log index 9a75624..ff49ba4 100644 --- a/post_debug.log +++ b/post_debug.log @@ -150,3 +150,4 @@ 2026-05-01 19:32:47 - POST: {"username":"admin","password":"admin","login":"1"} 2026-05-02 02:56:23 - POST: {"username":"admin","password":"admin","login":"1"} 2026-05-02 03:00:39 - POST: {"username":"admin","password":"admin","login":"1"} +2026-05-02 03:32:58 - POST: {"username":"admin","password":"admin","login":"1"}