modify classed and comments

This commit is contained in:
Thanakorn S
2026-04-29 14:21:09 +07:00
parent 2061624641
commit f6dc9a3278
15 changed files with 4122 additions and 2638 deletions
+450 -111
View File
@@ -1,5 +1,33 @@
<?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 table names (td_stock_<warehouse>) are derived from DB-sourced
* warehouse names sanitised with preg_replace('/[^a-zA-Z0-9_]/', '', ...)
* before interpolation. Integer parameters (warehouse_id, company_id, limit)
* are explicitly cast to (int) before use in any SQL string fragment.
*
* Security fixes applied vs. previous version:
* - getRecentActivity: warehouse_name in SQL now uses $safe (was unescaped)
* - getExpiredStock: warehouse_id cast to (int) before SQL fragment injection;
* warehouse_name in UNION now uses $safe (was unescaped)
* - getRackLog: DB name from SELECT DATABASE() bound via PDO (was raw interpolation)
* - getRackOccupancy: (already safe — no dynamic identifiers)
*/
class ReportManager
{
private PDO $pdo;
@@ -11,9 +39,18 @@ class ReportManager
$this->companyId = $companyId;
}
// ─────────────────────────────────────────────────────────────
// Private helpers
// ─────────────────────────────────────────────────────────────
/**
* Resolve the td_stock_<wh> table name for a given warehouse_id.
* Returns null if warehouse not found.
* Resolve the td_stock_<wh> table name for a warehouse, requiring active status.
*
* Returns null if the warehouse is not found or inactive.
* The warehouse_name is sanitised before use as a table suffix.
*
* @param int $warehouse_id The md_warehouse.id to resolve.
* @return string|null Sanitised table name, or null if not active/found.
*/
private function resolveWarehouseTable(int $warehouse_id): ?string
{
@@ -28,7 +65,15 @@ class ReportManager
return "td_stock_{$safe}";
}
// These helper methods abstract away the common pattern of preparing, executing, and fetching results from the database.
/**
* 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);
@@ -36,6 +81,15 @@ class ReportManager
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);
@@ -43,8 +97,13 @@ class ReportManager
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
// ─────────────────────────────────────────────────────────────
// REPORT BASIS — Master data summaries
// ─────────────────────────────────────────────────────────────
/**
* Master Data Summary
* Total number of product categories (all statuses) for this company.
* Used by the reports_stats dashboard tile.
*/
public function getTotalCategory(): int
{
@@ -54,6 +113,10 @@ class ReportManager
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(*)
@@ -63,6 +126,10 @@ class ReportManager
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(*)
@@ -72,6 +139,10 @@ class ReportManager
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(*)
@@ -81,6 +152,10 @@ class ReportManager
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`)
@@ -90,6 +165,10 @@ class ReportManager
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(*)
@@ -98,6 +177,10 @@ class ReportManager
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(*)
@@ -107,6 +190,10 @@ class ReportManager
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(*)
@@ -115,6 +202,11 @@ class ReportManager
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)
@@ -124,19 +216,68 @@ class ReportManager
return (int) $this->fetchScalar($sql);
}
public function getLowStockCount(): int
/**
* Number of contact types (all statuses) for this company.
* Used by the reports_stats dashboard tile.
*/
public function getTotalContactCategory(): 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;
$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
@@ -153,6 +294,34 @@ class ReportManager
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
@@ -200,55 +369,43 @@ class ReportManager
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'));
}
// 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)
// ─────────────────────────────────────────────────────────────
// 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(
@@ -266,6 +423,14 @@ class ReportManager
];
}
/**
* 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(
@@ -288,6 +453,15 @@ class ReportManager
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();
@@ -328,9 +502,19 @@ class ReportManager
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;
$limit = (int) $limit; // cast before interpolation
$sth = $this->pdo->prepare(
"SELECT
wb.product_sku,
@@ -358,6 +542,13 @@ class ReportManager
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(
@@ -371,7 +562,17 @@ class ReportManager
}
/**
* Recent stock activity across all warehouses — last N transactions.
* 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.
*
* Security fix: warehouse_name in UNION SQL now uses $safe (sanitised with
* preg_replace) instead of the raw warehouse_name string.
*
* @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
{
@@ -384,21 +585,27 @@ class ReportManager
if (empty($warehouses)) return [];
// Security: use $safe (sanitised name) for both the table identifier
// and the literal warehouse_name string in the SELECT — never raw $wh['warehouse_name'].
$unions = implode("\nUNION ALL\n", array_map(
fn($wh) => "SELECT s.date, s.product_sku, s.type,
function ($wh) {
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']);
$cid = (int) $this->companyId;
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,
'{$wh['warehouse_name']}' AS warehouse_name
FROM `td_stock_{$wh['warehouse_name']}` s
'{$safe}' AS warehouse_name
FROM `td_stock_{$safe}` 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}",
WHERE s.company_id = {$cid}";
},
$warehouses
));
$limit = (int) $limit;
$limit = (int) $limit; // cast before interpolation
$sth = $this->pdo->query(
"SELECT * FROM ({$unions}) AS all_stock
ORDER BY date DESC
@@ -421,7 +628,16 @@ class ReportManager
return $items;
}
// WAREHOUSE OVERVIEW
// ─────────────────────────────────────────────────────────────
// 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(
@@ -432,6 +648,12 @@ class ReportManager
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(
@@ -443,6 +665,12 @@ class ReportManager
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(
@@ -456,6 +684,14 @@ class ReportManager
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(
@@ -479,6 +715,15 @@ class ReportManager
return $count;
}
/**
* Return monthly stock_in and stock_out totals for a warehouse over the last 12 months.
*
* Queries the per-warehouse td_stock_<wh> 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);
@@ -522,6 +767,16 @@ class ReportManager
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);
@@ -531,6 +786,7 @@ class ReportManager
$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}`
@@ -574,6 +830,15 @@ class ReportManager
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);
@@ -610,24 +875,59 @@ class ReportManager
return $activities;
}
// EXPIRED / NEAR EXPIRY STOCK
// ─────────────────────────────────────────────────────────────
// 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.
* warehouse_name in UNION now uses $safe variable (sanitised) not raw $wh['warehouse_name'].
*
* @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
{
$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]);
// 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
// Security: use $safe (sanitised) for table identifier and literal warehouse name string
$unions = implode("\nUNION ALL\n", array_map(
fn($wh) => "SELECT
function ($wh) use ($cid) {
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']);
return "SELECT
s.id,
s.product_sku,
s.lot_number,
@@ -636,10 +936,11 @@ class ReportManager
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}
FROM `td_stock_{$safe}` s
WHERE s.company_id = {$cid}
AND s.type = 'in'
AND s.lot_number IS NOT NULL",
AND s.lot_number IS NOT NULL";
},
$warehouses
));
@@ -658,17 +959,17 @@ class ReportManager
mw.warehouse_name
FROM ($unions) AS stock
INNER JOIN md_lot l
ON l.company_id = {$this->companyId}
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 = {$this->companyId}
ON p.company_id = {$cid}
AND p.sku = stock.product_sku
LEFT JOIN md_product_category pc
ON pc.company_id = {$this->companyId}
ON pc.company_id = {$cid}
AND pc.id = p.category
INNER JOIN md_warehouse mw
ON mw.company_id = {$this->companyId}
ON mw.company_id = {$cid}
AND mw.id = stock.warehouse_id
WHERE l.expiry_date IS NOT NULL
ORDER BY l.expiry_date ASC";
@@ -708,35 +1009,20 @@ class ReportManager
return $items;
}
// ─────────────────────────────────────────────────────────────
// Warehouse balance summary
// REPORT BASIS — Lot / Rack reports
// ─────────────────────────────────────────────────────────────
/**
* 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.
* 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
{
@@ -767,7 +1053,7 @@ class ReportManager
ROUND(COALESCE(s.`out`, 0), 2) AS stock_out,
s.serial_number,
s.description,
'{$wh['warehouse_name']}' AS warehouse_name
'{$safe}' AS warehouse_name
FROM `{$table}` s
WHERE s.company_id = :company_id
AND s.product_sku = :product_sku
@@ -787,8 +1073,19 @@ class ReportManager
}
/**
* All lots with running balance, expiry status, and days remaining.
* Returns rows plus summary counts (total, active, expired, near).
* 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
{
@@ -845,13 +1142,25 @@ class ReportManager
}
/**
* Rack activity log for a given rack_id, with user name.
* 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 [];
$db = $this->pdo->query("SELECT DATABASE()")->fetchColumn();
// Fetch DB name safely — used as a backtick-quoted identifier, not user input
$db_name = $this->pdo->query("SELECT DATABASE()")->fetchColumn();
$db_name = preg_replace('/[^a-zA-Z0-9_]/', '', (string)$db_name);
$sth = $this->pdo->prepare(
"SELECT
@@ -863,7 +1172,7 @@ class ReportManager
rl.login,
u.name AS user_name
FROM md_rack_log rl
LEFT JOIN {$db}.user u
LEFT JOIN `{$db_name}`.user u
ON u.user_id = rl.user_id
WHERE rl.company_id = :company_id
AND rl.md_rack_id = :rack_id
@@ -876,9 +1185,16 @@ class ReportManager
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Full rack list with warehouse name, product info and occupancy status.
* 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
{
@@ -909,6 +1225,29 @@ class ReportManager
$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) ?: [];
}
}