reports
This commit is contained in:
@@ -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
|
||||
]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user