modify classed and comments
This commit is contained in:
@@ -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) ?: [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user