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 { // Guard: refuse if any rack under this storage_id is still occupied $this->validateRangeChange($storage_id, $range); // Wipe all empty racks belonging to this storage_id $sql = "DELETE FROM md_rack WHERE company_id = :company_id AND storage_id = :storage_id AND product_sku IS NULL"; $sth = $this->pdo->prepare($sql); $sth->execute([ ':company_id' => $this->company_id, ':storage_id' => $storage_id, ]); // Reinsert the full range fresh $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"); } } /** * 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) // ───────────────────────────────────────────────────────────── /** * Insert a log entry into md_rack_log for the given rack and action. * Captures user_id, dt (now), and login (session start) for audit context, * plus any action-specific fields passed via $extra. */ private function insertRackLog(int $rack_id, string $action, array $extra = []): void { $sql = "INSERT INTO md_rack_log (company_id, md_rack_id, user_id, dt, login, action, product_sku, td_stock_id) VALUES (:company_id, :md_rack_id, :user_id, :dt, :login, :action, :product_sku, :td_stock_id)"; $sth = $this->pdo->prepare($sql); $sth->execute([ ':company_id' => $this->company_id, ':md_rack_id' => $rack_id, ':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, ':product_sku' => $extra['product_sku'] ?? null, ':td_stock_id' => $extra['td_stock_id'] ?? null, ]); } /** * 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 { $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) { 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 WHERE id = :id"; $sth = $this->pdo->prepare($sql); $sth->execute([ ":product_sku" => $product_sku, ":td_stock_id" => $td_stock_id, ":id" => $current['id'], ]); $this->insertRackLog($current['id'], 'occupy', [ 'product_sku' => $product_sku, 'td_stock_id' => $td_stock_id, ]); } /** * Release a rack (clear both SKU and stock reference together). */ public function releaseRack($warehouse_id, $zone, $aisle, $rack): void { $sql = "SELECT id, product_sku, td_stock_id 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 WHERE id = :id"; $sth = $this->pdo->prepare($sql); $sth->execute([ ":id" => $current['id'], ]); $this->insertRackLog($current['id'], 'release', [ 'product_sku' => $current['product_sku'], 'td_stock_id' => $current['td_stock_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 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 WHERE id = :id"; $this->pdo->prepare($sql)->execute([ ":id" => $from['id'], ]); $this->insertRackLog($from['id'], 'transfer_out', [ 'product_sku' => $sku, 'td_stock_id' => $td_stock_id, ]); // Populate destination — state + log in one UPDATE $sql = "UPDATE md_rack SET product_sku = :sku, td_stock_id = :td_stock_id WHERE id = :id"; $this->pdo->prepare($sql)->execute([ ":sku" => $sku, ":td_stock_id" => $td_stock_id, ":id" => $to['id'], ]); $this->insertRackLog($to['id'], 'transfer_in', [ 'product_sku' => $sku, 'td_stock_id' => $td_stock_id, ]); } // ───────────────────────────────────────────────────────────── // Delete (soft-delete) operations // ───────────────────────────────────────────────────────────── /** * Validate that a given row is the latest transaction for its SKU in the table. * Throws a descriptive exception if a newer transaction exists. * * @param string $product_sku SKU to check * @param string $row_date The date of the row being deleted * @param string $product_name Human-readable product name for error message * @throws Exception if a newer transaction exists */ private function validateLatestTransaction( string $product_sku, string $row_date, string $product_name ): void { // Discover all td_stock_* tables for this database $sth = $this->pdo->prepare( "SELECT table_name FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name LIKE 'td_stock_%'" ); $sth->execute(); $tables = $sth->fetchAll(PDO::FETCH_COLUMN); if (empty($tables)) { return; } // Build UNION across all td_stock_* tables to find global MAX(date) $unions = implode(' UNION ALL ', array_map( fn($t) => "SELECT `type`, `date` FROM `$t` WHERE company_id = :company_id AND product_sku = :product_sku", $tables )); $sth = $this->pdo->prepare( "SELECT `type`, `date` FROM ($unions) AS all_stock ORDER BY `date` DESC LIMIT 1" ); $sth->execute([ ':company_id' => $this->company_id, ':product_sku' => $product_sku, ]); $latest = $sth->fetch(PDO::FETCH_ASSOC); if (!$latest || $row_date === $latest['date']) { return; // ✓ this is the latest globally — allow } $type = strtoupper($latest['type']); $date = $latest['date']; throw new Exception( "Cannot delete — \"{$product_name}\" has a newer " . "{$type} transaction on {$date} that must be deleted first." ); } /** * Helper to build a log entry with consistent user and timestamp info for warehouse operations. */ private function buildLogEntry(string $action): array { return [ '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, ]; } /** * Resolve product name from md_product for readable error messages. */ private function getProductName(string $product_sku): string { $sth = $this->pdo->prepare( "SELECT product_name FROM md_product WHERE company_id = :company_id AND sku = :sku" ); $sth->execute([ ':company_id' => $this->company_id, ':sku' => $product_sku, ]); return $sth->fetchColumn() ?: $product_sku; } /** * Soft-delete a stock_in record and reverse its side effects. * * - Validates this is the latest transaction for the SKU * - Negates company_id (soft-delete) * - Releases the rack * - Reverses the balance * * Must be called inside a DB transaction by the caller. * * @throws Exception if not the latest transaction or record not found */ public function deleteStockIn(int $stock_id, int $warehouse_id): void { $ctx = $this->getStockContext($warehouse_id, $stock_id); $table = $ctx['table']; $row = $ctx['row']; if (!$row) { throw new Exception("Stock in record not found."); } $product_sku = $row['product_sku']; $product_name = $this->getProductName($product_sku); $this->validateLatestTransaction($product_sku, $row['date'], $product_name); // Soft-delete $this->pdo->prepare( "UPDATE `$table` SET company_id = company_id * -1 WHERE id = :id AND company_id = :company_id" )->execute([':id' => $stock_id, ':company_id' => $this->company_id]); // Release the rack $this->releaseRack($warehouse_id, $row['zone'], $row['aisle'], $row['rack']); // Reverse balance $this->adjustBalance('in', $warehouse_id, $product_sku, (int)$row['in'], 0); } /** * Soft-delete a stock_out record and reverse its side effects. * * - Validates this is the latest transaction for the SKU * - Negates company_id (soft-delete) * - Re-occupies the rack with the original stock_in batch (via ref_id) * - Reverses the balance * * Must be called inside a DB transaction by the caller. * * @throws Exception if not the latest transaction or record not found */ public function deleteStockOut(int $stock_id, int $warehouse_id): void { $ctx = $this->getStockContext($warehouse_id, $stock_id); $table = $ctx['table']; $row = $ctx['row']; if (!$row) { throw new Exception("Stock out record not found."); } $product_sku = $row['product_sku']; $product_name = $this->getProductName($product_sku); $this->validateLatestTransaction($product_sku, $row['date'], $product_name); // Soft-delete $this->pdo->prepare( "UPDATE `$table` SET company_id = company_id * -1 WHERE id = :id AND company_id = :company_id" )->execute([':id' => $stock_id, ':company_id' => $this->company_id]); // Re-occupy the rack — restore the stock_in batch that was consumed $this->occupyRack( $warehouse_id, $row['zone'], $row['aisle'], $row['rack'], $product_sku, (int)$row['ref_id'] ); // Reverse balance $this->adjustBalance('out', $warehouse_id, $product_sku, (int)$row['out'], 0); } /** * Soft-delete a transfer record pair and reverse all side effects. * * - Validates this is the latest transaction for the SKU on BOTH warehouses * - Negates company_id on both rows (soft-delete) * - Releases destination rack, re-occupies source rack * - Reverses balances on both warehouses * * Must be called inside a DB transaction by the caller. * * @throws Exception if not the latest transaction on either side, or records not found */ public function deleteTransfer(int $from_stock_id, int $from_warehouse_id): void { // Fetch the "from" row (transfer_out side) $from_ctx = $this->getStockContext($from_warehouse_id, $from_stock_id); $from_table = $from_ctx['table']; $from_row = $from_ctx['row']; if (!$from_row || $from_row['type'] !== 'transfer') { throw new Exception("Transfer record not found."); } $product_sku = $from_row['product_sku']; $product_name = $this->getProductName($product_sku); $to_warehouse = (int)$from_row['ref_warehouse']; $ref_id = (int)$from_row['ref_id']; // Fetch the "to" row (transfer_in side) $to_ctx = $this->getStockContext($to_warehouse, $ref_id); $to_row = $to_ctx['row']; if (!$to_row) { throw new Exception("Paired destination record missing — data integrity issue."); } // Single global validation covers both warehouses $this->validateLatestTransaction($product_sku, $from_row['date'], $product_name); // Soft-delete both rows $this->pdo->prepare( "UPDATE `$from_table` SET company_id = company_id * -1 WHERE id = :id AND company_id = :company_id" )->execute([':id' => $from_stock_id, ':company_id' => $this->company_id]); $this->pdo->prepare( "UPDATE `{$to_ctx['table']}` SET company_id = company_id * -1 WHERE id = :id AND company_id = :company_id" )->execute([':id' => $ref_id, ':company_id' => $this->company_id]); // Destination rack was occupied by transfer_in — release it $this->releaseRack( $to_warehouse, $to_row['zone'], $to_row['aisle'], $to_row['rack'] ); // Source rack was released by transfer_out — re-occupy with original batch $this->occupyRack( $from_warehouse_id, $from_row['zone'], $from_row['aisle'], $from_row['rack'], $product_sku, $from_stock_id ); // Reverse balances $quantity = (int)$from_row['out']; $this->adjustBalance('out', $from_warehouse_id, $product_sku, $quantity, 0); $this->adjustBalance('in', $to_warehouse, $product_sku, $quantity, 0); } /** * Soft-delete a warehouse. * * Blocks if any md_storage references this warehouse by name. * * Must be called inside a DB transaction by the caller. * * @throws Exception if warehouse not found or has dependent storage */ public function deleteWarehouse(int $warehouse_id): void { $sth = $this->pdo->prepare( "SELECT id, warehouse_name, `log` FROM md_warehouse WHERE company_id = :company_id AND id = :id" ); $sth->execute([ ':company_id' => $this->company_id, ':id' => $warehouse_id, ]); $row = $sth->fetch(PDO::FETCH_ASSOC); if (!$row) { throw new Exception("Warehouse not found."); } // Block if any md_storage references this warehouse by name $sth = $this->pdo->prepare( "SELECT COUNT(*) FROM md_storage WHERE company_id = :company_id AND warehouse = :warehouse_name" ); $sth->execute([ ':company_id' => $this->company_id, ':warehouse_name' => $row['warehouse_name'], ]); if ($sth->fetchColumn() > 0) { throw new Exception( "Cannot delete — warehouse \"{$row['warehouse_name']}\" " . "still has storage locations assigned to it." ); } // Append delete event to log $log = json_decode($row['log'] ?? '[]', true) ?: []; $log[] = $this->buildLogEntry('delete'); // Soft-delete $this->pdo->prepare( "UPDATE md_warehouse SET company_id = company_id * -1, `log` = :log WHERE id = :id AND company_id = :company_id" )->execute([ ':log' => json_encode($log), ':id' => $warehouse_id, ':company_id' => $this->company_id, ]); } /** * Soft-delete a storage record and its associated racks. * * - Blocks if any md_rack under this storage_id is occupied * - Hard-deletes md_rack_log rows for those racks * - Soft-deletes all md_rack rows under this storage_id * - Soft-deletes md_storage * * Must be called inside a DB transaction by the caller. * * @throws Exception if storage not found or has occupied racks */ public function deleteStorage(int $storage_id): void { $sth = $this->pdo->prepare( "SELECT id, zone, `log` FROM md_storage WHERE company_id = :company_id AND id = :id" ); $sth->execute([ ':company_id' => $this->company_id, ':id' => $storage_id, ]); $row = $sth->fetch(PDO::FETCH_ASSOC); if (!$row) { throw new Exception("Storage not found."); } // Block if any rack under this storage is occupied $sth = $this->pdo->prepare( "SELECT COUNT(*) FROM md_rack WHERE company_id = :company_id AND storage_id = :storage_id AND product_sku IS NOT NULL" ); $sth->execute([ ':company_id' => $this->company_id, ':storage_id' => $storage_id, ]); if ($sth->fetchColumn() > 0) { throw new Exception( "Cannot delete — storage zone \"{$row['zone']}\" " . "still has occupied racks with stock." ); } // Collect rack IDs before deleting — needed for rack log cleanup $sth = $this->pdo->prepare( "SELECT id FROM md_rack WHERE company_id = :company_id AND storage_id = :storage_id" ); $sth->execute([ ':company_id' => $this->company_id, ':storage_id' => $storage_id, ]); $rack_ids = $sth->fetchAll(PDO::FETCH_COLUMN); // Hard-delete md_rack_log for these racks if (!empty($rack_ids)) { $placeholders = implode(',', array_fill(0, count($rack_ids), '?')); $this->pdo->prepare( "DELETE FROM md_rack_log WHERE md_rack_id IN ($placeholders)" )->execute($rack_ids); } // Soft-delete all md_rack rows under this storage $this->pdo->prepare( "UPDATE md_rack SET company_id = company_id * -1 WHERE company_id = :company_id AND storage_id = :storage_id" )->execute([ ':company_id' => $this->company_id, ':storage_id' => $storage_id, ]); // Append delete event to log $log = json_decode($row['log'] ?? '[]', true) ?: []; $log[] = $this->buildLogEntry('delete'); // Soft-delete md_storage $this->pdo->prepare( "UPDATE md_storage SET company_id = company_id * -1, `log` = :log WHERE id = :id AND company_id = :company_id" )->execute([ ':log' => json_encode($log), ':id' => $storage_id, ':company_id' => $this->company_id, ]); } } ?>