Files
wms-app/app/assets/utils/classes/ReportManager.php
T
2026-04-28 14:35:42 +07:00

699 lines
25 KiB
PHP

<?php
class ReportManager
{
private PDO $pdo;
private int $companyId;
public function __construct(PDO $pdo, int $companyId)
{
$this->pdo = $pdo;
$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
*/
public function getTotalCategory(): int
{
$sql = "SELECT COUNT(*)
FROM md_product_category
WHERE company_id = :company_id";
return (int) $this->fetchScalar($sql);
}
public function getActiveCategory(): int
{
$sql = "SELECT COUNT(*)
FROM md_product_category
WHERE company_id = :company_id
AND `status` = 1";
return (int) $this->fetchScalar($sql);
}
public function getInactiveCategory(): int
{
$sql = "SELECT COUNT(*)
FROM md_product_category
WHERE company_id = :company_id
AND `status` = 0";
return (int) $this->fetchScalar($sql);
}
public function getTotalWarehouse(): int
{
$sql = "SELECT COUNT(*)
FROM md_warehouse
WHERE company_id = :company_id
AND `status` = 1";
return (int) $this->fetchScalar($sql);
}
public function getTotalLocation(): int
{
$sql = "SELECT COUNT(DISTINCT `location`)
FROM md_warehouse
WHERE company_id = :company_id
AND `status` = 1";
return (int) $this->fetchScalar($sql);
}
public function getTotalCapacity(): int
{
$sql = "SELECT COUNT(*)
FROM md_rack
WHERE company_id = :company_id";
return (int) $this->fetchScalar($sql);
}
public function getSpaceUsed(): int
{
$sql = "SELECT COUNT(*)
FROM md_rack
WHERE company_id = :company_id
AND product_sku IS NOT NULL";
return (int) $this->fetchScalar($sql);
}
public function getTotalProduct(): int
{
$sql = "SELECT COUNT(*)
FROM md_product
WHERE company_id = :company_id";
return (int) $this->fetchScalar($sql);
}
public function getTotalProductInStock(): int
{
$sql = "SELECT COUNT(DISTINCT product_sku)
FROM warehouse_balance
WHERE company_id = :company_id
AND (total_in - total_out) > 0";
return (int) $this->fetchScalar($sql);
}
public function getLowStockCount(): int
{
$products = $this->getStockBalance();
$count = 0;
foreach ($products as $product) {
$balance = (float) $product["total_in"] - (float) $product["total_out"];
if ($balance < (float) $product["min_stock"]) {
$count++;
}
}
return $count;
}
public function getStockBalance(): array
{
$sql = "SELECT
a.product_sku,
SUM(a.total_in) AS total_in,
SUM(a.total_out) AS total_out,
b.min_stock
FROM warehouse_balance a
LEFT JOIN md_product b
ON a.company_id = b.company_id
AND a.product_sku = b.sku
WHERE a.company_id = :company_id
GROUP BY a.product_sku, b.min_stock";
return $this->fetchAll($sql);
}
public function getLowStockItems(): array
{
$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'];
$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' => ($balance <= $min_stock) ? 'critical' : 'warning',
];
}
return $items;
}
public function getCriticalStockCount(): int
{
$items = $this->getLowStockItems();
return count(array_filter($items, fn($i) => $i['status'] === 'critical'));
}
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);
}
// 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
{
$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');
$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
{
$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 [];
$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
));
$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; }
$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;
}
}
?>