diff --git a/app.zip b/app.zip index 5cf4c6e..46fb58e 100644 Binary files a/app.zip and b/app.zip differ diff --git a/app/assets/utils/classes/Reports.php b/app/assets/utils/classes/Reports.php index 65dc777..df57607 100644 --- a/app/assets/utils/classes/Reports.php +++ b/app/assets/utils/classes/Reports.php @@ -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(*) diff --git a/app/assets/utils/classes/WarehouseManager.php b/app/assets/utils/classes/WarehouseManager.php index 97604f5..e3b278a 100644 --- a/app/assets/utils/classes/WarehouseManager.php +++ b/app/assets/utils/classes/WarehouseManager.php @@ -1,5 +1,16 @@ 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'], ]); } } - - - ?> \ No newline at end of file diff --git a/app/contact/api/engine/manage_contact_type.php b/app/contact/api/engine/manage_contact_type.php index ed3d1d0..21f27ee 100644 --- a/app/contact/api/engine/manage_contact_type.php +++ b/app/contact/api/engine/manage_contact_type.php @@ -59,7 +59,7 @@ } catch (Exception $e) { $answer["success"] = 0; - $answer["message"] = "Something went wrong"; + $answer["message"] = $e->getMessage(); } exit(json_encode($answer)); diff --git a/app/ics/api/engine/manage_stock_in.php b/app/ics/api/engine/manage_stock_in.php index 3e63f15..7185698 100644 --- a/app/ics/api/engine/manage_stock_in.php +++ b/app/ics/api/engine/manage_stock_in.php @@ -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)); diff --git a/app/ics/api/engine/manage_stock_out.php b/app/ics/api/engine/manage_stock_out.php index 30406bd..3c77afc 100644 --- a/app/ics/api/engine/manage_stock_out.php +++ b/app/ics/api/engine/manage_stock_out.php @@ -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)); diff --git a/app/ics/api/engine/manage_stock_transfer.php b/app/ics/api/engine/manage_stock_transfer.php index 7ab2d09..b58e9e4 100644 --- a/app/ics/api/engine/manage_stock_transfer.php +++ b/app/ics/api/engine/manage_stock_transfer.php @@ -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)); diff --git a/app/inventory/api/engine/manage_category.php b/app/inventory/api/engine/manage_category.php index dc37c10..e746fc6 100644 --- a/app/inventory/api/engine/manage_category.php +++ b/app/inventory/api/engine/manage_category.php @@ -67,7 +67,7 @@ } catch (Exception $e) { $answer["success"] = 0; - $answer["message"] = "Something went wrong"; + $answer["message"] = $e->getMessage(); } exit(json_encode($answer)); diff --git a/app/inventory/api/engine/manage_product.php b/app/inventory/api/engine/manage_product.php index e968e37..c24d8bf 100644 --- a/app/inventory/api/engine/manage_product.php +++ b/app/inventory/api/engine/manage_product.php @@ -115,7 +115,7 @@ } catch (Exception $e) { $answer["success"] = 0; - $answer["message"] = "Something went wrong"; + $answer["message"] = $e->getMessage(); $answer["error"] = $e->getMessage(); } diff --git a/app/inventory/api/engine/manage_storage.php b/app/inventory/api/engine/manage_storage.php index 9ff4a0b..593ea0c 100644 --- a/app/inventory/api/engine/manage_storage.php +++ b/app/inventory/api/engine/manage_storage.php @@ -1,6 +1,7 @@ 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)); diff --git a/app/inventory/api/engine/manage_warehouse.php b/app/inventory/api/engine/manage_warehouse.php index 9c90d31..73f919f 100644 --- a/app/inventory/api/engine/manage_warehouse.php +++ b/app/inventory/api/engine/manage_warehouse.php @@ -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)); diff --git a/app/inventory/api/engine/warehouse.php b/app/inventory/api/engine/warehouse.php index ad30552..aa04750 100644 --- a/app/inventory/api/engine/warehouse.php +++ b/app/inventory/api/engine/warehouse.php @@ -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 diff --git a/app/inventory/api/engine/warehouse_stats.php b/app/inventory/api/engine/warehouse_stats.php index e6c0ae4..b506b6a 100644 --- a/app/inventory/api/engine/warehouse_stats.php +++ b/app/inventory/api/engine/warehouse_stats.php @@ -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; diff --git a/app/inventory/manage_warehouse.php b/app/inventory/manage_warehouse.php index 4d2732a..aa884de 100644 --- a/app/inventory/manage_warehouse.php +++ b/app/inventory/manage_warehouse.php @@ -51,10 +51,6 @@ -