warehouse capacity and occupancy
This commit is contained in:
@@ -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(*)
|
||||
|
||||
@@ -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'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
?>
|
||||
Reference in New Issue
Block a user