warehouse capacity and occupancy

This commit is contained in:
Thanakorn S
2026-04-23 08:24:30 +07:00
parent 35dd1a9fce
commit ed23c269ea
15 changed files with 429 additions and 85 deletions
BIN
View File
Binary file not shown.
+34 -6
View File
@@ -11,6 +11,12 @@ class Reports
$this->companyId = $companyId;
}
/**
* Master Data Summary:
* - Total Categories: Count of all categories in md_product_category for the company.
* - Active Categories: Count of categories where status = 1.
* - Inactive Categories: Count of categories where status = 0.
*/
public function getTotalCategory(): int
{
$sql = "SELECT COUNT(*)
@@ -41,7 +47,8 @@ class Reports
{
$sql = "SELECT COUNT(*)
FROM md_warehouse
WHERE company_id = :company_id";
WHERE company_id = :company_id
AND `status` = 1";
return (int) $this->fetchScalar($sql);
}
@@ -49,18 +56,39 @@ class Reports
{
$sql = "SELECT COUNT(DISTINCT `location`)
FROM md_warehouse
WHERE company_id = :company_id
AND `status` = 1";
return (int) $this->fetchScalar($sql);
}
/**
* Total capacity = total count of active racks across all warehouses.
* One rack = one storage position (holds one product unit).
*/
public function getTotalCapacity(): int
{
$sql = "SELECT COUNT(*)
FROM md_rack
WHERE company_id = :company_id";
return (int) $this->fetchScalar($sql);
}
public function getTotalCapacity(): float
/**
* Space Used = count of racks currently occupied (product_sku IS NOT NULL).
*/
public function getSpaceUsed(): int
{
$sql = "SELECT SUM(capacity)
FROM md_warehouse
WHERE company_id = :company_id";
return (float) $this->fetchScalar($sql);
$sql = "SELECT COUNT(*)
FROM md_rack
WHERE company_id = :company_id
AND product_sku IS NOT NULL";
return (int) $this->fetchScalar($sql);
}
/**
* Product Count = total count of distinct products in the md_product table for the company.
* Low Stock Count = count of products where current stock balance (total_in - total_out) is less than the min_stock defined in md_product.
*/
public function getTotalProduct(): int
{
$sql = "SELECT COUNT(*)
+314 -39
View File
@@ -1,5 +1,16 @@
<?php
/**
* WarehouseManager
*
* Encapsulates all warehouse-related operations:
* - Warehouse lookup and stock context resolution
* - Rack lifecycle (sync with md_storage ranges)
* - Warehouse balance adjustments (warehouse_balance table)
*
* 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 WarehouseManager {
private $pdo;
@@ -10,6 +21,13 @@ class WarehouseManager {
$this->company_id = $company_id;
}
// ─────────────────────────────────────────────────────────────
// Warehouse lookup
// ─────────────────────────────────────────────────────────────
/**
* Fetch the warehouse name by its ID.
*/
public function getWarehouseName($warehouse_id) {
$sql = "SELECT warehouse_name FROM md_warehouse
WHERE company_id = :company_id AND id = :warehouse_id";
@@ -22,9 +40,16 @@ class WarehouseManager {
return $sth->fetchColumn();
}
/**
* Resolve the per-warehouse stock table and (optionally) fetch a specific row.
* Returns: ['table' => 'td_stock_xxx', 'name' => 'xxx', 'row' => [...] | []]
*/
public function getStockContext($warehouse_id, $id) {
$name = $this->getWarehouseName($warehouse_id);
$table = "td_stock_" . $name;
// Sanitize table suffix to prevent SQL injection
$safe_name = preg_replace('/[^a-zA-Z0-9_]/', '', $name);
$table = "td_stock_" . $safe_name;
if (empty($id)) {
return [
@@ -50,56 +75,306 @@ class WarehouseManager {
];
}
// ─────────────────────────────────────────────────────────────
// Warehouse balance (warehouse_balance table)
// ─────────────────────────────────────────────────────────────
/**
* Adjust the running balance for a (warehouse, SKU) pair.
*
* Used by stock_in / stock_out engines to keep warehouse_balance in sync with movements.
* The delta-based update (-old_qty + new_qty) supports both create and edit flows.
*/
public function adjustBalance($type, $warehouse_id, $product_sku, $old_qty, $new_qty) {
// Lock the row — other transactions wait here
$sql = "SELECT id FROM warehouse_balance
WHERE company_id = :company_id
AND warehouse_id = :warehouse_id
AND product_sku = :product_sku
FOR UPDATE";
$sth = $this->pdo->prepare($sql);
$sth->execute([
":company_id" => $this->company_id,
":warehouse_id" => $warehouse_id,
":product_sku" => $product_sku
]);
if (!$sth->fetchColumn()) {
$sql = "INSERT INTO warehouse_balance
(company_id, warehouse_id, product_sku)
VALUES (:company_id, :warehouse_id, :product_sku)";
$sth = $this->pdo->prepare($sql);
$sth->execute([
":company_id" => $this->company_id,
":warehouse_id" => $warehouse_id,
":product_sku" => $product_sku
]);
}
// Upsert the balance row (atomic, no race condition)
$column = $type === 'in' ? 'total_in' : 'total_out';
$delta = $new_qty - $old_qty;
// This is already atomic since it uses relative update
$sql = "UPDATE warehouse_balance
SET `$column` = `$column` - :old_qty + :new_qty
WHERE company_id = :company_id
AND warehouse_id = :warehouse_id
AND product_sku = :product_sku";
$sql = "INSERT INTO warehouse_balance
(company_id, warehouse_id, product_sku, `$column`)
VALUES
(:company_id, :warehouse_id, :product_sku, :delta)
ON DUPLICATE KEY UPDATE
`$column` = `$column` + VALUES(`$column`)";
$sth = $this->pdo->prepare($sql);
$sth->execute([
":company_id" => $this->company_id,
":warehouse_id" => $warehouse_id,
":product_sku" => $product_sku,
":old_qty" => $old_qty,
":new_qty" => $new_qty
":delta" => $delta
]);
}
// ─────────────────────────────────────────────────────────────
// Rack lifecycle (md_rack table)
// ─────────────────────────────────────────────────────────────
/**
* Ensure md_rack rows match the md_storage range.
*
* - Validates no occupied racks fall outside the new range
* - Deletes racks outside the new range
* - Inserts new racks inside the range (INSERT IGNORE skips existing)
*
* Must be called inside a DB transaction by the caller.
*
* @param int $storage_id The md_storage.id this range belongs to
* @param array $range Keys: warehouse, zone, aisle_from, aisle_to, rack_from, rack_to
* @throws Exception if occupied racks would be removed
*/
public function syncRacks(int $storage_id, array $range): void {
$this->validateRangeChange($storage_id, $range);
$this->removeRacksOutsideRange($storage_id, $range);
$this->insertRacksInRange($storage_id, $range);
}
/**
* Guard: reject the update if any occupied rack would be removed.
*/
private function validateRangeChange(int $storage_id, array $range): void {
$sql = "SELECT COUNT(*) FROM md_rack
WHERE company_id = :company_id
AND storage_id = :storage_id
AND product_sku IS NOT NULL
AND (
CAST(aisle AS UNSIGNED) < :aisle_from OR
CAST(aisle AS UNSIGNED) > :aisle_to OR
CAST(rack AS UNSIGNED) < :rack_from OR
CAST(rack AS UNSIGNED) > :rack_to
)";
$sth = $this->pdo->prepare($sql);
$sth->execute([
":company_id" => $this->company_id,
":storage_id" => $storage_id,
":aisle_from" => (int) $range['aisle_from'],
":aisle_to" => (int) $range['aisle_to'],
":rack_from" => (int) $range['rack_from'],
":rack_to" => (int) $range['rack_to'],
]);
if ($sth->fetchColumn() > 0) {
throw new Exception("Cannot shrink range — some racks still have stock");
}
}
/**
* Delete empty racks that fall outside the new range.
*/
private function removeRacksOutsideRange(int $storage_id, array $range): void {
$sql = "DELETE FROM md_rack
WHERE company_id = :company_id
AND storage_id = :storage_id
AND (
CAST(aisle AS UNSIGNED) < :aisle_from OR
CAST(aisle AS UNSIGNED) > :aisle_to OR
CAST(rack AS UNSIGNED) < :rack_from OR
CAST(rack AS UNSIGNED) > :rack_to
)";
$sth = $this->pdo->prepare($sql);
$sth->execute([
":company_id" => $this->company_id,
":storage_id" => $storage_id,
":aisle_from" => (int) $range['aisle_from'],
":aisle_to" => (int) $range['aisle_to'],
":rack_from" => (int) $range['rack_from'],
":rack_to" => (int) $range['rack_to'],
]);
}
/**
* Insert rack rows for the full range (IGNORE skips duplicates).
*/
private function insertRacksInRange(int $storage_id, array $range): void {
$sql = "INSERT IGNORE INTO md_rack
(company_id, warehouse, storage_id, zone, aisle, rack)
VALUES
(:company_id, :warehouse, :storage_id, :zone, :aisle, :rack)";
$sth = $this->pdo->prepare($sql);
$aisle_from = (int) $range['aisle_from'];
$aisle_to = (int) $range['aisle_to'];
$rack_from = (int) $range['rack_from'];
$rack_to = (int) $range['rack_to'];
for ($a = $aisle_from; $a <= $aisle_to; $a++) {
for ($r = $rack_from; $r <= $rack_to; $r++) {
$sth->execute([
":company_id" => $this->company_id,
":warehouse" => $range['warehouse'],
":storage_id" => $storage_id,
":zone" => $range['zone'],
":aisle" => (string) $a,
":rack" => (string) $r,
]);
}
}
}
// ─────────────────────────────────────────────────────────────
// Rack occupancy (md_rack.product_sku state)
// ─────────────────────────────────────────────────────────────
/**
* Assign a product SKU to an empty rack.
*
* @throws Exception if the rack is already occupied by a different SKU
* or if the rack doesn't exist.
*/
public function occupyRack($warehouse_id, $zone, $aisle, $rack, $product_sku): void {
// Lock and check the rack's current state
$sql = "SELECT id, product_sku FROM md_rack
WHERE company_id = :company_id
AND warehouse = :warehouse
AND zone = :zone
AND aisle = :aisle
AND rack = :rack
FOR UPDATE";
$sth = $this->pdo->prepare($sql);
$sth->execute([
":company_id" => $this->company_id,
":warehouse" => $warehouse_id,
":zone" => $zone,
":aisle" => $aisle,
":rack" => $rack,
]);
$current = $sth->fetch(PDO::FETCH_ASSOC);
if (!$current) {
throw new Exception("Rack {$zone}-{$aisle}-{$rack} does not exist");
}
if ($current['product_sku'] !== null
&& $current['product_sku'] !== $product_sku) {
throw new Exception(
"Rack {$zone}-{$aisle}-{$rack} is already occupied by {$current['product_sku']}"
);
}
// Assign the SKU
$sql = "UPDATE md_rack
SET product_sku = :product_sku
WHERE id = :id";
$sth = $this->pdo->prepare($sql);
$sth->execute([
":product_sku" => $product_sku,
":id" => $current['id'],
]);
}
/**
* Release a rack (set product_sku to NULL).
*/
public function releaseRack($warehouse_id, $zone, $aisle, $rack): void {
$sql = "UPDATE md_rack
SET product_sku = NULL
WHERE company_id = :company_id
AND warehouse = :warehouse
AND zone = :zone
AND aisle = :aisle
AND rack = :rack";
$sth = $this->pdo->prepare($sql);
$sth->execute([
":company_id" => $this->company_id,
":warehouse" => $warehouse_id,
":zone" => $zone,
":aisle" => $aisle,
":rack" => $rack,
]);
}
/**
* Move a rack assignment from one location to another.
*
* Source rack must be occupied, destination rack must be empty.
* Locks both racks (lower ID first) to avoid deadlocks.
*/
public function transferRack(
$from_warehouse, $from_zone, $from_aisle, $from_rack,
$to_warehouse, $to_zone, $to_aisle, $to_rack
): void {
// Fetch both racks with FOR UPDATE, ordered by id to prevent deadlocks
$sql = "SELECT id, warehouse, zone, aisle, rack, product_sku
FROM md_rack
WHERE company_id = :company_id
AND (
(warehouse = :from_wh AND zone = :from_zone
AND aisle = :from_aisle AND rack = :from_rack)
OR
(warehouse = :to_wh AND zone = :to_zone
AND aisle = :to_aisle AND rack = :to_rack)
)
ORDER BY id
FOR UPDATE";
$sth = $this->pdo->prepare($sql);
$sth->execute([
":company_id" => $this->company_id,
":from_wh" => $from_warehouse,
":from_zone" => $from_zone,
":from_aisle" => $from_aisle,
":from_rack" => $from_rack,
":to_wh" => $to_warehouse,
":to_zone" => $to_zone,
":to_aisle" => $to_aisle,
":to_rack" => $to_rack,
]);
$racks = $sth->fetchAll(PDO::FETCH_ASSOC);
// Identify source and destination from the fetched rows
$from = null;
$to = null;
foreach ($racks as $r) {
if ($r['warehouse'] == $from_warehouse && $r['zone'] == $from_zone
&& $r['aisle'] == $from_aisle && $r['rack'] == $from_rack) {
$from = $r;
}
if ($r['warehouse'] == $to_warehouse && $r['zone'] == $to_zone
&& $r['aisle'] == $to_aisle && $r['rack'] == $to_rack) {
$to = $r;
}
}
if (!$from) {
throw new Exception("Source rack not found");
}
if (!$to) {
throw new Exception("Destination rack not found");
}
if ($from['product_sku'] === null) {
throw new Exception("Source rack is empty");
}
if ($to['product_sku'] !== null) {
throw new Exception("Destination rack is already occupied");
}
$sku = $from['product_sku'];
// Clear source
$sql = "UPDATE md_rack SET product_sku = NULL WHERE id = :id";
$this->pdo->prepare($sql)->execute([":id" => $from['id']]);
// Populate destination
$sql = "UPDATE md_rack SET product_sku = :sku WHERE id = :id";
$this->pdo->prepare($sql)->execute([
":sku" => $sku,
":id" => $to['id'],
]);
}
}
?>
@@ -59,7 +59,7 @@
} catch (Exception $e) {
$answer["success"] = 0;
$answer["message"] = "Something went wrong";
$answer["message"] = $e->getMessage();
}
exit(json_encode($answer));
+2 -1
View File
@@ -69,6 +69,7 @@
]);
}
$whMgmt->occupyRack($warehouse_id,$data["zone"],$data["aisle"],$data["rack"],$data["product_sku"]);
$whMgmt->adjustBalance('in', $warehouse_id, $data["product_sku"], $row['in'] ?? 0, $data["quantity"]);
});
@@ -81,7 +82,7 @@
} catch (Exception $e) {
$answer["success"] = 0;
$answer["message"] = "Something went wrong";
$answer["message"] = $e->getMessage();
}
exit(json_encode($answer));
+2 -1
View File
@@ -69,6 +69,7 @@
]);
}
$whMgmt->releaseRack($warehouse_id, $data["zone"], $data["aisle"], $data["rack"],$data["product_sku"]);
$whMgmt->adjustBalance('out', $warehouse_id, $data["product_sku"], $row['out'] ?? 0, $data["quantity"]);
});
@@ -81,7 +82,7 @@
} catch (Exception $e) {
$answer["success"] = 0;
$answer["message"] = "Something went wrong";
$answer["message"] = $e->getMessage();
}
exit(json_encode($answer));
+7 -1
View File
@@ -93,6 +93,12 @@
":log" => json_encode($table_log),
]);
}
if($data['type'] == 'from'){
$whMgmt->releaseRack($warehouse_id, $data["zone"], $data["aisle"], $data["rack"]);
}elseif($data['type'] == 'to'){
$whMgmt->occupyRack($warehouse_id, $data["zone"], $data["aisle"], $data["rack"]);
}
$whMgmt->adjustBalance($data['col'], $warehouse_id, $data["product_sku"], $row[$data['col']] ?? 0, $data["quantity"]);
}
@@ -107,7 +113,7 @@
} catch (Exception $e) {
$answer["success"] = 0;
$answer["message"] = "Something went wrong";
$answer["message"] = $e->getMessage();
}
exit(json_encode($answer));
+1 -1
View File
@@ -67,7 +67,7 @@
} catch (Exception $e) {
$answer["success"] = 0;
$answer["message"] = "Something went wrong";
$answer["message"] = $e->getMessage();
}
exit(json_encode($answer));
+1 -1
View File
@@ -115,7 +115,7 @@
} catch (Exception $e) {
$answer["success"] = 0;
$answer["message"] = "Something went wrong";
$answer["message"] = $e->getMessage();
$answer["error"] = $e->getMessage();
}
+26 -6
View File
@@ -1,6 +1,7 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes/WarehouseManager.php';
try {
dbTransaction($pdo2, function($pdo) use ($data, $company_id, $logging) {
@@ -48,17 +49,36 @@
$params[":id"] = $id;
$sth = $pdo->prepare($sql);
$sth->execute($params);
$storage_id = $id;
} else {
$sql = "INSERT INTO md_storage
(company_id, warehouse, zone, aisle_from, aisle_to, rack_from, rack_to, description, status, `log`)
(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)";
(:company_id, :warehouse, :zone, :aisle_from, :aisle_to,
:rack_from, :rack_to, :description, :status, :log)";
$sth = $pdo->prepare($sql);
$sth->execute($params);
$storage_id = (int) $pdo->lastInsertId();
}
$sth = $pdo->prepare($sql);
$sth->execute($params);
// Sync md_rack rows — same $pdo, inside the same transaction
$wm = new WarehouseManager($pdo, $company_id);
$wm->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'],
]);
});
$answer["success"] = 1;
@@ -69,7 +89,7 @@
} catch (Exception $e) {
$answer["success"] = 0;
$answer["message"] = "Something went wrong";
$answer["message"] = $e->getMessage();
}
exit(json_encode($answer));
@@ -24,7 +24,6 @@
":warehouse_name" => $data["warehouse_name"] ?? "",
":location" => $data["location"] ?? "",
":manager" => (int)($data["manager"] ?? 0),
":capacity" => (int)($data["capacity"] ?? 0),
":description" => $data["description"] ?? "",
":status" => (int)($data["status"] ?? 1),
":log" => json_encode($table_log, JSON_UNESCAPED_UNICODE),
@@ -36,7 +35,6 @@
`warehouse_name` = :warehouse_name,
`location` = :location,
`manager` = :manager,
`capacity` = :capacity,
`description` = :description,
`status` = :status,
`log` = :log
@@ -47,9 +45,9 @@
} else {
$sql = "INSERT INTO md_warehouse
(company_id, warehouse_name, location, manager, capacity, description, status, `log`)
(company_id, warehouse_name, location, manager, description, status, `log`)
VALUES
(:company_id, :warehouse_name, :location, :manager, :capacity, :description, :status, :log)";
(:company_id, :warehouse_name, :location, :manager, :description, :status, :log)";
}
$sth = $pdo->prepare($sql);
@@ -72,7 +70,7 @@
} catch (Exception $e) {
$answer["success"] = 0;
$answer["message"] = "Something went wrong";
$answer["message"] = $e->getMessage();
}
exit(json_encode($answer));
+20 -11
View File
@@ -3,17 +3,26 @@
require '../../../assets/utils/db_auth.php';
$sql = "SELECT a.*, IFNULL(COUNT(DISTINCT b.product_sku), 0) AS unique_product,
c.name AS manager_name, c.surname AS manager_surname
FROM md_warehouse a
LEFT JOIN warehouse_balance b ON
a.company_id = b.company_id AND
a.id = b.warehouse_id
LEFT JOIN wms.user c ON
a.manager = c.user_id
WHERE
a.company_id = :company_id
GROUP BY a.id";
$sql = "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 = $pdo2->prepare($sql);
$sth->execute([
":company_id" => $company_id
@@ -9,6 +9,7 @@
$answer["total_warehouse"] = $report->getTotalWarehouse();
$answer["total_location"] = $report->getTotalLocation();
$answer["total_capacity"] = $report->getTotalCapacity();
$answer["space_used"] = $report->getSpaceUsed();
$answer["total_product"] = $report->getTotalProduct();
$answer["success"] = 1;
-4
View File
@@ -51,10 +51,6 @@
<option value="0">inactive</option>
</select>
</div>
<div class="col-md-6 mb-3">
<label for="capacity" class="form-label">Capacity</label>
<input type="number" class="form-control" id="capacity" placeholder="0.00" step="0.01" required>
</div>
</div>
+17 -8
View File
@@ -53,9 +53,10 @@
<div class=" p-6 flex-grow-1 d-flex justify-content-between" id="total_capacity">
<div class="d-flex flex-column">
<span class="fs-2 fw-bold mb-3 d-block">41,000</span>
<!-- Total Capacity card -->
<span class="fs-5">Total Capacity</span>
<small class="text-warning">
Storage units available</small>
<small class="text-warning">Rack positions available</small>
<!-- was "Storage units available" -->
</div>
<div class="">
<span class="text-warning">
@@ -81,9 +82,10 @@
<div class=" p-6 flex-grow-1 d-flex justify-content-between" id="total_space_used">
<div class="d-flex flex-column">
<span class="fs-2 fw-bold mb-3 d-block">25,500</span>
<!-- Space Used card -->
<span class="fs-5">Space Used</span>
<small class="text-success">
Storage units available</small>
<small class="text-success">Racks occupied</small>
<!-- was "Storage units available" (which was wrong!) -->
</div>
<div class="">
<span class="text-success">
@@ -252,7 +254,6 @@
<script>
function retrieve_stats() {
return ajax_request({
url: "<?php echo $server_url?>inventory/api/engine/warehouse_stats.php",
autoPrepare: true,
@@ -263,13 +264,21 @@
$("#total_warehouse span.fs-2").text(res.total_warehouse);
$("#total_warehouse small.text-info").text(`Across ${res.total_location} locations`);
$("#total_capacity span.fs-2").text(format_number(res.total_capacity,2));
// Capacity = rack count (integer, no decimals)
$("#total_capacity span.fs-2").text(format_number(res.total_capacity, 0));
// NEW: Space Used wiring
$("#total_space_used span.fs-2").text(format_number(res.space_used, 0));
// Optional: show % utilization in the small text
var pct = res.total_capacity > 0
? Math.round(res.space_used / res.total_capacity * 100)
: 0;
$("#total_space_used small").text(`${pct}% utilized`);
$("#total_product span.fs-2").text(res.total_product);
}
});
}