Files
wms-app/app/assets/utils/classes/WarehouseManager.php
T
2026-04-25 14:37:00 +07:00

1274 lines
47 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
*
* Encapsulates all warehouse-related operations:
* - Warehouse lookup and stock context resolution
* - Rack lifecycle (sync with md_storage ranges)
* - Warehouse balance adjustments (warehouse_balance table)
*
* Note: Methods that modify data do NOT manage their own DB transactions.
* Callers are responsible for wrapping operations in dbTransaction() when atomicity is needed.
*/
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 given warehouse_id.
* Returns null if warehouse not found or inactive.
*/
private function resolveWarehouseTable(int $warehouse_id): ?string
{
$sth = $this->pdo->prepare(
"SELECT warehouse_name FROM md_warehouse
WHERE company_id = :company_id AND id = :id AND status = 1"
);
$sth->execute([':company_id' => $this->company_id, ':id' => $warehouse_id]);
$name = $sth->fetchColumn();
if (!$name) return null;
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $name);
return "td_stock_{$safe}";
}
// ─────────────────────────────────────────────────────────────
// Range helper
// ─────────────────────────────────────────────────────────────
/**
* Convert a from/to range into an array of string values.
* Supports:
* - Numeric ranges: "1" → "10" → ["1","2",...,"10"]
* - Alpha ranges: "A" → "D" → ["A","B","C","D"]
*
* @throws Exception if values are not pure numeric or single alpha characters
*/
private function rangeToArray(string $from, string $to): array
{
$from = trim($from);
$to = trim($to);
// Numeric range
if (is_numeric($from) && is_numeric($to)) {
$f = (int) $from;
$t = (int) $to;
if ($f > $t) throw new Exception("Range start ($from) must be <= end ($to)");
return array_map('strval', range($f, $t));
}
// Single-letter alpha range
if (ctype_alpha($from) && ctype_alpha($to)
&& strlen($from) === 1 && strlen($to) === 1) {
if (ord($from) > ord($to)) throw new Exception("Range start ($from) must be <= end ($to)");
return range($from, $to); // PHP range() handles 'A' → 'Z' natively
}
throw new Exception(
"Aisle/rack values must be either numbers (e.g. 1–99) or single uppercase letters (e.g. A–Z). Got: '$from' → '$to'"
);
}
// ─────────────────────────────────────────────────────────────
// Warehouse lookup
// ─────────────────────────────────────────────────────────────
/**
* Fetch the warehouse name by its ID.
*/
public function getWarehouseName($warehouse_id) {
$sql = "SELECT warehouse_name FROM md_warehouse
WHERE company_id = :company_id AND id = :warehouse_id";
$sth = $this->pdo->prepare($sql);
$sth->execute([
":company_id" => $this->company_id,
":warehouse_id" => $warehouse_id
]);
return $sth->fetchColumn();
}
/**
* Resolve the per-warehouse stock table and (optionally) fetch a specific row.
* Returns: ['table' => 'td_stock_xxx', 'name' => 'xxx', 'row' => [...] | []]
*/
public function getStockContext($warehouse_id, $id) {
$name = $this->getWarehouseName($warehouse_id);
// Sanitize table suffix to prevent SQL injection
$safe_name = preg_replace('/[^a-zA-Z0-9_]/', '', $name);
$table = "td_stock_" . $safe_name;
if (empty($id)) {
return [
'table' => $table,
'name' => $name,
'row' => []
];
}
$sql = "SELECT * FROM `$table`
WHERE company_id = :company_id AND id = :id";
$sth = $this->pdo->prepare($sql);
$sth->execute([
":company_id" => $this->company_id,
":id" => $id
]);
return [
'table' => $table,
'name' => $name,
'row' => $sth->fetch(PDO::FETCH_ASSOC) ?: []
];
}
// ─────────────────────────────────────────────────────────────
// Warehouse balance (warehouse_balance table)
// ─────────────────────────────────────────────────────────────
/**
* Adjust the running balance for a (warehouse, SKU) pair.
*
* Used by stock_in / stock_out engines to keep warehouse_balance in sync with movements.
* The delta-based update (-old_qty + new_qty) supports both create and edit flows.
*/
public function adjustBalance($type, $warehouse_id, $product_sku, $old_qty, $new_qty) {
// Upsert the balance row (atomic, no race condition)
$column = $type === 'in' ? 'total_in' : 'total_out';
$delta = $new_qty - $old_qty;
$sql = "INSERT INTO warehouse_balance
(company_id, warehouse_id, product_sku, `$column`)
VALUES
(:company_id, :warehouse_id, :product_sku, :delta)
ON DUPLICATE KEY UPDATE
`$column` = `$column` + VALUES(`$column`)";
$sth = $this->pdo->prepare($sql);
$sth->execute([
":company_id" => $this->company_id,
":warehouse_id" => $warehouse_id,
":product_sku" => $product_sku,
":delta" => $delta
]);
}
// ─────────────────────────────────────────────────────────────
// Rack lifecycle (md_rack table)
// ─────────────────────────────────────────────────────────────
/**
* Ensure md_rack rows match the md_storage range.
*
* - Validates no occupied racks fall outside the new range
* - Deletes racks outside the new range
* - Inserts new racks inside the range (INSERT IGNORE skips existing)
*
* Must be called inside a DB transaction by the caller.
*
* @param int $storage_id The md_storage.id this range belongs to
* @param array $range Keys: warehouse, zone, aisle_from, aisle_to, rack_from, rack_to
* @throws Exception if occupied racks would be removed
*/
public function syncRacks(int $storage_id, array $range): void {
// Guard: refuse if any rack under this storage_id is still occupied
$this->validateRangeChange($storage_id, $range);
// Wipe all empty racks belonging to this storage_id
$sql = "DELETE FROM md_rack
WHERE company_id = :company_id
AND storage_id = :storage_id
AND product_sku IS NULL";
$sth = $this->pdo->prepare($sql);
$sth->execute([
':company_id' => $this->company_id,
':storage_id' => $storage_id,
]);
// Reinsert the full range fresh
$this->insertRacksInRange($storage_id, $range);
}
/**
* Guard: reject the update if any occupied rack would be removed.
* Works for both numeric and alpha aisle/rack values.
*/
private function validateRangeChange(int $storage_id, array $range): void
{
// Build the valid sets from the new range
$valid_aisles = $this->rangeToArray($range['aisle_from'], $range['aisle_to']);
$valid_racks = $this->rangeToArray($range['rack_from'], $range['rack_to']);
// Fetch all occupied racks under this storage_id
$sth = $this->pdo->prepare(
"SELECT aisle, rack FROM md_rack
WHERE company_id = :company_id
AND storage_id = :storage_id
AND product_sku IS NOT NULL"
);
$sth->execute([
':company_id' => $this->company_id,
':storage_id' => $storage_id,
]);
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
$a = trim($row['aisle']);
$r = trim($row['rack']);
if (!in_array($a, $valid_aisles, true) || !in_array($r, $valid_racks, true)) {
throw new Exception("Cannot shrink range — some racks still have stock");
}
}
}
/**
* Insert rack rows for the full range (IGNORE skips duplicates).
* Supports both numeric (1-10) and alpha (A-Z) aisle/rack values.
*/
private function insertRacksInRange(int $storage_id, array $range): void
{
$sql = "INSERT IGNORE INTO md_rack
(company_id, warehouse, storage_id, zone, aisle, rack)
VALUES
(:company_id, :warehouse, :storage_id, :zone, :aisle, :rack)";
$sth = $this->pdo->prepare($sql);
$aisles = $this->rangeToArray($range['aisle_from'], $range['aisle_to']);
$racks = $this->rangeToArray($range['rack_from'], $range['rack_to']);
foreach ($aisles as $a) {
foreach ($racks as $r) {
$sth->execute([
":company_id" => $this->company_id,
":warehouse" => $range['warehouse'],
":storage_id" => $storage_id,
":zone" => $range['zone'],
":aisle" => $a,
":rack" => $r,
]);
}
}
}
/**
* Fetch the stock record currently linked to a rack.
*
* Under the 1:1 model, each occupied rack points to exactly one
* td_stock_<wh> row via md_rack.td_stock_id. This method resolves
* that pointer — returning the full stock row, or null if the rack
* is empty or the link is broken.
*
* The td_stock table name is derived from the warehouse (via
* getStockContext), so callers don't need to know the naming convention.
*/
public function getRackStock($warehouse_id, $zone, $aisle, $rack): ?array {
// Get the stock pointer from md_rack
$sql = "SELECT td_stock_id FROM md_rack
WHERE company_id = :company_id
AND warehouse = :warehouse
AND zone = :zone
AND aisle = :aisle
AND rack = :rack";
$sth = $this->pdo->prepare($sql);
$sth->execute([
":company_id" => $this->company_id,
":warehouse" => $warehouse_id,
":zone" => $zone,
":aisle" => $aisle,
":rack" => $rack,
]);
$td_stock_id = $sth->fetchColumn();
// Rack is empty or doesn't exist
if (!$td_stock_id) {
return null;
}
// Resolve the td_stock_<wh> table from the warehouse name
// and fetch the full row by ID.
$context = $this->getStockContext($warehouse_id, $td_stock_id);
return $context['row'] ?: null;
}
// ─────────────────────────────────────────────────────────────
// Rack occupancy (md_rack.product_sku + md_rack.td_stock_id state)
// ─────────────────────────────────────────────────────────────
/**
* Insert a log entry into md_rack_log for the given rack and action.
* Captures user_id, dt (now), and login (session start) for audit context,
* plus any action-specific fields passed via $extra.
*/
private function insertRackLog(int $rack_id, string $action, array $extra = []): void {
$sql = "INSERT INTO md_rack_log
(company_id, md_rack_id, user_id, dt, login, action, product_sku, td_stock_id)
VALUES
(:company_id, :md_rack_id, :user_id, :dt, :login, :action, :product_sku, :td_stock_id)";
$sth = $this->pdo->prepare($sql);
$sth->execute([
':company_id' => $this->company_id,
':md_rack_id' => $rack_id,
':user_id' => $_SESSION['login_user_id'] ?? null,
':dt' => date('Y-m-d H:i:s'),
':login' => isset($_SESSION['otpTime'])
? date('Y-m-d H:i:s', $_SESSION['otpTime'])
: null,
':action' => $action,
':product_sku' => $extra['product_sku'] ?? null,
':td_stock_id' => $extra['td_stock_id'] ?? null,
]);
}
/**
* Assign a product SKU to an empty rack and link it to its stock record.
*
* The td_stock_<warehouse> table is derived from the warehouse,
* so only the row ID needs to be passed here.
*
* @throws Exception if the rack is already occupied or doesn't exist.
*/
/**
* Validate that the combination of product_sku + lot_number + serial_number
* does not already occupy an active rack across all warehouses.
*
* "Active" means md_rack.td_stock_id is still linked (rack is occupied).
* Once a rack is released (stock_out/transfer), the combo is free again.
*
* Rules:
* - product_sku alone → allowed in multiple racks (normal stocking)
* - product_sku + lot → allowed in multiple racks (lot spread across racks)
* - product_sku + serial → must be unique (1 physical unit = 1 location)
* - product_sku + lot + serial → must be unique
*
* @param string $product_sku
* @param string|null $lot_number
* @param string|null $serial_number
* @param int $exclude_stock_id Skip this stock row (for edit flows)
* @throws Exception if a duplicate active record is found
*/
public function validateStockUnique(
string $product_sku,
?string $lot_number = null,
?string $serial_number = null,
int $exclude_stock_id = 0
): void {
// Nothing to validate if neither lot nor serial is provided
if (empty($lot_number) && empty($serial_number)) return;
$cid = $this->company_id;
// Discover all warehouses
$sth = $this->pdo->prepare(
"SELECT id, warehouse_name FROM md_warehouse
WHERE company_id = :company_id AND status = 1"
);
$sth->execute([':company_id' => $cid]);
$warehouses = $sth->fetchAll(PDO::FETCH_ASSOC);
if (empty($warehouses)) return;
foreach ($warehouses as $wh) {
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']);
$table = "td_stock_{$safe}";
// Check for any existing active stock_in row matching sku + lot + serial
// "Active" = rack still occupied (EXISTS in md_rack with product_sku IS NOT NULL)
// NOTE: called from inside occupyRack, AFTER INSERT but BEFORE md_rack is updated
// so the current row's td_stock_id is not yet in md_rack — won't match itself
$sql = "SELECT s.id
FROM `{$table}` s
WHERE s.company_id = :company_id
AND s.product_sku = :product_sku
AND s.type = 'in'
AND EXISTS (
SELECT 1 FROM md_rack r
WHERE r.company_id = :company_id2
AND r.td_stock_id = s.id
AND r.product_sku IS NOT NULL
)";
if (!empty($lot_number)) {
$sql .= " AND s.lot_number = :lot_number";
}
if (!empty($serial_number)) {
$sql .= " AND s.serial_number = :serial_number";
}
if ($exclude_stock_id > 0) {
$sql .= " AND s.id != :exclude_id";
}
$params = [
':company_id' => $cid,
':company_id2' => $cid,
':product_sku' => $product_sku,
];
if (!empty($lot_number)) $params[':lot_number'] = $lot_number;
if (!empty($serial_number)) $params[':serial_number'] = $serial_number;
if ($exclude_stock_id > 0) $params[':exclude_id'] = $exclude_stock_id;
$sth = $this->pdo->prepare($sql);
$sth->execute($params);
if ($sth->fetchColumn()) {
$combo = implode(' / ', array_filter([
$lot_number ? "lot: {$lot_number}" : null,
$serial_number ? "serial: {$serial_number}" : null,
]));
throw new Exception(
"Active stock already exists for product '{$product_sku}' [{$combo}] "
. "in warehouse '{$wh['warehouse_name']}'. "
. "Stock must be moved out before it can be received again."
);
}
}
}
public function occupyRack($warehouse_id, $zone, $aisle, $rack, $product_sku, $td_stock_id): void {
$sql = "SELECT id, product_sku FROM md_rack
WHERE company_id = :company_id
AND warehouse = :warehouse
AND zone = :zone
AND aisle = :aisle
AND rack = :rack
FOR UPDATE";
$sth = $this->pdo->prepare($sql);
$sth->execute([
":company_id" => $this->company_id,
":warehouse" => $warehouse_id,
":zone" => $zone,
":aisle" => $aisle,
":rack" => $rack,
]);
$current = $sth->fetch(PDO::FETCH_ASSOC);
if (!$current) {
throw new Exception("Rack {$zone}-{$aisle}-{$rack} does not exist");
}
if ($current['product_sku'] !== null) {
throw new Exception(
"Rack {$zone}-{$aisle}-{$rack} is already occupied by {$current['product_sku']}"
);
}
// Validate sku + lot + serial uniqueness before occupying the rack
// Fetch lot/serial from the td_stock row being linked
$ctx = $this->getStockContext($warehouse_id, $td_stock_id);
$row = $ctx['row'] ?? null;
if ($row) {
$this->validateStockUnique(
$product_sku,
$row['lot_number'] ?? null,
$row['serial_number'] ?? null,
$td_stock_id // exclude self so edit flows don't block
);
}
// Single atomic UPDATE: state + log together
$sql = "UPDATE md_rack
SET product_sku = :product_sku,
td_stock_id = :td_stock_id
WHERE id = :id";
$sth = $this->pdo->prepare($sql);
$sth->execute([
":product_sku" => $product_sku,
":td_stock_id" => $td_stock_id,
":id" => $current['id'],
]);
$this->insertRackLog($current['id'], 'occupy', [
'product_sku' => $product_sku,
'td_stock_id' => $td_stock_id,
]);
}
/**
* Release a rack (clear both SKU and stock reference together).
*/
public function releaseRack($warehouse_id, $zone, $aisle, $rack): void {
$sql = "SELECT id, product_sku, td_stock_id FROM md_rack
WHERE company_id = :company_id
AND warehouse = :warehouse
AND zone = :zone
AND aisle = :aisle
AND rack = :rack
FOR UPDATE";
$sth = $this->pdo->prepare($sql);
$sth->execute([
":company_id" => $this->company_id,
":warehouse" => $warehouse_id,
":zone" => $zone,
":aisle" => $aisle,
":rack" => $rack,
]);
$current = $sth->fetch(PDO::FETCH_ASSOC);
// Nothing to release — exit silently (idempotent behavior)
if (!$current || $current['product_sku'] === null) {
return;
}
// Clear state + record log in one UPDATE
$sql = "UPDATE md_rack
SET product_sku = NULL,
td_stock_id = NULL
WHERE id = :id";
$sth = $this->pdo->prepare($sql);
$sth->execute([
":id" => $current['id'],
]);
$this->insertRackLog($current['id'], 'release', [
'product_sku' => $current['product_sku'],
'td_stock_id' => $current['td_stock_id'],
]);
}
/**
* Move a rack assignment from one location to another.
*
* Source rack must be occupied, destination rack must be empty.
* Both product_sku and td_stock_id travel together to the destination.
* Locks both racks (lower ID first) to avoid deadlocks.
*/
public function transferRack(
$from_warehouse, $from_zone, $from_aisle, $from_rack,
$to_warehouse, $to_zone, $to_aisle, $to_rack
): void {
// Fetch both racks with FOR UPDATE, ordered by id to prevent deadlocks
$sql = "SELECT id, warehouse, zone, aisle, rack, product_sku, td_stock_id
FROM md_rack
WHERE company_id = :company_id
AND (
(warehouse = :from_wh AND zone = :from_zone
AND aisle = :from_aisle AND rack = :from_rack)
OR
(warehouse = :to_wh AND zone = :to_zone
AND aisle = :to_aisle AND rack = :to_rack)
)
ORDER BY id
FOR UPDATE";
$sth = $this->pdo->prepare($sql);
$sth->execute([
":company_id" => $this->company_id,
":from_wh" => $from_warehouse,
":from_zone" => $from_zone,
":from_aisle" => $from_aisle,
":from_rack" => $from_rack,
":to_wh" => $to_warehouse,
":to_zone" => $to_zone,
":to_aisle" => $to_aisle,
":to_rack" => $to_rack,
]);
$racks = $sth->fetchAll(PDO::FETCH_ASSOC);
// Identify source and destination from the fetched rows
$from = null;
$to = null;
foreach ($racks as $r) {
if ($r['warehouse'] == $from_warehouse && $r['zone'] == $from_zone
&& $r['aisle'] == $from_aisle && $r['rack'] == $from_rack) {
$from = $r;
}
if ($r['warehouse'] == $to_warehouse && $r['zone'] == $to_zone
&& $r['aisle'] == $to_aisle && $r['rack'] == $to_rack) {
$to = $r;
}
}
if (!$from) {
throw new Exception("Source rack not found");
}
if (!$to) {
throw new Exception("Destination rack not found");
}
if ($from['product_sku'] === null) {
throw new Exception("Source rack is empty");
}
if ($to['product_sku'] !== null) {
throw new Exception("Destination rack is already occupied");
}
// Carry both SKU and stock reference across
$sku = $from['product_sku'];
$td_stock_id = $from['td_stock_id'];
// Clear source — state + log in one UPDATE
$sql = "UPDATE md_rack
SET product_sku = NULL, td_stock_id = NULL
WHERE id = :id";
$this->pdo->prepare($sql)->execute([
":id" => $from['id'],
]);
$this->insertRackLog($from['id'], 'transfer_out', [
'product_sku' => $sku,
'td_stock_id' => $td_stock_id,
]);
// Populate destination — state + log in one UPDATE
$sql = "UPDATE md_rack
SET product_sku = :sku, td_stock_id = :td_stock_id
WHERE id = :id";
$this->pdo->prepare($sql)->execute([
":sku" => $sku,
":td_stock_id" => $td_stock_id,
":id" => $to['id'],
]);
$this->insertRackLog($to['id'], 'transfer_in', [
'product_sku' => $sku,
'td_stock_id' => $td_stock_id,
]);
}
// ─────────────────────────────────────────────────────────────
// Zone / Aisle / Rack resolution
// ─────────────────────────────────────────────────────────────
/**
* Zones with at least one empty rack — for stock_in destination.
*/
public function getZonesIn(int $warehouse_id): array
{
$sth = $this->pdo->prepare(
"SELECT DISTINCT zone FROM md_rack
WHERE company_id = :company_id
AND warehouse = :warehouse
AND product_sku IS NULL
ORDER BY CAST(zone AS UNSIGNED), zone"
);
$sth->execute([':company_id' => $this->company_id, ':warehouse' => $warehouse_id]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Zones holding active stock for sku + lot + serial — for stock_out source.
*/
public function getZonesOut(
int $warehouse_id,
string $product_sku,
?string $lot_number = null,
?string $serial_number = null
): array {
$table = $this->resolveWarehouseTable($warehouse_id);
if (!$table) return [];
[$lot_cond, $serial_cond, $params] = $this->buildLotSerialCondition(
$warehouse_id, $product_sku, $lot_number, $serial_number
);
$sth = $this->pdo->prepare(
"SELECT DISTINCT r.zone
FROM md_rack r
WHERE r.company_id = :company_id
AND r.warehouse = :warehouse
AND r.product_sku = :product_sku
AND r.td_stock_id IN (
SELECT s.id FROM `{$table}` s
WHERE s.company_id = :company_id2
AND s.product_sku = :product_sku2
AND s.type = 'in'
{$lot_cond}
{$serial_cond}
)
ORDER BY CAST(r.zone AS UNSIGNED), r.zone"
);
$sth->execute($params);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Aisles with at least one empty rack in a zone — for stock_in destination.
*/
public function getAislesIn(int $warehouse_id, string $zone): array
{
$sth = $this->pdo->prepare(
"SELECT DISTINCT aisle FROM md_rack
WHERE company_id = :company_id
AND warehouse = :warehouse
AND zone = :zone
AND product_sku IS NULL
ORDER BY CAST(aisle AS UNSIGNED), aisle"
);
$sth->execute([
':company_id' => $this->company_id,
':warehouse' => $warehouse_id,
':zone' => $zone,
]);
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'aisle');
}
/**
* Aisles holding active stock for sku + lot + serial — for stock_out source.
*/
public function getAislesOut(
int $warehouse_id,
string $zone,
string $product_sku,
?string $lot_number = null,
?string $serial_number = null
): array {
$table = $this->resolveWarehouseTable($warehouse_id);
if (!$table) return [];
[$lot_cond, $serial_cond, $params] = $this->buildLotSerialCondition(
$warehouse_id, $product_sku, $lot_number, $serial_number
);
$params[':zone'] = $zone;
$sth = $this->pdo->prepare(
"SELECT DISTINCT r.aisle
FROM md_rack r
WHERE r.company_id = :company_id
AND r.warehouse = :warehouse
AND r.zone = :zone
AND r.product_sku = :product_sku
AND r.td_stock_id IN (
SELECT s.id FROM `{$table}` s
WHERE s.company_id = :company_id2
AND s.product_sku = :product_sku2
AND s.type = 'in'
{$lot_cond}
{$serial_cond}
)
ORDER BY CAST(r.aisle AS UNSIGNED), r.aisle"
);
$sth->execute($params);
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'aisle');
}
/**
* Racks that are empty in a given aisle — for stock_in destination.
*/
public function getRacksIn(int $warehouse_id, string $zone, string $aisle): array
{
$sth = $this->pdo->prepare(
"SELECT DISTINCT rack FROM md_rack
WHERE company_id = :company_id
AND warehouse = :warehouse
AND zone = :zone
AND aisle = :aisle
AND product_sku IS NULL
ORDER BY CAST(rack AS UNSIGNED), rack"
);
$sth->execute([
':company_id' => $this->company_id,
':warehouse' => $warehouse_id,
':zone' => $zone,
':aisle' => $aisle,
]);
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'rack');
}
/**
* Racks holding active stock for sku + lot + serial — for stock_out source.
*/
public function getRacksOut(
int $warehouse_id,
string $zone,
string $aisle,
string $product_sku,
?string $lot_number = null,
?string $serial_number = null
): array {
$table = $this->resolveWarehouseTable($warehouse_id);
if (!$table) return [];
[$lot_cond, $serial_cond, $params] = $this->buildLotSerialCondition(
$warehouse_id, $product_sku, $lot_number, $serial_number
);
$params[':zone'] = $zone;
$params[':aisle'] = $aisle;
$sth = $this->pdo->prepare(
"SELECT DISTINCT r.rack
FROM md_rack r
WHERE r.company_id = :company_id
AND r.warehouse = :warehouse
AND r.zone = :zone
AND r.aisle = :aisle
AND r.product_sku = :product_sku
AND r.td_stock_id IN (
SELECT s.id FROM `{$table}` s
WHERE s.company_id = :company_id2
AND s.product_sku = :product_sku2
AND s.type = 'in'
{$lot_cond}
{$serial_cond}
)
ORDER BY CAST(r.rack AS UNSIGNED), r.rack"
);
$sth->execute($params);
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'rack');
}
/**
* Build lot/serial WHERE conditions and bound params for Out methods.
*/
private function buildLotSerialCondition(
int $warehouse_id,
string $product_sku,
?string $lot_number = null,
?string $serial_number = null
): array {
$lot_cond = !empty($lot_number) ? "AND s.lot_number = :lot_number" : "";
$serial_cond = !empty($serial_number) ? "AND s.serial_number = :serial_number" : "";
$params = [
':company_id' => $this->company_id,
':warehouse' => $warehouse_id,
':product_sku' => $product_sku,
':company_id2' => $this->company_id,
':product_sku2' => $product_sku,
];
if (!empty($lot_number)) $params[':lot_number'] = $lot_number;
if (!empty($serial_number)) $params[':serial_number'] = $serial_number;
return [$lot_cond, $serial_cond, $params];
}
// ─────────────────────────────────────────────────────────────
// Delete (soft-delete) operations
// ─────────────────────────────────────────────────────────────
/**
* Validate that a given row is the latest transaction for its SKU in the table.
* Throws a descriptive exception if a newer transaction exists.
*
* @param string $product_sku SKU to check
* @param string $row_date The date of the row being deleted
* @param string $product_name Human-readable product name for error message
* @throws Exception if a newer transaction exists
*/
private function validateLatestTransaction(
string $product_sku,
string $row_date,
string $product_name
): void {
// Discover all td_stock_* tables for this database
$sth = $this->pdo->prepare(
"SELECT table_name FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name LIKE 'td_stock_%'"
);
$sth->execute();
$tables = $sth->fetchAll(PDO::FETCH_COLUMN);
if (empty($tables)) {
return;
}
// Build UNION across all td_stock_* tables to find global MAX(date)
$unions = implode(' UNION ALL ', array_map(
fn($t) => "SELECT `type`, `date` FROM `$t`
WHERE company_id = :company_id
AND product_sku = :product_sku",
$tables
));
$sth = $this->pdo->prepare(
"SELECT `type`, `date` FROM ($unions) AS all_stock
ORDER BY `date` DESC
LIMIT 1"
);
$sth->execute([
':company_id' => $this->company_id,
':product_sku' => $product_sku,
]);
$latest = $sth->fetch(PDO::FETCH_ASSOC);
if (!$latest || $row_date === $latest['date']) {
return; // ✓ this is the latest globally — allow
}
$type = strtoupper($latest['type']);
$date = $latest['date'];
throw new Exception(
"Cannot delete — \"{$product_name}\" has a newer " .
"{$type} transaction on {$date} that must be deleted first."
);
}
/**
* Helper to build a log entry with consistent user and timestamp info for warehouse operations.
*/
private function buildLogEntry(string $action): array {
return [
'user_id' => $_SESSION['login_user_id'] ?? null,
'dt' => date('Y-m-d H:i:s'),
'login' => isset($_SESSION['otpTime'])
? date('Y-m-d H:i:s', $_SESSION['otpTime'])
: null,
'action' => $action,
];
}
/**
* Resolve product name from md_product for readable error messages.
*/
private function getProductName(string $product_sku): string {
$sth = $this->pdo->prepare(
"SELECT product_name FROM md_product
WHERE company_id = :company_id AND sku = :sku"
);
$sth->execute([
':company_id' => $this->company_id,
':sku' => $product_sku,
]);
return $sth->fetchColumn() ?: $product_sku;
}
/**
* Soft-delete a stock_in record and reverse its side effects.
*
* - Validates this is the latest transaction for the SKU
* - Negates company_id (soft-delete)
* - Releases the rack
* - Reverses the balance
*
* Must be called inside a DB transaction by the caller.
*
* @throws Exception if not the latest transaction or record not found
*/
public function deleteStockIn(int $stock_id, int $warehouse_id): void {
$ctx = $this->getStockContext($warehouse_id, $stock_id);
$table = $ctx['table'];
$row = $ctx['row'];
if (!$row) {
throw new Exception("Stock in record not found.");
}
$product_sku = $row['product_sku'];
$product_name = $this->getProductName($product_sku);
$this->validateLatestTransaction($product_sku, $row['date'], $product_name);
// Soft-delete
$this->pdo->prepare(
"UPDATE `$table`
SET company_id = company_id * -1
WHERE id = :id AND company_id = :company_id"
)->execute([':id' => $stock_id, ':company_id' => $this->company_id]);
// Release the rack
$this->releaseRack($warehouse_id, $row['zone'], $row['aisle'], $row['rack']);
// Reverse balance
$this->adjustBalance('in', $warehouse_id, $product_sku, (int)$row['in'], 0);
}
/**
* Soft-delete a stock_out record and reverse its side effects.
*
* - Validates this is the latest transaction for the SKU
* - Negates company_id (soft-delete)
* - Re-occupies the rack with the original stock_in batch (via ref_id)
* - Reverses the balance
*
* Must be called inside a DB transaction by the caller.
*
* @throws Exception if not the latest transaction or record not found
*/
public function deleteStockOut(int $stock_id, int $warehouse_id): void {
$ctx = $this->getStockContext($warehouse_id, $stock_id);
$table = $ctx['table'];
$row = $ctx['row'];
if (!$row) {
throw new Exception("Stock out record not found.");
}
$product_sku = $row['product_sku'];
$product_name = $this->getProductName($product_sku);
$this->validateLatestTransaction($product_sku, $row['date'], $product_name);
// Soft-delete
$this->pdo->prepare(
"UPDATE `$table`
SET company_id = company_id * -1
WHERE id = :id AND company_id = :company_id"
)->execute([':id' => $stock_id, ':company_id' => $this->company_id]);
// Re-occupy the rack — restore the stock_in batch that was consumed
$this->occupyRack(
$warehouse_id,
$row['zone'], $row['aisle'], $row['rack'],
$product_sku,
(int)$row['ref_id']
);
// Reverse balance
$this->adjustBalance('out', $warehouse_id, $product_sku, (int)$row['out'], 0);
}
/**
* Soft-delete a transfer record pair and reverse all side effects.
*
* - Validates this is the latest transaction for the SKU on BOTH warehouses
* - Negates company_id on both rows (soft-delete)
* - Releases destination rack, re-occupies source rack
* - Reverses balances on both warehouses
*
* Must be called inside a DB transaction by the caller.
*
* @throws Exception if not the latest transaction on either side, or records not found
*/
public function deleteTransfer(int $from_stock_id, int $from_warehouse_id): void {
// Fetch the "from" row (transfer_out side)
$from_ctx = $this->getStockContext($from_warehouse_id, $from_stock_id);
$from_table = $from_ctx['table'];
$from_row = $from_ctx['row'];
if (!$from_row || $from_row['type'] !== 'transfer') {
throw new Exception("Transfer record not found.");
}
$product_sku = $from_row['product_sku'];
$product_name = $this->getProductName($product_sku);
$to_warehouse = (int)$from_row['ref_warehouse'];
$ref_id = (int)$from_row['ref_id'];
// Fetch the "to" row (transfer_in side)
$to_ctx = $this->getStockContext($to_warehouse, $ref_id);
$to_row = $to_ctx['row'];
if (!$to_row) {
throw new Exception("Paired destination record missing — data integrity issue.");
}
// Single global validation covers both warehouses
$this->validateLatestTransaction($product_sku, $from_row['date'], $product_name);
// Soft-delete both rows
$this->pdo->prepare(
"UPDATE `$from_table`
SET company_id = company_id * -1
WHERE id = :id AND company_id = :company_id"
)->execute([':id' => $from_stock_id, ':company_id' => $this->company_id]);
$this->pdo->prepare(
"UPDATE `{$to_ctx['table']}`
SET company_id = company_id * -1
WHERE id = :id AND company_id = :company_id"
)->execute([':id' => $ref_id, ':company_id' => $this->company_id]);
// Destination rack was occupied by transfer_in — release it
$this->releaseRack(
$to_warehouse,
$to_row['zone'], $to_row['aisle'], $to_row['rack']
);
// Source rack was released by transfer_out — re-occupy with original batch
$this->occupyRack(
$from_warehouse_id,
$from_row['zone'], $from_row['aisle'], $from_row['rack'],
$product_sku,
$from_stock_id
);
// Reverse balances
$quantity = (int)$from_row['out'];
$this->adjustBalance('out', $from_warehouse_id, $product_sku, $quantity, 0);
$this->adjustBalance('in', $to_warehouse, $product_sku, $quantity, 0);
}
/**
* Soft-delete a warehouse.
*
* Blocks if any md_storage references this warehouse by name.
*
* Must be called inside a DB transaction by the caller.
*
* @throws Exception if warehouse not found or has dependent storage
*/
public function deleteWarehouse(int $warehouse_id): void {
$sth = $this->pdo->prepare(
"SELECT id, warehouse_name, `log` FROM md_warehouse
WHERE company_id = :company_id AND id = :id"
);
$sth->execute([
':company_id' => $this->company_id,
':id' => $warehouse_id,
]);
$row = $sth->fetch(PDO::FETCH_ASSOC);
if (!$row) {
throw new Exception("Warehouse not found.");
}
// Block if any md_storage references this warehouse by name
$sth = $this->pdo->prepare(
"SELECT COUNT(*) FROM md_storage
WHERE company_id = :company_id
AND warehouse = :warehouse_name"
);
$sth->execute([
':company_id' => $this->company_id,
':warehouse_name' => $row['warehouse_name'],
]);
if ($sth->fetchColumn() > 0) {
throw new Exception(
"Cannot delete — warehouse \"{$row['warehouse_name']}\" " .
"still has storage locations assigned to it."
);
}
// Append delete event to log
$log = json_decode($row['log'] ?? '[]', true) ?: [];
$log[] = $this->buildLogEntry('delete');
// Soft-delete
$this->pdo->prepare(
"UPDATE md_warehouse
SET company_id = company_id * -1,
`log` = :log
WHERE id = :id AND company_id = :company_id"
)->execute([
':log' => json_encode($log),
':id' => $warehouse_id,
':company_id' => $this->company_id,
]);
}
/**
* Soft-delete a storage record and its associated racks.
*
* - Blocks if any md_rack under this storage_id is occupied
* - Hard-deletes md_rack_log rows for those racks
* - Soft-deletes all md_rack rows under this storage_id
* - Soft-deletes md_storage
*
* Must be called inside a DB transaction by the caller.
*
* @throws Exception if storage not found or has occupied racks
*/
public function deleteStorage(int $storage_id): void {
$sth = $this->pdo->prepare(
"SELECT id, zone, `log` FROM md_storage
WHERE company_id = :company_id AND id = :id"
);
$sth->execute([
':company_id' => $this->company_id,
':id' => $storage_id,
]);
$row = $sth->fetch(PDO::FETCH_ASSOC);
if (!$row) {
throw new Exception("Storage not found.");
}
// Block if any rack under this storage is occupied
$sth = $this->pdo->prepare(
"SELECT COUNT(*) FROM md_rack
WHERE company_id = :company_id
AND storage_id = :storage_id
AND product_sku IS NOT NULL"
);
$sth->execute([
':company_id' => $this->company_id,
':storage_id' => $storage_id,
]);
if ($sth->fetchColumn() > 0) {
throw new Exception(
"Cannot delete — storage zone \"{$row['zone']}\" " .
"still has occupied racks with stock."
);
}
// Collect rack IDs before deleting — needed for rack log cleanup
$sth = $this->pdo->prepare(
"SELECT id FROM md_rack
WHERE company_id = :company_id
AND storage_id = :storage_id"
);
$sth->execute([
':company_id' => $this->company_id,
':storage_id' => $storage_id,
]);
$rack_ids = $sth->fetchAll(PDO::FETCH_COLUMN);
// Hard-delete md_rack_log for these racks
if (!empty($rack_ids)) {
$placeholders = implode(',', array_fill(0, count($rack_ids), '?'));
$this->pdo->prepare(
"DELETE FROM md_rack_log WHERE md_rack_id IN ($placeholders)"
)->execute($rack_ids);
}
// Soft-delete all md_rack rows under this storage
$this->pdo->prepare(
"UPDATE md_rack
SET company_id = company_id * -1
WHERE company_id = :company_id
AND storage_id = :storage_id"
)->execute([
':company_id' => $this->company_id,
':storage_id' => $storage_id,
]);
// Append delete event to log
$log = json_decode($row['log'] ?? '[]', true) ?: [];
$log[] = $this->buildLogEntry('delete');
// Soft-delete md_storage
$this->pdo->prepare(
"UPDATE md_storage
SET company_id = company_id * -1,
`log` = :log
WHERE id = :id AND company_id = :company_id"
)->execute([
':log' => json_encode($log),
':id' => $storage_id,
':company_id' => $this->company_id,
]);
}
}
?>