This commit is contained in:
Thanakorn S
2026-04-29 17:04:11 +07:00
parent f6dc9a3278
commit db5c47b6ca
15 changed files with 679 additions and 43 deletions
+179
View File
@@ -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_<warehouse>
* 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,
];
}
}
+22 -18
View File
@@ -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(
+15
View File
@@ -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
+31 -19
View File
@@ -14,6 +14,7 @@
</div>
<ul class="nav flex-column">
<!-- Main -->
<li class="nav-text-space">
<small class="nav-text text-muted">Main</small>
@@ -26,28 +27,14 @@
</a>
</li>
<li>
<a class="nav-link" href="<?php echo $server_url?>dashboard/occupy_rack.php">
<i class="ti ti-receipt"></i>
<span class="nav-text">Occupied Racks</span>
</a>
</li>
<li>
<a class="nav-link" href="<?php echo $server_url?>dashboard/product_lot.php">
<i class="ti ti-receipt"></i>
<span class="nav-text">Product Lots</span>
</a>
</li>
<li>
<a class="nav-link" href="<?php echo $server_url?>dashboard/low_stock_products.php">
<i class="ti ti-alert-triangle"></i>
<span class="nav-text">Low Stock Products</span>
<span class="nav-text">Low Stock</span>
</a>
</li>
<!-- Product -->
<!-- Master Data -->
<li class="nav-text-space">
<small class="nav-text text-muted">Master Data</small>
</li>
@@ -106,15 +93,40 @@
</a>
</li>
<!-- Reports -->
<li class="nav-text-space">
<small class="nav-text text-muted">Reports</small>
</li>
<li>
<a class="nav-link" href="<?php echo $server_url?>ics/expired_stock.php">
<a class="nav-link" href="<?php echo $server_url?>reports/stock_movement.php">
<i class="ti ti-chart-bar"></i>
<span class="nav-text">Stock Movement</span>
</a>
</li>
<li>
<a class="nav-link" href="<?php echo $server_url?>reports/expired_stock.php">
<i class="ti ti-clock"></i>
<span class="nav-text">Expired Stock</span>
</a>
</li>
<li>
<a class="nav-link" href="<?php echo $server_url?>reports/occupy_rack.php">
<i class="ti ti-layout-grid"></i>
<span class="nav-text">Rack Occupancy</span>
</a>
</li>
<li>
<a class="nav-link" href="<?php echo $server_url?>reports/product_lot.php">
<i class="ti ti-packages"></i>
<span class="nav-text">Product Lots</span>
</a>
</li>
<li><br><br><br><br></li>
</ul>
</aside>
</aside>
@@ -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;
@@ -0,0 +1,23 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes/ReportManager.php';
$data = json_decode($_POST['json'] ?? '{}', true) ?: [];
$warehouse_id = (int)($data['warehouse_id'] ?? 0);
$from_month = $data['from_month'] ?? '';
$to_month = $data['to_month'] ?? '';
if (!$warehouse_id || !$from_month || !$to_month) {
$answer['message'] = 'warehouse_id, from_month, and to_month are required.';
exit(json_encode($answer));
}
if ($from_month > $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));
?>
@@ -0,0 +1,24 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes/ReportManager.php';
$data = json_decode($_POST['json'] ?? '{}', true) ?: [];
$warehouse_id = (int)($data['warehouse_id'] ?? 0);
$product_sku = trim($data['product_sku'] ?? '');
$from_month = $data['from_month'] ?? '';
$to_month = $data['to_month'] ?? '';
if (!$warehouse_id || !$product_sku || !$from_month || !$to_month) {
$answer['message'] = 'warehouse_id, product_sku, from_month, and to_month are required.';
exit(json_encode($answer));
}
if ($from_month > $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));
?>
@@ -202,9 +202,12 @@
if (!warehouse_id) return;
return ajax_request({
url: "<?php echo $server_url?>ics/api/engine_report/expired_stock.php",
url: "<?php echo $server_url?>reports/api/engine_report/expired_stock.php",
autoPrepare: true,
checkRequired: 0,
data: {
warehouse_id: warehouse_id
},
action: 'read',
debugMode: 0,
onSuccess: function(res) {
@@ -272,7 +272,7 @@
function retrieve_rack_occupancy() {
return ajax_request({
url: "<?php echo $server_url?>dashboard/api/engine_report/rack_occupancy.php",
url: "<?php echo $server_url?>reports/api/engine_report/rack_occupancy.php",
autoPrepare: true,
checkRequired: 0,
action: 'read',
@@ -476,7 +476,7 @@
modal.show();
ajax_request({
url: "<?php echo $server_url?>dashboard/api/engine_report/rack_log.php",
url: "<?php echo $server_url?>reports/api/engine_report/rack_log.php",
autoPrepare: true,
checkRequired: 0,
action: 'read',
@@ -200,7 +200,7 @@
function retrieve_product_lot() {
return ajax_request({
url: "<?php echo $server_url?>dashboard/api/engine_report/product_lot.php",
url: "<?php echo $server_url?>reports/api/engine_report/product_lot.php",
autoPrepare: true,
checkRequired: 0,
action: 'read',
@@ -324,7 +324,7 @@
modal.show();
ajax_request({
url: "<?php echo $server_url?>dashboard/api/engine_report/lot_stock_log.php",
url: "<?php echo $server_url?>reports/api/engine_report/lot_stock_log.php",
autoPrepare: true,
checkRequired: 0,
action: 'read',
+374
View File
@@ -0,0 +1,374 @@
<?php
session_start();
require '../config.php';
require '../include_header.php';
?>
<body>
<?php require '../include_topbar.php';?>
<?php require '../include_sidebar.php';?>
<main id="content" class="content py-15">
<div class="container-fluid">
<!-- Page header -->
<div class="row mb-5">
<div class="col-12">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-3">
<div>
<h1 class="fs-4 mb-0">Stock Movement</h1>
</div>
<div class="d-flex align-items-center gap-2 flex-nowrap">
<select id="warehouse" class="form-select form-select-sm" style="min-width:140px;max-width:180px;">
<option value="">Warehouse...</option>
</select>
<input type="month" id="from_month" class="form-control form-control-sm" style="width:150px;">
<span class="text-muted small text-nowrap">to</span>
<input type="month" id="to_month" class="form-control form-control-sm" style="width:150px;">
<button class="btn btn-primary btn-sm text-nowrap" onclick="do_search();">
<i class="ti ti-search"></i> Search
</button>
</div>
</div>
</div>
</div>
<!-- Summary stats -->
<div class="row mb-4 g-3">
<div class="col-xl-3 col-md-6 col-12">
<div class="card">
<div class="card-body p-4 d-flex align-items-center gap-3">
<span class="icon-shape icon-sm bg-primary text-white rounded-2">
<i class="ti ti-list fs-5"></i>
</span>
<div>
<div class="fs-3 fw-bold mb-0" id="stat_transactions">—</div>
<div class="text-muted small">Total Transactions</div>
</div>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6 col-12">
<div class="card">
<div class="card-body p-4 d-flex align-items-center gap-3">
<span class="icon-shape icon-sm bg-success text-white rounded-2">
<i class="ti ti-package-import fs-5"></i>
</span>
<div>
<div class="fs-3 fw-bold mb-0 text-success" id="stat_in">—</div>
<div class="text-muted small">Total Stock In</div>
</div>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6 col-12">
<div class="card">
<div class="card-body p-4 d-flex align-items-center gap-3">
<span class="icon-shape icon-sm bg-danger text-white rounded-2">
<i class="ti ti-package-export fs-5"></i>
</span>
<div>
<div class="fs-3 fw-bold mb-0 text-danger" id="stat_out">—</div>
<div class="text-muted small">Total Stock Out</div>
</div>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6 col-12">
<div class="card">
<div class="card-body p-4 d-flex align-items-center gap-3">
<span class="icon-shape icon-sm bg-warning text-white rounded-2">
<i class="ti ti-arrows-diff fs-5"></i>
</span>
<div>
<div class="fs-3 fw-bold mb-0" id="stat_net">—</div>
<div class="text-muted small">Net Movement</div>
</div>
</div>
</div>
</div>
</div>
<!-- Table -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-body p-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="fs-4 mb-0">Movement Detail</h2>
<span id="result_summary" class="text-muted small"></span>
</div>
<div class="table-responsive">
<table class="table mb-0 text-nowrap table-hover table-centered table-sm" id="tbl_movement">
<thead class="table-light border-light">
<tr>
<th>Date</th>
<th>Type</th>
<th>SKU</th>
<th>Lot</th>
<th>Location</th>
<th>Contact</th>
<th class="text-end">Stock In</th>
<th class="text-end">Stock Out</th>
<th class="text-end">Running Total</th>
</tr>
</thead>
<tbody id="tbl_body">
<tr>
<td colspan="9" class="text-center py-5 text-muted">
Select a warehouse and date range, then click Search.
</td>
</tr>
</tbody>
</table>
</div>
<!-- Lazy load sentinel + loader -->
<div id="lazy_sentinel" class="py-3 text-center text-muted small" style="display:none;">
<div id="lazy_loader"><i class="ti ti-loader-2 me-1"></i>Loading...</div>
<div id="lazy_done" style="display:none;">All products loaded.</div>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<?php require "../include_ending.php";?>
<script>
// ── State ─────────────────────────────────────────────────────────────
var sku_queue = []; // ordered list of SKUs returned by summary call
var sku_index = 0; // next SKU to load
var is_loading = false;
var search_params = {}; // warehouse_id, from_month, to_month — fixed per search
var type_badge = {
'in': '<span class="badge bg-success">In</span>',
'out': '<span class="badge bg-danger">Out</span>',
'transfer': '<span class="badge bg-info text-dark">Transfer</span>',
};
// ── Default months ────────────────────────────────────────────────────
(function() {
var now = new Date();
var month = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0');
$('#from_month').val(month);
$('#to_month').val(month);
})();
// ── Warehouse dropdown ────────────────────────────────────────────────
function retrieve_warehouse() {
return ajax_request({
url: "<?php echo $server_url?>ics/api/engine/retrieve_warehouse.php",
autoPrepare: true, checkRequired: 0, action: 'read',
onSuccess: function(res) {
var options = '';
$.each(res.output, function(i, item) {
options += `<option value="${item.id}">${item.warehouse_name}</option>`;
});
$('#warehouse').html(options);
if (res.output && res.output.length > 0) {
$('#warehouse').val(res.output[0].id);
do_search();
}
}
});
}
// ── Search — fetch summary, reset state, start lazy load ─────────────
function do_search() {
var warehouse_id = $('#warehouse').val();
var from_month = $('#from_month').val();
var to_month = $('#to_month').val();
if (!warehouse_id || !from_month || !to_month) return;
// Reset state
sku_queue = [];
sku_index = 0;
is_loading = false;
search_params = { warehouse_id: warehouse_id, from_month: from_month, to_month: to_month };
// Reset UI
$('#tbl_body').html('<tr><td colspan="9" class="text-center py-4 text-muted">Loading…</td></tr>');
$('#lazy_sentinel').hide();
$('#lazy_done').hide();
$('#lazy_loader').show();
$('#result_summary').text('');
// Reset stats
$('#stat_transactions, #stat_in, #stat_out, #stat_net').text('—');
return ajax_request({
url: "<?php echo $server_url?>reports/api/engine_report/stock_movement.php",
autoPrepare: true, checkRequired: 0, action: 'read',
data: { warehouse_id: warehouse_id, from_month: from_month, to_month: to_month },
onSuccess: function(res) {
var d = res.output;
// Populate stats
$('#stat_transactions').text(d.total_transactions);
$('#stat_in').text(fmt(d.total_in));
$('#stat_out').text(fmt(d.total_out));
var net = d.net;
$('#stat_net')
.text((net >= 0 ? '+' : '') + fmt(net))
.removeClass('text-success text-danger')
.addClass(net >= 0 ? 'text-success' : 'text-danger');
if (!d.skus || d.skus.length === 0) {
$('#tbl_body').html('<tr><td colspan="9" class="text-center py-5 text-muted">No movement data found.</td></tr>');
return;
}
sku_queue = d.skus;
sku_index = 0;
$('#tbl_body').html('');
$('#result_summary').text(d.skus.length + ' product' + (d.skus.length > 1 ? 's' : ''));
$('#lazy_sentinel').show();
// Start loading — sentinel_is_visible() will keep pulling until window is filled
load_next_sku();
}
});
}
// ── Load one SKU group, then keep going if sentinel still visible ─────
function load_next_sku() {
if (is_loading || sku_index >= sku_queue.length) {
if (sku_index >= sku_queue.length) {
$('#lazy_loader').hide();
$('#lazy_done').show();
}
return;
}
is_loading = true;
var sku = sku_queue[sku_index];
return ajax_request({
url: "<?php echo $server_url?>reports/api/engine_report/stock_movement_sku.php",
autoPrepare: true, checkRequired: 0, action: 'read',
queueLock: false,
data: {
warehouse_id : search_params.warehouse_id,
product_sku : sku,
from_month : search_params.from_month,
to_month : search_params.to_month,
},
onSuccess: function(res) {
append_sku_group(res.output);
sku_index++;
is_loading = false;
if (sku_index >= sku_queue.length) {
$('#lazy_loader').hide();
$('#lazy_done').show();
return;
}
// If sentinel is still within the viewport (or above it),
// the table hasn't filled the window yet — keep loading immediately.
if (sentinel_is_visible()) {
load_next_sku();
}
}
});
}
// ── Returns true if the sentinel is visible in the current viewport ───
function sentinel_is_visible() {
var el = document.getElementById('lazy_sentinel');
var rect = el.getBoundingClientRect();
return rect.top <= (window.innerHeight || document.documentElement.clientHeight);
}
// ── Render one SKU group into the table ───────────────────────────────
function append_sku_group(d) {
var bf = d.brought_forward;
var rows = d.rows;
// SKU header row
var header = `<tr class="table-secondary">
<td colspan="9" class="py-2 fw-semibold">
<code>${d.sku}</code>
<span class="text-muted fw-normal ms-2">${d.product_name}</span>
</td>
</tr>`;
// Brought-forward row
var bf_in_val = bf.in > 0 ? fmt(bf.in) : '<span class="text-muted">—</span>';
var bf_out_val = bf.out > 0 ? fmt(bf.out) : '<span class="text-muted">—</span>';
var bf_bal_cls = bf.balance >= 0 ? 'text-success' : 'text-danger';
var bf_row = `<tr class="table-light text-muted fst-italic">
<td class="py-2" colspan="6">Brought Forward</td>
<td class="py-2 text-end text-success">${bf_in_val}</td>
<td class="py-2 text-end text-danger">${bf_out_val}</td>
<td class="py-2 text-end fw-semibold ${bf_bal_cls}">${fmt(bf.balance)}</td>
</tr>`;
// Transaction rows
var body = '';
if (rows.length === 0) {
body = `<tr>
<td colspan="9" class="text-center py-2 text-muted fst-italic">No transactions in range.</td>
</tr>`;
} else {
$.each(rows, function(i, row) {
var in_val = row.stock_in > 0 ? fmt(row.stock_in) : '<span class="text-muted">—</span>';
var out_val = row.stock_out > 0 ? fmt(row.stock_out) : '<span class="text-muted">—</span>';
var rt_cls = row.running_total >= 0 ? 'text-success' : 'text-danger';
body += `<tr>
<td class="py-2">${row.date}</td>
<td class="py-2">${type_badge[row.type] || row.type}</td>
<td class="py-2"><code>${row.product_sku}</code></td>
<td class="py-2">${row.lot_number || '<span class="text-muted">—</span>'}</td>
<td class="py-2">${row.zone}-${row.aisle}-${row.rack}</td>
<td class="py-2">${row.contact_name || '<span class="text-muted">—</span>'}</td>
<td class="py-2 text-end text-success">${in_val}</td>
<td class="py-2 text-end text-danger">${out_val}</td>
<td class="py-2 text-end fw-semibold ${rt_cls}">${fmt(row.running_total)}</td>
</tr>`;
});
}
$('#tbl_body').append(header + bf_row + body);
}
// ── Intersection Observer for lazy load ───────────────────────────────
var observer = new IntersectionObserver(function(entries) {
if (entries[0].isIntersecting && !is_loading && sku_index < sku_queue.length) {
load_next_sku();
}
}, { rootMargin: '200px' });
observer.observe(document.getElementById('lazy_sentinel'));
// ── Helpers ───────────────────────────────────────────────────────────
function fmt(n) {
return parseFloat(n || 0).toLocaleString(undefined, {
minimumFractionDigits: 2, maximumFractionDigits: 2
});
}
// ── Boot ──────────────────────────────────────────────────────────────
$(async function() {
try { await retrieve_warehouse(); } catch(e) { console.log(e); }
});
</script>
</body>
</html>