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

495 lines
18 KiB
PHP

<?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;
}
// ─────────────────────────────────────────────────────────────
// 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 {
$this->validateRangeChange($storage_id, $range);
$this->removeRacksOutsideRange($storage_id, $range);
$this->insertRacksInRange($storage_id, $range);
}
/**
* Guard: reject the update if any occupied rack would be removed.
*/
private function validateRangeChange(int $storage_id, array $range): void {
$sql = "SELECT COUNT(*) FROM md_rack
WHERE company_id = :company_id
AND storage_id = :storage_id
AND product_sku IS NOT NULL
AND (
CAST(aisle AS UNSIGNED) < :aisle_from OR
CAST(aisle AS UNSIGNED) > :aisle_to OR
CAST(rack AS UNSIGNED) < :rack_from OR
CAST(rack AS UNSIGNED) > :rack_to
)";
$sth = $this->pdo->prepare($sql);
$sth->execute([
":company_id" => $this->company_id,
":storage_id" => $storage_id,
":aisle_from" => (int) $range['aisle_from'],
":aisle_to" => (int) $range['aisle_to'],
":rack_from" => (int) $range['rack_from'],
":rack_to" => (int) $range['rack_to'],
]);
if ($sth->fetchColumn() > 0) {
throw new Exception("Cannot shrink range — some racks still have stock");
}
}
/**
* Delete empty racks that fall outside the new range.
*/
private function removeRacksOutsideRange(int $storage_id, array $range): void {
$sql = "DELETE FROM md_rack
WHERE company_id = :company_id
AND storage_id = :storage_id
AND (
CAST(aisle AS UNSIGNED) < :aisle_from OR
CAST(aisle AS UNSIGNED) > :aisle_to OR
CAST(rack AS UNSIGNED) < :rack_from OR
CAST(rack AS UNSIGNED) > :rack_to
)";
$sth = $this->pdo->prepare($sql);
$sth->execute([
":company_id" => $this->company_id,
":storage_id" => $storage_id,
":aisle_from" => (int) $range['aisle_from'],
":aisle_to" => (int) $range['aisle_to'],
":rack_from" => (int) $range['rack_from'],
":rack_to" => (int) $range['rack_to'],
]);
}
/**
* Insert rack rows for the full range (IGNORE skips duplicates).
*/
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);
$aisle_from = (int) $range['aisle_from'];
$aisle_to = (int) $range['aisle_to'];
$rack_from = (int) $range['rack_from'];
$rack_to = (int) $range['rack_to'];
for ($a = $aisle_from; $a <= $aisle_to; $a++) {
for ($r = $rack_from; $r <= $rack_to; $r++) {
$sth->execute([
":company_id" => $this->company_id,
":warehouse" => $range['warehouse'],
":storage_id" => $storage_id,
":zone" => $range['zone'],
":aisle" => (string) $a,
":rack" => (string) $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)
// ─────────────────────────────────────────────────────────────
/**
* Build a log JSON by appending an action entry to an existing md_rack.log.
* Entry captures user_id, dt (now), and login (session start) for audit context,
* plus any action-specific fields passed via $extra.
*/
private function buildRackLog(?string $existing_log, string $action, array $extra = []): string {
$log = json_decode($existing_log ?: '[]', true) ?: [];
$log[] = array_merge([
'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,
], $extra);
return json_encode($log, JSON_UNESCAPED_UNICODE);
}
/**
* 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.
*/
public function occupyRack($warehouse_id, $zone, $aisle, $rack, $product_sku, $td_stock_id): void {
// Lock and fetch current state (including log for in-place append)
$sql = "SELECT id, product_sku, `log` 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']}"
);
}
// Single atomic UPDATE: state + log together
$sql = "UPDATE md_rack
SET product_sku = :product_sku,
td_stock_id = :td_stock_id,
`log` = :log
WHERE id = :id";
$sth = $this->pdo->prepare($sql);
$sth->execute([
":product_sku" => $product_sku,
":td_stock_id" => $td_stock_id,
":log" => $this->buildRackLog($current['log'], 'occupy', [
'product_sku' => $product_sku,
'td_stock_id' => $td_stock_id,
]),
":id" => $current['id'],
]);
}
/**
* Release a rack (clear both SKU and stock reference together).
*/
public function releaseRack($warehouse_id, $zone, $aisle, $rack): void {
// Fetch current state + log (needed to append release event)
$sql = "SELECT id, product_sku, td_stock_id, `log` 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,
`log` = :log
WHERE id = :id";
$sth = $this->pdo->prepare($sql);
$sth->execute([
":log" => $this->buildRackLog($current['log'], 'release', [
'product_sku' => $current['product_sku'],
'td_stock_id' => $current['td_stock_id'],
]),
":id" => $current['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, `log`
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, `log` = :log
WHERE id = :id";
$this->pdo->prepare($sql)->execute([
":log" => $this->buildRackLog($from['log'], 'transfer_out', [
'product_sku' => $sku,
'td_stock_id' => $td_stock_id,
'to_rack_id' => $to['id'],
]),
":id" => $from['id'],
]);
// Populate destination — state + log in one UPDATE
$sql = "UPDATE md_rack
SET product_sku = :sku, td_stock_id = :td_stock_id, `log` = :log
WHERE id = :id";
$this->pdo->prepare($sql)->execute([
":sku" => $sku,
":td_stock_id" => $td_stock_id,
":log" => $this->buildRackLog($to['log'], 'transfer_in', [
'product_sku' => $sku,
'td_stock_id' => $td_stock_id,
'from_rack_id' => $from['id'],
]),
":id" => $to['id'],
]);
}
}
?>