Lot, Serial, ExpiryDate

This commit is contained in:
Thanakorn S
2026-04-25 14:37:00 +07:00
parent 92d116fd21
commit 3cc5baba3f
35 changed files with 3518 additions and 1890 deletions
+406 -32
View File
@@ -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
// ─────────────────────────────────────────────────────────────