modify classed and comments
This commit is contained in:
@@ -3,11 +3,18 @@
|
||||
/**
|
||||
* ProductManager
|
||||
*
|
||||
* Encapsulates product and product category operations:
|
||||
* - Soft-delete with downstream validation
|
||||
* Handles all CRUD and soft-delete operations for products and product categories.
|
||||
*
|
||||
* Note: Methods that modify data do NOT manage their own DB transactions.
|
||||
* Callers are responsible for wrapping operations in dbTransaction() when atomicity is needed.
|
||||
* 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 {
|
||||
|
||||
@@ -24,8 +31,16 @@ class ProductManager {
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check if a SKU has any active stock across all td_stock_* tables.
|
||||
* Returns the first blocking table name found, or null if clear.
|
||||
* 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 {
|
||||
|
||||
@@ -56,7 +71,17 @@ class ProductManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a log entry array for audit context.
|
||||
* 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 [
|
||||
@@ -70,180 +95,17 @@ class ProductManager {
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Product category
|
||||
// MASTER FILE BASIS — Product Category
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Soft-delete a product category.
|
||||
* Return all product categories with their product count.
|
||||
*
|
||||
* Blocks if any md_product references this category.
|
||||
* 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.
|
||||
*
|
||||
* @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.
|
||||
* @return array All md_product_category rows for this company,
|
||||
* each augmented with a 'product_count' field.
|
||||
*/
|
||||
public function getCategoryList(): array
|
||||
{
|
||||
@@ -261,7 +123,12 @@ class ProductManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single category row by ID.
|
||||
* 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
|
||||
{
|
||||
@@ -274,11 +141,16 @@ class ProductManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert or update a product category.
|
||||
* Pass $data['id'] > 0 for update, 0 for insert.
|
||||
* 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.
|
||||
*
|
||||
* @throws Exception on validation failure
|
||||
* @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
|
||||
{
|
||||
@@ -322,15 +194,153 @@ class ProductManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
]);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Product write
|
||||
// MASTER FILE BASIS — Product
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Insert or update a product.
|
||||
* Pass $data['id'] > 0 for update, 0 for insert.
|
||||
* 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
|
||||
{
|
||||
@@ -386,9 +396,77 @@ class ProductManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Full rack list with warehouse name, product info and occupancy status.
|
||||
* 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
|
||||
{
|
||||
@@ -416,7 +494,7 @@ class ProductManager {
|
||||
CAST(r.aisle AS UNSIGNED), r.aisle,
|
||||
CAST(r.rack AS UNSIGNED), r.rack"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->companyId]);
|
||||
$sth->execute([':company_id' => $this->company_id]);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user