Files
wms-app/app/assets/utils/classes/ReportManager.php
T
2026-05-06 09:37:28 +07:00

1480 lines
58 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* ReportManager
*
* Provides all read-only reporting and dashboard data aggregation.
* Contains no write operations — purely for data retrieval and summarisation.
*
* Method order (all report basis):
* Master data summaries → category, product, warehouse, contact counts
* Stock summaries → balance, low stock, critical/warning counts
* Dashboard reports → stats, movement charts, most moved, recent activity
* Warehouse detail → capacity, space used, balance, movement, trend, activity
* Expiry reports → expired / near-expiry stock
* Lot / Rack reports → lot stock log, product lots, rack log, rack occupancy
* Balance summary → getWarehouseBalanceSummary
*
* Security: All SQL uses PDO prepared statements with bound parameters.
* Dynamic stock table names are derived only from md_warehouse.id.
*/
class ReportManager
{
private PDO $pdo;
private int $companyId;
private string $mainDb;
public function __construct(PDO $pdo, int $companyId, string $mainDb = '')
{
$this->pdo = $pdo;
$this->companyId = $companyId;
$this->mainDb = $mainDb;
}
// ─────────────────────────────────────────────────────────────
// Private helpers
// ─────────────────────────────────────────────────────────────
private function stockTableNameFromWarehouseId(int $warehouse_id): string
{
if ($warehouse_id <= 0) {
throw new Exception("Invalid warehouse id.");
}
return 'td_stock_' . $warehouse_id;
}
private function resolveWarehouseTable(int $warehouse_id): ?string
{
$sth = $this->pdo->prepare(
"SELECT id FROM md_warehouse
WHERE company_id = :company_id AND id = :id AND status = 1"
);
$sth->execute([':company_id' => $this->companyId, ':id' => $warehouse_id]);
$id = $sth->fetchColumn();
if (!$id) return null;
return $this->stockTableNameFromWarehouseId((int)$id);
}
/**
* Execute a simple SELECT COUNT(*) or scalar query and return one value.
*
* Used by the stat-count methods that only need a single integer result.
* The SQL must contain a :company_id placeholder.
*
* @param string $sql Prepared SQL with :company_id placeholder.
* @return mixed The first column of the first result row.
*/
private function fetchScalar(string $sql)
{
$sth = $this->pdo->prepare($sql);
$sth->execute([":company_id" => $this->companyId]);
return $sth->fetchColumn();
}
/**
* Execute a SELECT query and return all rows as an associative array.
*
* Used by list-style reporting methods that need multiple rows.
* The SQL must contain a :company_id placeholder.
*
* @param string $sql Prepared SQL with :company_id placeholder.
* @return array All result rows as PDO::FETCH_ASSOC arrays.
*/
private function fetchAll(string $sql): array
{
$sth = $this->pdo->prepare($sql);
$sth->execute([":company_id" => $this->companyId]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
// ─────────────────────────────────────────────────────────────
// REPORT BASIS — Master data summaries
// ─────────────────────────────────────────────────────────────
/**
* Total number of product categories (all statuses) for this company.
* Used by the reports_stats dashboard tile.
*/
public function getTotalCategory(): int
{
$sql = "SELECT COUNT(*)
FROM md_product_category
WHERE company_id = :company_id";
return (int) $this->fetchScalar($sql);
}
/**
* Number of active (status = 1) product categories for this company.
* Used by the reports_stats dashboard tile.
*/
public function getActiveCategory(): int
{
$sql = "SELECT COUNT(*)
FROM md_product_category
WHERE company_id = :company_id
AND `status` = 1";
return (int) $this->fetchScalar($sql);
}
/**
* Number of inactive (status = 0) product categories for this company.
* Used by the reports_stats dashboard tile.
*/
public function getInactiveCategory(): int
{
$sql = "SELECT COUNT(*)
FROM md_product_category
WHERE company_id = :company_id
AND `status` = 0";
return (int) $this->fetchScalar($sql);
}
/**
* Number of active warehouses (status = 1) for this company.
* Used by the reports_stats dashboard tile.
*/
public function getTotalWarehouse(): int
{
$sql = "SELECT COUNT(*)
FROM md_warehouse
WHERE company_id = :company_id
AND `status` = 1";
return (int) $this->fetchScalar($sql);
}
/**
* Number of distinct warehouse locations for this company's active warehouses.
* Used by the reports_stats dashboard tile.
*/
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 rack capacity (all racks across all warehouses) for this company.
* Used by the reports_stats dashboard tile.
*/
public function getTotalCapacity(): int
{
$sql = "SELECT COUNT(*)
FROM md_rack
WHERE company_id = :company_id";
return (int) $this->fetchScalar($sql);
}
/**
* Number of occupied racks (product_sku IS NOT NULL) for this company.
* Used by the reports_stats dashboard tile.
*/
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);
}
/**
* Total number of products (all statuses) for this company.
* Used by the reports_stats dashboard tile.
*/
public function getTotalProduct(): int
{
$sql = "SELECT COUNT(*)
FROM md_product
WHERE company_id = :company_id";
return (int) $this->fetchScalar($sql);
}
/**
* Number of distinct SKUs with a positive running balance across all warehouses.
* "In stock" means (total_in - total_out) > 0 in warehouse_balance.
* Used by the reports_stats dashboard tile.
*/
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);
}
/**
* Number of contact types (all statuses) for this company.
* Used by the reports_stats dashboard tile.
*/
public function getTotalContactCategory(): int
{
$sql = "SELECT COUNT(*)
FROM md_contact_type
WHERE company_id = :company_id";
return (int) $this->fetchScalar($sql);
}
/**
* Number of active (status = 1) contact types for this company.
* Used by the reports_stats dashboard tile.
*/
public function getActiveContactCategory(): int
{
$sql = "SELECT COUNT(*)
FROM md_contact_type
WHERE company_id = :company_id
AND `status` = 1";
return (int) $this->fetchScalar($sql);
}
/**
* Number of inactive (status = 0) contact types for this company.
* Used by the reports_stats dashboard tile.
*/
public function getInactiveContactCategory(): int
{
$sql = "SELECT COUNT(*)
FROM md_contact_type
WHERE company_id = :company_id
AND `status` = 0";
return (int) $this->fetchScalar($sql);
}
/**
* Total number of contacts for this company.
* Used by the reports_stats dashboard tile.
*/
public function getTotalContact(): int
{
$sql = "SELECT COUNT(*)
FROM md_contact
WHERE company_id = :company_id";
return (int) $this->fetchScalar($sql);
}
// ─────────────────────────────────────────────────────────────
// REPORT BASIS — Stock summaries
// ─────────────────────────────────────────────────────────────
/**
* Return aggregated total_in, total_out, and min_stock for every SKU
* across all warehouses from warehouse_balance.
*
* Used internally by getLowStockCount and as a general balance query.
*
* @return array Rows with product_sku, total_in, total_out, min_stock.
*/
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,
b.cost_price
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, b.cost_price";
return $this->fetchAll($sql);
}
/**
* Count how many SKUs are currently below their min_stock threshold.
*
* Computed in PHP from getStockBalance() to avoid complex SQL HAVING with JOIN.
*
* @return int Number of SKUs with balance < min_stock.
*/
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;
}
/**
* Return all SKUs at or below their reorder_point with full product and warehouse details.
*
* Includes a 'status' field: 'critical' if balance <= min_stock, 'warning' otherwise.
* Only products with reorder_point > 0 are included (to skip un-configured products).
*
* @return array Low/critical stock items with warehouse_name, product_name, balance, status.
*/
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,
mp.cost_price,
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, mp.cost_price
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'],
'cost_price' => (float) ($row['cost_price'] ?? 0),
'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;
}
/**
* Count SKUs with status = 'critical' (balance <= min_stock).
* Derived from getLowStockItems().
*
* @return int Number of critical stock items.
*/
public function getCriticalStockCount(): int
{
$items = $this->getLowStockItems();
return count(array_filter($items, fn($i) => $i['status'] === 'critical'));
}
/**
* Count SKUs with status = 'warning' (reorder_point < balance <= min_stock boundary).
* Derived from getLowStockItems().
*
* @return int Number of warning stock items.
*/
public function getWarningStockCount(): int
{
$items = $this->getLowStockItems();
return count(array_filter($items, fn($i) => $i['status'] === 'warning'));
}
// ─────────────────────────────────────────────────────────────
// REPORT BASIS — Dashboard reports
// ─────────────────────────────────────────────────────────────
/**
* Return aggregate stock movement stats for a specific month.
*
* Used by the main dashboard stats widget.
* $month format: 'YYYY-MM' (e.g. '2025-04').
*
* @param string $month Month in YYYY-MM format.
* @return array Keys: total_in, total_out, active_products.
*/
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
];
}
/**
* Count SKUs currently below their min_stock threshold — for the dashboard alert tile.
*
* Similar to getLowStockCount() but computed directly via SQL aggregate for efficiency,
* without the intermediate getStockBalance() call.
*
* @return int Number of SKUs with balance < min_stock.
*/
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;
}
/**
* Return monthly stock_in and stock_out totals for a rolling N-month window.
*
* Produces chart-ready arrays with labels (e.g. "Apr 2025"), stock_in, and stock_out.
* Months with no data return 0. Data is sourced from warehouse_balance (all warehouses).
*
* @param int $months Number of months to include (default 12).
* @return array Keys: labels (array), stock_in (array), stock_out (array).
*/
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];
}
/**
* Return the top N most-moved products (by total_out) for a given month.
*
* Used by the dashboard "most moved" widget.
* $limit is cast to (int) before interpolation to prevent SQL injection.
*
* @param string $month Month in YYYY-MM format.
* @param int $limit Maximum number of products to return (default 10).
* @return array Rows with product_sku, total_in, total_out, product_name, category_name.
*/
public function getMostMovedProducts(string $month, int $limit = 10): array
{
$limit = (int) $limit; // cast before interpolation
$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);
}
/**
* Return all months for which warehouse_balance data exists, newest first.
*
* Used to populate the month selector on the dashboard and reports pages.
*
* @return array Flat array of YYYY-MM strings.
*/
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);
}
/**
* Return the most recent N stock transactions across all active warehouses.
*
* Builds a UNION ALL across all td_stock_* tables to produce a unified
* activity feed ordered by date DESC. Used by the dashboard "recent activity" widget.
*
* @param int $limit Maximum number of transactions to return (default 10).
* @return array Activity rows with product_name, product_sku, warehouse_name,
* direction ('in'|'out'), qty, type, date.
*/
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(
function ($wh) {
$table = $this->stockTableNameFromWarehouseId((int)$wh['id']);
$cid = (int) $this->companyId;
$warehouse_name = $this->pdo->quote($wh['warehouse_name']);
return "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,
{$warehouse_name} AS warehouse_name
FROM `{$table}` s
LEFT JOIN md_product p
ON p.company_id = s.company_id
AND p.sku = s.product_sku
WHERE s.company_id = {$cid}
AND s.status = 1";
},
$warehouses
));
$limit = (int) $limit; // cast before interpolation
$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;
}
// ─────────────────────────────────────────────────────────────
// REPORT BASIS — Warehouse detail reports
// ─────────────────────────────────────────────────────────────
/**
* Total rack capacity for a specific warehouse (all racks regardless of occupancy).
*
* @param int $warehouse_id The warehouse to query.
* @return int Total rack count.
*/
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();
}
/**
* Number of occupied racks (product_sku IS NOT NULL) for a specific warehouse.
*
* @param int $warehouse_id The warehouse to query.
* @return int Occupied rack count.
*/
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();
}
/**
* Total in and out balance for a specific warehouse from warehouse_balance.
*
* @param int $warehouse_id The warehouse to query.
* @return array Keys: total_in, total_out.
*/
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];
}
/**
* Count SKUs below min_stock threshold for a specific warehouse.
*
* Scoped to warehouse_balance rows for this warehouse only.
*
* @param int $warehouse_id The warehouse to query.
* @return int Number of SKUs below min_stock.
*/
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;
}
/**
* Return monthly stock_in and stock_out totals for a warehouse over the last 12 months.
*
* Queries the per-warehouse td_stock_<warehouse_id> table directly (not warehouse_balance)
* for per-warehouse granularity. Produces chart-ready arrays with month labels.
*
* @param int $warehouse_id The warehouse to query.
* @return array Keys: labels, stock_in, stock_out (each a 12-element array).
*/
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 status = 1
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];
}
/**
* Return a running cumulative balance trend for a warehouse over the last 12 months.
*
* Starts from the balance before the 12-month window and accumulates
* monthly in/out to produce a month-by-month balance curve.
* Used by the warehouse overview balance trend chart.
*
* @param int $warehouse_id The warehouse to query.
* @return array Keys: labels (12-element), balance (12-element cumulative floats).
*/
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');
// Opening balance: everything before the 12-month window
$sth = $this->pdo->prepare(
"SELECT ROUND(COALESCE(SUM(`in`) - SUM(`out`), 0), 2) AS balance
FROM `{$table}`
WHERE company_id = :company_id AND status = 1 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 status = 1
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];
}
/**
* Return the 10 most recent stock movements for a warehouse.
*
* Used by the warehouse overview recent activity list.
* Includes only rows with in > 0 or out > 0 (excludes zero-quantity rows).
*
* @param int $warehouse_id The warehouse to query.
* @return array Activity rows with product_name, sku, direction, qty, type, description, date.
*/
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;
}
// ─────────────────────────────────────────────────────────────
// REPORT BASIS — Expiry reports
// ─────────────────────────────────────────────────────────────
/**
* Return all stock rows with lot numbers that are expired or expiring within 30 days.
*
* Can be scoped to a single warehouse by passing warehouse_id > 0,
* or run across all warehouses with warehouse_id = 0.
*
* Status thresholds:
* days_remaining < 0 → 'expired'
* 0–7 → 'critical'
* 8–14 → 'warning'
* 15–30 → 'caution'
* > 30 → excluded
*
* Security fix: $warehouse_id is now cast to (int) and the WHERE condition
* uses a bound parameter (:warehouse_id) instead of raw string interpolation.
* @param int $warehouse_id Warehouse filter (0 = all warehouses).
* @return array Expiry-status stock items with product, lot, location, and days_remaining.
*/
public function getExpiredStock(int $warehouse_id = 0): array
{
// Security: cast to int, use bound parameter in WHERE
$warehouse_id = (int) $warehouse_id;
if ($warehouse_id > 0) {
$sth = $this->pdo->prepare(
"SELECT id, warehouse_name FROM md_warehouse
WHERE company_id = :company_id AND status = 1 AND id = :warehouse_id"
);
$sth->execute([':company_id' => $this->companyId, ':warehouse_id' => $warehouse_id]);
} else {
$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 [];
$cid = (int) $this->companyId; // cast before interpolation
$unions = implode("\nUNION ALL\n", array_map(
function ($wh) use ($cid) {
$table = $this->stockTableNameFromWarehouseId((int)$wh['id']);
return "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 `{$table}` s
WHERE s.company_id = {$cid}
AND s.type = 'in'
AND s.lot_number IS NOT NULL
AND s.status = 1";
},
$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 = {$cid}
AND l.product_sku = stock.product_sku
AND l.lot_number = stock.lot_number
INNER JOIN md_product p
ON p.company_id = {$cid}
AND p.sku = stock.product_sku
LEFT JOIN md_product_category pc
ON pc.company_id = {$cid}
AND pc.id = p.category
INNER JOIN md_warehouse mw
ON mw.company_id = {$cid}
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;
}
// ─────────────────────────────────────────────────────────────
// REPORT BASIS — Lot / Rack reports
// ─────────────────────────────────────────────────────────────
/**
* Return all stock movements for a specific product_sku + lot_number across all warehouses.
*
* Used by the lot stock log dashboard report page.
* Results are merged from all td_stock_* tables and sorted by date DESC.
* Returns empty array if either parameter is blank.
*
* @param string $product_sku SKU to filter by.
* @param string $lot_number Lot number to filter by.
* @return array Stock rows with date, type, zone/aisle/rack, in/out amounts, warehouse_name.
*/
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) {
$table = $this->stockTableNameFromWarehouseId((int)$wh['id']);
$warehouse_name = $this->pdo->quote($wh['warehouse_name']);
$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,
{$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
AND s.status = 1
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;
}
/**
* Return all lots with running balance, expiry status, and summary counts.
*
* Used by the product lot dashboard report page.
* Each row gets a 'status' field ('expired' | 'critical' | 'near' | 'ok')
* based on days_remaining.
*
* Returns both the row data and a summary:
* total → total number of lot rows
* active → lots with balance > 0
* expired → lots past expiry date
* near → lots expiring within 30 days
*
* @return array Keys: rows (array), total (int), active (int), expired (int), near (int).
*/
public function getProductLots(): array
{
$warehouses = $this->pdo->prepare(
"SELECT id, warehouse_name FROM md_warehouse
WHERE company_id = :company_id AND status = 1"
);
$warehouses->execute([':company_id' => $this->companyId]);
$wh_list = $warehouses->fetchAll(PDO::FETCH_ASSOC);
// Build per-lot balance by summing approved td_stock rows across all warehouses.
// warehouse_balance is per-product, not per-lot, so we query td_stock directly.
$lot_balance = [];
$cid = (int) $this->companyId;
foreach ($wh_list as $wh) {
$table = $this->stockTableNameFromWarehouseId((int)$wh['id']);
$sth = $this->pdo->query(
"SELECT lot_number,
ROUND(SUM(COALESCE(`in`, 0)) - SUM(COALESCE(`out`, 0)), 2) AS lot_balance
FROM `{$table}`
WHERE company_id = {$cid}
AND status = 1
AND lot_number IS NOT NULL
GROUP BY lot_number"
);
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $lb) {
$key = $lb['lot_number'];
$lot_balance[$key] = ($lot_balance[$key] ?? 0) + (float)$lb['lot_balance'];
}
}
$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
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;
// Exclude lots that have no td_stock record at all (e.g. all rows were soft-deleted)
$rows = array_values(array_filter($rows, fn($r) => array_key_exists($r['lot_number'], $lot_balance)));
foreach ($rows as &$row) {
$days = (int)$row['days_remaining'];
$balance = round($lot_balance[$row['lot_number']] ?? 0, 2);
$row['balance'] = $balance;
$row['is_active'] = $balance > 0 ? 1 : 0;
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,
];
}
/**
* Return the action log for a specific rack, with user name.
*
* Used by the rack log dashboard report page.
* Joins the wms.user table for the acting user's name.
*
* Security fix: the database name is now fetched once via a bound query
* and stored as $db_name (string), then used as a backtick-quoted identifier
* rather than being embedded in the JOIN without escaping.
*
* @param int $rack_id The md_rack.id to fetch logs for (0 returns empty array).
* @return array Log rows with dt, action, product_sku, td_stock_id, login, user_name.
*/
public function getRackLog(int $rack_id): array
{
if (!$rack_id) return [];
$main_db = $this->mainDb;
if ($main_db === '' || !ctype_alnum(str_replace('_', '', $main_db))) {
throw new Exception("Invalid main database name.");
}
$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 `{$main_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);
}
/**
* Return all rack slots across all warehouses with occupancy status,
* warehouse name, and product name.
*
* Used by the rack occupancy dashboard report page to visualise which
* racks are empty vs occupied and which product is in each slot.
* Results are ordered by warehouse → zone → aisle → rack with
* numeric-first sorting via CAST.
*
* @return array All md_rack rows joined to warehouse and product with 'status' field.
*/
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);
}
// ─────────────────────────────────────────────────────────────
// REPORT BASIS — Balance summary
// ─────────────────────────────────────────────────────────────
/**
* Return aggregate total_in, total_out, and warehouse count across all warehouses.
*
* Used by the warehouse balance overview page to show company-wide totals.
*
* @return array Keys: total_in, total_out, total_warehouse.
*/
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) ?: [];
}
/**
* Return paginated raw stock movement transactions from td_stock_<warehouse_id>
* within a date range (by month), with a balance brought forward.
*
* Each row is one individual transaction — no grouping. Full datetime is
* returned as stored in the td_stock_* table.
*
* Brought-forward balance is sourced from warehouse_balance for all months
* strictly before from_month, giving the cumulative net before the range.
*
* Used by reports/stock_movement.php.
*
* @param int $warehouse_id Warehouse to scope the report to.
* @param string $from_month Start of range in YYYY-MM format (inclusive).
* @param string $to_month End of range in YYYY-MM format (inclusive).
* @param int $page Current page number (1-based).
* @param int $per_page Rows per page (default 20).
* @return array Keys: rows (array), total_rows (int), brought_forward (float).
*/
/**
* Return summary stats + ordered SKU list for the stock movement report.
*
* Called once when the user clicks Search. Returns:
* - stats tile values (total_transactions, total_in, total_out, net)
* - ordered list of distinct product_skus present in the date range,
* used by the client to drive lazy-load requests one SKU at a time.
*
* @param int $warehouse_id
* @param string $from_month YYYY-MM inclusive start
* @param string $to_month YYYY-MM inclusive end
* @return array Keys: skus (string[]), total_transactions, total_in, total_out, net
*/
public function getStockMovementSummary(
int $warehouse_id,
string $from_month,
string $to_month
): array {
$table = $this->resolveWarehouseTable($warehouse_id);
if (!$table) return ['skus' => [], 'total_transactions' => 0, 'total_in' => 0, 'total_out' => 0, 'net' => 0];
$date_from = $from_month . '-01 00:00:00';
$date_to = date('Y-m-t 23:59:59', strtotime($to_month . '-01'));
// Ordered distinct SKUs in the range — drives lazy-load sequence
$sth = $this->pdo->prepare(
"SELECT DISTINCT product_sku
FROM `{$table}`
WHERE company_id = :company_id
AND status = 1
AND date BETWEEN :date_from AND :date_to
ORDER BY product_sku ASC"
);
$sth->execute([':company_id' => $this->companyId, ':date_from' => $date_from, ':date_to' => $date_to]);
$skus = $sth->fetchAll(PDO::FETCH_COLUMN);
// Summary stats across full range
$sth = $this->pdo->prepare(
"SELECT
COUNT(*) AS total_transactions,
ROUND(SUM(COALESCE(`in`, 0)), 2) AS total_in,
ROUND(SUM(COALESCE(`out`, 0)), 2) AS total_out
FROM `{$table}`
WHERE company_id = :company_id
AND status = 1
AND date BETWEEN :date_from AND :date_to"
);
$sth->execute([':company_id' => $this->companyId, ':date_from' => $date_from, ':date_to' => $date_to]);
$stats = $sth->fetch(PDO::FETCH_ASSOC);
return [
'skus' => $skus,
'total_transactions' => (int) ($stats['total_transactions'] ?? 0),
'total_in' => (float) ($stats['total_in'] ?? 0),
'total_out' => (float) ($stats['total_out'] ?? 0),
'net' => round(($stats['total_in'] ?? 0) - ($stats['total_out'] ?? 0), 2),
];
}
/**
* Return all transactions for a single product_sku within the date range,
* with a brought-forward balance and a running total column.
*
* Called once per SKU by the lazy-load scroll handler.
*
* Brought-forward: sum of (in - out) for this SKU in this warehouse
* for all months strictly before from_month.
*
* Running total: cumulative (brought_forward + in - out) across rows,
* computed in PHP after the DB fetch so no window function is needed.
*
* @param int $warehouse_id
* @param string $product_sku
* @param string $from_month YYYY-MM inclusive start
* @param string $to_month YYYY-MM inclusive end
* @return array Keys: sku, product_name, brought_forward (array with in/out/balance),
* rows (each row has running_total appended)
*/
public function getStockMovementBySku(
int $warehouse_id,
string $product_sku,
string $from_month,
string $to_month
): array {
$table = $this->resolveWarehouseTable($warehouse_id);
if (!$table) return ['sku' => $product_sku, 'rows' => [], 'brought_forward' => []];
$date_from = $from_month . '-01 00:00:00';
$date_to = date('Y-m-t 23:59:59', strtotime($to_month . '-01'));
// Brought-forward: all transactions for this SKU before the range
$sth = $this->pdo->prepare(
"SELECT
ROUND(COALESCE(SUM(COALESCE(`in`, 0)), 0), 2) AS bf_in,
ROUND(COALESCE(SUM(COALESCE(`out`, 0)), 0), 2) AS bf_out
FROM `{$table}`
WHERE company_id = :company_id
AND product_sku = :sku
AND status = 1
AND date < :date_from"
);
$sth->execute([':company_id' => $this->companyId, ':sku' => $product_sku, ':date_from' => $date_from]);
$bf = $sth->fetch(PDO::FETCH_ASSOC);
$bf_in = (float)($bf['bf_in'] ?? 0);
$bf_out = (float)($bf['bf_out'] ?? 0);
$bf_bal = round($bf_in - $bf_out, 2);
// Product name + UOM
$sth = $this->pdo->prepare(
"SELECT product_name, uom FROM md_product
WHERE company_id = :company_id AND sku = :sku LIMIT 1"
);
$sth->execute([':company_id' => $this->companyId, ':sku' => $product_sku]);
$product_row = $sth->fetch(PDO::FETCH_ASSOC) ?: [];
$product_name = $product_row['product_name'] ?: $product_sku;
$product_uom = $product_row['uom'] ?? '';
// All transactions for this SKU in range, sorted by date then id.
// For stock-out rows, si.price is the purchase cost from the source stock-in (via ref_id).
// p.cost_price is the product-level fallback when si.price is 0 or ref_id is absent.
$sth = $this->pdo->prepare(
"SELECT
s.id,
s.date,
s.type,
s.product_sku,
s.lot_number,
s.serial_number,
s.zone,
s.aisle,
s.rack,
ROUND(COALESCE(s.`in`, 0), 2) AS stock_in,
ROUND(COALESCE(s.`out`, 0), 2) AS stock_out,
ROUND(s.price, 4) AS price,
ROUND(COALESCE(NULLIF(si.price, 0), p.cost_price, 0), 4) AS unit_cost,
c.contact_name,
s.description
FROM `{$table}` s
LEFT JOIN `{$table}` si
ON si.id = s.ref_id
AND s.`out` > 0
LEFT JOIN md_product p
ON p.company_id = s.company_id
AND p.sku = s.product_sku
LEFT JOIN md_contact c
ON c.company_id = s.company_id
AND c.id = s.contact_id
WHERE s.company_id = :company_id
AND s.product_sku = :sku
AND s.status = 1
AND s.date BETWEEN :date_from AND :date_to
ORDER BY s.date ASC, s.id ASC"
);
$sth->execute([':company_id' => $this->companyId, ':sku' => $product_sku, ':date_from' => $date_from, ':date_to' => $date_to]);
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
// Append running_total and margin to each row.
// Margin is only meaningful for stock-out rows with both sell price and cost.
$running = $bf_bal;
foreach ($rows as &$row) {
$running += (float)$row['stock_in'] - (float)$row['stock_out'];
$row['running_total'] = round($running, 2);
$sell = (float)$row['price'];
$cost = (float)$row['unit_cost'];
$row['margin'] = ($row['stock_out'] > 0 && $sell > 0)
? round(($sell - $cost) / $sell * 100, 2)
: null;
}
unset($row);
return [
'sku' => $product_sku,
'product_name' => $product_name,
'uom' => $product_uom,
'brought_forward' => [
'in' => $bf_in,
'out' => $bf_out,
'balance' => $bf_bal,
],
'rows' => $rows,
];
}
}