Lot, Serial, ExpiryDate
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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