Files
wms-app/app/assets/utils/classes/WarehouseManager.php
T

2490 lines
98 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
/**
* WarehouseManager
*
* Handles all warehouse, storage, rack, lot/serial, and stock deletion operations.
*
* Method order:
* Master file basis → Warehouse (get/save/delete), Storage (get/save/delete),
* Zone/Aisle/Rack resolution (getAll variants for admin views),
* Lot/Serial queries
* Transaction basis → Stock context resolution, rack lifecycle (occupy/release/transfer),
* balance adjustment, delete operations (stock in/out/transfer)
* Report basis → Warehouse list queries (getWarehouseList, getWarehouseListAll)
*
* Note: Write methods do NOT manage their own DB transactions.
* Callers must wrap multi-step operations inside dbTransaction().
*
* Security: All SQL uses PDO prepared statements with bound parameters.
* Dynamic stock table names are derived only from md_warehouse.id.
*/
class WarehouseManager {
private PDO $pdo;
private int $company_id;
public function __construct(PDO $pdo, int $company_id, $logging = null) {
$this->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;
}
public function assertStockMovementWindow(?string $date, string $context = 'Stock movement'): void
{
global $pdo1;
if (!isset($pdo1) || !($pdo1 instanceof PDO)) {
throw new Exception("Posting-window validation is unavailable.");
}
$guard = new PostingWindowGuard($pdo1, $this->company_id);
$guard->assertOpenDate($date ?: date('Y-m-d'), $context);
}
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 td_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.
*
* td_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 td_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
$sth = $this->pdo->prepare(
"SELECT COUNT(*) FROM md_storage
WHERE company_id = :company_id
AND warehouse = :warehouse_id"
);
$sth->execute([
':company_id' => $this->company_id,
':warehouse_id' => $warehouse_id,
]);
if ((int)$sth->fetchColumn() > 0) {
throw new Exception(
"Cannot delete — warehouse \"{$row['warehouse_name']}\" " .
"still has storage locations assigned to it."
);
}
// Block if the warehouse's stock table has any rows
$stock_table = $this->stockTableNameFromWarehouseId($warehouse_id);
$sth = $this->pdo->prepare(
"SELECT COUNT(*) FROM `{$stock_table}`
WHERE company_id = :company_id"
);
$sth->execute([':company_id' => $this->company_id]);
if ((int)$sth->fetchColumn() > 0) {
throw new Exception(
"Cannot delete — warehouse \"{$row['warehouse_name']}\" " .
"still has stock records. Clear the stock first."
);
}
// 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.
* Soft-deletes md_rack rows (company_id negation) and retains td_rack_log
* for audit purposes. 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."
);
}
// 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_<warehouse_id> 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_<warehouse_id>.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);
}
private function getRackRow(int $warehouse_id, string $zone, string $aisle, string $rack): array|false
{
$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
LIMIT 1";
$sth = $this->pdo->prepare($sql);
$sth->execute([
':company_id' => $this->company_id,
':warehouse' => $warehouse_id,
':zone' => $zone,
':aisle' => $aisle,
':rack' => $rack,
]);
return $sth->fetch(PDO::FETCH_ASSOC);
}
public function validateScanLocation(
string $mode,
int $warehouse_id,
string $zone,
string $aisle,
string $rack,
string $product_sku = '',
string $lot_number = '',
string $serial_number = ''
): void {
if (!$warehouse_id || $zone === '' || $aisle === '' || $rack === '') {
throw new Exception('Location label is incomplete.');
}
$rack_row = $this->getRackRow($warehouse_id, $zone, $aisle, $rack);
if (!$rack_row) {
throw new Exception("Location {$zone}-{$aisle}-{$rack} does not exist.");
}
if ($mode === 'in' || $mode === 'to') {
if ($rack_row['product_sku'] !== null) {
throw new Exception("Location {$zone}-{$aisle}-{$rack} is already occupied by {$rack_row['product_sku']}.");
}
if ($mode === 'in' && $product_sku !== '') {
$this->validateStockUnique($product_sku, $lot_number ?: null, $serial_number ?: null);
}
return;
}
if ($mode === 'out' || $mode === 'from') {
if ($product_sku === '') {
throw new Exception('Scan the SKU label first.');
}
$stock = $this->getRackStock($warehouse_id, $zone, $aisle, $rack);
if (!$stock) {
throw new Exception("Location {$zone}-{$aisle}-{$rack} has no approved stock to move out.");
}
if ($stock['product_sku'] !== $product_sku) {
throw new Exception("Location holds {$stock['product_sku']}, not {$product_sku}.");
}
if ($lot_number !== '' && (string)$stock['lot_number'] !== $lot_number) {
throw new Exception("Location holds lot '{$stock['lot_number']}', not '{$lot_number}'.");
}
if ($serial_number !== '' && (string)$stock['serial_number'] !== $serial_number) {
throw new Exception("Location holds serial '{$stock['serial_number']}', not '{$serial_number}'.");
}
return;
}
throw new Exception('Invalid scan validation mode.');
}
/**
* 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_<warehouse_id> 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_<warehouse_id>.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 etl_stock_summary.
*
* 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,
int $stock_id = 0, string $source = '', int $source_id = 0,
string $stock_date = ''
): void {
$column = $type === 'in' ? 'total_in' : 'total_out';
$delta = $new_qty - $old_qty;
$month = $stock_date ? date('Y-m', strtotime($stock_date)) : date('Y-m');
$this->pdo->prepare(
"INSERT INTO etl_stock_summary
(company_id, warehouse_id, product_sku, month, `{$column}`, source_updated_at)
VALUES
(:company_id, :warehouse_id, :product_sku, :month, :delta, NOW())
ON DUPLICATE KEY UPDATE
`{$column}` = `{$column}` + VALUES(`{$column}`),
source_updated_at = NOW()"
)->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_<warehouse_id>.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);
if ((int)$row['status'] === 1) {
$this->assertStockMovementWindow($row['date'] ?? null, 'Stock-in deletion');
}
$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, 0, '', 0, $row['date'] ?? '');
}
}
/**
* 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_<warehouse_id>.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);
if ((int)$row['status'] === 1) {
$this->assertStockMovementWindow($row['date'] ?? null, 'Stock-out deletion');
}
$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, 0, '', 0, $row['date'] ?? '');
}
}
/**
* 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_<from>.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.");
}
if ((int)$from_row['status'] === 1 || (int)$to_row['status'] === 1) {
$this->assertStockMovementWindow($from_row['date'] ?? null, 'Stock transfer cancellation');
$this->assertStockMovementWindow($to_row['date'] ?? null, 'Stock transfer cancellation');
}
// 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, 0, '', 0, $from_row['date'] ?? '');
$this->adjustBalance('in', $to_warehouse, $product_sku, $quantity, 0, 0, '', 0, $from_row['date'] ?? '');
}
}
// ─────────────────────────────────────────────────────────────
// 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);
}
}