422 lines
15 KiB
PHP
422 lines
15 KiB
PHP
<?php
|
|
|
|
/**
|
|
* ProductManager
|
|
*
|
|
* Encapsulates product and product category operations:
|
|
* - Soft-delete with downstream validation
|
|
*
|
|
* Note: Methods that modify data do NOT manage their own DB transactions.
|
|
* Callers are responsible for wrapping operations in dbTransaction() when atomicity is needed.
|
|
*/
|
|
class ProductManager {
|
|
|
|
private $pdo;
|
|
private $company_id;
|
|
|
|
public function __construct($pdo, $company_id) {
|
|
$this->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);
|
|
}
|
|
} |