pdo = $pdo; $this->company_id = $company_id; } // ───────────────────────────────────────────────────────────── // Private helpers // ───────────────────────────────────────────────────────────── /** * Resolve the td_stock_ table name for a given warehouse_id. * Returns null if warehouse not found or inactive. */ private function resolveWarehouseTable(int $warehouse_id): ?string { $sth = $this->pdo->prepare( "SELECT warehouse_name FROM md_warehouse WHERE company_id = :company_id AND id = :id AND status = 1" ); $sth->execute([':company_id' => $this->company_id, ':id' => $warehouse_id]); $name = $sth->fetchColumn(); if (!$name) return null; $safe = preg_replace('/[^a-zA-Z0-9_]/', '', $name); return "td_stock_{$safe}"; } // ───────────────────────────────────────────────────────────── // Range helper // ───────────────────────────────────────────────────────────── /** * Convert a from/to range into an array of string values. * Supports: * - Numeric ranges: "1" → "10" → ["1","2",...,"10"] * - Alpha ranges: "A" → "D" → ["A","B","C","D"] * * @throws Exception if values are not pure numeric or single alpha characters */ private function rangeToArray(string $from, string $to): array { $from = trim($from); $to = trim($to); // Numeric range if (is_numeric($from) && is_numeric($to)) { $f = (int) $from; $t = (int) $to; if ($f > $t) throw new Exception("Range start ($from) must be <= end ($to)"); return array_map('strval', range($f, $t)); } // Single-letter alpha range if (ctype_alpha($from) && ctype_alpha($to) && strlen($from) === 1 && strlen($to) === 1) { if (ord($from) > ord($to)) throw new Exception("Range start ($from) must be <= end ($to)"); return range($from, $to); // PHP range() handles 'A' → 'Z' natively } throw new Exception( "Aisle/rack values must be either numbers (e.g. 1–99) or single uppercase letters (e.g. A–Z). Got: '$from' → '$to'" ); } // ───────────────────────────────────────────────────────────── // 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. * Works for both numeric and alpha aisle/rack values. */ private function validateRangeChange(int $storage_id, array $range): void { // Build the valid sets from the new range $valid_aisles = $this->rangeToArray($range['aisle_from'], $range['aisle_to']); $valid_racks = $this->rangeToArray($range['rack_from'], $range['rack_to']); // Fetch all occupied racks under this storage_id $sth = $this->pdo->prepare( "SELECT aisle, rack 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, ]); foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) { $a = trim($row['aisle']); $r = trim($row['rack']); if (!in_array($a, $valid_aisles, true) || !in_array($r, $valid_racks, true)) { throw new Exception("Cannot shrink range — some racks still have stock"); } } } /** * Insert rack rows for the full range (IGNORE skips duplicates). * Supports both numeric (1-10) and alpha (A-Z) aisle/rack values. */ 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); $aisles = $this->rangeToArray($range['aisle_from'], $range['aisle_to']); $racks = $this->rangeToArray($range['rack_from'], $range['rack_to']); foreach ($aisles as $a) { foreach ($racks as $r) { $sth->execute([ ":company_id" => $this->company_id, ":warehouse" => $range['warehouse'], ":storage_id" => $storage_id, ":zone" => $range['zone'], ":aisle" => $a, ":rack" => $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. */ /** * Validate that the combination of product_sku + lot_number + serial_number * does not already occupy an active rack across all warehouses. * * "Active" means md_rack.td_stock_id is still linked (rack is occupied). * Once a rack is released (stock_out/transfer), the combo is free again. * * Rules: * - product_sku alone → allowed in multiple racks (normal stocking) * - product_sku + lot → allowed in multiple racks (lot spread across racks) * - product_sku + serial → must be unique (1 physical unit = 1 location) * - product_sku + lot + serial → must be unique * * @param string $product_sku * @param string|null $lot_number * @param string|null $serial_number * @param int $exclude_stock_id Skip this stock row (for edit flows) * @throws Exception if a duplicate active record is found */ public function validateStockUnique( string $product_sku, ?string $lot_number = null, ?string $serial_number = null, int $exclude_stock_id = 0 ): void { // Nothing to validate if neither lot nor serial is provided if (empty($lot_number) && empty($serial_number)) return; $cid = $this->company_id; // Discover all warehouses $sth = $this->pdo->prepare( "SELECT id, warehouse_name FROM md_warehouse WHERE company_id = :company_id AND status = 1" ); $sth->execute([':company_id' => $cid]); $warehouses = $sth->fetchAll(PDO::FETCH_ASSOC); if (empty($warehouses)) return; foreach ($warehouses as $wh) { $safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']); $table = "td_stock_{$safe}"; // Check for any existing active stock_in row matching sku + lot + serial // "Active" = rack still occupied (EXISTS in md_rack with product_sku IS NOT NULL) // NOTE: called from inside occupyRack, AFTER INSERT but BEFORE md_rack is updated // so the current row's td_stock_id is not yet in md_rack — won't match itself $sql = "SELECT s.id FROM `{$table}` s WHERE s.company_id = :company_id AND s.product_sku = :product_sku AND s.type = 'in' AND EXISTS ( SELECT 1 FROM md_rack r WHERE r.company_id = :company_id2 AND r.td_stock_id = s.id AND r.product_sku IS NOT NULL )"; if (!empty($lot_number)) { $sql .= " AND s.lot_number = :lot_number"; } if (!empty($serial_number)) { $sql .= " AND s.serial_number = :serial_number"; } if ($exclude_stock_id > 0) { $sql .= " AND s.id != :exclude_id"; } $params = [ ':company_id' => $cid, ':company_id2' => $cid, ':product_sku' => $product_sku, ]; if (!empty($lot_number)) $params[':lot_number'] = $lot_number; if (!empty($serial_number)) $params[':serial_number'] = $serial_number; if ($exclude_stock_id > 0) $params[':exclude_id'] = $exclude_stock_id; $sth = $this->pdo->prepare($sql); $sth->execute($params); if ($sth->fetchColumn()) { $combo = implode(' / ', array_filter([ $lot_number ? "lot: {$lot_number}" : null, $serial_number ? "serial: {$serial_number}" : null, ])); throw new Exception( "Active stock already exists for product '{$product_sku}' [{$combo}] " . "in warehouse '{$wh['warehouse_name']}'. " . "Stock must be moved out before it can be received again." ); } } } 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']}" ); } // Validate sku + lot + serial uniqueness before occupying the rack // Fetch lot/serial from the td_stock row being linked $ctx = $this->getStockContext($warehouse_id, $td_stock_id); $row = $ctx['row'] ?? null; if ($row) { $this->validateStockUnique( $product_sku, $row['lot_number'] ?? null, $row['serial_number'] ?? null, $td_stock_id // exclude self so edit flows don't block ); } // 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, ]); } // ───────────────────────────────────────────────────────────── // Zone / Aisle / Rack resolution // ───────────────────────────────────────────────────────────── /** * Zones with at least one empty rack — for stock_in destination. */ public function getZonesIn(int $warehouse_id): array { $sth = $this->pdo->prepare( "SELECT DISTINCT zone FROM md_rack WHERE company_id = :company_id AND warehouse = :warehouse AND product_sku IS NULL ORDER BY CAST(zone AS UNSIGNED), zone" ); $sth->execute([':company_id' => $this->company_id, ':warehouse' => $warehouse_id]); return $sth->fetchAll(PDO::FETCH_ASSOC); } /** * Zones holding active stock for sku + lot + serial — for stock_out source. */ public function getZonesOut( int $warehouse_id, string $product_sku, ?string $lot_number = null, ?string $serial_number = null ): array { $table = $this->resolveWarehouseTable($warehouse_id); if (!$table) return []; [$lot_cond, $serial_cond, $params] = $this->buildLotSerialCondition( $warehouse_id, $product_sku, $lot_number, $serial_number ); $sth = $this->pdo->prepare( "SELECT DISTINCT r.zone FROM md_rack r WHERE r.company_id = :company_id AND r.warehouse = :warehouse AND r.product_sku = :product_sku AND r.td_stock_id IN ( SELECT s.id FROM `{$table}` s WHERE s.company_id = :company_id2 AND s.product_sku = :product_sku2 AND s.type = 'in' {$lot_cond} {$serial_cond} ) ORDER BY CAST(r.zone AS UNSIGNED), r.zone" ); $sth->execute($params); return $sth->fetchAll(PDO::FETCH_ASSOC); } /** * Aisles with at least one empty rack in a zone — for stock_in destination. */ public function getAislesIn(int $warehouse_id, string $zone): array { $sth = $this->pdo->prepare( "SELECT DISTINCT aisle FROM md_rack WHERE company_id = :company_id AND warehouse = :warehouse AND zone = :zone AND product_sku IS NULL ORDER BY CAST(aisle AS UNSIGNED), aisle" ); $sth->execute([ ':company_id' => $this->company_id, ':warehouse' => $warehouse_id, ':zone' => $zone, ]); return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'aisle'); } /** * Aisles holding active stock for sku + lot + serial — for stock_out source. */ public function getAislesOut( int $warehouse_id, string $zone, string $product_sku, ?string $lot_number = null, ?string $serial_number = null ): array { $table = $this->resolveWarehouseTable($warehouse_id); if (!$table) return []; [$lot_cond, $serial_cond, $params] = $this->buildLotSerialCondition( $warehouse_id, $product_sku, $lot_number, $serial_number ); $params[':zone'] = $zone; $sth = $this->pdo->prepare( "SELECT DISTINCT r.aisle FROM md_rack r WHERE r.company_id = :company_id AND r.warehouse = :warehouse AND r.zone = :zone AND r.product_sku = :product_sku AND r.td_stock_id IN ( SELECT s.id FROM `{$table}` s WHERE s.company_id = :company_id2 AND s.product_sku = :product_sku2 AND s.type = 'in' {$lot_cond} {$serial_cond} ) ORDER BY CAST(r.aisle AS UNSIGNED), r.aisle" ); $sth->execute($params); return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'aisle'); } /** * Racks that are empty in a given aisle — for stock_in destination. */ public function getRacksIn(int $warehouse_id, string $zone, string $aisle): array { $sth = $this->pdo->prepare( "SELECT DISTINCT rack FROM md_rack WHERE company_id = :company_id AND warehouse = :warehouse AND zone = :zone AND aisle = :aisle AND product_sku IS NULL ORDER BY CAST(rack AS UNSIGNED), rack" ); $sth->execute([ ':company_id' => $this->company_id, ':warehouse' => $warehouse_id, ':zone' => $zone, ':aisle' => $aisle, ]); return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'rack'); } /** * Racks holding active stock for sku + lot + serial — for stock_out source. */ public function getRacksOut( int $warehouse_id, string $zone, string $aisle, string $product_sku, ?string $lot_number = null, ?string $serial_number = null ): array { $table = $this->resolveWarehouseTable($warehouse_id); if (!$table) return []; [$lot_cond, $serial_cond, $params] = $this->buildLotSerialCondition( $warehouse_id, $product_sku, $lot_number, $serial_number ); $params[':zone'] = $zone; $params[':aisle'] = $aisle; $sth = $this->pdo->prepare( "SELECT DISTINCT r.rack FROM md_rack r WHERE r.company_id = :company_id AND r.warehouse = :warehouse AND r.zone = :zone AND r.aisle = :aisle AND r.product_sku = :product_sku AND r.td_stock_id IN ( SELECT s.id FROM `{$table}` s WHERE s.company_id = :company_id2 AND s.product_sku = :product_sku2 AND s.type = 'in' {$lot_cond} {$serial_cond} ) ORDER BY CAST(r.rack AS UNSIGNED), r.rack" ); $sth->execute($params); return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'rack'); } /** * Build lot/serial WHERE conditions and bound params for Out methods. */ private function buildLotSerialCondition( int $warehouse_id, string $product_sku, ?string $lot_number = null, ?string $serial_number = null ): array { $lot_cond = !empty($lot_number) ? "AND s.lot_number = :lot_number" : ""; $serial_cond = !empty($serial_number) ? "AND s.serial_number = :serial_number" : ""; $params = [ ':company_id' => $this->company_id, ':warehouse' => $warehouse_id, ':product_sku' => $product_sku, ':company_id2' => $this->company_id, ':product_sku2' => $product_sku, ]; if (!empty($lot_number)) $params[':lot_number'] = $lot_number; if (!empty($serial_number)) $params[':serial_number'] = $serial_number; return [$lot_cond, $serial_cond, $params]; } // ───────────────────────────────────────────────────────────── // 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, ]); } } ?>