pdo = $pdo; $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"; $sth = $this->pdo->prepare($sql); $sth->execute([ ":company_id" => $this->company_id, ":warehouse_id" => $warehouse_id ]); 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); // 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 [ 'table' => $table, 'name' => $name, 'row' => [] ]; } $sql = "SELECT * FROM `$table` WHERE company_id = :company_id AND id = :id"; $sth = $this->pdo->prepare($sql); $sth->execute([ ":company_id" => $this->company_id, ":id" => $id ]); return [ 'table' => $table, 'name' => $name, 'row' => $sth->fetch(PDO::FETCH_ASSOC) ?: [] ]; } // ───────────────────────────────────────────────────────────── // 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) { // Upsert the balance row (atomic, no race condition) $column = $type === 'in' ? 'total_in' : 'total_out'; $delta = $new_qty - $old_qty; $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, ":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, ]); } } } /** * Fetch the stock record currently linked to a rack. * * Under the 1:1 model, each occupied rack points to exactly one * td_stock_ row via md_rack.td_stock_id. This method resolves * that pointer — returning the full stock row, or null if the rack * is empty or the link is broken. * * The td_stock table name is derived from the warehouse (via * getStockContext), so callers don't need to know the naming convention. */ public function getRackStock($warehouse_id, $zone, $aisle, $rack): ?array { // Get the stock pointer from md_rack $sql = "SELECT td_stock_id FROM md_rack 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, ]); $td_stock_id = $sth->fetchColumn(); // Rack is empty or doesn't exist if (!$td_stock_id) { return null; } // Resolve the td_stock_ table from the warehouse name // and fetch the full row by ID. $context = $this->getStockContext($warehouse_id, $td_stock_id); return $context['row'] ?: null; } // ───────────────────────────────────────────────────────────── // Rack occupancy (md_rack.product_sku + md_rack.td_stock_id state) // ───────────────────────────────────────────────────────────── /** * Build a log JSON by appending an action entry to an existing md_rack.log. * Entry captures user_id, dt (now), and login (session start) for audit context, * plus any action-specific fields passed via $extra. */ private function buildRackLog(?string $existing_log, string $action, array $extra = []): string { $log = json_decode($existing_log ?: '[]', true) ?: []; $log[] = array_merge([ 'user_id' => $_SESSION['login_user_id'] ?? null, 'dt' => date('Y-m-d H:i:s'), 'login' => isset($_SESSION['otpTime']) ? date('Y-m-d H:i:s', $_SESSION['otpTime']) : null, 'action' => $action, ], $extra); return json_encode($log, JSON_UNESCAPED_UNICODE); } /** * Assign a product SKU to an empty rack and link it to its stock record. * * The td_stock_ table is derived from the warehouse, * so only the row ID needs to be passed here. * * @throws Exception if the rack is already occupied or doesn't exist. */ public function occupyRack($warehouse_id, $zone, $aisle, $rack, $product_sku, $td_stock_id): void { // Lock and fetch current state (including log for in-place append) $sql = "SELECT id, product_sku, `log` 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) { throw new Exception( "Rack {$zone}-{$aisle}-{$rack} is already occupied by {$current['product_sku']}" ); } // Single atomic UPDATE: state + log together $sql = "UPDATE md_rack SET product_sku = :product_sku, td_stock_id = :td_stock_id, `log` = :log WHERE id = :id"; $sth = $this->pdo->prepare($sql); $sth->execute([ ":product_sku" => $product_sku, ":td_stock_id" => $td_stock_id, ":log" => $this->buildRackLog($current['log'], 'occupy', [ 'product_sku' => $product_sku, 'td_stock_id' => $td_stock_id, ]), ":id" => $current['id'], ]); } /** * Release a rack (clear both SKU and stock reference together). */ public function releaseRack($warehouse_id, $zone, $aisle, $rack): void { // Fetch current state + log (needed to append release event) $sql = "SELECT id, product_sku, td_stock_id, `log` 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); // Nothing to release — exit silently (idempotent behavior) if (!$current || $current['product_sku'] === null) { return; } // Clear state + record log in one UPDATE $sql = "UPDATE md_rack SET product_sku = NULL, td_stock_id = NULL, `log` = :log WHERE id = :id"; $sth = $this->pdo->prepare($sql); $sth->execute([ ":log" => $this->buildRackLog($current['log'], 'release', [ 'product_sku' => $current['product_sku'], 'td_stock_id' => $current['td_stock_id'], ]), ":id" => $current['id'], ]); } /** * Move a rack assignment from one location to another. * * Source rack must be occupied, destination rack must be empty. * Both product_sku and td_stock_id travel together to the destination. * 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, td_stock_id, `log` 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"); } // Carry both SKU and stock reference across $sku = $from['product_sku']; $td_stock_id = $from['td_stock_id']; // Clear source — state + log in one UPDATE $sql = "UPDATE md_rack SET product_sku = NULL, td_stock_id = NULL, `log` = :log WHERE id = :id"; $this->pdo->prepare($sql)->execute([ ":log" => $this->buildRackLog($from['log'], 'transfer_out', [ 'product_sku' => $sku, 'td_stock_id' => $td_stock_id, 'to_rack_id' => $to['id'], ]), ":id" => $from['id'], ]); // Populate destination — state + log in one UPDATE $sql = "UPDATE md_rack SET product_sku = :sku, td_stock_id = :td_stock_id, `log` = :log WHERE id = :id"; $this->pdo->prepare($sql)->execute([ ":sku" => $sku, ":td_stock_id" => $td_stock_id, ":log" => $this->buildRackLog($to['log'], 'transfer_in', [ 'product_sku' => $sku, 'td_stock_id' => $td_stock_id, 'from_rack_id' => $from['id'], ]), ":id" => $to['id'], ]); } } ?>