577 lines
21 KiB
PHP
577 lines
21 KiB
PHP
<?php
|
|
|
|
class Reports
|
|
{
|
|
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:
|
|
* - 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.
|
|
*/
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* 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(*)
|
|
FROM md_rack
|
|
WHERE company_id = :company_id";
|
|
return (int) $this->fetchScalar($sql);
|
|
}
|
|
|
|
/**
|
|
* Space Used = count of racks currently occupied (product_sku IS NOT NULL).
|
|
*/
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* 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(*)
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
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;
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
?>
|