This commit is contained in:
Thanakorn S
2026-04-25 16:02:55 +07:00
parent 3cc5baba3f
commit 76b9b2a2cb
12 changed files with 1625 additions and 669 deletions
+183 -61
View File
@@ -44,10 +44,7 @@ class Reports
}
/**
* Master Data Summary:
* - Total Categories: Count of all categories in md_product_category for the company.
* - Active Categories: Count of categories where status = 1.
* - Inactive Categories: Count of categories where status = 0.
* Master Data Summary
*/
public function getTotalCategory(): int
{
@@ -93,10 +90,6 @@ class Reports
return (int) $this->fetchScalar($sql);
}
/**
* Total capacity = total count of active racks across all warehouses.
* One rack = one storage position (holds one product unit).
*/
public function getTotalCapacity(): int
{
$sql = "SELECT COUNT(*)
@@ -105,9 +98,6 @@ class Reports
return (int) $this->fetchScalar($sql);
}
/**
* Space Used = count of racks currently occupied (product_sku IS NOT NULL).
*/
public function getSpaceUsed(): int
{
$sql = "SELECT COUNT(*)
@@ -117,10 +107,6 @@ class Reports
return (int) $this->fetchScalar($sql);
}
/**
* Product Count = total count of distinct products in the md_product table for the company.
* Low Stock Count = count of products where current stock balance (total_in - total_out) is less than the min_stock defined in md_product.
*/
public function getTotalProduct(): int
{
$sql = "SELECT COUNT(*)
@@ -130,7 +116,7 @@ class Reports
}
public function getTotalProductInStock(): int
{
{
$sql = "SELECT COUNT(DISTINCT product_sku)
FROM warehouse_balance
WHERE company_id = :company_id
@@ -167,17 +153,6 @@ class Reports
return $this->fetchAll($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
{
$sql = "SELECT
@@ -210,9 +185,6 @@ class Reports
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'],
@@ -222,25 +194,18 @@ class Reports
'balance' => max(0, round($balance, 2)),
'min_stock' => (float) $row['min_stock'],
'reorder_point' => (float) $row['reorder_point'],
'status' => $is_critical ? 'critical' : 'warning',
'status' => ($balance <= $min_stock) ? 'critical' : 'warning',
];
}
return $items;
}
/**
* Count of low-stock items at CRITICAL level (balance <= min_stock).
*/
public function getCriticalStockCount(): int
{
$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();
@@ -282,6 +247,180 @@ class Reports
return (int) $this->fetchScalar($sql);
}
// DASHBOARD REPORTS (cross-warehouse, month-filtered via warehouse_balance)
public function getDashboardStats(string $month): 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,
COUNT(DISTINCT product_sku) AS active_products
FROM warehouse_balance
WHERE company_id = :company_id
AND month = :month"
);
$sth->execute([':company_id' => $this->companyId, ':month' => $month]);
return $sth->fetch(PDO::FETCH_ASSOC) ?: [
'total_in' => 0, 'total_out' => 0, 'active_products' => 0
];
}
public function getDashboardLowStockCount(): 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
GROUP BY wb.product_sku, mp.min_stock"
);
$sth->execute([':company_id' => $this->companyId]);
$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 getStockMovementChart(int $months = 12): array
{
$now = new DateTime();
$start = (clone $now)->modify("-" . ($months - 1) . " months")->format('Y-m');
$end = $now->format('Y-m');
$sth = $this->pdo->prepare(
"SELECT
month,
ROUND(SUM(total_in), 2) AS stock_in,
ROUND(SUM(total_out), 2) AS stock_out
FROM warehouse_balance
WHERE company_id = :company_id
AND month BETWEEN :start AND :end
GROUP BY month
ORDER BY month ASC"
);
$sth->execute([
':company_id' => $this->companyId,
':start' => $start,
':end' => $end,
]);
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
$dataByMonth = [];
foreach ($rows as $row) {
$dataByMonth[$row['month']] = $row;
}
$labels = []; $stock_in = []; $stock_out = [];
for ($i = $months - 1; $i >= 0; $i--) {
$key = (clone $now)->modify("-{$i} months")->format('Y-m');
$labels[] = (clone $now)->modify("-{$i} months")->format('M Y');
$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 getMostMovedProducts(string $month, int $limit = 10): array
{
$limit = (int) $limit;
$sth = $this->pdo->prepare(
"SELECT
wb.product_sku,
ROUND(SUM(wb.total_in), 2) AS total_in,
ROUND(SUM(wb.total_out), 2) AS total_out,
p.product_name,
pc.category AS category_name
FROM warehouse_balance wb
INNER JOIN md_product p
ON p.company_id = wb.company_id
AND p.sku = wb.product_sku
LEFT JOIN md_product_category pc
ON pc.company_id = wb.company_id
AND pc.id = p.category
WHERE wb.company_id = :company_id
AND wb.month = :month
GROUP BY wb.product_sku, p.product_name, pc.category
ORDER BY total_out DESC
LIMIT {$limit}"
);
$sth->execute([
':company_id' => $this->companyId,
':month' => $month,
]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
public function getAvailableMonths(): array
{
$sth = $this->pdo->prepare(
"SELECT DISTINCT month
FROM warehouse_balance
WHERE company_id = :company_id
ORDER BY month DESC"
);
$sth->execute([':company_id' => $this->companyId]);
return $sth->fetchAll(PDO::FETCH_COLUMN);
}
/**
* Recent stock activity across all warehouses — last N transactions.
*/
public function getRecentActivity(int $limit = 10): array
{
$sth = $this->pdo->prepare(
"SELECT id, warehouse_name FROM md_warehouse
WHERE company_id = :company_id AND status = 1"
);
$sth->execute([':company_id' => $this->companyId]);
$warehouses = $sth->fetchAll(PDO::FETCH_ASSOC);
if (empty($warehouses)) return [];
$unions = implode("\nUNION ALL\n", array_map(
fn($wh) => "SELECT s.date, s.product_sku, s.type,
ROUND(COALESCE(s.`in`, 0), 2) AS stock_in,
ROUND(COALESCE(s.`out`, 0), 2) AS stock_out,
p.product_name,
'{$wh['warehouse_name']}' AS warehouse_name
FROM `td_stock_{$wh['warehouse_name']}` s
LEFT JOIN md_product p
ON p.company_id = s.company_id
AND p.sku = s.product_sku
WHERE s.company_id = {$this->companyId}",
$warehouses
));
$limit = (int) $limit;
$sth = $this->pdo->query(
"SELECT * FROM ({$unions}) AS all_stock
ORDER BY date DESC
LIMIT {$limit}"
);
$items = [];
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
$is_in = (float)$row['stock_in'] > 0;
$items[] = [
'product_name' => $row['product_name'] ?: $row['product_sku'],
'product_sku' => $row['product_sku'],
'warehouse_name' => $row['warehouse_name'],
'direction' => $is_in ? 'in' : 'out',
'qty' => $is_in ? (float)$row['stock_in'] : (float)$row['stock_out'],
'type' => $row['type'],
'date' => $row['date'],
];
}
return $items;
}
// WAREHOUSE OVERVIEW
public function getWarehouseCapacity(int $warehouse_id): int
{
@@ -386,7 +525,6 @@ class Reports
$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}`
@@ -461,9 +599,6 @@ class Reports
// 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
@@ -477,8 +612,6 @@ class Reports
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,
@@ -496,8 +629,6 @@ class Reports
$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,
@@ -509,7 +640,7 @@ class Reports
l.expiry_date,
DATEDIFF(l.expiry_date, CURDATE()) AS days_remaining,
p.product_name,
pc.category as category_name,
pc.category AS category_name,
mw.warehouse_name
FROM ($unions) AS stock
INNER JOIN md_lot l
@@ -537,17 +668,11 @@ class Reports
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
}
if ($days < 0) { $status = 'expired'; }
elseif ($days <= 7) { $status = 'critical'; }
elseif ($days <= 14) { $status = 'warning'; }
elseif ($days <= 30) { $status = 'caution'; }
else { continue; }
$items[] = [
'id' => (int) $row['id'],
@@ -571,7 +696,4 @@ class Reports
}
?>
@@ -23,7 +23,7 @@ class WarehouseManager {
// ─────────────────────────────────────────────────────────────
// Private helpers
// ─────────────────────────────────────────────────────────────
/**
* Resolve the td_stock_<wh> table name for a given warehouse_id.
* Returns null if warehouse not found or inactive.
@@ -145,13 +145,15 @@ class WarehouseManager {
public function adjustBalance($type, $warehouse_id, $product_sku, $old_qty, $new_qty) {
// Upsert the balance row (atomic, no race condition)
// Partitioned by month (YYYY-MM) for efficient monthly reporting
$column = $type === 'in' ? 'total_in' : 'total_out';
$delta = $new_qty - $old_qty;
$month = date('Y-m');
$sql = "INSERT INTO warehouse_balance
(company_id, warehouse_id, product_sku, `$column`)
(company_id, warehouse_id, product_sku, month, `$column`)
VALUES
(:company_id, :warehouse_id, :product_sku, :delta)
(:company_id, :warehouse_id, :product_sku, :month, :delta)
ON DUPLICATE KEY UPDATE
`$column` = `$column` + VALUES(`$column`)";
@@ -160,6 +162,7 @@ class WarehouseManager {
":company_id" => $this->company_id,
":warehouse_id" => $warehouse_id,
":product_sku" => $product_sku,
":month" => $month,
":delta" => $delta
]);
}