[test] contact to setting modules
This commit is contained in:
@@ -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];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user