pdo = $pdo; $this->company_id = $company_id; } // ───────────────────────────────────────────────────────────── // Private helpers // ───────────────────────────────────────────────────────────── /** * Check if a SKU has any active stock across all td_stock_* tables. * Returns the first blocking table name found, or null if clear. */ private function findActiveStock(string $sku): ?string { $sth = $this->pdo->prepare( "SELECT table_name FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name LIKE 'td_stock_%'" ); $sth->execute(); $tables = $sth->fetchAll(PDO::FETCH_COLUMN); foreach ($tables as $table) { $sth = $this->pdo->prepare( "SELECT COUNT(*) FROM `$table` WHERE company_id = :company_id AND product_sku = :sku" ); $sth->execute([ ':company_id' => $this->company_id, ':sku' => $sku, ]); if ($sth->fetchColumn() > 0) { return $table; } } return null; } /** * Build a log entry array for audit context. */ private function buildLogEntry(string $action): array { return [ 'user_id' => $_SESSION['login_user_id'] ?? null, 'dt' => date('Y-m-d H:i:s'), 'login' => isset($_SESSION['otpTime']) ? date('Y-m-d H:i:s', $_SESSION['otpTime']) : null, 'action' => $action, ]; } // ───────────────────────────────────────────────────────────── // Product category // ───────────────────────────────────────────────────────────── /** * Soft-delete a product category. * * Blocks if any md_product references this category. * * @throws Exception if category not found or has dependent products */ public function deleteCategory(int $category_id): void { $sth = $this->pdo->prepare( "SELECT id, category, `log` FROM md_product_category WHERE company_id = :company_id AND id = :id" ); $sth->execute([ ':company_id' => $this->company_id, ':id' => $category_id, ]); $row = $sth->fetch(PDO::FETCH_ASSOC); if (!$row) { throw new Exception("Product category not found."); } // Block if any product references this category $sth = $this->pdo->prepare( "SELECT COUNT(*) FROM md_product WHERE company_id = :company_id AND category = :category_id" ); $sth->execute([ ':company_id' => $this->company_id, ':category_id' => $category_id, ]); if ($sth->fetchColumn() > 0) { throw new Exception( "Cannot delete — category \"{$row['category']}\" " . "still has products assigned to it." ); } // Append delete event to log $log = json_decode($row['log'] ?? '[]', true) ?: []; $log[] = $this->buildLogEntry('delete'); // Soft-delete $this->pdo->prepare( "UPDATE md_product_category SET company_id = company_id * -1, `log` = :log WHERE id = :id AND company_id = :company_id" )->execute([ ':log' => json_encode($log), ':id' => $category_id, ':company_id' => $this->company_id, ]); } // ───────────────────────────────────────────────────────────── // Product // ───────────────────────────────────────────────────────────── /** * Soft-delete a product. * * Blocks if the product SKU has any active stock across any td_stock_* table. * * @throws Exception if product not found or has active stock */ public function deleteProduct(int $product_id): void { $sth = $this->pdo->prepare( "SELECT id, product_name, sku, `log` FROM md_product WHERE company_id = :company_id AND id = :id" ); $sth->execute([ ':company_id' => $this->company_id, ':id' => $product_id, ]); $row = $sth->fetch(PDO::FETCH_ASSOC); if (!$row) { throw new Exception("Product not found."); } // Block if active stock exists for this SKU in any warehouse $blocking_table = $this->findActiveStock($row['sku']); if ($blocking_table !== null) { throw new Exception( "Cannot delete — \"{$row['product_name']}\" " . "still has active stock in the system." ); } // Append delete event to log $log = json_decode($row['log'] ?? '[]', true) ?: []; $log[] = $this->buildLogEntry('delete'); // Soft-delete $this->pdo->prepare( "UPDATE md_product SET company_id = company_id * -1, `log` = :log WHERE id = :id AND company_id = :company_id" )->execute([ ':log' => json_encode($log), ':id' => $product_id, ':company_id' => $this->company_id, ]); } // ───────────────────────────────────────────────────────────── // Product queries // ───────────────────────────────────────────────────────────── /** * Full product list with current warehouse balance. */ public function getProductList(): array { $sth = $this->pdo->prepare( "SELECT a.*, IFNULL(SUM(b.total_in - b.total_out), 0) AS product_balance FROM md_product a LEFT JOIN warehouse_balance b ON a.company_id = b.company_id AND a.sku = b.product_sku WHERE a.company_id = :company_id GROUP BY a.id" ); $sth->execute([':company_id' => $this->company_id]); return $sth->fetchAll(PDO::FETCH_ASSOC); } /** * Fetch a single product row by ID. */ public function getProductById(int $id): array|false { $sth = $this->pdo->prepare( "SELECT * FROM md_product WHERE company_id = :company_id AND id = :id" ); $sth->execute([':company_id' => $this->company_id, ':id' => $id]); return $sth->fetch(PDO::FETCH_ASSOC); } /** * Search products by SKU keyword — for autocomplete. */ public function searchProduct(string $keyword): array { $sth = $this->pdo->prepare( "SELECT * FROM md_product WHERE company_id = :company_id AND sku LIKE :keyword LIMIT 50" ); $sth->execute([ ':company_id' => $this->company_id, ':keyword' => '%' . $keyword . '%', ]); return $sth->fetchAll(PDO::FETCH_ASSOC); } // ───────────────────────────────────────────────────────────── // Product category queries // ───────────────────────────────────────────────────────────── /** * Full category list with product count per category. */ public function getCategoryList(): array { $sth = $this->pdo->prepare( "SELECT a.*, COUNT(b.id) AS product_count FROM md_product_category a LEFT JOIN md_product b ON a.company_id = b.company_id AND a.id = b.category WHERE a.company_id = :company_id GROUP BY a.id" ); $sth->execute([':company_id' => $this->company_id]); return $sth->fetchAll(PDO::FETCH_ASSOC); } /** * Fetch a single category row by ID. */ public function getCategoryById(int $id): array|false { $sth = $this->pdo->prepare( "SELECT * FROM md_product_category WHERE company_id = :company_id AND id = :id" ); $sth->execute([':company_id' => $this->company_id, ':id' => $id]); return $sth->fetch(PDO::FETCH_ASSOC); } /** * Insert or update a product category. * Pass $data['id'] > 0 for update, 0 for insert. * Must be called inside dbTransaction() by the caller. * * @throws Exception on validation failure */ public function saveCategory(array $data, array $logging): void { $id = (int)($data['id'] ?? 0); $sth = $this->pdo->prepare( "SELECT `log` FROM md_product_category WHERE company_id = :company_id AND id = :id" ); $sth->execute([':company_id' => $this->company_id, ':id' => $id]); $table_log = json_decode($sth->fetchColumn() ?: '[]', true) ?: []; $table_log[] = $logging; $params = [ ':company_id' => $this->company_id, ':category' => $data['category'], ':slug' => $data['slug'], ':description' => $data['description'], ':status' => (int)$data['status'], ':log' => json_encode($table_log), ]; if ($id > 0) { $params[':id'] = $id; $this->pdo->prepare( "UPDATE md_product_category SET `category` = :category, `slug` = :slug, `description` = :description, `status` = :status, `log` = :log WHERE id = :id AND company_id = :company_id" )->execute($params); } else { $this->pdo->prepare( "INSERT INTO md_product_category (company_id, category, slug, `description`, `status`, `log`) VALUES (:company_id, :category, :slug, :description, :status, :log)" )->execute($params); } } // ───────────────────────────────────────────────────────────── // Product write // ───────────────────────────────────────────────────────────── /** * Insert or update a product. * Pass $data['id'] > 0 for update, 0 for insert. * Must be called inside dbTransaction() by the caller. */ public function saveProduct(array $data, array $logging, string $product_image): void { $id = (int)($data['id'] ?? 0); $sth = $this->pdo->prepare( "SELECT `log` FROM md_product WHERE company_id = :company_id AND id = :id" ); $sth->execute([':company_id' => $this->company_id, ':id' => $id]); $table_log = json_decode($sth->fetchColumn() ?: '[]', true) ?: []; $table_log[] = $logging; $params = [ ':company_id' => $this->company_id, ':product_name' => $data['product_name'], ':sku' => $data['sku'], ':price' => $data['price'], ':min_stock' => $data['min_stock'], ':reorder_point' => $data['reorder_point'], ':category' => $data['category'], ':product_image' => $product_image, ':description' => $data['description'], ':status' => (int)$data['status'], ':log' => json_encode($table_log), ]; if ($id > 0) { $params[':id'] = $id; $this->pdo->prepare( "UPDATE md_product SET product_name = :product_name, sku = :sku, price = :price, min_stock = :min_stock, reorder_point = :reorder_point, category = :category, product_image = :product_image, `description` = :description, `status` = :status, `log` = :log WHERE id = :id AND company_id = :company_id" )->execute($params); } else { $this->pdo->prepare( "INSERT INTO md_product (company_id, product_name, sku, price, min_stock, reorder_point, category, product_image, `description`, `status`, `log`) VALUES (:company_id, :product_name, :sku, :price, :min_stock, :reorder_point, :category, :product_image, :description, :status, :log)" )->execute($params); } } /** * Full rack list with warehouse name, product info and occupancy status. */ public function getRackOccupancy(): array { $sth = $this->pdo->prepare( "SELECT r.id, r.zone, r.aisle, r.rack, r.product_sku, r.td_stock_id, mw.warehouse_name, mw.id AS warehouse_id, p.product_name, CASE WHEN r.product_sku IS NOT NULL THEN 'occupied' ELSE 'empty' END AS status FROM md_rack r INNER JOIN md_warehouse mw ON mw.company_id = r.company_id AND mw.id = r.warehouse LEFT JOIN md_product p ON p.company_id = r.company_id AND p.sku = r.product_sku WHERE r.company_id = :company_id ORDER BY mw.warehouse_name, r.zone, CAST(r.aisle AS UNSIGNED), r.aisle, CAST(r.rack AS UNSIGNED), r.rack" ); $sth->execute([':company_id' => $this->companyId]); return $sth->fetchAll(PDO::FETCH_ASSOC); } }