pdo = $pdo; $this->company_id = $company_id; } // ───────────────────────────────────────────────────────────── // Private helpers // ───────────────────────────────────────────────────────────── /** * Scan all td_stock_* warehouse tables for any active stock row * that references the given SKU. * * Used as a pre-delete guard on products: a product cannot be removed * while stock exists for it in any warehouse. * Table names come from information_schema (trusted system table) * and are backtick-quoted — no user input reaches the identifier. * * @param string $sku The product SKU to search for. * @return string|null 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 standard audit log entry array for append-to-JSON log columns. * * Captures the acting user_id, current datetime, session login time, * and a short action label (e.g. 'delete', 'update'). * * Usage: * $log[] = $this->buildLogEntry('delete'); * $params[':log'] = json_encode($log); * * @param string $action Short label describing the operation (e.g. 'delete'). * @return array Associative array ready to be appended to a log array. */ 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, ]; } // ───────────────────────────────────────────────────────────── // MASTER FILE BASIS — Product Category // ───────────────────────────────────────────────────────────── /** * Return all product categories with their product count. * * Used to populate the category listing page and category dropdowns. * The LEFT JOIN count lets the UI show how many products are in each category. * * @return array All md_product_category rows for this company, * each augmented with a 'product_count' field. */ 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 product category row by its primary key. * * Used to pre-fill the edit form on the manage category page. * * @param int $id The md_product_category.id to fetch. * @return array|false Associative row, or false if not found. */ 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 a new product category or update an existing one. * * Pass $data['id'] = 0 to insert; pass $data['id'] > 0 to update. * The $logging array is appended to the row's JSON log column. * * Must be called inside dbTransaction() by the caller. * * @param array $data Keys: id, category, slug, description, status. * @param array $logging Audit entry to append to the log column. * @throws Exception On validation failure (e.g. duplicate slug). */ public function saveCategory(array $data, array $logging): void { $id = (int)($data['id'] ?? 0); $slug = (string)($data['slug'] ?? ''); $dupSth = $this->pdo->prepare( "SELECT id FROM md_product_category WHERE company_id = :cid AND slug = :slug" . ($id > 0 ? " AND id != :id" : "") . " LIMIT 1" ); $dupParams = [':cid' => $this->company_id, ':slug' => $slug]; if ($id > 0) $dupParams[':id'] = $id; $dupSth->execute($dupParams); if ($dupSth->fetchColumn()) throw new Exception("A category with this slug already exists."); $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); } } /** * Soft-delete a product category by negating its company_id. * * Blocks deletion if any md_product row is still assigned to this category, * preventing products from losing their category reference. * * Must be called inside dbTransaction() by the caller. * * @param int $category_id The md_product_category.id to delete. * @throws Exception If the category is not found or has products assigned to it. */ 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: negate company_id so row is hidden but recoverable $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, ]); } // ───────────────────────────────────────────────────────────── // MASTER FILE BASIS — Product // ───────────────────────────────────────────────────────────── /** * Return all products with their current aggregate warehouse balance. * * The balance is the sum of (total_in - total_out) across all warehouses * from the etl_stock_summary table. Products with no balance rows show 0. * * Used to populate the product listing page. * * @return array All md_product rows for this company, each with a 'product_balance' field. */ 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 etl_stock_summary 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 its primary key. * * Used to pre-fill the edit form on the manage product page. * * @param int $id The md_product.id to fetch. * @return array|false Associative row, or false if not found. */ 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); } public function getProductImage(int $id): string { $sth = $this->pdo->prepare( "SELECT product_image FROM md_product WHERE company_id = :company_id AND id = :id" ); $sth->execute([':company_id' => $this->company_id, ':id' => $id]); return (string)($sth->fetchColumn() ?: ''); } /** * Search products by SKU keyword — for live autocomplete on stock forms. * * Returns up to 50 matches. The keyword is safely bound as a LIKE parameter; * no wildcard escaping is needed here since '%' wrapping is the intended behaviour. * * @param string $keyword Partial SKU to match. * @return array Matching md_product rows. */ public function searchProduct(string $keyword): array { $sth = $this->pdo->prepare( "SELECT * FROM md_product WHERE company_id = :company_id AND (sku LIKE :keyword OR barcode LIKE :keyword) AND status > 0 LIMIT 50" ); $sth->execute([ ':company_id' => $this->company_id, ':keyword' => '%' . $keyword . '%', ]); return $sth->fetchAll(PDO::FETCH_ASSOC); } /** * Insert a new product or update an existing one. * * Pass $data['id'] = 0 to insert; pass $data['id'] > 0 to update. * $product_image is the resolved filename/path from FileUploader — may be * the existing image when no new file was uploaded. * The $logging array is appended to the row's JSON log column. * * Must be called inside dbTransaction() by the caller. * * @param array $data Keys: id, product_name, sku, price, min_stock, * reorder_point, category, description, status. * @param array $logging Audit entry to append to the log column. * @param string $product_image Stored filename for the product image. */ public function saveProduct(array $data, array $logging, string $product_image): void { $id = (int)($data['id'] ?? 0); $sku = (string)($data['sku'] ?? ''); $dupSth = $this->pdo->prepare( "SELECT id FROM md_product WHERE company_id = :cid AND sku = :sku" . ($id > 0 ? " AND id != :id" : "") . " LIMIT 1" ); $dupParams = [':cid' => $this->company_id, ':sku' => $sku]; if ($id > 0) $dupParams[':id'] = $id; $dupSth->execute($dupParams); if ($dupSth->fetchColumn()) throw new Exception("A product with this SKU already exists."); $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'], ':barcode' => trim($data['barcode'] ?? '') ?: null, ':uom' => $data['uom'] ?? 'pcs', ':price' => $data['price'], ':cost_price' => (float)($data['cost_price'] ?? 0), ':min_stock' => $data['min_stock'], ':reorder_point' => $data['reorder_point'], ':category' => $data['category'], ':product_image' => $product_image, ':description' => $data['description'], ':sales_account_code' => trim((string)($data['sales_account_code'] ?? '')) ?: null, ':purchase_account_code' => trim((string)($data['purchase_account_code'] ?? '')) ?: null, ':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, barcode = :barcode, uom = :uom, price = :price, cost_price = :cost_price, min_stock = :min_stock, reorder_point = :reorder_point, category = :category, product_image = :product_image, sales_account_code = :sales_account_code, purchase_account_code = :purchase_account_code, `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, barcode, uom, price, cost_price, min_stock, reorder_point, category, product_image, sales_account_code, purchase_account_code, `description`, `status`, `log`) VALUES (:company_id, :product_name, :sku, :barcode, :uom, :price, :cost_price, :min_stock, :reorder_point, :category, :product_image, :sales_account_code, :purchase_account_code, :description, :status, :log)" )->execute($params); } } /** * Update only the accounting account mapping fields for a product. * * This is used by Accounting so product master data can stay locked while * finance users maintain GL routing for SKU-based posting. */ public function updateAccountMapping(int $id, ?string $sales_account_code, ?string $purchase_account_code, array $logging): void { $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]); $existing_log = $sth->fetchColumn(); if ($existing_log === false) { throw new Exception('Product not found.'); } $table_log = json_decode($existing_log ?: '[]', true) ?: []; $table_log[] = $logging; $sth = $this->pdo->prepare( "UPDATE md_product SET sales_account_code = :sales_account_code, purchase_account_code = :purchase_account_code, `log` = :log WHERE id = :id AND company_id = :company_id" ); $sth->execute([ ':company_id' => $this->company_id, ':id' => $id, ':sales_account_code' => $sales_account_code, ':purchase_account_code' => $purchase_account_code, ':log' => json_encode($table_log), ]); } /** * Soft-delete a product by negating its company_id. * * Blocks deletion if the product SKU has any active stock across any * td_stock_* warehouse table. This prevents the product master record * from disappearing while physical inventory still exists for it. * * Must be called inside dbTransaction() by the caller. * * @param int $product_id The md_product.id to delete. * @throws Exception If the product is 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: negate company_id so row is hidden but recoverable $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, ]); } // ───────────────────────────────────────────────────────────── // REPORT BASIS // ───────────────────────────────────────────────────────────── /** * Return all rack slots across all warehouses with occupancy status, * warehouse name, and product name. * * Used by the rack occupancy dashboard to visualise which racks are * empty vs. occupied, and which product is in each slot. * Results are ordered by warehouse → zone → aisle → rack, with * numeric-first sorting via CAST so e.g. "2" sorts before "10". * * Security fix: was previously using $this->companyId (undefined property), * corrected to $this->company_id. * * @return array All md_rack rows joined to warehouse and product, with 'status' field. */ 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->company_id]); return $sth->fetchAll(PDO::FETCH_ASSOC); } }