[test] contact to setting modules

This commit is contained in:
Thanakorn S
2026-05-06 08:58:09 +07:00
parent 414f1cd81d
commit 6501d326ca
43 changed files with 4295 additions and 581 deletions
+367 -121
View File
@@ -17,9 +17,7 @@
* 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.
* Dynamic stock table names are derived only from md_warehouse.id.
*/
class WarehouseManager {
@@ -35,27 +33,26 @@ class WarehouseManager {
// 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 stockTableNameFromWarehouseId(int $warehouse_id): string
{
if ($warehouse_id <= 0) {
throw new Exception("Invalid warehouse id.");
}
return 'td_stock_' . $warehouse_id;
}
private function resolveWarehouseTable(int $warehouse_id): ?string
{
$sth = $this->pdo->prepare(
"SELECT warehouse_name FROM md_warehouse
"SELECT id FROM md_warehouse
WHERE company_id = :company_id AND id = :id AND status = 1"
);
$sth->execute([':company_id' => $this->company_id, ':id' => $warehouse_id]);
$name = $sth->fetchColumn();
if (!$name) return null;
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $name);
return "td_stock_{$safe}";
$id = $sth->fetchColumn();
if (!$id) return null;
return $this->stockTableNameFromWarehouseId((int)$id);
}
/**
@@ -330,6 +327,90 @@ class WarehouseManager {
]);
}
/**
* Soft-delete rack logs created by a stock movement and its reversal.
*
* md_rack_log follows the same soft-delete convention as other tenant rows:
* negate company_id so normal company_id filters hide the records.
*/
private function softDeleteRackLogs(
$warehouse_id,
string $zone,
string $aisle,
string $rack,
array $actions,
string $product_sku,
array $td_stock_ids = [],
?string $since = null
): void {
if (empty($actions)) {
return;
}
$rack_sth = $this->pdo->prepare(
"SELECT id FROM md_rack
WHERE company_id = :company_id
AND warehouse = :warehouse
AND zone = :zone
AND aisle = :aisle
AND rack = :rack
LIMIT 1"
);
$rack_sth->execute([
':company_id' => $this->company_id,
':warehouse' => $warehouse_id,
':zone' => $zone,
':aisle' => $aisle,
':rack' => $rack,
]);
$rack_id = (int)$rack_sth->fetchColumn();
if (!$rack_id) {
return;
}
$params = [
':company_id' => $this->company_id,
':md_rack_id' => $rack_id,
':product_sku' => $product_sku,
];
$action_placeholders = [];
foreach (array_values($actions) as $idx => $action) {
$key = ':action_' . $idx;
$action_placeholders[] = $key;
$params[$key] = $action;
}
$td_stock_ids = array_values(array_filter(array_map('intval', $td_stock_ids)));
$td_stock_cond = '';
if (!empty($td_stock_ids)) {
$td_placeholders = [];
foreach ($td_stock_ids as $idx => $td_stock_id) {
$key = ':td_stock_id_' . $idx;
$td_placeholders[] = $key;
$params[$key] = $td_stock_id;
}
$td_stock_cond = 'AND td_stock_id IN (' . implode(', ', $td_placeholders) . ')';
}
$since_cond = '';
if ($since !== null && $since !== '') {
$since_cond = 'AND dt >= :since';
$params[':since'] = $since;
}
$sql = "UPDATE md_rack_log
SET company_id = company_id * -1
WHERE company_id = :company_id
AND md_rack_id = :md_rack_id
AND product_sku = :product_sku
AND action IN (" . implode(', ', $action_placeholders) . ")
{$td_stock_cond}
{$since_cond}";
$this->pdo->prepare($sql)->execute($params);
}
/**
* Validate that the occupied racks under a storage_id all fall inside a new range.
*
@@ -340,15 +421,14 @@ class WarehouseManager {
* @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
private function validateRangeChange(int $storage_id, array $range, bool $advanced_location): 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
"SELECT zone, aisle, rack FROM md_rack
WHERE company_id = :company_id
AND storage_id = :storage_id
AND product_sku IS NOT NULL"
@@ -359,10 +439,18 @@ class WarehouseManager {
]);
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
$z = trim($row['zone']);
$a = trim($row['aisle']);
$r = trim($row['rack']);
if (!in_array($a, $valid_aisles, true) || !in_array($r, $valid_racks, true)) {
if ($advanced_location) {
if (in_array($a, $valid_aisles, true) && in_array($r, $valid_racks, true)) {
continue;
}
throw new Exception("Cannot shrink range — some racks still have stock");
}
if ($z !== $a || $a !== $r || !in_array($r, $valid_racks, true)) {
throw new Exception("Cannot shrink range — some racks still have stock");
}
}
@@ -377,7 +465,7 @@ class WarehouseManager {
* @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
private function insertRacksInRange(int $storage_id, array $range, bool $advanced_location): void
{
$sql = "INSERT IGNORE INTO md_rack
(company_id, warehouse, storage_id, zone, aisle, rack)
@@ -388,6 +476,20 @@ class WarehouseManager {
$aisles = $this->rangeToArray($range['aisle_from'], $range['aisle_to']);
$racks = $this->rangeToArray($range['rack_from'], $range['rack_to']);
if (!$advanced_location) {
foreach ($racks as $location) {
$sth->execute([
":company_id" => $this->company_id,
":warehouse" => $range['warehouse'],
":storage_id" => $storage_id,
":zone" => $location,
":aisle" => $location,
":rack" => $location,
]);
}
return;
}
foreach ($aisles as $a) {
foreach ($racks as $r) {
$sth->execute([
@@ -481,41 +583,43 @@ class WarehouseManager {
return $sth->fetchColumn();
}
/**
* Reverse lookup — return warehouse_id for a given warehouse_name.
* Used by approveStock() to resolve the destination warehouse on transfers.
*
* @param string $name The warehouse_name stored in td_stock.ref_warehouse.
* @return int|null The warehouse id, or null if not found.
*/
public function getWarehouseIdByName(string $name): ?int
{
$sth = $this->pdo->prepare(
"SELECT id FROM md_warehouse
WHERE company_id = :company_id AND warehouse_name = :name
LIMIT 1"
);
$sth->execute([':company_id' => $this->company_id, ':name' => $name]);
$id = $sth->fetchColumn();
return $id !== false ? (int)$id : null;
}
/**
* 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.
* On insert, returns the new warehouse id so the caller can create the
* per-warehouse stock table outside the DB transaction.
*
* Pass $data['id'] = 0 to insert; pass $data['id'] > 0 to update.
* Must be called inside dbTransaction() by the caller.
*
* @param array $data Keys: id, warehouse_name, location, manager, description, status.
* @param array $logging Audit entry to append to the log column.
* @return int|null New warehouse id on insert, null on update.
*/
public function saveWarehouse(array $data, array $logging): void
public function saveWarehouse(array $data, array $logging): ?int
{
$id = (int)($data['id'] ?? 0);
$warehouse_name = trim((string)($data['warehouse_name'] ?? ''));
if ($warehouse_name === '') {
throw new Exception("Warehouse name is required.");
}
$duplicate = $this->pdo->prepare(
"SELECT COUNT(*) FROM md_warehouse
WHERE company_id = :company_id
AND warehouse_name = :warehouse_name
AND id != :id"
);
$duplicate->execute([
':company_id' => $this->company_id,
':warehouse_name' => $warehouse_name,
':id' => $id,
]);
if ((int)$duplicate->fetchColumn() > 0) {
throw new Exception("Warehouse name already exists. Please use a different warehouse name.");
}
$sth = $this->pdo->prepare(
"SELECT `log` FROM md_warehouse
@@ -527,7 +631,7 @@ class WarehouseManager {
$params = [
':company_id' => $this->company_id,
':warehouse_name' => $data['warehouse_name'] ?? '',
':warehouse_name' => $warehouse_name,
':location' => $data['location'] ?? '',
':manager' => (int)($data['manager'] ?? 0),
':description' => $data['description'] ?? '',
@@ -555,12 +659,16 @@ class WarehouseManager {
(: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`");
}
return (int)$this->pdo->lastInsertId();
}
return null;
}
public function createStockTableForWarehouse(int $warehouse_id): void
{
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
$this->pdo->exec("CREATE TABLE IF NOT EXISTS `{$table}` LIKE `td_stock`");
}
/**
@@ -682,10 +790,43 @@ class WarehouseManager {
* 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
public function saveStorage(array $data, array $logging, bool $advanced_location = false): void
{
$id = (int)($data['id'] ?? 0);
$warehouse = trim((string)($data['warehouse'] ?? ''));
$zone = trim((string)($data['zone'] ?? ''));
$aisle_from = trim((string)($data['aisle_from'] ?? ''));
$aisle_to = trim((string)($data['aisle_to'] ?? ''));
$rack_from = trim((string)($data['rack_from'] ?? ''));
$rack_to = trim((string)($data['rack_to'] ?? ''));
if ($warehouse === '') {
throw new Exception("Warehouse is required.");
}
if ($advanced_location) {
if ($zone === '' || $aisle_from === '' || $aisle_to === '' || $rack_from === '' || $rack_to === '') {
throw new Exception("Zone, aisle range, and rack range are required.");
}
} else {
$location_from = $rack_from !== '' ? $rack_from : $aisle_from;
$location_to = $rack_to !== '' ? $rack_to : $aisle_to;
if ($location_from === '') {
throw new Exception("Location From is required.");
}
if ($location_to === '') {
$location_to = $location_from;
}
$zone = $location_from;
$aisle_from = $location_from;
$aisle_to = $location_to;
$rack_from = $location_from;
$rack_to = $location_to;
}
$sth = $this->pdo->prepare(
"SELECT `log` FROM md_storage
WHERE company_id = :company_id AND id = :id"
@@ -696,12 +837,12 @@ class WarehouseManager {
$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'] ?? '',
':warehouse' => $warehouse,
':zone' => $zone,
':aisle_from' => $aisle_from,
':aisle_to' => $aisle_to,
':rack_from' => $rack_from,
':rack_to' => $rack_to,
':description' => $data['description'] ?? '',
':status' => (int)($data['status'] ?? 1),
':log' => json_encode($table_log, JSON_UNESCAPED_UNICODE),
@@ -739,13 +880,13 @@ class WarehouseManager {
// 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'],
]);
'warehouse' => $warehouse,
'zone' => $zone,
'aisle_from' => $aisle_from,
'aisle_to' => $aisle_to,
'rack_from' => $rack_from,
'rack_to' => $rack_to,
], $advanced_location);
}
/**
@@ -938,7 +1079,7 @@ class WarehouseManager {
// Only return lots that have at least one active td_stock row.
// Lots whose stock was fully soft-deleted (company_id negated) are excluded.
$sth = $this->pdo->prepare(
"SELECT id, warehouse_name FROM md_warehouse
"SELECT id FROM md_warehouse
WHERE company_id = :company_id AND status = 1"
);
$sth->execute([':company_id' => $this->company_id]);
@@ -950,8 +1091,8 @@ class WarehouseManager {
// Build UNION EXISTS subquery across all td_stock_* tables
$unions = implode(' UNION ALL ', array_map(function($wh) use ($cid) {
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']);
return "SELECT lot_number FROM `td_stock_{$safe}`
$table = $this->stockTableNameFromWarehouseId((int)$wh['id']);
return "SELECT lot_number FROM `{$table}`
WHERE company_id = {$cid} AND lot_number IS NOT NULL";
}, $warehouses));
@@ -987,7 +1128,7 @@ class WarehouseManager {
public function getActiveLots(string $product_sku): array
{
$sth = $this->pdo->prepare(
"SELECT id, warehouse_name FROM md_warehouse
"SELECT id FROM md_warehouse
WHERE company_id = :company_id AND status = 1"
);
$sth->execute([':company_id' => $this->company_id]);
@@ -996,8 +1137,7 @@ class WarehouseManager {
$active_lots = [];
foreach ($warehouses as $wh) {
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']);
$table = "td_stock_{$safe}";
$table = $this->stockTableNameFromWarehouseId((int)$wh['id']);
$sth = $this->pdo->prepare(
"SELECT DISTINCT s.lot_number, l.expiry_date
@@ -1008,7 +1148,7 @@ class WarehouseManager {
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.`in` > 0
AND s.status = 1
AND s.lot_number IS NOT NULL
AND EXISTS (
@@ -1047,7 +1187,7 @@ class WarehouseManager {
public function getActiveSerials(string $product_sku, string $lot_number = ''): array
{
$sth = $this->pdo->prepare(
"SELECT id, warehouse_name FROM md_warehouse
"SELECT id FROM md_warehouse
WHERE company_id = :company_id AND status = 1"
);
$sth->execute([':company_id' => $this->company_id]);
@@ -1056,15 +1196,14 @@ class WarehouseManager {
$active_serials = [];
foreach ($warehouses as $wh) {
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']);
$table = "td_stock_{$safe}";
$table = $this->stockTableNameFromWarehouseId((int)$wh['id']);
$wh_id = $wh['id'];
$sql = "SELECT DISTINCT s.serial_number
FROM `{$table}` s
WHERE s.company_id = :company_id
AND s.product_sku = :product_sku
AND s.type = 'in'
AND s.`in` > 0
AND s.status = 1
AND s.serial_number IS NOT NULL
AND EXISTS (
@@ -1104,8 +1243,8 @@ class WarehouseManager {
* 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
* - 'table': the td_stock_<warehouse_id> table name
* - 'name': the raw warehouse_name string for display
* - 'row': the specific stock row (or empty array if $id = 0)
*
* Pass $id = 0 to get just the table name without fetching a row.
@@ -1113,15 +1252,17 @@ class WarehouseManager {
* 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.
* @param int|string $id The td_stock_<warehouse_id>.id to fetch, or 0 for table-only.
* @return array Keys: 'table' (string), 'name' (string), 'row' (array|[]).
*/
public function getStockContext($warehouse_id, $id) {
$warehouse_id = (int)$warehouse_id;
$name = $this->getWarehouseName($warehouse_id);
if (!$name) {
throw new Exception("Warehouse ID {$warehouse_id} not found.");
}
// Sanitise table suffix to prevent SQL injection via warehouse names
$safe_name = preg_replace('/[^a-zA-Z0-9_]/', '', $name);
$table = "td_stock_" . $safe_name;
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
if (empty($id)) {
return [
@@ -1166,10 +1307,10 @@ class WarehouseManager {
* @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 {
public function syncRacks(int $storage_id, array $range, bool $advanced_location): void {
// Guard: refuse if any occupied rack under this storage_id falls outside the new range
$this->validateRangeChange($storage_id, $range);
$this->validateRangeChange($storage_id, $range, $advanced_location);
// Wipe all empty racks belonging to this storage_id
$sql = "DELETE FROM md_rack
@@ -1184,14 +1325,14 @@ class WarehouseManager {
]);
// Reinsert the full range fresh (INSERT IGNORE skips any occupied racks already there)
$this->insertRacksInRange($storage_id, $range);
$this->insertRacksInRange($storage_id, $range, $advanced_location);
}
/**
* 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
* to exactly one td_stock_<warehouse_id> row. This method resolves that pointer and
* returns the full stock row, or null if the rack is empty or the link is broken.
*
* Used by saveStockOut and saveStockTransfer to validate rack contents before
@@ -1227,9 +1368,17 @@ class WarehouseManager {
return null; // Rack is empty or doesn't exist
}
// Fetch the full stock row from the per-warehouse table
// Fetch the full stock row from the per-warehouse table. Source
// movements can only consume approved stock-in rows; draft stock-in
// rows may reserve a rack but are not available inventory yet.
$context = $this->getStockContext($warehouse_id, $td_stock_id);
return $context['row'] ?: null;
$row = $context['row'] ?: null;
if (!$row || $row['type'] !== 'in' || (int)$row['status'] !== 1) {
return null;
}
return $row;
}
/**
@@ -1274,20 +1423,24 @@ class WarehouseManager {
if (empty($warehouses)) return;
foreach ($warehouses as $wh) {
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']);
$table = "td_stock_{$safe}";
$wh_id = (int)$wh['id'];
$table = $this->stockTableNameFromWarehouseId($wh_id);
// Find any stock_in row for this SKU that is still rack-linked (active)
// and matches the lot/serial combination being validated.
// The exclude_id skips the just-inserted row to avoid false self-conflict.
// r.warehouse = :warehouse_id scopes the rack check to THIS warehouse only —
// td_stock_id values are per-table integers, not globally unique, so without
// this filter a rack in a different warehouse could cause a false positive.
$sql = "SELECT s.id
FROM `{$table}` s
WHERE s.company_id = :company_id
AND s.product_sku = :product_sku
AND s.type = 'in'
AND s.`in` > 0
AND EXISTS (
SELECT 1 FROM md_rack r
WHERE r.company_id = :company_id2
AND r.warehouse = :warehouse_id
AND r.td_stock_id = s.id
AND r.product_sku IS NOT NULL
)";
@@ -1303,9 +1456,10 @@ class WarehouseManager {
}
$params = [
':company_id' => $cid,
':company_id2' => $cid,
':product_sku' => $product_sku,
':company_id' => $cid,
':company_id2' => $cid,
':warehouse_id' => $wh_id,
':product_sku' => $product_sku,
];
if (!empty($lot_number)) $params[':lot_number'] = $lot_number;
if (!empty($serial_number)) $params[':serial_number'] = $serial_number;
@@ -1332,7 +1486,8 @@ class WarehouseManager {
* 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.
* Also calls validateStockUnique to enforce no-duplicate-serial rules unless
* the caller is replaying a validated source transaction, such as a return.
* Updates md_rack state (product_sku + td_stock_id) in a single UPDATE
* and appends a rack log entry.
*
@@ -1343,12 +1498,12 @@ class WarehouseManager {
* @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.
* @param int $td_stock_id The td_stock_<warehouse_id>.id to link.
* @throws Exception If the rack does not exist or is already occupied.
*/
public function occupyRack($warehouse_id, $zone, $aisle, $rack, $product_sku, $td_stock_id): void {
public function occupyRack($warehouse_id, $zone, $aisle, $rack, $product_sku, $td_stock_id, bool $skip_unique_validation = false): void {
$sql = "SELECT id, product_sku FROM md_rack
$sql = "SELECT id, product_sku, td_stock_id FROM md_rack
WHERE company_id = :company_id
AND warehouse = :warehouse
AND zone = :zone
@@ -1371,22 +1526,31 @@ class WarehouseManager {
}
if ($current['product_sku'] !== null) {
if (
$current['product_sku'] === $product_sku
&& (int)$current['td_stock_id'] === (int)$td_stock_id
) {
return;
}
throw new Exception(
"Rack {$zone}-{$aisle}-{$rack} is already occupied by {$current['product_sku']}"
);
}
// Validate sku + lot + serial uniqueness before linking the rack
$ctx = $this->getStockContext($warehouse_id, $td_stock_id);
$row = $ctx['row'] ?? null;
if (!$skip_unique_validation) {
// Validate sku + lot + serial uniqueness before linking the rack
$ctx = $this->getStockContext($warehouse_id, $td_stock_id);
$row = $ctx['row'] ?? null;
if ($row) {
$this->validateStockUnique(
$product_sku,
$row['lot_number'] ?? null,
$row['serial_number'] ?? null,
$td_stock_id // exclude self so insert flows don't self-conflict
);
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
@@ -1674,7 +1838,8 @@ class WarehouseManager {
SELECT s.id FROM `{$table}` s
WHERE s.company_id = :company_id2
AND s.product_sku = :product_sku2
AND s.type = 'in'
AND s.`in` > 0
AND s.status = 1
{$lot_cond}
{$serial_cond}
)
@@ -1745,7 +1910,8 @@ class WarehouseManager {
SELECT s.id FROM `{$table}` s
WHERE s.company_id = :company_id2
AND s.product_sku = :product_sku2
AND s.type = 'in'
AND s.`in` > 0
AND s.status = 1
{$lot_cond}
{$serial_cond}
)
@@ -1841,7 +2007,8 @@ class WarehouseManager {
SELECT s.id FROM `{$table}` s
WHERE s.company_id = :company_id2
AND s.product_sku = :product_sku2
AND s.type = 'in'
AND s.`in` > 0
AND s.status = 1
{$lot_cond}
{$serial_cond}
)
@@ -1861,12 +2028,12 @@ class WarehouseManager {
* 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).
* 3. Releases the reserved/occupied rack back to empty.
* 4. Reverses the balance only if the row was approved.
*
* Must be called inside a DB transaction by the caller.
*
* @param int $stock_id The td_stock_<wh>.id of the row to delete.
* @param int $stock_id The td_stock_<warehouse_id>.id of the row to delete.
* @param int $warehouse_id The warehouse the row belongs to.
* @throws Exception If not the latest transaction or record not found.
*/
@@ -1892,9 +2059,19 @@ class WarehouseManager {
WHERE id = :id AND company_id = :company_id"
)->execute([':id' => $stock_id, ':company_id' => $this->company_id]);
// Only reverse side effects if the row was approved — draft rows never had them applied
// Stock-in reserves the rack at creation time, even while draft.
// Balance is only applied for approved rows.
$this->releaseRack($warehouse_id, $row['zone'], $row['aisle'], $row['rack']);
$this->softDeleteRackLogs(
$warehouse_id,
$row['zone'], $row['aisle'], $row['rack'],
['occupy', 'release'],
$product_sku,
[$stock_id],
$row['date']
);
if ((int)$row['status'] === 1) {
$this->releaseRack($warehouse_id, $row['zone'], $row['aisle'], $row['rack']);
$this->adjustBalance('in', $warehouse_id, $product_sku, (int)$row['in'], 0);
}
}
@@ -1910,7 +2087,7 @@ class WarehouseManager {
*
* 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 $stock_id The td_stock_<warehouse_id>.id of the row to delete.
* @param int $warehouse_id The warehouse the row belongs to.
* @throws Exception If not the latest transaction or record not found.
*/
@@ -1945,6 +2122,14 @@ class WarehouseManager {
$product_sku,
(int)$row['ref_id']
);
$this->softDeleteRackLogs(
$warehouse_id,
$row['zone'], $row['aisle'], $row['rack'],
['release', 'occupy'],
$product_sku,
[(int)$row['ref_id']],
$row['date']
);
$this->adjustBalance('out', $warehouse_id, $product_sku, (int)$row['out'], 0);
}
}
@@ -2020,6 +2205,22 @@ class WarehouseManager {
$product_sku,
$from_stock_id
);
$this->softDeleteRackLogs(
$to_warehouse,
$to_row['zone'], $to_row['aisle'], $to_row['rack'],
['occupy', 'release'],
$product_sku,
[$ref_id],
$from_row['date']
);
$this->softDeleteRackLogs(
$from_warehouse_id,
$from_row['zone'], $from_row['aisle'], $from_row['rack'],
['release', 'occupy'],
$product_sku,
[],
$from_row['date']
);
// Reverse balances on both sides
$quantity = (int)$from_row['out'];
@@ -2056,8 +2257,7 @@ class WarehouseManager {
$cid = (int)$this->company_id;
foreach ($warehouses as $wh) {
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']);
$table = "td_stock_{$safe}";
$table = $this->stockTableNameFromWarehouseId((int)$wh['id']);
$wh_id = (int)$wh['id'];
$sth = $this->pdo->prepare(
@@ -2065,7 +2265,7 @@ class WarehouseManager {
WHERE s.company_id = :company_id
AND s.product_sku = :product_sku
AND s.lot_number = :lot_number
AND s.type = 'in'
AND s.`in` > 0
AND s.status = 1
AND EXISTS (
SELECT 1 FROM md_rack r
@@ -2108,6 +2308,52 @@ class WarehouseManager {
*/
public function getWarehouseList(string $type, string $product_sku = '', int $id = 0): array
{
if (!$id && $type === 'from') {
if ($product_sku === '') {
return [];
}
$sth = $this->pdo->prepare(
"SELECT id, warehouse_name FROM md_warehouse
WHERE company_id = :company_id AND status = 1
ORDER BY warehouse_name"
);
$sth->execute([':company_id' => $this->company_id]);
$warehouses = $sth->fetchAll(PDO::FETCH_ASSOC);
$result = [];
foreach ($warehouses as $wh) {
$table = $this->stockTableNameFromWarehouseId((int)$wh['id']);
$stock = $this->pdo->prepare(
"SELECT 1
FROM md_rack r
INNER JOIN `{$table}` s
ON s.company_id = r.company_id
AND s.id = r.td_stock_id
WHERE r.company_id = :company_id
AND r.warehouse = :warehouse
AND r.product_sku = :product_sku
AND s.product_sku = :product_sku2
AND s.`in` > 0
AND s.status = 1
LIMIT 1"
);
$stock->execute([
':company_id' => $this->company_id,
':warehouse' => (int)$wh['id'],
':product_sku' => $product_sku,
':product_sku2' => $product_sku,
]);
if ($stock->fetchColumn() !== false) {
$result[] = $wh;
}
}
return $result;
}
$product_sku_filter = '';
$params = [':company_id' => $this->company_id];