Lot, Serial, ExpiryDate
This commit is contained in:
@@ -20,6 +20,64 @@ class WarehouseManager {
|
||||
$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
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
@@ -146,60 +204,60 @@ class WarehouseManager {
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
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']);
|
||||
|
||||
$sql = "SELECT COUNT(*) FROM md_rack
|
||||
WHERE company_id = :company_id
|
||||
AND storage_id = :storage_id
|
||||
AND product_sku IS NOT NULL
|
||||
AND (
|
||||
CAST(aisle AS UNSIGNED) < :aisle_from OR
|
||||
CAST(aisle AS UNSIGNED) > :aisle_to OR
|
||||
CAST(rack AS UNSIGNED) < :rack_from OR
|
||||
CAST(rack AS UNSIGNED) > :rack_to
|
||||
)";
|
||||
|
||||
$sth = $this->pdo->prepare($sql);
|
||||
// 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,
|
||||
":aisle_from" => (int) $range['aisle_from'],
|
||||
":aisle_to" => (int) $range['aisle_to'],
|
||||
":rack_from" => (int) $range['rack_from'],
|
||||
":rack_to" => (int) $range['rack_to'],
|
||||
':company_id' => $this->company_id,
|
||||
':storage_id' => $storage_id,
|
||||
]);
|
||||
|
||||
if ($sth->fetchColumn() > 0) {
|
||||
throw new Exception("Cannot shrink range — some racks still have stock");
|
||||
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 {
|
||||
|
||||
private function insertRacksInRange(int $storage_id, array $range): void
|
||||
{
|
||||
$sql = "INSERT IGNORE INTO md_rack
|
||||
(company_id, warehouse, storage_id, zone, aisle, rack)
|
||||
VALUES
|
||||
(:company_id, :warehouse, :storage_id, :zone, :aisle, :rack)";
|
||||
$sth = $this->pdo->prepare($sql);
|
||||
|
||||
$aisle_from = (int) $range['aisle_from'];
|
||||
$aisle_to = (int) $range['aisle_to'];
|
||||
$rack_from = (int) $range['rack_from'];
|
||||
$rack_to = (int) $range['rack_to'];
|
||||
$aisles = $this->rangeToArray($range['aisle_from'], $range['aisle_to']);
|
||||
$racks = $this->rangeToArray($range['rack_from'], $range['rack_to']);
|
||||
|
||||
for ($a = $aisle_from; $a <= $aisle_to; $a++) {
|
||||
for ($r = $rack_from; $r <= $rack_to; $r++) {
|
||||
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" => (string) $a,
|
||||
":rack" => (string) $r,
|
||||
":aisle" => $a,
|
||||
":rack" => $r,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -288,8 +346,103 @@ class WarehouseManager {
|
||||
*
|
||||
* @throws Exception if the rack is already occupied or doesn't exist.
|
||||
*/
|
||||
public function occupyRack($warehouse_id, $zone, $aisle, $rack, $product_sku, $td_stock_id): void {
|
||||
/**
|
||||
* 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
|
||||
@@ -319,6 +472,20 @@ class WarehouseManager {
|
||||
);
|
||||
}
|
||||
|
||||
// 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,
|
||||
@@ -486,6 +653,213 @@ class WarehouseManager {
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 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
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user