1274 lines
47 KiB
PHP
1274 lines
47 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;
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────
|
||
// 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,
|
||
]);
|
||
}
|
||
|
||
}
|
||
|
||
?>
|