From db5c47b6caa8441cd64e06c3272fd729e6d35871 Mon Sep 17 00:00:00 2001 From: Thanakorn S Date: Wed, 29 Apr 2026 17:04:11 +0700 Subject: [PATCH] Reports --- app/assets/utils/classes/ReportManager.php | 179 +++++++++ app/assets/utils/classes/StockManager.php | 40 +- app/dbconn.php | 15 + app/include_sidebar.php | 50 ++- .../api/engine_report/expired_stock.php | 4 +- .../api/engine_report/lot_stock_log.php | 0 .../api/engine_report/product_lot.php | 0 .../api/engine_report/rack_log.php | 0 .../api/engine_report/rack_occupancy.php | 0 .../api/engine_report/stock_movement.php | 23 ++ .../api/engine_report/stock_movement_sku.php | 24 ++ app/{ics => reports}/expired_stock.php | 5 +- app/{dashboard => reports}/occupy_rack.php | 4 +- app/{dashboard => reports}/product_lot.php | 4 +- app/reports/stock_movement.php | 374 ++++++++++++++++++ 15 files changed, 679 insertions(+), 43 deletions(-) rename app/{ics => reports}/api/engine_report/expired_stock.php (88%) rename app/{dashboard => reports}/api/engine_report/lot_stock_log.php (100%) rename app/{dashboard => reports}/api/engine_report/product_lot.php (100%) rename app/{dashboard => reports}/api/engine_report/rack_log.php (100%) rename app/{dashboard => reports}/api/engine_report/rack_occupancy.php (100%) create mode 100644 app/reports/api/engine_report/stock_movement.php create mode 100644 app/reports/api/engine_report/stock_movement_sku.php rename app/{ics => reports}/expired_stock.php (96%) rename app/{dashboard => reports}/occupy_rack.php (96%) rename app/{dashboard => reports}/product_lot.php (96%) create mode 100644 app/reports/stock_movement.php diff --git a/app/assets/utils/classes/ReportManager.php b/app/assets/utils/classes/ReportManager.php index dc63290..4a7f366 100644 --- a/app/assets/utils/classes/ReportManager.php +++ b/app/assets/utils/classes/ReportManager.php @@ -1250,4 +1250,183 @@ class ReportManager $sth->execute([':company_id' => $this->companyId]); 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 warehouse_balance for all months + * strictly before from_month, giving the cumulative net before the range. + * + * Used by reports/stock_movement.php. + * + * @param int $warehouse_id Warehouse to scope the report to. + * @param string $from_month Start of range in YYYY-MM format (inclusive). + * @param string $to_month End of range in YYYY-MM format (inclusive). + * @param int $page Current page number (1-based). + * @param int $per_page Rows per page (default 20). + * @return array Keys: rows (array), total_rows (int), brought_forward (float). + */ + /** + * Return summary stats + ordered SKU list for the stock movement report. + * + * Called once when the user clicks Search. Returns: + * - stats tile values (total_transactions, total_in, total_out, net) + * - ordered list of distinct product_skus present in the date range, + * used by the client to drive lazy-load requests one SKU at a time. + * + * @param int $warehouse_id + * @param string $from_month YYYY-MM inclusive start + * @param string $to_month YYYY-MM inclusive end + * @return array Keys: skus (string[]), total_transactions, total_in, total_out, net + */ + public function getStockMovementSummary( + int $warehouse_id, + string $from_month, + string $to_month + ): array { + $table = $this->resolveWarehouseTable($warehouse_id); + if (!$table) return ['skus' => [], 'total_transactions' => 0, 'total_in' => 0, 'total_out' => 0, 'net' => 0]; + + $date_from = $from_month . '-01 00:00:00'; + $date_to = date('Y-m-t 23:59:59', strtotime($to_month . '-01')); + + // Ordered distinct SKUs in the range — drives lazy-load sequence + $sth = $this->pdo->prepare( + "SELECT DISTINCT product_sku + FROM `{$table}` + WHERE company_id = :company_id + AND 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 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 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 + $sth = $this->pdo->prepare( + "SELECT product_name FROM md_product + WHERE company_id = :company_id AND sku = :sku LIMIT 1" + ); + $sth->execute([':company_id' => $this->companyId, ':sku' => $product_sku]); + $product_name = $sth->fetchColumn() ?: $product_sku; + + // All transactions for this SKU in range, sorted by date then id + $sth = $this->pdo->prepare( + "SELECT + s.id, + s.date, + s.type, + s.product_sku, + s.lot_number, + s.serial_number, + s.zone, + s.aisle, + s.rack, + ROUND(COALESCE(s.`in`, 0), 2) AS stock_in, + ROUND(COALESCE(s.`out`, 0), 2) AS stock_out, + c.contact_name, + s.description + FROM `{$table}` s + 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.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 to each row (carried forward from brought-forward balance) + $running = $bf_bal; + foreach ($rows as &$row) { + $running += (float)$row['stock_in'] - (float)$row['stock_out']; + $row['running_total'] = round($running, 2); + } + unset($row); + + return [ + 'sku' => $product_sku, + 'product_name' => $product_name, + 'brought_forward' => [ + 'in' => $bf_in, + 'out' => $bf_out, + 'balance' => $bf_bal, + ], + 'rows' => $rows, + ]; + } } \ No newline at end of file diff --git a/app/assets/utils/classes/StockManager.php b/app/assets/utils/classes/StockManager.php index 962ba7a..285da12 100644 --- a/app/assets/utils/classes/StockManager.php +++ b/app/assets/utils/classes/StockManager.php @@ -254,14 +254,15 @@ class StockManager { */ public function saveStockIn(array $data, array $logging, string $uuid): void { - $id = (int)$data['id']; - $warehouse_id = (int)$data['warehouse']; + $id = (int)($data['id'] ?? 0); + $warehouse_id = (int)($data['warehouse'] ?? 0); $whMgmt = new WarehouseManager($this->pdo, $this->company_id); $ctx = $whMgmt->getStockContext($warehouse_id, $id); $row = $ctx['row'] ?? []; $table = $ctx['table']; - $table_log = json_decode($row['log'] ?? '[]', true) ?: []; + $raw_log = $row['log'] ?? []; + $table_log = is_array($raw_log) ? $raw_log : (json_decode($raw_log, true) ?: []); $table_log[] = $logging; if ($id > 0) { @@ -276,8 +277,8 @@ class StockManager { )->execute([ ':id' => $id, ':company_id' => $this->company_id, - ':contact_id' => (int)$data['contact_id'], - ':description' => $data['description'], + ':contact_id' => (int)($data['contact_id'] ?? 0), + ':description' => $data['description'] ?? '', ':log' => json_encode($table_log), ]); @@ -316,11 +317,11 @@ class StockManager { ':zone' => $data['zone'], ':aisle' => $data['aisle'], ':rack' => $data['rack'], - ':contact_id' => (int)$data['contact_id'], - ':description' => $data['description'], + ':contact_id' => (int)($data['contact_id'] ?? 0), + ':description' => $data['description'] ?? '', ':log' => json_encode($table_log), ':lot_number' => $lot_number, - ':serial_number' => $data['serial_number'] ?: null, + ':serial_number' => ($data['serial_number'] ?? null) ?: null, ]); $td_stock_id = (int)$this->pdo->lastInsertId(); @@ -366,14 +367,15 @@ class StockManager { */ public function saveStockOut(array $data, array $logging, string $uuid): void { - $id = (int)$data['id']; - $warehouse_id = (int)$data['warehouse']; + $id = (int)($data['id'] ?? 0); + $warehouse_id = (int)($data['warehouse'] ?? 0); $whMgmt = new WarehouseManager($this->pdo, $this->company_id); $ctx = $whMgmt->getStockContext($warehouse_id, $id); $row = $ctx['row'] ?? []; $table = $ctx['table']; - $table_log = json_decode($row['log'] ?? '[]', true) ?: []; + $raw_log = $row['log'] ?? []; + $table_log = is_array($raw_log) ? $raw_log : (json_decode($raw_log, true) ?: []); $table_log[] = $logging; if ($id > 0) { @@ -388,8 +390,8 @@ class StockManager { )->execute([ ':id' => $id, ':company_id' => $this->company_id, - ':contact_id' => (int)$data['contact_id'], - ':description' => $data['description'], + ':contact_id' => (int)($data['contact_id'] ?? 0), + ':description' => $data['description'] ?? '', ':log' => json_encode($table_log), ]); @@ -447,8 +449,8 @@ class StockManager { ':zone' => $data['zone'], ':aisle' => $data['aisle'], ':rack' => $data['rack'], - ':contact_id' => (int)$data['contact_id'], - ':description' => $data['description'], + ':contact_id' => (int)($data['contact_id'] ?? 0), + ':description' => $data['description'] ?? '', ':log' => json_encode($table_log), ':ref_id' => $ref_id, ':lot_number' => $lot_number, @@ -504,7 +506,7 @@ class StockManager { $whMgmt = new WarehouseManager($this->pdo, $this->company_id); $contact_id = (int)($data['contact_id'] ?? 0); - $description = ($data['description'] ?? ''); + $description = ($data['description'] ?? '' ?? ''); if ($id > 0) { @@ -527,9 +529,11 @@ class StockManager { throw new Exception("Paired destination record missing — data integrity issue."); } - $from_log = json_decode($from_row['log'] ?? '[]', true) ?: []; + $raw_from = $from_row['log'] ?? []; + $from_log = is_array($raw_from) ? $raw_from : (json_decode($raw_from, true) ?: []); $from_log[] = $logging; - $to_log = json_decode($to_row['log'] ?? '[]', true) ?: []; + $raw_to = $to_row['log'] ?? []; + $to_log = is_array($raw_to) ? $raw_to : (json_decode($raw_to, true) ?: []); $to_log[] = $logging; $this->pdo->prepare( diff --git a/app/dbconn.php b/app/dbconn.php index 95515cd..63e8cb5 100644 --- a/app/dbconn.php +++ b/app/dbconn.php @@ -34,9 +34,18 @@ class db_statement extends PDOStatement { if (!is_array($args)) { $args = func_get_args(); }else{ + // Cast all values to string before XSS processing. + // json_decode requires a string — integers, booleans, and nulls + // passed as bound parameters would otherwise cause a TypeError. + // null is preserved as-is so PDO can bind NULL columns correctly. + $args = array_map(fn($v) => is_null($v) ? null : (string)$v, $args); + // escaping array // prevent store XSS foreach($args as &$item){ + + if (is_null($item)) continue; + // decode the JSON data // set second parameter boolean TRUE for associative array output. $result = json_decode($item); @@ -45,6 +54,12 @@ class db_statement extends PDOStatement { $tmp = json_decode($item,true); foreach((array)$tmp as &$ii){ + // Inner values may be arrays (nested JSON objects) — cast to string + if (!is_string($ii)) { + $ii = json_encode($ii); + continue; + } + $result = json_decode($ii); if (json_last_error() === JSON_ERROR_NONE) { // json inside json diff --git a/app/include_sidebar.php b/app/include_sidebar.php index 9c81471..6d0b412 100644 --- a/app/include_sidebar.php +++ b/app/include_sidebar.php @@ -14,6 +14,7 @@ - + \ No newline at end of file diff --git a/app/ics/api/engine_report/expired_stock.php b/app/reports/api/engine_report/expired_stock.php similarity index 88% rename from app/ics/api/engine_report/expired_stock.php rename to app/reports/api/engine_report/expired_stock.php index 01d2031..7901603 100644 --- a/app/ics/api/engine_report/expired_stock.php +++ b/app/reports/api/engine_report/expired_stock.php @@ -6,7 +6,9 @@ $report = new ReportManager($pdo2, $company_id); - $items = $report->getExpiredStock(); + $warehouse_id = (int)($data['warehouse_id'] ?? 0); + + $items = $report->getExpiredStock($warehouse_id); $expired = 0; $critical = 0; diff --git a/app/dashboard/api/engine_report/lot_stock_log.php b/app/reports/api/engine_report/lot_stock_log.php similarity index 100% rename from app/dashboard/api/engine_report/lot_stock_log.php rename to app/reports/api/engine_report/lot_stock_log.php diff --git a/app/dashboard/api/engine_report/product_lot.php b/app/reports/api/engine_report/product_lot.php similarity index 100% rename from app/dashboard/api/engine_report/product_lot.php rename to app/reports/api/engine_report/product_lot.php diff --git a/app/dashboard/api/engine_report/rack_log.php b/app/reports/api/engine_report/rack_log.php similarity index 100% rename from app/dashboard/api/engine_report/rack_log.php rename to app/reports/api/engine_report/rack_log.php diff --git a/app/dashboard/api/engine_report/rack_occupancy.php b/app/reports/api/engine_report/rack_occupancy.php similarity index 100% rename from app/dashboard/api/engine_report/rack_occupancy.php rename to app/reports/api/engine_report/rack_occupancy.php diff --git a/app/reports/api/engine_report/stock_movement.php b/app/reports/api/engine_report/stock_movement.php new file mode 100644 index 0000000..814ce9d --- /dev/null +++ b/app/reports/api/engine_report/stock_movement.php @@ -0,0 +1,23 @@ + $to_month) [$from_month, $to_month] = [$to_month, $from_month]; + + $report = new ReportManager($pdo2, $company_id); + + $answer['output'] = $report->getStockMovementSummary($warehouse_id, $from_month, $to_month); + $answer['success'] = 1; + exit(json_encode($answer)); +?> \ No newline at end of file diff --git a/app/reports/api/engine_report/stock_movement_sku.php b/app/reports/api/engine_report/stock_movement_sku.php new file mode 100644 index 0000000..78060f2 --- /dev/null +++ b/app/reports/api/engine_report/stock_movement_sku.php @@ -0,0 +1,24 @@ + $to_month) [$from_month, $to_month] = [$to_month, $from_month]; + + $report = new ReportManager($pdo2, $company_id); + + $answer['output'] = $report->getStockMovementBySku($warehouse_id, $product_sku, $from_month, $to_month); + $answer['success'] = 1; + exit(json_encode($answer)); +?> \ No newline at end of file diff --git a/app/ics/expired_stock.php b/app/reports/expired_stock.php similarity index 96% rename from app/ics/expired_stock.php rename to app/reports/expired_stock.php index 704c177..8688d17 100644 --- a/app/ics/expired_stock.php +++ b/app/reports/expired_stock.php @@ -202,9 +202,12 @@ if (!warehouse_id) return; return ajax_request({ - url: "ics/api/engine_report/expired_stock.php", + url: "reports/api/engine_report/expired_stock.php", autoPrepare: true, checkRequired: 0, + data: { + warehouse_id: warehouse_id + }, action: 'read', debugMode: 0, onSuccess: function(res) { diff --git a/app/dashboard/occupy_rack.php b/app/reports/occupy_rack.php similarity index 96% rename from app/dashboard/occupy_rack.php rename to app/reports/occupy_rack.php index 0a7fd7b..d346aeb 100644 --- a/app/dashboard/occupy_rack.php +++ b/app/reports/occupy_rack.php @@ -272,7 +272,7 @@ function retrieve_rack_occupancy() { return ajax_request({ - url: "dashboard/api/engine_report/rack_occupancy.php", + url: "reports/api/engine_report/rack_occupancy.php", autoPrepare: true, checkRequired: 0, action: 'read', @@ -476,7 +476,7 @@ modal.show(); ajax_request({ - url: "dashboard/api/engine_report/rack_log.php", + url: "reports/api/engine_report/rack_log.php", autoPrepare: true, checkRequired: 0, action: 'read', diff --git a/app/dashboard/product_lot.php b/app/reports/product_lot.php similarity index 96% rename from app/dashboard/product_lot.php rename to app/reports/product_lot.php index 3c2b8bb..e3c3e40 100644 --- a/app/dashboard/product_lot.php +++ b/app/reports/product_lot.php @@ -200,7 +200,7 @@ function retrieve_product_lot() { return ajax_request({ - url: "dashboard/api/engine_report/product_lot.php", + url: "reports/api/engine_report/product_lot.php", autoPrepare: true, checkRequired: 0, action: 'read', @@ -324,7 +324,7 @@ modal.show(); ajax_request({ - url: "dashboard/api/engine_report/lot_stock_log.php", + url: "reports/api/engine_report/lot_stock_log.php", autoPrepare: true, checkRequired: 0, action: 'read', diff --git a/app/reports/stock_movement.php b/app/reports/stock_movement.php new file mode 100644 index 0000000..d0250c5 --- /dev/null +++ b/app/reports/stock_movement.php @@ -0,0 +1,374 @@ + + + + + + +
+
+ + +
+
+
+
+

Stock Movement

+
+
+ + + to + + +
+
+
+
+ + +
+
+
+
+ + + +
+
—
+
Total Transactions
+
+
+
+
+
+
+
+ + + +
+
—
+
Total Stock In
+
+
+
+
+
+
+
+ + + +
+
—
+
Total Stock Out
+
+
+
+
+
+
+
+ + + +
+
—
+
Net Movement
+
+
+
+
+
+ + +
+
+
+
+ +
+

Movement Detail

+ +
+ +
+ + + + + + + + + + + + + + + + + + + +
DateTypeSKULotLocationContactStock InStock OutRunning Total
+ Select a warehouse and date range, then click Search. +
+
+ + + + +
+
+
+
+ +
+
+ + + + + + + \ No newline at end of file