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

1948 lines
78 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
/**
* 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 table names (td_stock_<warehouse>) are derived exclusively from
* DB-sourced warehouse names sanitised with preg_replace('/[^a-zA-Z0-9_]/', '', ...)
* before interpolation — no user input ever reaches a table identifier directly.
*/
class WarehouseManager {
private $pdo;
private $company_id;
public function __construct($pdo, $company_id, $logging = null) {
$this->pdo = $pdo;
$this->company_id = $company_id;
}
// ─────────────────────────────────────────────────────────────
// Private helpers
// ─────────────────────────────────────────────────────────────
/**
* Resolve the td_stock_<wh> table name for a warehouse, requiring active status.
*
* Returns null if the warehouse is not found or is inactive (status != 1).
* Used by Zone/Aisle/Rack out-query methods where inactive warehouses
* should not contribute available stock locations.
*
* @param int $warehouse_id The md_warehouse.id to resolve.
* @return string|null Sanitised table name, or null if not active/found.
*/
private function resolveWarehouseTable(int $warehouse_id): ?string
{
$sth = $this->pdo->prepare(
"SELECT warehouse_name FROM md_warehouse
WHERE company_id = :company_id AND id = :id AND status = 1"
);
$sth->execute([':company_id' => $this->company_id, ':id' => $warehouse_id]);
$name = $sth->fetchColumn();
if (!$name) return null;
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $name);
return "td_stock_{$safe}";
}
/**
* 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);
// Numeric range
if (is_numeric($from) && is_numeric($to)) {
$f = (int) $from;
$t = (int) $to;
if ($f > $t) throw new Exception("Range start ($from) must be <= end ($to)");
return array_map('strval', range($f, $t));
}
// Single-letter alpha range
if (ctype_alpha($from) && ctype_alpha($to)
&& strlen($from) === 1 && strlen($to) === 1) {
if (ord($from) > ord($to)) throw new Exception("Range start ($from) must be <= end ($to)");
return range($from, $to); // PHP range() handles 'A' → 'Z' natively
}
throw new Exception(
"Aisle/rack values must be either numbers (e.g. 1–99) or single uppercase letters (e.g. A–Z). Got: '$from' → '$to'"
);
}
/**
* 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",
$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,
]);
}
/**
* 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): void
{
// Build the valid sets from the new range
$valid_aisles = $this->rangeToArray($range['aisle_from'], $range['aisle_to']);
$valid_racks = $this->rangeToArray($range['rack_from'], $range['rack_to']);
// Fetch all occupied racks under this storage_id
$sth = $this->pdo->prepare(
"SELECT aisle, rack FROM md_rack
WHERE company_id = :company_id
AND storage_id = :storage_id
AND product_sku IS NOT NULL"
);
$sth->execute([
':company_id' => $this->company_id,
':storage_id' => $storage_id,
]);
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
$a = trim($row['aisle']);
$r = trim($row['rack']);
if (!in_array($a, $valid_aisles, true) || !in_array($r, $valid_racks, true)) {
throw new Exception("Cannot shrink range — some racks still have stock");
}
}
}
/**
* Insert rack rows for the full 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): void
{
$sql = "INSERT IGNORE INTO md_rack
(company_id, warehouse, storage_id, zone, aisle, rack)
VALUES
(:company_id, :warehouse, :storage_id, :zone, :aisle, :rack)";
$sth = $this->pdo->prepare($sql);
$aisles = $this->rangeToArray($range['aisle_from'], $range['aisle_to']);
$racks = $this->rangeToArray($range['rack_from'], $range['rack_to']);
foreach ($aisles as $a) {
foreach ($racks as $r) {
$sth->execute([
":company_id" => $this->company_id,
":warehouse" => $range['warehouse'],
":storage_id" => $storage_id,
":zone" => $range['zone'],
":aisle" => $a,
":rack" => $r,
]);
}
}
}
// ─────────────────────────────────────────────────────────────
// 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, creates a new per-warehouse stock table (td_stock_<name>)
* using the td_stock template via CREATE TABLE LIKE.
* The warehouse_name is sanitised before use as a table name suffix.
*
* 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.
*/
public function saveWarehouse(array $data, array $logging): void
{
$id = (int)($data['id'] ?? 0);
$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' => $data['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);
// Create the per-warehouse stock table using td_stock as template
if (!empty($data['warehouse_name'])) {
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $data['warehouse_name']);
$this->pdo->exec("CREATE TABLE `td_stock_{$safe}` 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): void
{
$id = (int)($data['id'] ?? 0);
$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' => $data['warehouse'] ?? '',
':zone' => $data['zone'] ?? '',
':aisle_from' => $data['aisle_from'] ?? '',
':aisle_to' => $data['aisle_to'] ?? '',
':rack_from' => $data['rack_from'] ?? '',
':rack_to' => $data['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' => $data['warehouse'],
'zone' => $data['zone'],
'aisle_from' => $data['aisle_from'],
'aisle_to' => $data['aisle_to'],
'rack_from' => $data['rack_from'],
'rack_to' => $data['rack_to'],
]);
}
/**
* 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
{
$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
{
$sth = $this->pdo->prepare(
"SELECT *
FROM md_lot
WHERE company_id = :company_id
AND product_sku = :product_sku
AND lot_number LIKE :keyword
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, 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);
$active_lots = [];
foreach ($warehouses as $wh) {
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']);
$table = "td_stock_{$safe}";
$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.type = 'in'
AND s.lot_number IS NOT NULL
AND EXISTS (
SELECT 1 FROM md_rack r
WHERE r.company_id = s.company_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,
]);
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, 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);
$active_serials = [];
foreach ($warehouses as $wh) {
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']);
$table = "td_stock_{$safe}";
$sql = "SELECT DISTINCT s.serial_number
FROM `{$table}` s
WHERE s.company_id = :company_id
AND s.product_sku = :product_sku
AND s.type = 'in'
AND s.serial_number IS NOT NULL
AND EXISTS (
SELECT 1 FROM md_rack r
WHERE r.company_id = s.company_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> table name
* - 'name': the raw warehouse_name string
* - '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_<wh>.id to fetch, or 0 for table-only.
* @return array Keys: 'table' (string), 'name' (string), 'row' (array|[]).
*/
public function getStockContext($warehouse_id, $id) {
$name = $this->getWarehouseName($warehouse_id);
// Sanitise table suffix to prevent SQL injection via warehouse names
$safe_name = preg_replace('/[^a-zA-Z0-9_]/', '', $name);
$table = "td_stock_" . $safe_name;
if (empty($id)) {
return [
'table' => $table,
'name' => $name,
'row' => []
];
}
$sql = "SELECT * FROM `$table`
WHERE company_id = :company_id AND id = :id";
$sth = $this->pdo->prepare($sql);
$sth->execute([
":company_id" => $this->company_id,
":id" => $id
]);
return [
'table' => $table,
'name' => $name,
'row' => $sth->fetch(PDO::FETCH_ASSOC) ?: []
];
}
// ─────────────────────────────────────────────────────────────
// 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): void {
// Guard: refuse if any occupied rack under this storage_id falls outside the new range
$this->validateRangeChange($storage_id, $range);
// Wipe all empty racks belonging to this storage_id
$sql = "DELETE FROM md_rack
WHERE company_id = :company_id
AND storage_id = :storage_id
AND product_sku IS NULL";
$sth = $this->pdo->prepare($sql);
$sth->execute([
':company_id' => $this->company_id,
':storage_id' => $storage_id,
]);
// Reinsert the full range fresh (INSERT IGNORE skips any occupied racks already there)
$this->insertRacksInRange($storage_id, $range);
}
/**
* 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_<wh> 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
$context = $this->getStockContext($warehouse_id, $td_stock_id);
return $context['row'] ?: null;
}
/**
* 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) {
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']);
$table = "td_stock_{$safe}";
// 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.
$sql = "SELECT s.id
FROM `{$table}` s
WHERE s.company_id = :company_id
AND s.product_sku = :product_sku
AND s.type = 'in'
AND EXISTS (
SELECT 1 FROM md_rack r
WHERE r.company_id = :company_id2
AND r.td_stock_id = s.id
AND r.product_sku IS NOT NULL
)";
if (!empty($lot_number)) {
$sql .= " AND s.lot_number = :lot_number";
}
if (!empty($serial_number)) {
$sql .= " AND s.serial_number = :serial_number";
}
if ($exclude_stock_id > 0) {
$sql .= " AND s.id != :exclude_id";
}
$params = [
':company_id' => $cid,
':company_id2' => $cid,
':product_sku' => $product_sku,
];
if (!empty($lot_number)) $params[':lot_number'] = $lot_number;
if (!empty($serial_number)) $params[':serial_number'] = $serial_number;
if ($exclude_stock_id > 0) $params[':exclude_id'] = $exclude_stock_id;
$sth = $this->pdo->prepare($sql);
$sth->execute($params);
if ($sth->fetchColumn()) {
$combo = implode(' / ', array_filter([
$lot_number ? "lot: {$lot_number}" : null,
$serial_number ? "serial: {$serial_number}" : null,
]));
throw new Exception(
"Active stock already exists for product '{$product_sku}' [{$combo}] "
. "in warehouse '{$wh['warehouse_name']}'. "
. "Stock must be moved out before it can be received again."
);
}
}
}
/**
* 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.
* 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_<wh>.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): void {
$sql = "SELECT id, product_sku FROM md_rack
WHERE company_id = :company_id
AND warehouse = :warehouse
AND zone = :zone
AND aisle = :aisle
AND rack = :rack
FOR UPDATE";
$sth = $this->pdo->prepare($sql);
$sth->execute([
":company_id" => $this->company_id,
":warehouse" => $warehouse_id,
":zone" => $zone,
":aisle" => $aisle,
":rack" => $rack,
]);
$current = $sth->fetch(PDO::FETCH_ASSOC);
if (!$current) {
throw new Exception("Rack {$zone}-{$aisle}-{$rack} does not exist");
}
if ($current['product_sku'] !== null) {
throw new Exception(
"Rack {$zone}-{$aisle}-{$rack} is already occupied by {$current['product_sku']}"
);
}
// Validate sku + lot + serial uniqueness before 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.type = 'in'
{$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.type = 'in'
{$lot_cond}
{$serial_cond}
)
ORDER BY CAST(r.aisle AS UNSIGNED), r.aisle"
);
$sth->execute($params);
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'aisle');
}
/**
* 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
{
$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
);
$params[':zone'] = $zone;
$params[':aisle'] = $aisle;
$sth = $this->pdo->prepare(
"SELECT DISTINCT r.rack
FROM md_rack r
WHERE r.company_id = :company_id
AND r.warehouse = :warehouse
AND r.zone = :zone
AND r.aisle = :aisle
AND r.product_sku = :product_sku
AND r.td_stock_id IN (
SELECT s.id FROM `{$table}` s
WHERE s.company_id = :company_id2
AND s.product_sku = :product_sku2
AND s.type = 'in'
{$lot_cond}
{$serial_cond}
)
ORDER BY CAST(r.rack AS UNSIGNED), r.rack"
);
$sth->execute($params);
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'rack');
}
// ─────────────────────────────────────────────────────────────
// 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 occupied rack back to empty.
* 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_<wh>.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]);
// Release the rack back to empty
$this->releaseRack($warehouse_id, $row['zone'], $row['aisle'], $row['rack']);
// Reverse the balance (subtract the previously added quantity)
$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_<wh>.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]);
// 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']
);
// Reverse the balance (subtract the previously deducted quantity)
$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_<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.");
}
// 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]);
// Destination rack was occupied by transfer_in — release it
$this->releaseRack(
$to_warehouse,
$to_row['zone'], $to_row['aisle'], $to_row['rack']
);
// Source rack was released by transfer_out — re-occupy with original batch
$this->occupyRack(
$from_warehouse_id,
$from_row['zone'], $from_row['aisle'], $from_row['rack'],
$product_sku,
$from_stock_id
);
// Reverse balances 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 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
{
$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);
}
}