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
+429 -10
View File
@@ -11,6 +11,38 @@ class Reports
$this->companyId = $companyId;
}
/**
* Resolve the td_stock_<wh> table name for a given warehouse_id.
* Returns null if warehouse not found.
*/
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->companyId, ':id' => $warehouse_id]);
$name = $sth->fetchColumn();
if (!$name) return null;
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $name);
return "td_stock_{$safe}";
}
// These helper methods abstract away the common pattern of preparing, executing, and fetching results from the database.
private function fetchScalar(string $sql)
{
$sth = $this->pdo->prepare($sql);
$sth->execute([":company_id" => $this->companyId]);
return $sth->fetchColumn();
}
private function fetchAll(string $sql): array
{
$sth = $this->pdo->prepare($sql);
$sth->execute([":company_id" => $this->companyId]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Master Data Summary:
* - Total Categories: Count of all categories in md_product_category for the company.
@@ -135,21 +167,408 @@ class Reports
return $this->fetchAll($sql);
}
// FETCH HELPERS
// These helper methods abstract away the common pattern of preparing, executing, and fetching results from the database.
private function fetchScalar(string $sql)
/**
* Low Stock Items
*
* Returns per-warehouse rows where balance has fallen to/below reorder_point.
*
* Status tiers:
* critical — balance <= min_stock (safety floor breached)
* warning — balance <= reorder_point (reorder trigger hit, still above min)
*
* Products with reorder_point = 0 are skipped (no threshold defined).
*/
public function getLowStockItems(): array
{
$sth = $this->pdo->prepare($sql);
$sth->execute([":company_id" => $this->companyId]);
return $sth->fetchColumn();
$sql = "SELECT
wb.warehouse_id,
wb.product_sku,
mw.warehouse_name,
mp.product_name,
mp.min_stock,
mp.reorder_point,
mp.product_image,
ROUND(SUM(wb.total_in) - SUM(wb.total_out), 2) AS balance
FROM warehouse_balance wb
INNER JOIN md_product mp
ON wb.company_id = mp.company_id
AND wb.product_sku = mp.sku
INNER JOIN md_warehouse mw
ON wb.company_id = mw.company_id
AND wb.warehouse_id = mw.id
WHERE wb.company_id = :company_id
AND mp.reorder_point > 0
GROUP BY
wb.warehouse_id, wb.product_sku, mw.warehouse_name,
mp.product_name, mp.min_stock, mp.reorder_point, mp.product_image
HAVING balance <= mp.reorder_point
ORDER BY mp.product_name ASC, mw.warehouse_name ASC";
$rows = $this->fetchAll($sql);
$items = [];
foreach ($rows as $row) {
$balance = (float) $row['balance'];
$min_stock = (float) $row['min_stock'];
$is_critical = ($balance <= $min_stock);
$items[] = [
'warehouse_id' => (int) $row['warehouse_id'],
'warehouse_name' => $row['warehouse_name'],
'product_sku' => $row['product_sku'],
'product_name' => $row['product_name'],
'product_image' => $row['product_image'],
'balance' => max(0, round($balance, 2)),
'min_stock' => (float) $row['min_stock'],
'reorder_point' => (float) $row['reorder_point'],
'status' => $is_critical ? 'critical' : 'warning',
];
}
return $items;
}
private function fetchAll(string $sql): array
/**
* Count of low-stock items at CRITICAL level (balance <= min_stock).
*/
public function getCriticalStockCount(): int
{
$sth = $this->pdo->prepare($sql);
$sth->execute([":company_id" => $this->companyId]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
$items = $this->getLowStockItems();
return count(array_filter($items, fn($i) => $i['status'] === 'critical'));
}
/**
* Count of low-stock items at WARNING level (balance <= reorder_point, > min_stock).
*/
public function getWarningStockCount(): int
{
$items = $this->getLowStockItems();
return count(array_filter($items, fn($i) => $i['status'] === 'warning'));
}
// CONTACT SUMMARY
public function getTotalContactCategory(): int
{
$sql = "SELECT COUNT(*)
FROM md_contact_type
WHERE company_id = :company_id";
return (int) $this->fetchScalar($sql);
}
public function getActiveContactCategory(): int
{
$sql = "SELECT COUNT(*)
FROM md_contact_type
WHERE company_id = :company_id
AND `status` = 1";
return (int) $this->fetchScalar($sql);
}
public function getInactiveContactCategory(): int
{
$sql = "SELECT COUNT(*)
FROM md_contact_type
WHERE company_id = :company_id
AND `status` = 0";
return (int) $this->fetchScalar($sql);
}
public function getTotalContact(): int
{
$sql = "SELECT COUNT(*)
FROM md_contact
WHERE company_id = :company_id";
return (int) $this->fetchScalar($sql);
}
// WAREHOUSE OVERVIEW
public function getWarehouseCapacity(int $warehouse_id): int
{
$sth = $this->pdo->prepare(
"SELECT COUNT(*) FROM md_rack
WHERE company_id = :company_id AND warehouse = :warehouse_id"
);
$sth->execute([':company_id' => $this->companyId, ':warehouse_id' => $warehouse_id]);
return (int) $sth->fetchColumn();
}
public function getWarehouseSpaceUsed(int $warehouse_id): int
{
$sth = $this->pdo->prepare(
"SELECT COUNT(*) FROM md_rack
WHERE company_id = :company_id AND warehouse = :warehouse_id
AND product_sku IS NOT NULL"
);
$sth->execute([':company_id' => $this->companyId, ':warehouse_id' => $warehouse_id]);
return (int) $sth->fetchColumn();
}
public function getWarehouseBalance(int $warehouse_id): array
{
$sth = $this->pdo->prepare(
"SELECT
ROUND(COALESCE(SUM(total_in), 0), 2) AS total_in,
ROUND(COALESCE(SUM(total_out), 0), 2) AS total_out
FROM warehouse_balance
WHERE company_id = :company_id AND warehouse_id = :warehouse_id"
);
$sth->execute([':company_id' => $this->companyId, ':warehouse_id' => $warehouse_id]);
return $sth->fetch(PDO::FETCH_ASSOC) ?: ['total_in' => 0, 'total_out' => 0];
}
public function getWarehouseLowStockCount(int $warehouse_id): int
{
$sth = $this->pdo->prepare(
"SELECT wb.product_sku,
ROUND(SUM(wb.total_in) - SUM(wb.total_out), 2) AS balance,
mp.min_stock
FROM warehouse_balance wb
INNER JOIN md_product mp
ON mp.company_id = wb.company_id
AND mp.sku = wb.product_sku
WHERE wb.company_id = :company_id
AND wb.warehouse_id = :warehouse_id
GROUP BY wb.product_sku, mp.min_stock"
);
$sth->execute([':company_id' => $this->companyId, ':warehouse_id' => $warehouse_id]);
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
$count = 0;
foreach ($rows as $row) {
if ((float)$row['balance'] < (float)$row['min_stock']) $count++;
}
return $count;
}
public function getWarehouseStockMovement(int $warehouse_id): array
{
$table = $this->resolveWarehouseTable($warehouse_id);
if (!$table) return ['labels' => [], 'stock_in' => [], 'stock_out' => []];
$now = new DateTime();
$start = (clone $now)->modify('-12 months')->format('Y-m-d 00:00:00');
$end = $now->format('Y-m-d 23:59:59');
$sth = $this->pdo->query(
"SELECT DATE_FORMAT(date, '%b') AS label,
DATE_FORMAT(date, '%Y-%m') AS sort_key,
ROUND(SUM(`in`), 2) AS stock_in,
ROUND(SUM(`out`), 2) AS stock_out
FROM `{$table}`
WHERE company_id = {$this->companyId}
AND date >= '{$start}' AND date <= '{$end}'
GROUP BY sort_key, label
ORDER BY sort_key ASC"
);
$dataByMonth = [];
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
$dataByMonth[$row['sort_key']] = $row;
}
$labels = []; $stock_in = []; $stock_out = [];
for ($i = 11; $i >= 0; $i--) {
$dt = (clone $now)->modify("-{$i} months");
$key = $dt->format('Y-m');
$labels[] = $dt->format('M');
$stock_in[] = isset($dataByMonth[$key]) ? (float)$dataByMonth[$key]['stock_in'] : 0;
$stock_out[] = isset($dataByMonth[$key]) ? (float)$dataByMonth[$key]['stock_out'] : 0;
}
return ['labels' => $labels, 'stock_in' => $stock_in, 'stock_out' => $stock_out];
}
public function getWarehouseStockTrend(int $warehouse_id): array
{
$table = $this->resolveWarehouseTable($warehouse_id);
if (!$table) return ['labels' => [], 'balance' => []];
$now = new DateTime();
$start = (clone $now)->modify('-12 months')->format('Y-m-d 00:00:00');
$end = $now->format('Y-m-d 23:59:59');
// Prior balance before the 12-month window
$sth = $this->pdo->query(
"SELECT ROUND(COALESCE(SUM(`in`) - SUM(`out`), 0), 2) AS balance
FROM `{$table}`
WHERE company_id = {$this->companyId} AND date < '{$start}'"
);
$running = (float) $sth->fetchColumn();
$sth = $this->pdo->query(
"SELECT DATE_FORMAT(date, '%Y-%m') AS sort_key,
ROUND(SUM(`in`), 2) AS stock_in,
ROUND(SUM(`out`), 2) AS stock_out
FROM `{$table}`
WHERE company_id = {$this->companyId}
AND date >= '{$start}' AND date <= '{$end}'
GROUP BY sort_key
ORDER BY sort_key ASC"
);
$dataByMonth = [];
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
$dataByMonth[$row['sort_key']] = $row;
}
$labels = []; $balance = [];
for ($i = 11; $i >= 0; $i--) {
$dt = (clone $now)->modify("-{$i} months");
$key = $dt->format('Y-m');
$running += isset($dataByMonth[$key])
? (float)$dataByMonth[$key]['stock_in'] - (float)$dataByMonth[$key]['stock_out']
: 0;
$labels[] = $dt->format('M');
$balance[] = round($running, 2);
}
return ['labels' => $labels, 'balance' => $balance];
}
public function getWarehouseActivity(int $warehouse_id): array
{
$table = $this->resolveWarehouseTable($warehouse_id);
if (!$table) return [];
$sth = $this->pdo->query(
"SELECT a.date, a.product_sku,
a.`in` AS stock_in, a.`out` AS stock_out,
a.type, a.description, b.product_name
FROM `{$table}` a
LEFT JOIN md_product b
ON a.company_id = b.company_id AND a.product_sku = b.sku
WHERE a.company_id = {$this->companyId}
AND (a.`in` > 0 OR a.`out` > 0)
ORDER BY a.date DESC, a.id DESC
LIMIT 10"
);
$activities = [];
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
$is_in = (float)$row['stock_in'] > 0;
$qty = $is_in ? (float)$row['stock_in'] : (float)$row['stock_out'];
$activities[] = [
'product_name' => $row['product_name'] ?: $row['product_sku'],
'sku' => $row['product_sku'],
'direction' => $is_in ? 'in' : 'out',
'qty' => round($qty, 2),
'type' => $row['type'],
'description' => $row['description'],
'date' => $row['date'],
];
}
return $activities;
}
// EXPIRED / NEAR EXPIRY STOCK
public function getExpiredStock(int $warehouse_id = 0): array
{
// Step 1: get warehouses from md_warehouse — fast, indexed, company-scoped
// avoids hitting information_schema on every request
// if warehouse_id is passed, restrict to that warehouse only
$where_wh = $warehouse_id > 0 ? "AND id = {$warehouse_id}" : '';
$sth = $this->pdo->prepare(
"SELECT id, warehouse_name
FROM md_warehouse
WHERE company_id = :company_id
AND status = 1
{$where_wh}"
);
$sth->execute([':company_id' => $this->companyId]);
$warehouses = $sth->fetchAll(PDO::FETCH_ASSOC);
if (empty($warehouses)) return [];
// Step 2: build UNION only across known warehouse tables
// warehouse_id (integer) carried through for a fast indexed JOIN later
$unions = implode("\nUNION ALL\n", array_map(
fn($wh) => "SELECT
s.id,
s.product_sku,
s.lot_number,
s.zone,
s.aisle,
s.rack,
ROUND(s.`in`, 2) AS quantity,
{$wh['id']} AS warehouse_id
FROM `td_stock_{$wh['warehouse_name']}` s
WHERE s.company_id = {$this->companyId}
AND s.type = 'in'
AND s.lot_number IS NOT NULL",
$warehouses
));
// Step 3: join md_lot by (company_id, product_sku, lot_number)
// join md_warehouse by integer warehouse_id — no string concat, index-friendly
$sql = "SELECT
stock.id,
stock.product_sku,
stock.lot_number,
stock.zone,
stock.aisle,
stock.rack,
stock.quantity,
l.expiry_date,
DATEDIFF(l.expiry_date, CURDATE()) AS days_remaining,
p.product_name,
pc.category as category_name,
mw.warehouse_name
FROM ($unions) AS stock
INNER JOIN md_lot l
ON l.company_id = {$this->companyId}
AND l.product_sku = stock.product_sku
AND l.lot_number = stock.lot_number
INNER JOIN md_product p
ON p.company_id = {$this->companyId}
AND p.sku = stock.product_sku
LEFT JOIN md_product_category pc
ON pc.company_id = {$this->companyId}
AND pc.id = p.category
INNER JOIN md_warehouse mw
ON mw.company_id = {$this->companyId}
AND mw.id = stock.warehouse_id
WHERE l.expiry_date IS NOT NULL
ORDER BY l.expiry_date ASC";
$sth = $this->pdo->prepare($sql);
$sth->execute([]);
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
$items = [];
foreach ($rows as $row) {
$days = (int) $row['days_remaining'];
if ($days < 0) {
$status = 'expired';
} elseif ($days <= 7) {
$status = 'critical';
} elseif ($days <= 14) {
$status = 'warning';
} elseif ($days <= 30) {
$status = 'caution';
} else {
continue; // beyond 30 days — not relevant for this report
}
$items[] = [
'id' => (int) $row['id'],
'product_sku' => $row['product_sku'],
'product_name' => $row['product_name'],
'category_name' => $row['category_name'],
'warehouse_name' => $row['warehouse_name'],
'lot_number' => $row['lot_number'],
'zone' => $row['zone'],
'aisle' => $row['aisle'],
'rack' => $row['rack'],
'quantity' => (float) $row['quantity'],
'expiry_date' => $row['expiry_date'],
'days_remaining' => $days,
'status' => $status,
];
}
return $items;
}
}
+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
// ─────────────────────────────────────────────────────────────