pdo = $pdo; $this->company_id = $company_id; $this->mainDb = $mainDb; } // ───────────────────────────────────────────────────────────── // Private helpers // ───────────────────────────────────────────────────────────── private ?array $transfer_totals = null; private function stockTableNameFromWarehouseId(int $warehouse_id): string { if ($warehouse_id <= 0) { throw new Exception("Invalid warehouse id."); } return 'td_stock_' . $warehouse_id; } /** * Approved warehouse-to-warehouse transfer quantities, per month and SKU. * * A transfer is stored as an `out` row in the source warehouse and an `in` * row in the destination, and both reach etl_stock_summary, which is right * for each warehouse's balance. Company-wide "Stock In / Stock Out" figures * must leave them out: the goods were already counted when first received, * and moving them between warehouses is neither a receipt nor an issue. * * @return array [month => [sku => ['in' => float, 'out' => float]]] */ private function transferTotals(): array { if ($this->transfer_totals !== null) return $this->transfer_totals; $totals = []; $sth = $this->pdo->prepare("SELECT id FROM md_warehouse WHERE company_id = :company_id"); $sth->execute([':company_id' => $this->company_id]); foreach ($sth->fetchAll(PDO::FETCH_COLUMN) as $wh_id) { $table = $this->stockTableNameFromWarehouseId((int)$wh_id); try { $rows = $this->fetchAll( "SELECT DATE_FORMAT(`date`, '%Y-%m') AS month, product_sku, SUM(`in`) AS qty_in, SUM(`out`) AS qty_out FROM `{$table}` WHERE company_id = :company_id AND status = 1 AND type = 'transfer' GROUP BY month, product_sku" ); } catch (PDOException $e) { continue; // warehouse without a stock table yet } foreach ($rows as $r) { $slot = &$totals[$r['month']][$r['product_sku']]; $slot['in'] = ($slot['in'] ?? 0) + (float)$r['qty_in']; $slot['out'] = ($slot['out'] ?? 0) + (float)$r['qty_out']; unset($slot); } } return $this->transfer_totals = $totals; } /** Transfer in/out summed over the given month (null = all months). */ private function transferSum(?string $month = null, ?string $sku = null): array { $in = 0.0; $out = 0.0; foreach ($this->transferTotals() as $m => $by_sku) { if ($month !== null && $m !== $month) continue; foreach ($by_sku as $k => $t) { if ($sku !== null && (string)$k !== $sku) continue; $in += $t['in']; $out += $t['out']; } } return ['in' => $in, 'out' => $out]; } 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->company_id, ':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->company_id]); 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->company_id]); 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 bin capacity (all bins across all warehouses) for this company. * Used by the reports_stats dashboard tile. */ public function getTotalCapacity(): int { $sql = "SELECT COUNT(*) FROM md_bin WHERE company_id = :company_id"; return (int) $this->fetchScalar($sql); } /** * Number of occupied bins (product_sku IS NOT NULL) for this company. * Used by the reports_stats dashboard tile. */ public function getSpaceUsed(): int { $sql = "SELECT COUNT(*) FROM md_bin 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 { return count($this->getLowStockItems()); } 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->company_id]); $row = $sth->fetch(PDO::FETCH_ASSOC) ?: ['total_in' => 0, 'total_out' => 0]; $transfers = $this->transferSum(); return [ 'total_in' => round(max(0, (float)$row['total_in'] - $transfers['in']), 2), 'total_out' => round(max(0, (float)$row['total_out'] - $transfers['out']), 2), ]; } public function getDashboardOrderStats(): array { $sth = $this->pdo->prepare( // Orders are counted unless cancelled; revenue only once confirmed — // a draft or pending order is not a sale yet. "SELECT COUNT(*), COALESCE(SUM(CASE WHEN status >= 1 THEN subtotal ELSE 0 END), 0) FROM td_order WHERE company_id = :company_id AND status != -1" ); $sth->execute([':company_id' => $this->company_id]); [$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->company_id]); $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->company_id]); $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. */ /* * The one definition of "low stock", shared by the dashboard tile, the Low * Stock page, the warehouse overview tile and the daily alert: an active * product in an active warehouse whose balance there is at or below its * reorder point (or minimum stock, whichever is higher). Each screen used * to apply its own threshold and grouping, so the counts never matched. */ 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.status > 0 AND mw.status = 1 AND GREATEST(mp.reorder_point, mp.min_stock) > 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 <= GREATEST(mp.reorder_point, mp.min_stock) 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->company_id, ':month' => $month]); $row = $sth->fetch(PDO::FETCH_ASSOC) ?: [ 'total_in' => 0, 'total_out' => 0, 'active_products' => 0 ]; $transfers = $this->transferSum($month); $row['total_in'] = round(max(0, (float)$row['total_in'] - $transfers['in']), 2); $row['total_out'] = round(max(0, (float)$row['total_out'] - $transfers['out']), 2); return $row; } /** * 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->company_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 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->company_id, ':start' => $start, ':end' => $end, ]); $rows = $sth->fetchAll(PDO::FETCH_ASSOC); $dataByMonth = []; foreach ($rows as $row) { $transfers = $this->transferSum($row['month']); $row['stock_in'] = round(max(0, (float)$row['stock_in'] - $transfers['in']), 2); $row['stock_out'] = round(max(0, (float)$row['stock_out'] - $transfers['out']), 2); $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" ); $sth->execute([ ':company_id' => $this->company_id, ':month' => $month, ]); $rows = $sth->fetchAll(PDO::FETCH_ASSOC); foreach ($rows as &$row) { $transfers = $this->transferSum($month, (string)$row['product_sku']); $row['total_in'] = round(max(0, (float)$row['total_in'] - $transfers['in']), 2); $row['total_out'] = round(max(0, (float)$row['total_out'] - $transfers['out']), 2); } unset($row); $rows = array_values(array_filter($rows, fn($r) => $r['total_in'] > 0 || $r['total_out'] > 0)); usort($rows, fn($a, $b) => $b['total_out'] <=> $a['total_out'] ?: $b['total_in'] <=> $a['total_in']); return array_slice($rows, 0, $limit); } /** * 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->company_id]); 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->company_id]); $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->company_id; $warehouse_name = $this->pdo->quote($wh['warehouse_name']); return "SELECT s.date, s.product_sku, s.type, COALESCE(s.`in`, 0) AS stock_in, COALESCE(s.`out`, 0) 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 AND (s.`in` > 0 OR s.`out` > 0)"; }, $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) { // Decided on the unrounded quantity: a receipt of 0.004 used to round // to 0.00, fall through to "out" and show as -0. $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 bin capacity for a specific warehouse (all bins regardless of occupancy). * * @param int $warehouse_id The warehouse to query. * @return int Total bin count. */ public function getWarehouseCapacity(int $warehouse_id): int { $sth = $this->pdo->prepare( "SELECT COUNT(*) FROM md_bin WHERE company_id = :company_id AND warehouse = :warehouse_id" ); $sth->execute([':company_id' => $this->company_id, ':warehouse_id' => $warehouse_id]); return (int) $sth->fetchColumn(); } /** * Number of occupied bins (product_sku IS NOT NULL) for a specific warehouse. * * @param int $warehouse_id The warehouse to query. * @return int Occupied bin count. */ public function getWarehouseSpaceUsed(int $warehouse_id): int { $sth = $this->pdo->prepare( "SELECT COUNT(*) FROM md_bin WHERE company_id = :company_id AND warehouse = :warehouse_id AND product_sku IS NOT NULL" ); $sth->execute([':company_id' => $this->company_id, ':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->company_id, ':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 { return count(array_filter( $this->getLowStockItems(), fn($item) => $item['warehouse_id'] === $warehouse_id )); } /** * Return monthly stock_in and stock_out totals for a warehouse over the last 12 months. * * Queries the per-warehouse td_stock_ 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->company_id, ':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->company_id, ':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->company_id, ':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->company_id]); $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->company_id, ':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->company_id]); } $warehouses = $sth->fetchAll(PDO::FETCH_ASSOC); if (empty($warehouses)) return []; $cid = (int) $this->company_id; // 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.bin, 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.bin HAVING quantity > 0"; }, $warehouses )); $sql = "SELECT stock.product_sku, stock.lot_number, stock.zone, stock.aisle, stock.bin, 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'], 'bin' => $row['bin'], 'quantity' => (float) $row['quantity'], 'expiry_date' => $row['expiry_date'], 'days_remaining' => $days, 'status' => $status, ]; } return $items; } // ───────────────────────────────────────────────────────────── // REPORT BASIS — Lot / Bin 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/bin, 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->company_id]); $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.bin, 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->company_id, ':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->company_id]); $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->company_id; foreach ($wh_list as $wh) { $table = $this->stockTableNameFromWarehouseId((int)$wh['id']); $sth = $this->pdo->query( // Every row of the lot makes it listable; only approved rows // count towards the balance. A lot received but not yet // approved used to vanish here while the lot master showed it. // Keyed by SKU as well: two products may share a lot number. "SELECT product_sku, lot_number, ROUND(SUM(CASE WHEN status = 1 THEN COALESCE(`in`, 0) - COALESCE(`out`, 0) ELSE 0 END), 4) AS lot_balance FROM `{$table}` WHERE company_id = {$cid} AND lot_number <> '' GROUP BY product_sku, lot_number" ); foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $lb) { $key = $lb['product_sku'] . "\0" . $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->company_id]); $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) $lot_key = fn($r) => $r['product_sku'] . "\0" . $r['lot_number']; $rows = array_values(array_filter($rows, fn($r) => array_key_exists($lot_key($r), $lot_balance))); foreach ($rows as &$row) { $days = (int)$row['days_remaining']; $balance = round($lot_balance[$lot_key($row)] ?? 0, 4); $row['balance'] = $balance; $row['is_active'] = $balance > 0 ? 1 : 0; if ($balance > 0) $active++; if ($row['expiry_date'] === null || $row['expiry_date'] === '') { // No expiry recorded: not "expiring today" $row['status'] = 'ok'; continue; } 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 bin, with user name. * * Used by the bin 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 $bin_id The md_bin.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 getBinLog(int $bin_id): array { if (!$bin_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_bin_log rl LEFT JOIN `{$main_db}`.user u ON u.user_id = rl.user_id WHERE rl.company_id = :company_id AND rl.md_bin_id = :bin_id ORDER BY rl.dt DESC" ); $sth->execute([ ':company_id' => $this->company_id, ':bin_id' => $bin_id, ]); return $sth->fetchAll(PDO::FETCH_ASSOC); } /** * Return all bin slots across all warehouses with occupancy status, * warehouse name, and product name. * * Used by the bin occupancy dashboard report page to visualise which * bins are empty vs occupied and which product is in each slot. * Results are ordered by warehouse → zone → aisle → bin with * numeric-first sorting via CAST. * * @return array All md_bin 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.bin, 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_bin 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, REGEXP_REPLACE(r.zone, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(r.zone, '[0-9]+$') AS UNSIGNED), r.zone, REGEXP_REPLACE(r.aisle, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(r.aisle, '[0-9]+$') AS UNSIGNED), r.aisle, REGEXP_REPLACE(r.bin, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(r.bin, '[0-9]+$') AS UNSIGNED), r.bin" ); $sth->execute([':company_id' => $this->company_id]); 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->company_id]); return $sth->fetch(PDO::FETCH_ASSOC) ?: []; } /** * Return paginated raw stock movement transactions from td_stock_ * 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->company_id, ':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->company_id, ':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->company_id, ':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->company_id, ':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.bin, 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->company_id, ':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, ]; } }