pdo = $pdo; $this->company_id = $company_id; } // ───────────────────────────────────────────────────────────── // Private helpers // ───────────────────────────────────────────────────────────── private function stockTableNameFromWarehouseId(int $warehouse_id): string { if ($warehouse_id <= 0) { throw new Exception("Invalid warehouse id."); } return 'td_stock_' . $warehouse_id; } private function resolveWarehouseTable(int $warehouse_id): ?string { $sth = $this->pdo->prepare( "SELECT id FROM md_warehouse WHERE company_id = :company_id AND id = :id AND status = 1" ); $sth->execute([':company_id' => $this->company_id, ':id' => $warehouse_id]); $id = $sth->fetchColumn(); if (!$id) return null; return $this->stockTableNameFromWarehouseId((int)$id); } /** * Convert a from/to range string pair into a flat array of string values. * * Supports two range types: * - Numeric: "1" → "10" expands to ["1", "2", ..., "10"] * - Single-letter alpha: "A" → "D" expands to ["A", "B", "C", "D"] * * Used when syncing md_rack rows against an md_storage aisle/rack range. * * @param string $from Start of range (e.g. "1" or "A"). * @param string $to End of range (e.g. "10" or "Z"). * @return array Flat array of string values in range (inclusive). * @throws Exception If values are not pure numeric or single alpha characters, * or if start > end. */ private function rangeToArray(string $from, string $to): array { $from = trim($from); $to = trim($to); // Same value — single location, any format allowed (e.g. "Shelf-A", "Bin-3") if ($from === $to) { return [$from]; } // Numeric range e.g. 1 → 10 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 e.g. A → Z if (ctype_alpha($from) && ctype_alpha($to) && strlen($from) === 1 && strlen($to) === 1) { if (ord(strtoupper($from)) > ord(strtoupper($to))) { throw new Exception("Range start ($from) must be <= end ($to)"); } return range(strtoupper($from), strtoupper($to)); } // Prefix + suffix range e.g. Shelf-A → Shelf-E or Bin-1 → Bin-5 // Split on the last sequence of digits or single letter at the end $pattern = '/^(.*?)([A-Za-z]|[0-9]+)$/'; if (preg_match($pattern, $from, $mf) && preg_match($pattern, $to, $mt)) { $prefix_f = $mf[1]; $prefix_t = $mt[1]; $suffix_f = $mf[2]; $suffix_t = $mt[2]; // Prefixes must match if ($prefix_f !== $prefix_t) { throw new Exception( "Location prefix mismatch: '$prefix_f' vs '$prefix_t'. " . "From and To must share the same prefix (e.g. Shelf-A → Shelf-E)." ); } $prefix = $prefix_f; // Suffix: numeric range if (is_numeric($suffix_f) && is_numeric($suffix_t)) { $f = (int) $suffix_f; $t = (int) $suffix_t; if ($f > $t) throw new Exception("Range start ($from) must be <= end ($to)"); return array_map(fn($n) => $prefix . $n, range($f, $t)); } // Suffix: single-letter range if (ctype_alpha($suffix_f) && ctype_alpha($suffix_t) && strlen($suffix_f) === 1 && strlen($suffix_t) === 1) { $sf = strtoupper($suffix_f); $st = strtoupper($suffix_t); if (ord($sf) > ord($st)) { throw new Exception("Range start ($from) must be <= end ($to)"); } return array_map(fn($c) => $prefix . $c, range($sf, $st)); } throw new Exception( "Location suffix must be a number (e.g. 1–99) or single letter (e.g. A–Z). " . "Got: '$suffix_f' → '$suffix_t'" ); } throw new Exception( "Invalid location range: '$from' → '$to'. " . "Use formats like: 1–10, A–Z, Shelf-1–Shelf-10, or Shelf-A–Shelf-Z." ); } /** * Build the WHERE conditions and bound params for lot/serial filtering * used across getZonesOut, getAislesOut, getRacksOut. * * Conditionally appends lot_number and/or serial_number filters only * when those values are non-empty, preventing unnecessary join conditions. * * @param int $warehouse_id Warehouse to scope the query to. * @param string $product_sku SKU being queried. * @param string|null $lot_number Optional lot filter. * @param string|null $serial_number Optional serial filter. * @return array Three-element array: [$lot_cond string, $serial_cond string, $params array]. */ 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]; } /** * Build a standard audit log entry array for append-to-JSON log columns. * * Captures the acting user_id, current datetime, session login time, * and a short action label (e.g. 'delete', 'update'). * * Usage: * $log[] = $this->buildLogEntry('delete'); * $params[':log'] = json_encode($log); * * @param string $action Short label describing the operation. * @return array Associative array ready to be appended to a log array. */ 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 a product's display name from md_product for use in error messages. * * Falls back to the SKU itself if the product record is not found. * * @param string $product_sku The SKU to look up. * @return string The product_name, or $product_sku if not found. */ 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; } /** * Validate that the given row is the globally latest transaction for its SKU. * * Scans all td_stock_* tables via UNION ALL to find the single most recent * transaction date across all warehouses for the SKU. If a newer row exists, * deletion is blocked to enforce LIFO (last-in-first-out) reversal order. * * Table names are sourced from information_schema and backtick-quoted; * no user input reaches the identifier. * * @param string $product_sku SKU of the row being deleted. * @param string $row_date The 'date' column value of the row being deleted. * @param string $product_name Human-readable name for the error message. * @throws Exception If a newer transaction for the same SKU exists anywhere. */ 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 the global latest date $unions = implode(' UNION ALL ', array_map( fn($t) => "SELECT `type`, `date` FROM `$t` WHERE company_id = :company_id AND product_sku = :product_sku AND status = 1", $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 deletion } $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." ); } /** * Insert a rack log entry for audit tracking of occupy/release/transfer events. * * Called internally by occupyRack, releaseRack, and transferRack. * $extra may contain 'product_sku' and 'td_stock_id' to record what * was in the rack at the time of the action. * * @param int $rack_id The md_rack.id being acted on. * @param string $action Event label: 'occupy' | 'release' | 'transfer_in' | 'transfer_out'. * @param array $extra Optional keys: product_sku, td_stock_id. */ 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, ]); } /** * Soft-delete rack logs created by a stock movement and its reversal. * * md_rack_log follows the same soft-delete convention as other tenant rows: * negate company_id so normal company_id filters hide the records. */ private function softDeleteRackLogs( $warehouse_id, string $zone, string $aisle, string $rack, array $actions, string $product_sku, array $td_stock_ids = [], ?string $since = null ): void { if (empty($actions)) { return; } $rack_sth = $this->pdo->prepare( "SELECT id FROM md_rack WHERE company_id = :company_id AND warehouse = :warehouse AND zone = :zone AND aisle = :aisle AND rack = :rack LIMIT 1" ); $rack_sth->execute([ ':company_id' => $this->company_id, ':warehouse' => $warehouse_id, ':zone' => $zone, ':aisle' => $aisle, ':rack' => $rack, ]); $rack_id = (int)$rack_sth->fetchColumn(); if (!$rack_id) { return; } $params = [ ':company_id' => $this->company_id, ':md_rack_id' => $rack_id, ':product_sku' => $product_sku, ]; $action_placeholders = []; foreach (array_values($actions) as $idx => $action) { $key = ':action_' . $idx; $action_placeholders[] = $key; $params[$key] = $action; } $td_stock_ids = array_values(array_filter(array_map('intval', $td_stock_ids))); $td_stock_cond = ''; if (!empty($td_stock_ids)) { $td_placeholders = []; foreach ($td_stock_ids as $idx => $td_stock_id) { $key = ':td_stock_id_' . $idx; $td_placeholders[] = $key; $params[$key] = $td_stock_id; } $td_stock_cond = 'AND td_stock_id IN (' . implode(', ', $td_placeholders) . ')'; } $since_cond = ''; if ($since !== null && $since !== '') { $since_cond = 'AND dt >= :since'; $params[':since'] = $since; } $sql = "UPDATE md_rack_log SET company_id = company_id * -1 WHERE company_id = :company_id AND md_rack_id = :md_rack_id AND product_sku = :product_sku AND action IN (" . implode(', ', $action_placeholders) . ") {$td_stock_cond} {$since_cond}"; $this->pdo->prepare($sql)->execute($params); } /** * Validate that the occupied racks under a storage_id all fall inside a new range. * * Called before syncRacks to prevent shrinking a range that still has occupied racks. * If any occupied rack has an aisle or rack value outside the proposed range, throws. * * @param int $storage_id The md_storage.id whose range is being changed. * @param array $range Keys: aisle_from, aisle_to, rack_from, rack_to. * @throws Exception If occupied racks would fall outside the new range. */ private function validateRangeChange(int $storage_id, array $range, bool $advanced_location): void { $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 zone, 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) { $z = trim($row['zone']); $a = trim($row['aisle']); $r = trim($row['rack']); if ($advanced_location) { if (in_array($a, $valid_aisles, true) && in_array($r, $valid_racks, true)) { continue; } throw new Exception("Cannot shrink range — some racks still have stock"); } if ($z !== $a || $a !== $r || !in_array($r, $valid_racks, true)) { throw new Exception("Cannot shrink range — some racks still have stock"); } } } /** * Insert rack rows for the full aisle × rack range (INSERT IGNORE skips duplicates). * * Called by syncRacks after cleaning up out-of-range empty racks. * Supports both numeric (1-10) and alpha (A-Z) aisle/rack values. * * @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. */ private function insertRacksInRange(int $storage_id, array $range, bool $advanced_location): 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']); if (!$advanced_location) { foreach ($racks as $location) { $sth->execute([ ":company_id" => $this->company_id, ":warehouse" => $range['warehouse'], ":storage_id" => $storage_id, ":zone" => $location, ":aisle" => $location, ":rack" => $location, ]); } return; } 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, ]); } } } // ───────────────────────────────────────────────────────────── // MASTER FILE BASIS — Warehouse // ───────────────────────────────────────────────────────────── /** * Return all warehouses with rack statistics and manager name. * * Used by the warehouse listing page (/inventory/warehouse.php). * Aggregates total_racks, occupied_racks, and unique_product per warehouse * from md_rack, and joins the wms.user table for the manager name. * * @return array All md_warehouse rows for this company with capacity/occupancy fields. */ public function getWarehouseListAll(): array { $sth = $this->pdo->prepare( "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->execute([':company_id' => $this->company_id]); return $sth->fetchAll(PDO::FETCH_ASSOC); } /** * Fetch a single warehouse row by its primary key. * * Used to pre-fill the edit form on the manage warehouse page. * * @param int $id The md_warehouse.id to fetch. * @return array|false Associative row, or false if not found. */ public function getWarehouseById(int $id): array|false { $sth = $this->pdo->prepare( "SELECT * FROM md_warehouse WHERE company_id = :company_id AND id = :id" ); $sth->execute([':company_id' => $this->company_id, ':id' => $id]); return $sth->fetch(PDO::FETCH_ASSOC); } /** * Fetch a warehouse's name string by its ID. * * Lightweight helper used internally and by engine files that only need * the name (e.g. for table name resolution or display). * * @param int|string $warehouse_id The md_warehouse.id to look up. * @return string|false The warehouse_name, or false if not found. */ 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(); } /** * Insert a new warehouse or update an existing one. * * On insert, returns the new warehouse id so the caller can create the * per-warehouse stock table outside the DB transaction. * * Pass $data['id'] = 0 to insert; pass $data['id'] > 0 to update. * Must be called inside dbTransaction() by the caller. * * @param array $data Keys: id, warehouse_name, location, manager, description, status. * @param array $logging Audit entry to append to the log column. * @return int|null New warehouse id on insert, null on update. */ public function saveWarehouse(array $data, array $logging): ?int { $id = (int)($data['id'] ?? 0); $warehouse_name = trim((string)($data['warehouse_name'] ?? '')); if ($warehouse_name === '') { throw new Exception("Warehouse name is required."); } $duplicate = $this->pdo->prepare( "SELECT COUNT(*) FROM md_warehouse WHERE company_id = :company_id AND warehouse_name = :warehouse_name AND id != :id" ); $duplicate->execute([ ':company_id' => $this->company_id, ':warehouse_name' => $warehouse_name, ':id' => $id, ]); if ((int)$duplicate->fetchColumn() > 0) { throw new Exception("Warehouse name already exists. Please use a different warehouse name."); } $sth = $this->pdo->prepare( "SELECT `log` FROM md_warehouse WHERE company_id = :company_id AND id = :id" ); $sth->execute([':company_id' => $this->company_id, ':id' => $id]); $table_log = json_decode($sth->fetchColumn() ?: '[]', true) ?: []; $table_log[] = $logging; $params = [ ':company_id' => $this->company_id, ':warehouse_name' => $warehouse_name, ':location' => $data['location'] ?? '', ':manager' => (int)($data['manager'] ?? 0), ':description' => $data['description'] ?? '', ':status' => (int)($data['status'] ?? 1), ':log' => json_encode($table_log, JSON_UNESCAPED_UNICODE), ]; if ($id > 0) { $params[':id'] = $id; $this->pdo->prepare( "UPDATE md_warehouse SET `warehouse_name` = :warehouse_name, `location` = :location, `manager` = :manager, `description` = :description, `status` = :status, `log` = :log WHERE id = :id AND company_id = :company_id" )->execute($params); } else { $this->pdo->prepare( "INSERT INTO md_warehouse (company_id, warehouse_name, location, manager, description, status, `log`) VALUES (:company_id, :warehouse_name, :location, :manager, :description, :status, :log)" )->execute($params); return (int)$this->pdo->lastInsertId(); } return null; } public function createStockTableForWarehouse(int $warehouse_id): void { $table = $this->stockTableNameFromWarehouseId($warehouse_id); $this->pdo->exec("CREATE TABLE IF NOT EXISTS `{$table}` LIKE `td_stock`"); } /** * Soft-delete a warehouse by negating its company_id. * * Blocks deletion if any md_storage record references this warehouse by name, * ensuring no orphaned storage locations remain. * * Must be called inside dbTransaction() by the caller. * * @param int $warehouse_id The md_warehouse.id to delete. * @throws Exception If the warehouse is not found or has storage locations. */ 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: negate company_id so row is hidden but recoverable $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, ]); } // ───────────────────────────────────────────────────────────── // MASTER FILE BASIS — Storage // ───────────────────────────────────────────────────────────── /** * Return all storage records with their associated warehouse name. * * Used to populate the storage listing page (/inventory/manage_storage.php). * * @return array All md_storage rows for this company with 'warehouse_name'. */ public function getStorageList(): array { $sth = $this->pdo->prepare( "SELECT a.*, b.warehouse_name FROM md_storage a LEFT JOIN md_warehouse b ON a.company_id = b.company_id AND a.warehouse = b.id WHERE a.company_id = :company_id" ); $sth->execute([':company_id' => $this->company_id]); return $sth->fetchAll(PDO::FETCH_ASSOC); } /** * Fetch a single storage row by its primary key. * * Used to pre-fill the edit form on the manage storage page. * * @param int $id The md_storage.id to fetch. * @return array|false Associative row, or false if not found. */ public function getStorageById(int $id): array|false { $sth = $this->pdo->prepare( "SELECT * FROM md_storage WHERE company_id = :company_id AND id = :id" ); $sth->execute([':company_id' => $this->company_id, ':id' => $id]); return $sth->fetch(PDO::FETCH_ASSOC); } /** * Insert a new storage record or update an existing one, then sync md_rack rows. * * After saving md_storage, calls syncRacks() to ensure the md_rack table * exactly matches the new aisle × rack range. On update, existing occupied * racks that fall outside the new range will block the operation (via syncRacks). * * Pass $data['id'] = 0 to insert; pass $data['id'] > 0 to update. * Must be called inside dbTransaction() by the caller. * * @param array $data Keys: id, warehouse, zone, aisle_from, aisle_to, * rack_from, rack_to, description, status. * @param array $logging Audit entry to append to the log column. */ public function saveStorage(array $data, array $logging, bool $advanced_location = false): void { $id = (int)($data['id'] ?? 0); $warehouse = trim((string)($data['warehouse'] ?? '')); $zone = trim((string)($data['zone'] ?? '')); $aisle_from = trim((string)($data['aisle_from'] ?? '')); $aisle_to = trim((string)($data['aisle_to'] ?? '')); $rack_from = trim((string)($data['rack_from'] ?? '')); $rack_to = trim((string)($data['rack_to'] ?? '')); if ($warehouse === '') { throw new Exception("Warehouse is required."); } if ($advanced_location) { if ($zone === '' || $aisle_from === '' || $aisle_to === '' || $rack_from === '' || $rack_to === '') { throw new Exception("Zone, aisle range, and rack range are required."); } } else { $location_from = $rack_from !== '' ? $rack_from : $aisle_from; $location_to = $rack_to !== '' ? $rack_to : $aisle_to; if ($location_from === '') { throw new Exception("Location From is required."); } if ($location_to === '') { $location_to = $location_from; } $zone = $location_from; $aisle_from = $location_from; $aisle_to = $location_to; $rack_from = $location_from; $rack_to = $location_to; } $sth = $this->pdo->prepare( "SELECT `log` FROM md_storage WHERE company_id = :company_id AND id = :id" ); $sth->execute([':company_id' => $this->company_id, ':id' => $id]); $table_log = json_decode($sth->fetchColumn() ?: '[]', true) ?: []; $table_log[] = $logging; $params = [ ':company_id' => $this->company_id, ':warehouse' => $warehouse, ':zone' => $zone, ':aisle_from' => $aisle_from, ':aisle_to' => $aisle_to, ':rack_from' => $rack_from, ':rack_to' => $rack_to, ':description' => $data['description'] ?? '', ':status' => (int)($data['status'] ?? 1), ':log' => json_encode($table_log, JSON_UNESCAPED_UNICODE), ]; if ($id > 0) { $params[':id'] = $id; $this->pdo->prepare( "UPDATE md_storage SET warehouse = :warehouse, zone = :zone, aisle_from = :aisle_from, aisle_to = :aisle_to, rack_from = :rack_from, rack_to = :rack_to, description = :description, status = :status, `log` = :log WHERE id = :id AND company_id = :company_id" )->execute($params); $storage_id = $id; } else { $this->pdo->prepare( "INSERT INTO md_storage (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)" )->execute($params); $storage_id = (int)$this->pdo->lastInsertId(); } // Sync md_rack rows to exactly match the new aisle × rack range $this->syncRacks($storage_id, [ 'warehouse' => $warehouse, 'zone' => $zone, 'aisle_from' => $aisle_from, 'aisle_to' => $aisle_to, 'rack_from' => $rack_from, 'rack_to' => $rack_to, ], $advanced_location); } /** * Soft-delete a storage record and its associated rack rows. * * Blocks deletion if any rack under this storage_id is occupied. * Also hard-deletes md_rack_log entries for those racks before soft-deleting * the md_rack rows themselves, then soft-deletes md_storage. * * Must be called inside dbTransaction() by the caller. * * @param int $storage_id The md_storage.id to delete. * @throws Exception If storage is 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 deletion — 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 (log records have no independent value) 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: negate company_id so row is hidden but recoverable $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, ]); } // ───────────────────────────────────────────────────────────── // MASTER FILE BASIS — Zone / Aisle / Rack (admin read-only views) // ───────────────────────────────────────────────────────────── /** * Return all distinct zones in a warehouse — for admin/read-only listing. * * Returns every zone regardless of occupancy. Used on warehouse detail * and storage admin pages where all zones must be shown. * Results are sorted numerically first, then alphabetically. * * @param int $warehouse_id The md_warehouse.id to query. * @return array Rows with 'zone' key. */ public function getZonesAll(int $warehouse_id): array { $sth = $this->pdo->prepare( "SELECT DISTINCT zone FROM md_rack WHERE company_id = :company_id AND warehouse = :warehouse ORDER BY CAST(zone AS UNSIGNED), zone" ); $sth->execute([':company_id' => $this->company_id, ':warehouse' => $warehouse_id]); return $sth->fetchAll(PDO::FETCH_ASSOC); } /** * Return all distinct aisles in a zone — for admin/read-only listing. * * Returns every aisle regardless of occupancy. Used on storage admin pages. * * @param int $warehouse_id The md_warehouse.id to query. * @param string $zone The zone to filter by. * @return array Flat array of aisle values (strings). */ public function getAislesAll(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 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'); } /** * Return all distinct racks in an aisle — for admin/read-only listing. * * Returns every rack regardless of occupancy. Used on storage admin pages. * * @param int $warehouse_id The md_warehouse.id to query. * @param string $zone The zone to filter by. * @param string $aisle The aisle to filter by. * @return array Flat array of rack values (strings). */ public function getRacksAll(int $warehouse_id, string $zone, string $aisle): array { if ($zone === '' || $aisle === '') { $sth = $this->pdo->prepare( "SELECT DISTINCT rack FROM md_rack WHERE company_id = :company_id AND warehouse = :warehouse ORDER BY CAST(rack AS UNSIGNED), rack" ); $sth->execute([':company_id' => $this->company_id, ':warehouse' => $warehouse_id]); return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'rack'); } $sth = $this->pdo->prepare( "SELECT DISTINCT rack FROM md_rack WHERE company_id = :company_id AND warehouse = :warehouse AND zone = :zone AND aisle = :aisle 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'); } // ───────────────────────────────────────────────────────────── // MASTER FILE BASIS — Lot / Serial queries // ───────────────────────────────────────────────────────────── /** * Search md_lot by product_sku and lot_number keyword — for lot autocomplete. * * Returns up to 50 matches ordered by lot_number ASC. The keyword is safely * bound as a LIKE parameter. * * @param string $product_sku The SKU to scope the search to. * @param string $keyword Partial lot number to match. * @return array Matching md_lot rows. */ public function getLotList(string $product_sku, string $keyword): array { // Only return lots that have at least one active td_stock row. // Lots whose stock was fully soft-deleted (company_id negated) are excluded. $sth = $this->pdo->prepare( "SELECT id FROM md_warehouse WHERE company_id = :company_id AND status = 1" ); $sth->execute([':company_id' => $this->company_id]); $warehouses = $sth->fetchAll(PDO::FETCH_ASSOC); if (empty($warehouses)) return []; $cid = (int)$this->company_id; // Build UNION EXISTS subquery across all td_stock_* tables $unions = implode(' UNION ALL ', array_map(function($wh) use ($cid) { $table = $this->stockTableNameFromWarehouseId((int)$wh['id']); return "SELECT lot_number FROM `{$table}` WHERE company_id = {$cid} AND lot_number IS NOT NULL"; }, $warehouses)); $sth = $this->pdo->prepare( "SELECT * FROM md_lot WHERE company_id = :company_id AND product_sku = :product_sku AND lot_number LIKE :keyword AND lot_number IN (SELECT lot_number FROM ({$unions}) AS all_lots) ORDER BY lot_number ASC LIMIT 50" ); $sth->execute([ ':company_id' => $this->company_id, ':product_sku' => $product_sku, ':keyword' => '%' . $keyword . '%', ]); return $sth->fetchAll(PDO::FETCH_ASSOC); } /** * Return distinct active lots for a product_sku across all warehouses. * * "Active" means the rack linked to the stock_in row is still occupied * (md_rack.product_sku IS NOT NULL). Includes expiry_date from md_lot. * Deduplicates across warehouses so each lot_number appears once. * Used to populate the lot dropdown on stock_out forms. * * @param string $product_sku The SKU to find active lots for. * @return array Rows with lot_number and expiry_date, deduplicated. */ public function getActiveLots(string $product_sku): array { $sth = $this->pdo->prepare( "SELECT id FROM md_warehouse WHERE company_id = :company_id AND status = 1" ); $sth->execute([':company_id' => $this->company_id]); $warehouses = $sth->fetchAll(PDO::FETCH_ASSOC); $active_lots = []; foreach ($warehouses as $wh) { $table = $this->stockTableNameFromWarehouseId((int)$wh['id']); $sth = $this->pdo->prepare( "SELECT DISTINCT s.lot_number, l.expiry_date FROM `{$table}` s LEFT JOIN md_lot l ON l.company_id = s.company_id AND l.product_sku = s.product_sku AND l.lot_number = s.lot_number WHERE s.company_id = :company_id AND s.product_sku = :product_sku AND s.`in` > 0 AND s.status = 1 AND s.lot_number IS NOT NULL AND EXISTS ( SELECT 1 FROM md_rack r WHERE r.company_id = s.company_id AND r.warehouse = :warehouse_id AND r.td_stock_id = s.id AND r.product_sku IS NOT NULL )" ); $sth->execute([ ':company_id' => $this->company_id, ':product_sku' => $product_sku, ':warehouse_id' => $wh['id'], ]); foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) { $active_lots[$row['lot_number']] ??= $row; } } return array_values($active_lots); } /** * Return distinct active serial numbers for a product_sku (and optional lot) * across all warehouses. * * "Active" means the rack linked to the stock_in row is still occupied. * Used to populate the serial dropdown on stock_out forms. * * @param string $product_sku The SKU to find active serials for. * @param string $lot_number Optional lot filter — pass empty string to skip. * @return array Flat array of active serial number strings. */ public function getActiveSerials(string $product_sku, string $lot_number = ''): array { $sth = $this->pdo->prepare( "SELECT id FROM md_warehouse WHERE company_id = :company_id AND status = 1" ); $sth->execute([':company_id' => $this->company_id]); $warehouses = $sth->fetchAll(PDO::FETCH_ASSOC); $active_serials = []; foreach ($warehouses as $wh) { $table = $this->stockTableNameFromWarehouseId((int)$wh['id']); $wh_id = $wh['id']; $sql = "SELECT DISTINCT s.serial_number FROM `{$table}` s WHERE s.company_id = :company_id AND s.product_sku = :product_sku AND s.`in` > 0 AND s.status = 1 AND s.serial_number IS NOT NULL AND EXISTS ( SELECT 1 FROM md_rack r WHERE r.company_id = s.company_id AND r.warehouse = {$wh_id} AND r.td_stock_id = s.id AND r.product_sku IS NOT NULL )"; $params = [ ':company_id' => $this->company_id, ':product_sku' => $product_sku, ]; if (!empty($lot_number)) { $sql .= ' AND s.lot_number = :lot_number'; $params[':lot_number'] = $lot_number; } $sth = $this->pdo->prepare($sql); $sth->execute($params); foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) { $active_serials[$row['serial_number']] = $row['serial_number']; } } return array_values($active_serials); } // ───────────────────────────────────────────────────────────── // TRANSACTION BASIS — Stock context resolution // ───────────────────────────────────────────────────────────── /** * Resolve the per-warehouse stock table name and optionally fetch a specific row. * * Core helper used throughout engine files and StockManager to get: * - 'table': the td_stock_ table name * - 'name': the raw warehouse_name string for display * - 'row': the specific stock row (or empty array if $id = 0) * * Pass $id = 0 to get just the table name without fetching a row. * Note: does NOT filter by status — inactive warehouses can still have * historical rows that need reading. * * @param int|string $warehouse_id The md_warehouse.id. * @param int|string $id The td_stock_.id to fetch, or 0 for table-only. * @return array Keys: 'table' (string), 'name' (string), 'row' (array|[]). */ public function getStockContext($warehouse_id, $id) { $warehouse_id = (int)$warehouse_id; $name = $this->getWarehouseName($warehouse_id); if (!$name) { throw new Exception("Warehouse ID {$warehouse_id} not found."); } $table = $this->stockTableNameFromWarehouseId($warehouse_id); 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) ?: [] ]; } // ───────────────────────────────────────────────────────────── // TRANSACTION BASIS — Rack lifecycle // ───────────────────────────────────────────────────────────── /** * Sync md_rack rows for a storage record to exactly match a new aisle × rack range. * * Steps: * 1. Validates that no occupied racks fall outside the new range (blocks if so). * 2. Hard-deletes all empty racks belonging to this storage_id. * 3. Re-inserts the full set of racks for the new range (INSERT IGNORE skips existing occupied racks). * * Called by saveStorage after every insert or update. * 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 by the new range. */ public function syncRacks(int $storage_id, array $range, bool $advanced_location): void { // Guard: refuse if any occupied rack under this storage_id falls outside the new range $this->validateRangeChange($storage_id, $range, $advanced_location); // 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 (INSERT IGNORE skips any occupied racks already there) $this->insertRacksInRange($storage_id, $range, $advanced_location); } /** * Fetch the stock record currently occupying a rack. * * Under the 1:1 rack model, each occupied rack's md_rack.td_stock_id points * to exactly one td_stock_ row. This method resolves that pointer and * returns the full stock row, or null if the rack is empty or the link is broken. * * Used by saveStockOut and saveStockTransfer to validate rack contents before * allowing a movement. * * @param int|string $warehouse_id The warehouse the rack belongs to. * @param string $zone Zone identifier. * @param string $aisle Aisle identifier. * @param string $rack Rack identifier. * @return array|null The full td_stock row linked to the rack, or null if empty. */ 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(); if (!$td_stock_id) { return null; // Rack is empty or doesn't exist } // Fetch the full stock row from the per-warehouse table. Source // movements can only consume approved stock-in rows; draft stock-in // rows may reserve a rack but are not available inventory yet. $context = $this->getStockContext($warehouse_id, $td_stock_id); $row = $context['row'] ?: null; if (!$row || $row['type'] !== 'in' || (int)$row['status'] !== 1) { return null; } return $row; } /** * Validate that a sku + lot + serial combination is not already active in any rack. * * "Active" means the td_stock row is currently linked to an occupied md_rack slot. * Enforces uniqueness rules: * - SKU alone → allowed in multiple racks (normal stocking) * - SKU + lot → allowed in multiple racks (lot spread across locations) * - SKU + serial → must be unique (one physical unit = one location) * - SKU + lot + serial → must be unique * * Called from occupyRack to prevent double-stocking the same serialised unit. * The $exclude_stock_id parameter skips the row just inserted (prevents self-conflict * in insert flows where the row exists but md_rack has not yet been updated). * * @param string $product_sku SKU to validate. * @param string|null $lot_number Lot number to check (or null to skip). * @param string|null $serial_number Serial number to check (or null to skip). * @param int $exclude_stock_id Stock row ID to exclude from the check. * @throws Exception If an active duplicate is found in any warehouse. */ 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 was provided if (empty($lot_number) && empty($serial_number)) return; $cid = $this->company_id; // Discover all active 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) { $wh_id = (int)$wh['id']; $table = $this->stockTableNameFromWarehouseId($wh_id); // Find any stock_in row for this SKU that is still rack-linked (active) // and matches the lot/serial combination being validated. // The exclude_id skips the just-inserted row to avoid false self-conflict. // r.warehouse = :warehouse_id scopes the rack check to THIS warehouse only — // td_stock_id values are per-table integers, not globally unique, so without // this filter a rack in a different warehouse could cause a false positive. $sql = "SELECT s.id FROM `{$table}` s WHERE s.company_id = :company_id AND s.product_sku = :product_sku AND s.`in` > 0 AND EXISTS ( SELECT 1 FROM md_rack r WHERE r.company_id = :company_id2 AND r.warehouse = :warehouse_id 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, ':warehouse_id' => $wh_id, ':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." ); } } } /** * Assign a product to an empty rack and link it to a td_stock row. * * Uses FOR UPDATE to lock the rack row and prevent concurrent occupancy. * Also calls validateStockUnique to enforce no-duplicate-serial rules unless * the caller is replaying a validated source transaction, such as a return. * Updates md_rack state (product_sku + td_stock_id) in a single UPDATE * and appends a rack log entry. * * Must be called inside a DB transaction by the caller. * * @param int|string $warehouse_id Warehouse the rack belongs to. * @param string $zone Zone identifier. * @param string $aisle Aisle identifier. * @param string $rack Rack identifier. * @param string $product_sku SKU to assign to this rack. * @param int $td_stock_id The td_stock_.id to link. * @throws Exception If the rack does not exist or is already occupied. */ public function occupyRack($warehouse_id, $zone, $aisle, $rack, $product_sku, $td_stock_id, bool $skip_unique_validation = false): 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); if (!$current) { throw new Exception("Rack {$zone}-{$aisle}-{$rack} does not exist"); } if ($current['product_sku'] !== null) { if ( $current['product_sku'] === $product_sku && (int)$current['td_stock_id'] === (int)$td_stock_id ) { return; } throw new Exception( "Rack {$zone}-{$aisle}-{$rack} is already occupied by {$current['product_sku']}" ); } if (!$skip_unique_validation) { // Validate sku + lot + serial uniqueness before linking the rack $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 insert flows don't self-conflict ); } } // Atomic UPDATE: set state and link in one statement $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 its product_sku and td_stock_id link. * * Uses FOR UPDATE to lock the rack row. If the rack is already empty, * exits silently (idempotent: safe to call on an already-empty rack). * Appends a rack log entry on successful release. * * Must be called inside a DB transaction by the caller. * * @param int|string $warehouse_id Warehouse the rack belongs to. * @param string $zone Zone identifier. * @param string $aisle Aisle identifier. * @param string $rack Rack identifier. */ 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) if (!$current || $current['product_sku'] === null) { return; } // Clear both state fields in a single 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. * * Transfers both product_sku and td_stock_id together from the source * rack to the destination rack. The source must be occupied; the destination * must be empty. Both racks are locked in id-ascending order to prevent * deadlocks under concurrent transfers. * * Must be called inside a DB transaction by the caller. * * @param int|string $from_warehouse Source warehouse. * @param string $from_zone Source zone. * @param string $from_aisle Source aisle. * @param string $from_rack Source rack. * @param int|string $to_warehouse Destination warehouse. * @param string $to_zone Destination zone. * @param string $to_aisle Destination aisle. * @param string $to_rack Destination rack. * @throws Exception If source/destination racks are not found, source is empty, * or destination is already occupied. */ public function transferRack( $from_warehouse, $from_zone, $from_aisle, $from_rack, $to_warehouse, $to_zone, $to_aisle, $to_rack ): void { // Lock both racks ordered by id to prevent deadlocks on concurrent transfers $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 from source to destination $sku = $from['product_sku']; $td_stock_id = $from['td_stock_id']; // Clear source $this->pdo->prepare("UPDATE md_rack SET product_sku = NULL, td_stock_id = NULL WHERE id = :id") ->execute([":id" => $from['id']]); $this->insertRackLog($from['id'], 'transfer_out', [ 'product_sku' => $sku, 'td_stock_id' => $td_stock_id, ]); // Populate destination $this->pdo->prepare("UPDATE md_rack SET product_sku = :sku, td_stock_id = :td_stock_id WHERE id = :id") ->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, ]); } // ───────────────────────────────────────────────────────────── // TRANSACTION BASIS — Warehouse balance // ───────────────────────────────────────────────────────────── /** * Adjust the running balance for a (warehouse, product_sku) pair in warehouse_balance. * * Uses an INSERT ... ON DUPLICATE KEY UPDATE to atomically upsert the balance row, * partitioned by month (YYYY-MM) for efficient monthly reporting queries. * * The delta formula ($new_qty - $old_qty) supports both create and edit flows: * - On insert: old_qty = 0, delta = new_qty (adds the full amount) * - On delete: new_qty = 0, delta = -old_qty (reverses the full amount) * - On edit: delta = new_qty - old_qty (adjusts for the difference) * * Called by StockManager and deleteStock* methods; never called directly by engine files. * * @param string $type 'in' or 'out' — which balance column to update. * @param int|string $warehouse_id Warehouse to adjust balance for. * @param string $product_sku SKU to adjust. * @param int|float $old_qty Previous quantity (0 for new records). * @param int|float $new_qty New quantity (0 to reverse/delete). */ public function adjustBalance($type, $warehouse_id, $product_sku, $old_qty, $new_qty) { $column = $type === 'in' ? 'total_in' : 'total_out'; $delta = $new_qty - $old_qty; $month = date('Y-m'); $sql = "INSERT INTO warehouse_balance (company_id, warehouse_id, product_sku, month, `$column`) VALUES (:company_id, :warehouse_id, :product_sku, :month, :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, ":month" => $month, ":delta" => $delta ]); } // ───────────────────────────────────────────────────────────── // TRANSACTION BASIS — Zone / Aisle / Rack (stock movement selectors) // ───────────────────────────────────────────────────────────── /** * Return distinct zones with at least one empty rack — for stock_in destination selector. * * Only zones that have at least one available (empty) rack slot are returned, * so the user cannot direct stock to a fully occupied zone. * * @param int $warehouse_id The warehouse to query. * @return array Rows with 'zone' key. */ 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); } /** * Return distinct zones holding active stock for a sku/lot/serial — for stock_out source selector. * * Only zones that currently have at least one occupied rack with the matching * product/lot/serial combination are returned. * * @param int $warehouse_id The warehouse to query. * @param string $product_sku SKU to filter by. * @param string|null $lot_number Optional lot filter. * @param string|null $serial_number Optional serial filter. * @return array Rows with 'zone' key. */ 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.`in` > 0 AND s.status = 1 {$lot_cond} {$serial_cond} ) ORDER BY CAST(r.zone AS UNSIGNED), r.zone" ); $sth->execute($params); return $sth->fetchAll(PDO::FETCH_ASSOC); } /** * Return distinct aisles with at least one empty rack in a zone — for stock_in destination. * * @param int $warehouse_id The warehouse to query. * @param string $zone Zone to filter by. * @return array Flat array of aisle values (strings). */ 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'); } /** * Return distinct aisles holding active stock for a sku/lot/serial in a zone — for stock_out source. * * @param int $warehouse_id The warehouse to query. * @param string $zone Zone to filter by. * @param string $product_sku SKU to filter by. * @param string|null $lot_number Optional lot filter. * @param string|null $serial_number Optional serial filter. * @return array Flat array of aisle values (strings). */ 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.`in` > 0 AND s.status = 1 {$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'); } /** * Return distinct empty racks in an aisle — for stock_in destination selector. * * @param int $warehouse_id The warehouse to query. * @param string $zone Zone to filter by. * @param string $aisle Aisle to filter by. * @return array Flat array of rack values (strings). */ public function getRacksIn(int $warehouse_id, string $zone, string $aisle): array { if ($zone === '' || $aisle === '') { $sth = $this->pdo->prepare( "SELECT DISTINCT rack FROM md_rack WHERE company_id = :company_id AND warehouse = :warehouse AND product_sku IS NULL ORDER BY CAST(rack AS UNSIGNED), rack" ); $sth->execute([ ':company_id' => $this->company_id, ':warehouse' => $warehouse_id, ]); return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'rack'); } $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'); } /** * Return distinct racks holding active stock for a sku/lot/serial in an aisle — for stock_out source. * * @param int $warehouse_id The warehouse to query. * @param string $zone Zone to filter by. * @param string $aisle Aisle to filter by. * @param string $product_sku SKU to filter by. * @param string|null $lot_number Optional lot filter. * @param string|null $serial_number Optional serial filter. * @return array Flat array of rack values (strings). */ 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 ); $location_cond = ''; if ($zone !== '' && $aisle !== '') { $location_cond = "AND r.zone = :zone AND r.aisle = :aisle"; $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.product_sku = :product_sku {$location_cond} 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.`in` > 0 AND s.status = 1 {$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'); } // ───────────────────────────────────────────────────────────── // TRANSACTION BASIS — Stock deletion (reversal) // ───────────────────────────────────────────────────────────── /** * Soft-delete a stock_in record and reverse all its side effects. * * Steps: * 1. Validates this is the globally latest transaction for the SKU. * 2. Soft-deletes the td_stock row (negates company_id). * 3. Releases the reserved/occupied rack back to empty. * 4. Reverses the balance only if the row was approved. * * Must be called inside a DB transaction by the caller. * * @param int $stock_id The td_stock_.id of the row to delete. * @param int $warehouse_id The warehouse the row belongs to. * @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: negate company_id so row is hidden but recoverable $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]); // Stock-in reserves the rack at creation time, even while draft. // Balance is only applied for approved rows. $this->releaseRack($warehouse_id, $row['zone'], $row['aisle'], $row['rack']); $this->softDeleteRackLogs( $warehouse_id, $row['zone'], $row['aisle'], $row['rack'], ['occupy', 'release'], $product_sku, [$stock_id], $row['date'] ); if ((int)$row['status'] === 1) { $this->adjustBalance('in', $warehouse_id, $product_sku, (int)$row['in'], 0); } } /** * Soft-delete a stock_out record and reverse all its side effects. * * Steps: * 1. Validates this is the globally latest transaction for the SKU. * 2. Soft-deletes the td_stock row (negates company_id). * 3. Re-occupies the rack with the original stock_in batch (via ref_id). * 4. Reverses the balance (adjustBalance with new_qty = 0). * * Must be called inside a DB transaction by the caller. * * @param int $stock_id The td_stock_.id of the row to delete. * @param int $warehouse_id The warehouse the row belongs to. * @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: negate company_id so row is hidden but recoverable $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]); // Only reverse side effects if the row was approved — draft rows never had them applied if ((int)$row['status'] === 1) { // Re-occupy the rack with the original stock_in batch that was consumed $this->occupyRack( $warehouse_id, $row['zone'], $row['aisle'], $row['rack'], $product_sku, (int)$row['ref_id'] ); $this->softDeleteRackLogs( $warehouse_id, $row['zone'], $row['aisle'], $row['rack'], ['release', 'occupy'], $product_sku, [(int)$row['ref_id']], $row['date'] ); $this->adjustBalance('out', $warehouse_id, $product_sku, (int)$row['out'], 0); } } /** * Soft-delete a transfer record pair and reverse all side effects on both warehouses. * * Steps: * 1. Validates this is the globally latest transaction for the SKU. * 2. Soft-deletes both the from and to rows (negates company_id on each). * 3. Releases the destination rack (which was occupied by the transfer_in). * 4. Re-occupies the source rack with the original from_stock_id batch. * 5. Reverses balances on both warehouses. * * Must be called inside a DB transaction by the caller. * * @param int $from_stock_id The td_stock_.id of the outbound transfer row. * @param int $from_warehouse_id The source warehouse the outbound row belongs to. * @throws Exception If not the latest transaction, or if either row is missing. */ public function deleteTransfer(int $from_stock_id, int $from_warehouse_id): void { // Fetch the outbound (from) row $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 inbound (to) row $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 (same SKU, same date) $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]); // Only reverse side effects if approved — draft rows never had them applied if ((int)$from_row['status'] === 1) { // 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 ); $this->softDeleteRackLogs( $to_warehouse, $to_row['zone'], $to_row['aisle'], $to_row['rack'], ['occupy', 'release'], $product_sku, [$ref_id], $from_row['date'] ); $this->softDeleteRackLogs( $from_warehouse_id, $from_row['zone'], $from_row['aisle'], $from_row['rack'], ['release', 'occupy'], $product_sku, [], $from_row['date'] ); // Reverse balances on both sides $quantity = (int)$from_row['out']; $this->adjustBalance('out', $from_warehouse_id, $product_sku, $quantity, 0); $this->adjustBalance('in', $to_warehouse, $product_sku, $quantity, 0); } } // ───────────────────────────────────────────────────────────── // REPORT BASIS — Warehouse list queries // ───────────────────────────────────────────────────────────── /** * Return warehouse ids that currently hold an approved, rack-occupied stock-in * row for a given product_sku + lot_number combination. * * Used to filter the warehouse dropdown in stock-out/transfer when a lot is selected, * so only warehouses actually holding that lot are shown. * * @param string $product_sku * @param string $lot_number * @return int[] Array of warehouse ids. */ public function getWarehousesByLot(string $product_sku, string $lot_number): array { $sth = $this->pdo->prepare( "SELECT id, warehouse_name FROM md_warehouse WHERE company_id = :company_id AND status = 1" ); $sth->execute([':company_id' => $this->company_id]); $warehouses = $sth->fetchAll(PDO::FETCH_ASSOC); $result = []; $cid = (int)$this->company_id; foreach ($warehouses as $wh) { $table = $this->stockTableNameFromWarehouseId((int)$wh['id']); $wh_id = (int)$wh['id']; $sth = $this->pdo->prepare( "SELECT 1 FROM `{$table}` s WHERE s.company_id = :company_id AND s.product_sku = :product_sku AND s.lot_number = :lot_number AND s.`in` > 0 AND s.status = 1 AND EXISTS ( SELECT 1 FROM md_rack r WHERE r.company_id = s.company_id AND r.warehouse = :warehouse_id AND r.td_stock_id = s.id AND r.product_sku IS NOT NULL ) LIMIT 1" ); $sth->execute([ ':company_id' => $cid, ':product_sku' => $product_sku, ':lot_number' => $lot_number, ':warehouse_id' => $wh_id, ]); if ($sth->fetchColumn() !== false) { $result[] = ['id' => $wh_id, 'warehouse_name' => $wh['warehouse_name']]; } } return $result; } /** * Return warehouses filtered by rack availability — for stock movement warehouse selectors. * * Used on stock_in / stock_out / transfer forms to present only valid warehouse options: * type = 'to' → warehouses with at least one empty rack (valid destination) * type = 'from' → warehouses holding the given product_sku (valid source) * other → all warehouses with any racks * * Passing $id > 0 bypasses the occupancy filter (edit mode — warehouse is already set). * * @param string $type 'to', 'from', or any other string for unfiltered. * @param string $product_sku SKU filter used when type = 'from'. * @param int $id If > 0, skips the filter (edit mode). * @return array md_warehouse rows (id, warehouse_name) ordered by name. */ public function getWarehouseList(string $type, string $product_sku = '', int $id = 0): array { if (!$id && $type === 'from') { if ($product_sku === '') { return []; } $sth = $this->pdo->prepare( "SELECT id, warehouse_name FROM md_warehouse WHERE company_id = :company_id AND status = 1 ORDER BY warehouse_name" ); $sth->execute([':company_id' => $this->company_id]); $warehouses = $sth->fetchAll(PDO::FETCH_ASSOC); $result = []; foreach ($warehouses as $wh) { $table = $this->stockTableNameFromWarehouseId((int)$wh['id']); $stock = $this->pdo->prepare( "SELECT 1 FROM md_rack r INNER JOIN `{$table}` s ON s.company_id = r.company_id AND s.id = r.td_stock_id WHERE r.company_id = :company_id AND r.warehouse = :warehouse AND r.product_sku = :product_sku AND s.product_sku = :product_sku2 AND s.`in` > 0 AND s.status = 1 LIMIT 1" ); $stock->execute([ ':company_id' => $this->company_id, ':warehouse' => (int)$wh['id'], ':product_sku' => $product_sku, ':product_sku2' => $product_sku, ]); if ($stock->fetchColumn() !== false) { $result[] = $wh; } } return $result; } $product_sku_filter = ''; $params = [':company_id' => $this->company_id]; if (!$id) { if ($type === 'to') { $product_sku_filter = 'AND r.product_sku IS NULL'; } elseif ($type === 'from') { $product_sku_filter = 'AND r.product_sku = :product_sku'; $params[':product_sku'] = $product_sku; } } $sth = $this->pdo->prepare( "SELECT w.id, w.warehouse_name FROM md_warehouse w WHERE w.company_id = :company_id AND EXISTS ( SELECT 1 FROM md_rack r WHERE r.company_id = w.company_id AND r.warehouse = w.id $product_sku_filter ) ORDER BY w.warehouse_name" ); $sth->execute($params); return $sth->fetchAll(PDO::FETCH_ASSOC); } }