1572 lines
61 KiB
PHP
1572 lines
61 KiB
PHP
<?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 $company_id;
|
||
private string $mainDb;
|
||
|
||
public function __construct(PDO $pdo, int $company_id, string $mainDb = '')
|
||
{
|
||
$this->pdo = $pdo;
|
||
$this->companyId = $company_id;
|
||
$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 etl_stock_summary.
|
||
* Used by the reports_stats dashboard tile.
|
||
*/
|
||
public function getTotalProductInStock(): int
|
||
{
|
||
$sql = "SELECT COUNT(DISTINCT product_sku)
|
||
FROM etl_stock_summary
|
||
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 etl_stock_summary.
|
||
*
|
||
* 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 etl_stock_summary 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;
|
||
}
|
||
|
||
public function getDashboardStockTotals(): 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 etl_stock_summary
|
||
WHERE company_id = :company_id"
|
||
);
|
||
$sth->execute([':company_id' => $this->companyId]);
|
||
return $sth->fetch(PDO::FETCH_ASSOC) ?: ['total_in' => 0, 'total_out' => 0];
|
||
}
|
||
|
||
public function getDashboardOrderStats(): array
|
||
{
|
||
$sth = $this->pdo->prepare(
|
||
"SELECT COUNT(*), COALESCE(SUM(subtotal), 0)
|
||
FROM td_order
|
||
WHERE company_id = :company_id
|
||
AND status != -1"
|
||
);
|
||
$sth->execute([':company_id' => $this->companyId]);
|
||
[$total_orders, $total_revenue] = $sth->fetch(PDO::FETCH_NUM) ?: [0, 0];
|
||
|
||
return [
|
||
'total_orders' => (int)$total_orders,
|
||
'total_revenue' => (float)$total_revenue,
|
||
];
|
||
}
|
||
|
||
public function getDashboardOpenDocumentCounts(): array
|
||
{
|
||
$sth = $this->pdo->prepare(
|
||
"SELECT COUNT(*) FROM td_invoice
|
||
WHERE company_id = :company_id
|
||
AND doc_type = 'invoice'
|
||
AND status IN (0, 1, 3)"
|
||
);
|
||
$sth->execute([':company_id' => $this->companyId]);
|
||
$unpaid_invoices = (int)$sth->fetchColumn();
|
||
|
||
$sth = $this->pdo->prepare(
|
||
"SELECT COUNT(*) FROM td_return
|
||
WHERE company_id = :company_id
|
||
AND status = 0"
|
||
);
|
||
$sth->execute([':company_id' => $this->companyId]);
|
||
$pending_returns = (int)$sth->fetchColumn();
|
||
|
||
return [
|
||
'unpaid_invoices' => $unpaid_invoices,
|
||
'pending_returns' => $pending_returns,
|
||
];
|
||
}
|
||
|
||
public function getDashboardStockHealth(): array
|
||
{
|
||
$low_stock_items = $this->getLowStockItems();
|
||
$critical_count = count(array_filter($low_stock_items, fn($i) => $i['status'] === 'critical'));
|
||
$warning_count = count(array_filter($low_stock_items, fn($i) => $i['status'] === 'warning'));
|
||
$total_in_stock = (int)$this->getTotalProductInStock();
|
||
$healthy_count = max(0, $total_in_stock - $critical_count - $warning_count);
|
||
|
||
return [
|
||
'low_stock' => count($low_stock_items),
|
||
'stock_health' => [
|
||
'healthy' => $healthy_count,
|
||
'warning' => $warning_count,
|
||
'critical' => $critical_count,
|
||
'total' => $total_in_stock,
|
||
],
|
||
'low_stock_items' => $low_stock_items,
|
||
];
|
||
}
|
||
|
||
public function getDashboardMasterCounts(): array
|
||
{
|
||
return [
|
||
'total_products' => (int)$this->getTotalProduct(),
|
||
'total_contacts' => (int)$this->getTotalContact(),
|
||
'total_warehouses' => (int)$this->getTotalWarehouse(),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 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 etl_stock_summary 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 etl_stock_summary
|
||
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 etl_stock_summary 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 etl_stock_summary (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 etl_stock_summary
|
||
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 etl_stock_summary 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 etl_stock_summary 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 etl_stock_summary
|
||
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 etl_stock_summary.
|
||
*
|
||
* @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 etl_stock_summary
|
||
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 etl_stock_summary 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 etl_stock_summary 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 etl_stock_summary)
|
||
* 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.product_sku,
|
||
s.lot_number,
|
||
s.zone,
|
||
s.aisle,
|
||
s.rack,
|
||
ROUND(SUM(s.`in`) - SUM(s.`out`), 2) AS quantity,
|
||
{$wh['id']} AS warehouse_id
|
||
FROM `{$table}` s
|
||
WHERE s.company_id = {$cid}
|
||
AND s.lot_number IS NOT NULL
|
||
AND s.status = 1
|
||
GROUP BY s.product_sku, s.lot_number, s.zone, s.aisle, s.rack
|
||
HAVING quantity > 0";
|
||
},
|
||
$warehouses
|
||
));
|
||
|
||
$sql = "SELECT
|
||
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[] = [
|
||
'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.
|
||
// etl_stock_summary 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 td_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 etl_stock_summary
|
||
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 etl_stock_summary 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.
|
||
// Pricing rules per movement type:
|
||
// PO in — s.price = actual purchase cost (user-defined on PO)
|
||
// sales out — s.price = selling price; unit_cost from linked stock-in via ref_id
|
||
// return in — price derived from linked SO stock-out via ref_id; no unit_cost needed
|
||
// others — s.price = standard cost from md_product.cost_price
|
||
$sth = $this->pdo->prepare(
|
||
"SELECT
|
||
s.id,
|
||
s.date,
|
||
s.type,
|
||
s.source,
|
||
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,
|
||
CASE
|
||
WHEN s.source = 'return' AND s.`in` > 0
|
||
THEN ROUND(COALESCE(NULLIF(si.price, 0), s.price, 0), 4)
|
||
ELSE
|
||
ROUND(s.price, 4)
|
||
END 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 OR s.source = 'return')
|
||
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,
|
||
];
|
||
}
|
||
}
|