914 lines
33 KiB
PHP
914 lines
33 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->prepare(
|
|
"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 = :company_id
|
|
AND date >= :start AND date <= :end
|
|
GROUP BY sort_key, label
|
|
ORDER BY sort_key ASC"
|
|
);
|
|
$sth->execute([
|
|
':company_id' => $this->companyId,
|
|
':start' => $start,
|
|
':end' => $end,
|
|
]);
|
|
|
|
$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->prepare(
|
|
"SELECT ROUND(COALESCE(SUM(`in`) - SUM(`out`), 0), 2) AS balance
|
|
FROM `{$table}`
|
|
WHERE company_id = :company_id AND date < :start"
|
|
);
|
|
$sth->execute([':company_id' => $this->companyId, ':start' => $start]);
|
|
$running = (float)$sth->fetchColumn();
|
|
|
|
$sth = $this->pdo->prepare(
|
|
"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 = :company_id
|
|
AND date >= :start AND date <= :end
|
|
GROUP BY sort_key
|
|
ORDER BY sort_key ASC"
|
|
);
|
|
$sth->execute([
|
|
':company_id' => $this->companyId,
|
|
':start' => $start,
|
|
':end' => $end,
|
|
]);
|
|
|
|
$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->prepare(
|
|
"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 = :company_id
|
|
AND (a.`in` > 0 OR a.`out` > 0)
|
|
ORDER BY a.date DESC, a.id DESC
|
|
LIMIT 10"
|
|
);
|
|
$sth->execute([':company_id' => $this->companyId]);
|
|
|
|
$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;
|
|
}
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
// Warehouse balance summary
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Aggregate total_in, total_out and warehouse count across all warehouses.
|
|
*/
|
|
public function getWarehouseBalanceSummary(): array
|
|
{
|
|
$sth = $this->pdo->prepare(
|
|
"SELECT
|
|
SUM(total_in) AS total_in,
|
|
SUM(total_out) AS total_out,
|
|
COUNT(DISTINCT warehouse_id) AS total_warehouse
|
|
FROM warehouse_balance
|
|
WHERE company_id = :company_id"
|
|
);
|
|
$sth->execute([':company_id' => $this->companyId]);
|
|
return $sth->fetch(PDO::FETCH_ASSOC) ?: [];
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
// Dashboard report queries
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* All stock movements for a given product_sku + lot_number across all warehouses,
|
|
* sorted by date DESC.
|
|
*/
|
|
public function getLotStockLog(string $product_sku, string $lot_number): array
|
|
{
|
|
if (!$product_sku || !$lot_number) return [];
|
|
|
|
$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);
|
|
|
|
$rows = [];
|
|
|
|
foreach ($warehouses as $wh) {
|
|
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']);
|
|
$table = "td_stock_{$safe}";
|
|
|
|
$sth = $this->pdo->prepare(
|
|
"SELECT
|
|
s.id,
|
|
s.date,
|
|
s.type,
|
|
s.zone,
|
|
s.aisle,
|
|
s.rack,
|
|
ROUND(COALESCE(s.`in`, 0), 2) AS stock_in,
|
|
ROUND(COALESCE(s.`out`, 0), 2) AS stock_out,
|
|
s.serial_number,
|
|
s.description,
|
|
'{$wh['warehouse_name']}' AS warehouse_name
|
|
FROM `{$table}` s
|
|
WHERE s.company_id = :company_id
|
|
AND s.product_sku = :product_sku
|
|
AND s.lot_number = :lot_number
|
|
ORDER BY s.date DESC"
|
|
);
|
|
$sth->execute([
|
|
':company_id' => $this->companyId,
|
|
':product_sku' => $product_sku,
|
|
':lot_number' => $lot_number,
|
|
]);
|
|
$rows = array_merge($rows, $sth->fetchAll(PDO::FETCH_ASSOC));
|
|
}
|
|
|
|
usort($rows, fn($a, $b) => strcmp($b['date'], $a['date']));
|
|
return $rows;
|
|
}
|
|
|
|
/**
|
|
* All lots with running balance, expiry status, and days remaining.
|
|
* Returns rows plus summary counts (total, active, expired, near).
|
|
*/
|
|
public function getProductLots(): array
|
|
{
|
|
$sth = $this->pdo->prepare(
|
|
"SELECT
|
|
l.id,
|
|
l.product_sku,
|
|
l.lot_number,
|
|
l.expiry_date,
|
|
l.description,
|
|
p.product_name,
|
|
DATEDIFF(l.expiry_date, CURDATE()) AS days_remaining,
|
|
COALESCE(
|
|
(SELECT ROUND(SUM(wb.total_in) - SUM(wb.total_out), 2)
|
|
FROM warehouse_balance wb
|
|
WHERE wb.company_id = l.company_id
|
|
AND wb.product_sku = l.product_sku),
|
|
0
|
|
) AS balance
|
|
FROM md_lot l
|
|
INNER JOIN md_product p
|
|
ON p.company_id = l.company_id
|
|
AND p.sku = l.product_sku
|
|
WHERE l.company_id = :company_id
|
|
ORDER BY l.product_sku, l.expiry_date ASC"
|
|
);
|
|
$sth->execute([':company_id' => $this->companyId]);
|
|
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
|
|
|
|
$active = $expired = $near = 0;
|
|
|
|
foreach ($rows as &$row) {
|
|
$days = (int)$row['days_remaining'];
|
|
$balance = (float)$row['balance'];
|
|
|
|
if ($balance > 0) $active++;
|
|
if ($days < 0) $expired++;
|
|
if ($days >= 0 && $days <= 30) $near++;
|
|
|
|
if ($days < 0) $row['status'] = 'expired';
|
|
elseif ($days <= 7) $row['status'] = 'critical';
|
|
elseif ($days <= 30) $row['status'] = 'near';
|
|
else $row['status'] = 'ok';
|
|
}
|
|
unset($row);
|
|
|
|
return [
|
|
'rows' => $rows,
|
|
'total' => count($rows),
|
|
'active' => $active,
|
|
'expired' => $expired,
|
|
'near' => $near,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Rack activity log for a given rack_id, with user name.
|
|
*/
|
|
public function getRackLog(int $rack_id): array
|
|
{
|
|
if (!$rack_id) return [];
|
|
|
|
$db = $this->pdo->query("SELECT DATABASE()")->fetchColumn();
|
|
|
|
$sth = $this->pdo->prepare(
|
|
"SELECT
|
|
rl.id,
|
|
rl.dt,
|
|
rl.action,
|
|
rl.product_sku,
|
|
rl.td_stock_id,
|
|
rl.login,
|
|
u.name AS user_name
|
|
FROM md_rack_log rl
|
|
LEFT JOIN {$db}.user u
|
|
ON u.user_id = rl.user_id
|
|
WHERE rl.company_id = :company_id
|
|
AND rl.md_rack_id = :rack_id
|
|
ORDER BY rl.dt DESC"
|
|
);
|
|
$sth->execute([
|
|
':company_id' => $this->companyId,
|
|
':rack_id' => $rack_id,
|
|
]);
|
|
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
|
}
|
|
|
|
|
|
/**
|
|
* Full rack list with warehouse name, product info and occupancy status.
|
|
*/
|
|
public function getRackOccupancy(): array
|
|
{
|
|
$sth = $this->pdo->prepare(
|
|
"SELECT
|
|
r.id,
|
|
r.zone,
|
|
r.aisle,
|
|
r.rack,
|
|
r.product_sku,
|
|
r.td_stock_id,
|
|
mw.warehouse_name,
|
|
mw.id AS warehouse_id,
|
|
p.product_name,
|
|
CASE WHEN r.product_sku IS NOT NULL THEN 'occupied' ELSE 'empty' END AS status
|
|
FROM md_rack r
|
|
INNER JOIN md_warehouse mw
|
|
ON mw.company_id = r.company_id
|
|
AND mw.id = r.warehouse
|
|
LEFT JOIN md_product p
|
|
ON p.company_id = r.company_id
|
|
AND p.sku = r.product_sku
|
|
WHERE r.company_id = :company_id
|
|
ORDER BY mw.warehouse_name, r.zone,
|
|
CAST(r.aisle AS UNSIGNED), r.aisle,
|
|
CAST(r.rack AS UNSIGNED), r.rack"
|
|
);
|
|
$sth->execute([':company_id' => $this->companyId]);
|
|
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
|
}
|
|
}
|
|
|
|
?>
|