[OOP] contact,inventory,ics,dashboard

This commit is contained in:
Thanakorn S
2026-04-28 15:44:57 +07:00
parent 3d3b497c08
commit 2061624641
49 changed files with 1946 additions and 2146 deletions
+188
View File
@@ -182,4 +182,192 @@ class ContactManager {
':company_id' => $this->company_id,
]);
}
// ─────────────────────────────────────────────────────────────
// Contact type queries
// ─────────────────────────────────────────────────────────────
/**
* Full contact type list.
*/
public function getContactTypeList(): array
{
$sth = $this->pdo->prepare(
"SELECT * FROM md_contact_type
WHERE company_id = :company_id"
);
$sth->execute([':company_id' => $this->company_id]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Fetch a single contact type row by ID.
*/
public function getContactTypeById(int $id): array|false
{
$sth = $this->pdo->prepare(
"SELECT * FROM md_contact_type
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 contact type.
* Pass $data['id'] > 0 for update, 0 for insert.
* Must be called inside dbTransaction() by the caller.
*/
public function saveContactType(array $data, array $logging): void
{
$id = (int)($data['id'] ?? 0);
$sth = $this->pdo->prepare(
"SELECT `log` FROM md_contact_type
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,
':contact_type' => $data['contact_type'],
':description' => $data['description'],
':status' => (int)$data['status'],
':log' => json_encode($table_log),
];
if ($id > 0) {
$params[':id'] = $id;
$this->pdo->prepare(
"UPDATE md_contact_type SET
`contact_type` = :contact_type,
`description` = :description,
`status` = :status,
`log` = :log
WHERE id = :id AND company_id = :company_id"
)->execute($params);
} else {
$this->pdo->prepare(
"INSERT INTO md_contact_type
(company_id, contact_type, `description`, `status`, `log`)
VALUES
(:company_id, :contact_type, :description, :status, :log)"
)->execute($params);
}
}
// ─────────────────────────────────────────────────────────────
// Contact queries
// ─────────────────────────────────────────────────────────────
/**
* Full contact list.
*/
public function getContactList(): array
{
$sth = $this->pdo->prepare(
"SELECT * FROM md_contact
WHERE company_id = :company_id"
);
$sth->execute([':company_id' => $this->company_id]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Fetch a single contact row by ID.
*/
public function getContactById(int $id): array|false
{
$sth = $this->pdo->prepare(
"SELECT * FROM md_contact
WHERE company_id = :company_id AND id = :id"
);
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
return $sth->fetch(PDO::FETCH_ASSOC);
}
/**
* Search contacts by name keyword — for autocomplete.
*/
public function searchContact(string $keyword): array
{
$sth = $this->pdo->prepare(
"SELECT * FROM md_contact
WHERE company_id = :company_id
AND contact_name LIKE :keyword
LIMIT 50"
);
$sth->execute([
':company_id' => $this->company_id,
':keyword' => '%' . $keyword . '%',
]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Insert or update a contact.
* Pass $data['id'] > 0 for update, 0 for insert.
* Must be called inside dbTransaction() by the caller.
*/
public function saveContact(array $data, array $logging, string $contact_image): void
{
$id = (int)($data['id'] ?? 0);
$sth = $this->pdo->prepare(
"SELECT `log` FROM md_contact
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,
':contact_name' => $data['contact_name'],
':tax_id' => $data['tax_id'],
':organization' => $data['organization'],
':branch' => $data['branch'],
':contact_type' => (int)$data['contact_type'],
':billing_address' => $data['billing_address'],
':shipping_location' => $data['shipping_location'],
':shipping_address' => $data['shipping_address'],
':remark' => $data['remark'],
':contact_image' => $contact_image,
':status' => (int)$data['status'],
':log' => json_encode($table_log),
];
if ($id > 0) {
$params[':id'] = $id;
$this->pdo->prepare(
"UPDATE md_contact SET
`contact_name` = :contact_name,
`tax_id` = :tax_id,
`organization` = :organization,
`branch` = :branch,
`contact_type` = :contact_type,
`billing_address` = :billing_address,
`shipping_location` = :shipping_location,
`shipping_address` = :shipping_address,
`remark` = :remark,
`contact_image` = :contact_image,
`status` = :status,
`log` = :log
WHERE id = :id AND company_id = :company_id"
)->execute($params);
} else {
$this->pdo->prepare(
"INSERT INTO md_contact
(company_id, contact_name, tax_id, organization, branch, contact_type,
billing_address, shipping_location, shipping_address, remark, contact_image, status, log)
VALUES
(:company_id, :contact_name, :tax_id, :organization, :branch, :contact_type,
:billing_address, :shipping_location, :shipping_address, :remark, :contact_image, :status, :log)"
)->execute($params);
}
}
}
+236
View File
@@ -183,4 +183,240 @@ class ProductManager {
':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);
}
}
+243 -28
View File
@@ -488,23 +488,29 @@ class ReportManager
$start = (clone $now)->modify('-12 months')->format('Y-m-d 00:00:00');
$end = $now->format('Y-m-d 23:59:59');
$sth = $this->pdo->query(
$sth = $this->pdo->prepare(
"SELECT DATE_FORMAT(date, '%b') AS label,
DATE_FORMAT(date, '%Y-%m') AS sort_key,
ROUND(SUM(`in`), 2) AS stock_in,
ROUND(SUM(`out`), 2) AS stock_out
FROM `{$table}`
WHERE company_id = {$this->companyId}
AND date >= '{$start}' AND date <= '{$end}'
GROUP BY sort_key, label
ORDER BY sort_key ASC"
FROM `{$table}`
WHERE company_id = :company_id
AND date >= :start AND date <= :end
GROUP BY sort_key, label
ORDER BY sort_key ASC"
);
$sth->execute([
':company_id' => $this->companyId,
':start' => $start,
':end' => $end,
]);
$dataByMonth = [];
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
$dataByMonth[$row['sort_key']] = $row;
}
$labels = []; $stock_in = []; $stock_out = [];
$labels = $stock_in = $stock_out = [];
for ($i = 11; $i >= 0; $i--) {
$dt = (clone $now)->modify("-{$i} months");
$key = $dt->format('Y-m');
@@ -525,29 +531,36 @@ class ReportManager
$start = (clone $now)->modify('-12 months')->format('Y-m-d 00:00:00');
$end = $now->format('Y-m-d 23:59:59');
$sth = $this->pdo->query(
$sth = $this->pdo->prepare(
"SELECT ROUND(COALESCE(SUM(`in`) - SUM(`out`), 0), 2) AS balance
FROM `{$table}`
WHERE company_id = {$this->companyId} AND date < '{$start}'"
FROM `{$table}`
WHERE company_id = :company_id AND date < :start"
);
$running = (float) $sth->fetchColumn();
$sth->execute([':company_id' => $this->companyId, ':start' => $start]);
$running = (float)$sth->fetchColumn();
$sth = $this->pdo->query(
$sth = $this->pdo->prepare(
"SELECT DATE_FORMAT(date, '%Y-%m') AS sort_key,
ROUND(SUM(`in`), 2) AS stock_in,
ROUND(SUM(`out`), 2) AS stock_out
FROM `{$table}`
WHERE company_id = {$this->companyId}
AND date >= '{$start}' AND date <= '{$end}'
GROUP BY sort_key
ORDER BY sort_key ASC"
FROM `{$table}`
WHERE company_id = :company_id
AND date >= :start AND date <= :end
GROUP BY sort_key
ORDER BY sort_key ASC"
);
$sth->execute([
':company_id' => $this->companyId,
':start' => $start,
':end' => $end,
]);
$dataByMonth = [];
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
$dataByMonth[$row['sort_key']] = $row;
}
$labels = []; $balance = [];
$labels = $balance = [];
for ($i = 11; $i >= 0; $i--) {
$dt = (clone $now)->modify("-{$i} months");
$key = $dt->format('Y-m');
@@ -566,23 +579,24 @@ class ReportManager
$table = $this->resolveWarehouseTable($warehouse_id);
if (!$table) return [];
$sth = $this->pdo->query(
$sth = $this->pdo->prepare(
"SELECT a.date, a.product_sku,
a.`in` AS stock_in, a.`out` AS stock_out,
a.type, a.description, b.product_name
FROM `{$table}` a
LEFT JOIN md_product b
ON a.company_id = b.company_id AND a.product_sku = b.sku
WHERE a.company_id = {$this->companyId}
AND (a.`in` > 0 OR a.`out` > 0)
ORDER BY a.date DESC, a.id DESC
LIMIT 10"
FROM `{$table}` a
LEFT JOIN md_product b
ON a.company_id = b.company_id AND a.product_sku = b.sku
WHERE a.company_id = :company_id
AND (a.`in` > 0 OR a.`out` > 0)
ORDER BY a.date DESC, a.id DESC
LIMIT 10"
);
$sth->execute([':company_id' => $this->companyId]);
$activities = [];
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
$is_in = (float)$row['stock_in'] > 0;
$qty = $is_in ? (float)$row['stock_in'] : (float)$row['stock_out'];
$is_in = (float)$row['stock_in'] > 0;
$qty = $is_in ? (float)$row['stock_in'] : (float)$row['stock_out'];
$activities[] = [
'product_name' => $row['product_name'] ?: $row['product_sku'],
'sku' => $row['product_sku'],
@@ -694,6 +708,207 @@ class ReportManager
return $items;
}
// ─────────────────────────────────────────────────────────────
// Warehouse balance summary
// ─────────────────────────────────────────────────────────────
/**
* Aggregate total_in, total_out and warehouse count across all warehouses.
*/
public function getWarehouseBalanceSummary(): array
{
$sth = $this->pdo->prepare(
"SELECT
SUM(total_in) AS total_in,
SUM(total_out) AS total_out,
COUNT(DISTINCT warehouse_id) AS total_warehouse
FROM warehouse_balance
WHERE company_id = :company_id"
);
$sth->execute([':company_id' => $this->companyId]);
return $sth->fetch(PDO::FETCH_ASSOC) ?: [];
}
// ─────────────────────────────────────────────────────────────
// Dashboard report queries
// ─────────────────────────────────────────────────────────────
/**
* All stock movements for a given product_sku + lot_number across all warehouses,
* sorted by date DESC.
*/
public function getLotStockLog(string $product_sku, string $lot_number): array
{
if (!$product_sku || !$lot_number) return [];
$sth = $this->pdo->prepare(
"SELECT id, warehouse_name FROM md_warehouse
WHERE company_id = :company_id AND status = 1"
);
$sth->execute([':company_id' => $this->companyId]);
$warehouses = $sth->fetchAll(PDO::FETCH_ASSOC);
$rows = [];
foreach ($warehouses as $wh) {
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']);
$table = "td_stock_{$safe}";
$sth = $this->pdo->prepare(
"SELECT
s.id,
s.date,
s.type,
s.zone,
s.aisle,
s.rack,
ROUND(COALESCE(s.`in`, 0), 2) AS stock_in,
ROUND(COALESCE(s.`out`, 0), 2) AS stock_out,
s.serial_number,
s.description,
'{$wh['warehouse_name']}' AS warehouse_name
FROM `{$table}` s
WHERE s.company_id = :company_id
AND s.product_sku = :product_sku
AND s.lot_number = :lot_number
ORDER BY s.date DESC"
);
$sth->execute([
':company_id' => $this->companyId,
':product_sku' => $product_sku,
':lot_number' => $lot_number,
]);
$rows = array_merge($rows, $sth->fetchAll(PDO::FETCH_ASSOC));
}
usort($rows, fn($a, $b) => strcmp($b['date'], $a['date']));
return $rows;
}
/**
* All lots with running balance, expiry status, and days remaining.
* Returns rows plus summary counts (total, active, expired, near).
*/
public function getProductLots(): array
{
$sth = $this->pdo->prepare(
"SELECT
l.id,
l.product_sku,
l.lot_number,
l.expiry_date,
l.description,
p.product_name,
DATEDIFF(l.expiry_date, CURDATE()) AS days_remaining,
COALESCE(
(SELECT ROUND(SUM(wb.total_in) - SUM(wb.total_out), 2)
FROM warehouse_balance wb
WHERE wb.company_id = l.company_id
AND wb.product_sku = l.product_sku),
0
) AS balance
FROM md_lot l
INNER JOIN md_product p
ON p.company_id = l.company_id
AND p.sku = l.product_sku
WHERE l.company_id = :company_id
ORDER BY l.product_sku, l.expiry_date ASC"
);
$sth->execute([':company_id' => $this->companyId]);
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
$active = $expired = $near = 0;
foreach ($rows as &$row) {
$days = (int)$row['days_remaining'];
$balance = (float)$row['balance'];
if ($balance > 0) $active++;
if ($days < 0) $expired++;
if ($days >= 0 && $days <= 30) $near++;
if ($days < 0) $row['status'] = 'expired';
elseif ($days <= 7) $row['status'] = 'critical';
elseif ($days <= 30) $row['status'] = 'near';
else $row['status'] = 'ok';
}
unset($row);
return [
'rows' => $rows,
'total' => count($rows),
'active' => $active,
'expired' => $expired,
'near' => $near,
];
}
/**
* Rack activity log for a given rack_id, with user name.
*/
public function getRackLog(int $rack_id): array
{
if (!$rack_id) return [];
$db = $this->pdo->query("SELECT DATABASE()")->fetchColumn();
$sth = $this->pdo->prepare(
"SELECT
rl.id,
rl.dt,
rl.action,
rl.product_sku,
rl.td_stock_id,
rl.login,
u.name AS user_name
FROM md_rack_log rl
LEFT JOIN {$db}.user u
ON u.user_id = rl.user_id
WHERE rl.company_id = :company_id
AND rl.md_rack_id = :rack_id
ORDER BY rl.dt DESC"
);
$sth->execute([
':company_id' => $this->companyId,
':rack_id' => $rack_id,
]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
/**
* 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);
}
}
?>
+578
View File
@@ -0,0 +1,578 @@
<?php
/**
* StockManager
*
* Encapsulates read operations for ICS stock transactions:
* - Stock list by type (in / out / transfer)
* - Single record retrieval for stock_in, stock_out, transfer
*
* Write operations (manage_stock_in, manage_stock_out, manage_stock_transfer)
* are handled in their engine files via WarehouseManager.
*/
class StockManager {
private $pdo;
private $company_id;
public function __construct($pdo, $company_id) {
$this->pdo = $pdo;
$this->company_id = $company_id;
}
// ─────────────────────────────────────────────────────────────
// Private helpers
// ─────────────────────────────────────────────────────────────
/**
* Resolve warehouse name by ID and return the td_stock_<wh> table name.
* Throws if warehouse not found.
*/
private function resolveTable(int $warehouse_id): string
{
$sth = $this->pdo->prepare(
"SELECT warehouse_name FROM md_warehouse
WHERE company_id = :company_id AND id = :id"
);
$sth->execute([':company_id' => $this->company_id, ':id' => $warehouse_id]);
$name = $sth->fetchColumn();
if (!$name) {
throw new Exception("Warehouse not found.");
}
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $name);
return "td_stock_{$safe}";
}
// ─────────────────────────────────────────────────────────────
// Stock list queries
// ─────────────────────────────────────────────────────────────
/**
* List all stock records of a given type for a warehouse.
* type: 'in' | 'out' | 'transfer'
*/
public function getStockList(int $warehouse_id, string $type): array
{
$table = $this->resolveTable($warehouse_id);
$column = $type === 'out' ? 'ROUND(a.out, 2)' : 'ROUND(a.in, 2)';
// transfer list shows outbound side only (out > 0)
$extra_cond = ($type === 'transfer') ? 'AND a.out > 0' : '';
$sth = $this->pdo->prepare(
"SELECT a.*, {$column} AS quantity, b.product_name
FROM `{$table}` a
LEFT JOIN md_product b
ON a.company_id = b.company_id
AND a.product_sku = b.sku
WHERE a.company_id = :company_id
AND a.type = :type
{$extra_cond}
ORDER BY a.date DESC"
);
$sth->execute([
':company_id' => $this->company_id,
':type' => $type,
]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
// ─────────────────────────────────────────────────────────────
// Single record retrieval
// ─────────────────────────────────────────────────────────────
/**
* Fetch a single stock_in record with product and contact name,
* plus lot expiry date if applicable.
*/
public function getStockInById(int $warehouse_id, int $id): array|false
{
$table = $this->resolveTable($warehouse_id);
$sth = $this->pdo->prepare(
"SELECT a.*, a.in AS quantity,
b.contact_name,
c.product_name,
d.expiry_date
FROM `{$table}` a
LEFT JOIN md_contact b
ON a.company_id = b.company_id AND a.contact_id = b.id
LEFT JOIN md_product c
ON a.company_id = c.company_id AND a.product_sku = c.sku
LEFT JOIN md_lot d
ON a.company_id = d.company_id
AND a.product_sku = d.product_sku
AND a.lot_number = d.lot_number
WHERE a.company_id = :company_id AND a.id = :id"
);
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
return $sth->fetch(PDO::FETCH_ASSOC);
}
/**
* Fetch a single stock_out record with product and contact name.
*/
public function getStockOutById(int $warehouse_id, int $id): array|false
{
$table = $this->resolveTable($warehouse_id);
$sth = $this->pdo->prepare(
"SELECT a.*, a.out AS quantity,
b.contact_name,
c.product_name
FROM `{$table}` a
LEFT JOIN md_contact b
ON a.company_id = b.company_id AND a.contact_id = b.id
LEFT JOIN md_product c
ON a.company_id = c.company_id AND a.product_sku = c.sku
WHERE a.company_id = :company_id AND a.id = :id"
);
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
return $sth->fetch(PDO::FETCH_ASSOC);
}
/**
* Fetch a transfer record pair — the outbound row plus its paired
* inbound row resolved via ref_warehouse + uuid.
* Returns the outbound row with a 'ref' key containing the inbound row.
*/
public function getTransferById(int $warehouse_id, int $id): array|false
{
$table = $this->resolveTable($warehouse_id);
// Fetch the outbound (from) row
$sth = $this->pdo->prepare(
"SELECT a.*, a.out AS quantity,
b.contact_name,
c.product_name
FROM `{$table}` a
LEFT JOIN md_contact b
ON a.company_id = b.company_id AND a.contact_id = b.id
LEFT JOIN md_product c
ON a.company_id = c.company_id AND a.product_sku = c.sku
WHERE a.company_id = :company_id AND a.id = :id"
);
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
$output = $sth->fetch(PDO::FETCH_ASSOC);
if (!$output) return false;
// Resolve the inbound (to) row via ref_warehouse + uuid
$to_warehouse_id = (int)$output['ref_warehouse'];
$to_table = $this->resolveTable($to_warehouse_id);
$sth = $this->pdo->prepare(
"SELECT a.*, a.in AS quantity,
b.contact_name,
c.product_name
FROM `{$to_table}` a
LEFT JOIN md_contact b
ON a.company_id = b.company_id AND a.contact_id = b.id
LEFT JOIN md_product c
ON a.company_id = b.company_id AND a.product_sku = c.sku
WHERE a.company_id = :company_id AND a.uuid = :uuid"
);
$sth->execute([':company_id' => $this->company_id, ':uuid' => $output['uuid']]);
$output['ref'] = $sth->fetch(PDO::FETCH_ASSOC);
return $output;
}
// ─────────────────────────────────────────────────────────────
// Stock write operations
// ─────────────────────────────────────────────────────────────
/**
* Insert or update a stock_in record.
* On insert: upserts md_lot, inserts td_stock row, occupies rack, adjusts balance.
* On update: updates metadata (contact, description, log) only.
* Must be called inside dbTransaction() by the caller.
*/
public function saveStockIn(array $data, array $logging, string $uuid): void
{
$id = (int)$data['id'];
$warehouse_id = (int)$data['warehouse'];
$whMgmt = new WarehouseManager($this->pdo, $this->company_id);
$ctx = $whMgmt->getStockContext($warehouse_id, $id);
$row = $ctx['row'] ?? [];
$table = $ctx['table'];
$table_log = json_decode($row['log'] ?? '[]', true) ?: [];
$table_log[] = $logging;
if ($id > 0) {
$this->pdo->prepare(
"UPDATE `$table` SET
`contact_id` = :contact_id,
`description` = :description,
`log` = :log
WHERE id = :id AND company_id = :company_id"
)->execute([
':id' => $id,
':company_id' => $this->company_id,
':contact_id' => (int)$data['contact_id'],
':description' => $data['description'],
':log' => json_encode($table_log),
]);
} else {
$lot_number = $data['lot_number'] ?: null;
$expiry_date = $data['expiry_date'] ?: null;
// Upsert md_lot if lot + expiry provided
if ($lot_number && $expiry_date) {
$this->pdo->prepare(
"INSERT INTO md_lot (company_id, product_sku, lot_number, expiry_date)
VALUES (:company_id, :product_sku, :lot_number, :expiry_date)
ON DUPLICATE KEY UPDATE expiry_date = expiry_date"
)->execute([
':company_id' => $this->company_id,
':product_sku' => $data['product_sku'],
':lot_number' => $lot_number,
':expiry_date' => $expiry_date,
]);
}
$this->pdo->prepare(
"INSERT INTO `$table`
(uuid, company_id, `date`, product_sku, `in`, zone, aisle, rack,
contact_id, `description`, `log`, `type`, lot_number, serial_number)
VALUES
(:uuid, :company_id, :date, :product_sku, :quantity, :zone, :aisle, :rack,
:contact_id, :description, :log, 'in', :lot_number, :serial_number)"
)->execute([
':uuid' => $uuid,
':company_id' => $this->company_id,
':date' => date('Y-m-d H:i:s'),
':product_sku' => $data['product_sku'],
':quantity' => $data['quantity'],
':zone' => $data['zone'],
':aisle' => $data['aisle'],
':rack' => $data['rack'],
':contact_id' => (int)$data['contact_id'],
':description' => $data['description'],
':log' => json_encode($table_log),
':lot_number' => $lot_number,
':serial_number' => $data['serial_number'] ?: null,
]);
$td_stock_id = (int)$this->pdo->lastInsertId();
$whMgmt->occupyRack(
$warehouse_id,
$data['zone'], $data['aisle'], $data['rack'],
$data['product_sku'],
$td_stock_id
);
$whMgmt->adjustBalance(
'in',
$warehouse_id,
$data['product_sku'],
$row['in'] ?? 0,
$data['quantity']
);
}
}
/**
* Insert or update a stock_out record.
* On insert: validates rack, inserts td_stock row, releases rack, adjusts balance.
* On update: updates metadata (contact, description, log) only.
* Must be called inside dbTransaction() by the caller.
*/
public function saveStockOut(array $data, array $logging, string $uuid): void
{
$id = (int)$data['id'];
$warehouse_id = (int)$data['warehouse'];
$whMgmt = new WarehouseManager($this->pdo, $this->company_id);
$ctx = $whMgmt->getStockContext($warehouse_id, $id);
$row = $ctx['row'] ?? [];
$table = $ctx['table'];
$table_log = json_decode($row['log'] ?? '[]', true) ?: [];
$table_log[] = $logging;
if ($id > 0) {
$this->pdo->prepare(
"UPDATE `$table` SET
`contact_id` = :contact_id,
`description` = :description,
`log` = :log
WHERE id = :id AND company_id = :company_id"
)->execute([
':id' => $id,
':company_id' => $this->company_id,
':contact_id' => (int)$data['contact_id'],
':description' => $data['description'],
':log' => json_encode($table_log),
]);
} else {
$source_stock = $whMgmt->getRackStock(
$warehouse_id,
$data['zone'], $data['aisle'], $data['rack']
);
if (!$source_stock) {
throw new Exception(
"Rack {$data['zone']}-{$data['aisle']}-{$data['rack']} is empty — nothing to take out."
);
}
if ($source_stock['product_sku'] !== $data['product_sku']) {
throw new Exception(
"Rack holds {$source_stock['product_sku']}, not {$data['product_sku']}."
);
}
if (!empty($data['lot_number']) && $source_stock['lot_number'] !== $data['lot_number']) {
throw new Exception(
"Rack holds lot '{$source_stock['lot_number']}', not '{$data['lot_number']}'."
);
}
if (!empty($data['serial_number']) && $source_stock['serial_number'] !== $data['serial_number']) {
throw new Exception(
"Rack holds serial '{$source_stock['serial_number']}', not '{$data['serial_number']}'."
);
}
$quantity = (int)$source_stock['in'];
$ref_id = (int)$source_stock['id'];
$lot_number = $source_stock['lot_number'] ?? null;
$serial_number = $source_stock['serial_number'] ?? null;
$this->pdo->prepare(
"INSERT INTO `$table`
(uuid, company_id, `date`, product_sku, `out`, zone, aisle, rack,
contact_id, `description`, `log`, `type`, ref_id, lot_number, serial_number)
VALUES
(:uuid, :company_id, :date, :product_sku, :quantity, :zone, :aisle, :rack,
:contact_id, :description, :log, 'out', :ref_id, :lot_number, :serial_number)"
)->execute([
':uuid' => $uuid,
':company_id' => $this->company_id,
':date' => date('Y-m-d H:i:s'),
':product_sku' => $data['product_sku'],
':quantity' => $quantity,
':zone' => $data['zone'],
':aisle' => $data['aisle'],
':rack' => $data['rack'],
':contact_id' => (int)$data['contact_id'],
':description' => $data['description'],
':log' => json_encode($table_log),
':ref_id' => $ref_id,
':lot_number' => $lot_number,
':serial_number' => $serial_number,
]);
$whMgmt->releaseRack(
$warehouse_id,
$data['zone'], $data['aisle'], $data['rack']
);
$whMgmt->adjustBalance(
'out',
$warehouse_id,
$data['product_sku'],
$row['out'] ?? 0,
$quantity
);
}
}
/**
* Insert or update a stock transfer record pair.
* On insert: validates source rack, inserts paired td_stock rows, moves rack state, adjusts both balances.
* On update: updates metadata (contact, description, log) on both rows only.
* Must be called inside dbTransaction() by the caller.
*/
public function saveStockTransfer(array $data, array $logging, string $uuid): void
{
$id = (int)($data['id'] ?? 0);
$whMgmt = new WarehouseManager($this->pdo, $this->company_id);
$contact_id = (int)($data['contact_id'] ?? 0);
$description = ($data['description'] ?? '');
if ($id > 0) {
$from_warehouse = (int)($data['warehouse_from'] ?? 0);
$to_warehouse = (int)($data['warehouse_to'] ?? 0);
$from_ctx = $whMgmt->getStockContext($from_warehouse, $id);
$from_row = $from_ctx['row'];
if (!$from_row) {
throw new Exception("Transfer record not found.");
}
$to_id = (int)$from_row['ref_id'];
$to_ctx = $whMgmt->getStockContext($to_warehouse, $to_id);
$to_row = $to_ctx['row'];
if (!$to_row) {
throw new Exception("Paired destination record missing — data integrity issue.");
}
$from_log = json_decode($from_row['log'] ?? '[]', true) ?: [];
$from_log[] = $logging;
$to_log = json_decode($to_row['log'] ?? '[]', true) ?: [];
$to_log[] = $logging;
$this->pdo->prepare(
"UPDATE `{$from_ctx['table']}` SET
contact_id = :contact_id,
description = :description,
`log` = :log
WHERE id = :id AND company_id = :company_id"
)->execute([
':contact_id' => $contact_id,
':description' => $description,
':log' => json_encode($from_log),
':id' => $id,
':company_id' => $this->company_id,
]);
$this->pdo->prepare(
"UPDATE `{$to_ctx['table']}` SET
contact_id = :contact_id,
description = :description,
`log` = :log
WHERE id = :id AND company_id = :company_id"
)->execute([
':contact_id' => $contact_id,
':description' => $description,
':log' => json_encode($to_log),
':id' => $to_id,
':company_id' => $this->company_id,
]);
return;
}
$from_warehouse = (int)$data['warehouse_from'];
$from_zone = $data['zone_from'];
$from_aisle = $data['aisle_from'];
$from_rack = $data['rack_from'];
$to_warehouse = (int)$data['warehouse_to'];
$to_zone = $data['zone_to'];
$to_aisle = $data['aisle_to'];
$to_rack = $data['rack_to'];
$source_stock = $whMgmt->getRackStock(
$from_warehouse, $from_zone, $from_aisle, $from_rack
);
if (!$source_stock) {
throw new Exception("Source rack {$from_zone}-{$from_aisle}-{$from_rack} is empty.");
}
if ($source_stock['product_sku'] !== $data['product_sku']) {
throw new Exception(
"Source rack holds {$source_stock['product_sku']}, not {$data['product_sku']}."
);
}
if (!empty($data['lot_number']) && $source_stock['lot_number'] !== $data['lot_number']) {
throw new Exception(
"Source rack holds lot '{$source_stock['lot_number']}', not '{$data['lot_number']}'."
);
}
if (!empty($data['serial_number']) && $source_stock['serial_number'] !== $data['serial_number']) {
throw new Exception(
"Source rack holds serial '{$source_stock['serial_number']}', not '{$data['serial_number']}'."
);
}
$quantity = (int)$source_stock['in'];
$lot_number = $source_stock['lot_number'] ?? null;
$serial_number = $source_stock['serial_number'] ?? null;
$from_table = $whMgmt->getStockContext($from_warehouse, 0)['table'];
$to_table = $whMgmt->getStockContext($to_warehouse, 0)['table'];
$table_log = [$logging];
$this->pdo->prepare(
"INSERT INTO `$from_table`
(uuid, company_id, `date`, product_sku, `out`,
ref_warehouse, zone, aisle, rack,
contact_id, `description`, `log`, `type`, lot_number, serial_number)
VALUES
(:uuid, :company_id, :date, :product_sku, :quantity,
:ref_warehouse, :zone, :aisle, :rack,
:contact_id, :description, :log, 'transfer', :lot_number, :serial_number)"
)->execute([
':uuid' => $uuid,
':company_id' => $this->company_id,
':date' => date('Y-m-d H:i:s'),
':product_sku' => $data['product_sku'],
':quantity' => $quantity,
':ref_warehouse' => $to_warehouse,
':zone' => $from_zone,
':aisle' => $from_aisle,
':rack' => $from_rack,
':contact_id' => $contact_id,
':description' => $description,
':log' => json_encode($table_log),
':lot_number' => $lot_number,
':serial_number' => $serial_number,
]);
$from_stock_id = (int)$this->pdo->lastInsertId();
$this->pdo->prepare(
"INSERT INTO `$to_table`
(uuid, company_id, `date`, product_sku, `in`,
ref_warehouse, ref_id, zone, aisle, rack,
contact_id, `description`, `log`, `type`, lot_number, serial_number)
VALUES
(:uuid, :company_id, :date, :product_sku, :quantity,
:ref_warehouse, :ref_id, :zone, :aisle, :rack,
:contact_id, :description, :log, 'transfer', :lot_number, :serial_number)"
)->execute([
':uuid' => $uuid,
':company_id' => $this->company_id,
':date' => date('Y-m-d H:i:s'),
':product_sku' => $data['product_sku'],
':quantity' => $quantity,
':ref_warehouse' => $from_warehouse,
':ref_id' => $from_stock_id,
':zone' => $to_zone,
':aisle' => $to_aisle,
':rack' => $to_rack,
':contact_id' => $contact_id,
':description' => $description,
':log' => json_encode($table_log),
':lot_number' => $lot_number,
':serial_number' => $serial_number,
]);
$to_stock_id = (int)$this->pdo->lastInsertId();
// Back-fill ref_id on from row so both rows point at each other
$this->pdo->prepare(
"UPDATE `$from_table` SET ref_id = :ref_id
WHERE id = :id AND company_id = :company_id"
)->execute([
':ref_id' => $to_stock_id,
':id' => $from_stock_id,
':company_id' => $this->company_id,
]);
$whMgmt->releaseRack($from_warehouse, $from_zone, $from_aisle, $from_rack);
$whMgmt->occupyRack($to_warehouse, $to_zone, $to_aisle, $to_rack, $data['product_sku'], $to_stock_id);
$whMgmt->adjustBalance('out', $from_warehouse, $data['product_sku'], 0, $quantity);
$whMgmt->adjustBalance('in', $to_warehouse, $data['product_sku'], 0, $quantity);
}
}
?>
+426 -2
View File
@@ -1272,6 +1272,430 @@ class WarehouseManager {
]);
}
}
?>
// ─────────────────────────────────────────────────────────────
// Warehouse list queries
// ─────────────────────────────────────────────────────────────
/**
* List warehouses filtered by rack occupancy type.
* type='to' → only warehouses with at least one empty rack
* type='from' → only warehouses holding the given product_sku
* other → all warehouses
* Pass $id > 0 to bypass the filter (edit flows).
*/
public function getWarehouseList(string $type, string $product_sku = '', int $id = 0): array
{
$product_sku_filter = '';
$params = [':company_id' => $this->company_id];
if (!$id) {
if ($type === 'to') {
$product_sku_filter = 'AND r.product_sku IS NULL';
} elseif ($type === 'from') {
$product_sku_filter = 'AND r.product_sku = :product_sku';
$params[':product_sku'] = $product_sku;
}
}
$sth = $this->pdo->prepare(
"SELECT w.id, w.warehouse_name
FROM md_warehouse w
WHERE w.company_id = :company_id
AND EXISTS (
SELECT 1 FROM md_rack r
WHERE r.company_id = w.company_id
AND r.warehouse = w.id
$product_sku_filter
)
ORDER BY w.warehouse_name"
);
$sth->execute($params);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Full warehouse list with rack stats and manager name — for inventory listing.
*/
public function getWarehouseListAll(): array
{
$sth = $this->pdo->prepare(
"SELECT
a.*,
COALESCE(r.total_racks, 0) AS capacity,
COALESCE(r.occupied_racks, 0) AS space_used,
COALESCE(r.unique_product, 0) AS unique_product,
c.name AS manager_name,
c.surname AS manager_surname
FROM md_warehouse a
LEFT JOIN (
SELECT
company_id, warehouse,
COUNT(*) AS total_racks,
COUNT(product_sku) AS occupied_racks,
COUNT(DISTINCT product_sku) AS unique_product
FROM md_rack
GROUP BY company_id, warehouse
) r ON a.company_id = r.company_id AND a.id = r.warehouse
LEFT JOIN wms.user c ON a.manager = c.user_id
WHERE a.company_id = :company_id"
);
$sth->execute([':company_id' => $this->company_id]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Fetch a single warehouse row by ID.
*/
public function getWarehouseById(int $id): array|false
{
$sth = $this->pdo->prepare(
"SELECT * FROM md_warehouse
WHERE company_id = :company_id AND id = :id"
);
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
return $sth->fetch(PDO::FETCH_ASSOC);
}
// ─────────────────────────────────────────────────────────────
// Lot / Serial queries
// ─────────────────────────────────────────────────────────────
/**
* Search md_lot by product_sku + keyword — for lot number autocomplete.
*/
public function getLotList(string $product_sku, string $keyword): array
{
$sth = $this->pdo->prepare(
"SELECT *
FROM md_lot
WHERE company_id = :company_id
AND product_sku = :product_sku
AND lot_number LIKE :keyword
ORDER BY lot_number ASC
LIMIT 50"
);
$sth->execute([
':company_id' => $this->company_id,
':product_sku' => $product_sku,
':keyword' => '%' . $keyword . '%',
]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Return distinct active lots for a product_sku across all warehouses.
* "Active" = rack is still occupied (product_sku IS NOT NULL in md_rack).
*/
public function getActiveLots(string $product_sku): array
{
$sth = $this->pdo->prepare(
"SELECT id, warehouse_name FROM md_warehouse
WHERE company_id = :company_id AND status = 1"
);
$sth->execute([':company_id' => $this->company_id]);
$warehouses = $sth->fetchAll(PDO::FETCH_ASSOC);
$active_lots = [];
foreach ($warehouses as $wh) {
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']);
$table = "td_stock_{$safe}";
$sth = $this->pdo->prepare(
"SELECT DISTINCT s.lot_number, l.expiry_date
FROM `{$table}` s
LEFT JOIN md_lot l
ON l.company_id = s.company_id
AND l.product_sku = s.product_sku
AND l.lot_number = s.lot_number
WHERE s.company_id = :company_id
AND s.product_sku = :product_sku
AND s.type = 'in'
AND s.lot_number IS NOT NULL
AND EXISTS (
SELECT 1 FROM md_rack r
WHERE r.company_id = s.company_id
AND r.td_stock_id = s.id
AND r.product_sku IS NOT NULL
)"
);
$sth->execute([
':company_id' => $this->company_id,
':product_sku' => $product_sku,
]);
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
$active_lots[$row['lot_number']] ??= $row;
}
}
return array_values($active_lots);
}
/**
* Return distinct active serial numbers for a product_sku (+ optional lot)
* across all warehouses.
* "Active" = rack is still occupied.
*/
public function getActiveSerials(string $product_sku, string $lot_number = ''): array
{
$sth = $this->pdo->prepare(
"SELECT id, warehouse_name FROM md_warehouse
WHERE company_id = :company_id AND status = 1"
);
$sth->execute([':company_id' => $this->company_id]);
$warehouses = $sth->fetchAll(PDO::FETCH_ASSOC);
$active_serials = [];
foreach ($warehouses as $wh) {
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']);
$table = "td_stock_{$safe}";
$sql = "SELECT DISTINCT s.serial_number
FROM `{$table}` s
WHERE s.company_id = :company_id
AND s.product_sku = :product_sku
AND s.type = 'in'
AND s.serial_number IS NOT NULL
AND EXISTS (
SELECT 1 FROM md_rack r
WHERE r.company_id = s.company_id
AND r.td_stock_id = s.id
AND r.product_sku IS NOT NULL
)";
$params = [
':company_id' => $this->company_id,
':product_sku' => $product_sku,
];
if (!empty($lot_number)) {
$sql .= ' AND s.lot_number = :lot_number';
$params[':lot_number'] = $lot_number;
}
$sth = $this->pdo->prepare($sql);
$sth->execute($params);
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
$active_serials[$row['serial_number']] = $row['serial_number'];
}
}
return array_values($active_serials);
}
// ─────────────────────────────────────────────────────────────
// Storage queries
// ─────────────────────────────────────────────────────────────
/**
* Full storage list with warehouse name.
*/
public function getStorageList(): array
{
$sth = $this->pdo->prepare(
"SELECT a.*, b.warehouse_name
FROM md_storage a
LEFT JOIN md_warehouse b
ON a.company_id = b.company_id
AND a.warehouse = b.id
WHERE a.company_id = :company_id"
);
$sth->execute([':company_id' => $this->company_id]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Fetch a single storage row by ID.
*/
public function getStorageById(int $id): array|false
{
$sth = $this->pdo->prepare(
"SELECT * FROM md_storage
WHERE company_id = :company_id AND id = :id"
);
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
return $sth->fetch(PDO::FETCH_ASSOC);
}
// ─────────────────────────────────────────────────────────────
// Warehouse write
// ─────────────────────────────────────────────────────────────
/**
* Insert or update a warehouse.
* Creates td_stock_<name> table on insert.
* Pass $data['id'] > 0 for update, 0 for insert.
* Must be called inside dbTransaction() by the caller.
*/
public function saveWarehouse(array $data, array $logging): void
{
$id = (int)($data['id'] ?? 0);
$sth = $this->pdo->prepare(
"SELECT `log` FROM md_warehouse
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,
':warehouse_name' => $data['warehouse_name'] ?? '',
':location' => $data['location'] ?? '',
':manager' => (int)($data['manager'] ?? 0),
':description' => $data['description'] ?? '',
':status' => (int)($data['status'] ?? 1),
':log' => json_encode($table_log, JSON_UNESCAPED_UNICODE),
];
if ($id > 0) {
$params[':id'] = $id;
$this->pdo->prepare(
"UPDATE md_warehouse SET
`warehouse_name` = :warehouse_name,
`location` = :location,
`manager` = :manager,
`description` = :description,
`status` = :status,
`log` = :log
WHERE id = :id AND company_id = :company_id"
)->execute($params);
} else {
$this->pdo->prepare(
"INSERT INTO md_warehouse
(company_id, warehouse_name, location, manager, description, status, `log`)
VALUES
(:company_id, :warehouse_name, :location, :manager, :description, :status, :log)"
)->execute($params);
// Create the per-warehouse stock table on insert
if (!empty($data['warehouse_name'])) {
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $data['warehouse_name']);
$this->pdo->exec("CREATE TABLE `td_stock_{$safe}` LIKE `td_stock`");
}
}
}
/**
* Insert or update a storage record.
* Pass $data['id'] > 0 for update, 0 for insert.
* Must be called inside dbTransaction() by the caller.
* Automatically syncs md_rack rows after save.
*/
public function saveStorage(array $data, array $logging): void
{
$id = (int)($data['id'] ?? 0);
$sth = $this->pdo->prepare(
"SELECT `log` FROM md_storage
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,
':warehouse' => $data['warehouse'] ?? '',
':zone' => $data['zone'] ?? '',
':aisle_from' => $data['aisle_from'] ?? '',
':aisle_to' => $data['aisle_to'] ?? '',
':rack_from' => $data['rack_from'] ?? '',
':rack_to' => $data['rack_to'] ?? '',
':description' => $data['description'] ?? '',
':status' => (int)($data['status'] ?? 1),
':log' => json_encode($table_log, JSON_UNESCAPED_UNICODE),
];
if ($id > 0) {
$params[':id'] = $id;
$this->pdo->prepare(
"UPDATE md_storage SET
warehouse = :warehouse,
zone = :zone,
aisle_from = :aisle_from,
aisle_to = :aisle_to,
rack_from = :rack_from,
rack_to = :rack_to,
description = :description,
status = :status,
`log` = :log
WHERE id = :id AND company_id = :company_id"
)->execute($params);
$storage_id = $id;
} else {
$this->pdo->prepare(
"INSERT INTO md_storage
(company_id, warehouse, zone, aisle_from, aisle_to,
rack_from, rack_to, description, status, `log`)
VALUES
(:company_id, :warehouse, :zone, :aisle_from, :aisle_to,
:rack_from, :rack_to, :description, :status, :log)"
)->execute($params);
$storage_id = (int)$this->pdo->lastInsertId();
}
// Sync md_rack rows to match the new range
$this->syncRacks($storage_id, [
'warehouse' => $data['warehouse'],
'zone' => $data['zone'],
'aisle_from' => $data['aisle_from'],
'aisle_to' => $data['aisle_to'],
'rack_from' => $data['rack_from'],
'rack_to' => $data['rack_to'],
]);
}
/**
* All distinct zones in a warehouse — for admin/read-only listing.
*/
public function getZonesAll(int $warehouse_id): array
{
$sth = $this->pdo->prepare(
"SELECT DISTINCT zone FROM md_rack
WHERE company_id = :company_id AND warehouse = :warehouse
ORDER BY CAST(zone AS UNSIGNED), zone"
);
$sth->execute([':company_id' => $this->company_id, ':warehouse' => $warehouse_id]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
/**
* All distinct aisles in a zone — for admin/read-only listing.
*/
public function getAislesAll(int $warehouse_id, string $zone): array
{
$sth = $this->pdo->prepare(
"SELECT DISTINCT aisle FROM md_rack
WHERE company_id = :company_id AND warehouse = :warehouse AND zone = :zone
ORDER BY CAST(aisle AS UNSIGNED), aisle"
);
$sth->execute([':company_id' => $this->company_id, ':warehouse' => $warehouse_id, ':zone' => $zone]);
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'aisle');
}
/**
* All distinct racks in an aisle — for admin/read-only listing.
*/
public function getRacksAll(int $warehouse_id, string $zone, string $aisle): array
{
$sth = $this->pdo->prepare(
"SELECT DISTINCT rack FROM md_rack
WHERE company_id = :company_id AND warehouse = :warehouse AND zone = :zone AND aisle = :aisle
ORDER BY CAST(rack AS UNSIGNED), rack"
);
$sth->execute([':company_id' => $this->company_id, ':warehouse' => $warehouse_id, ':zone' => $zone, ':aisle' => $aisle]);
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'rack');
}
}