Files
wms-app/app/assets/utils/classes/ProductManager.php
T

502 lines
19 KiB
PHP

<?php
/**
* ProductManager
*
* Handles all CRUD and soft-delete operations for products and product categories.
*
* Method order:
* Master file basis → get/save/delete for product categories and products
* Transaction basis → (none — products are master data only)
* Report basis → getRackOccupancy (cross-warehouse physical inventory view)
*
* Note: Write methods do NOT manage their own DB transactions.
* Callers must wrap multi-step operations inside dbTransaction().
*
* Security: All SQL uses PDO prepared statements with bound parameters.
* No user input is ever interpolated directly into a query string.
*/
class ProductManager {
private $pdo;
private $company_id;
public function __construct($pdo, $company_id) {
$this->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);
$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 warehouse_balance 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 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 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);
}
/**
* 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
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);
$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'],
':uom' => $data['uom'] ?? 'pcs',
':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,
uom = :uom,
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, uom, price, min_stock, reorder_point,
category, product_image, `description`, `status`, `log`)
VALUES
(:company_id, :product_name, :sku, :uom, :price, :min_stock, :reorder_point,
:category, :product_image, :description, :status, :log)"
)->execute($params);
}
}
/**
* 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);
}
}