From ba96de50a1be44f165df1d15436c727345b9bfe0 Mon Sep 17 00:00:00 2001
From: Thanakorn S
Date: Mon, 25 May 2026 11:28:47 +0700
Subject: [PATCH] stock aggregate table
---
.../utils/classes/CompanySettingManager.php | 2 +-
app/assets/utils/classes/EtlStockManager.php | 227 ++++++++++++++++
app/assets/utils/classes/ProductManager.php | 4 +-
app/assets/utils/classes/ReportManager.php | 42 +--
app/assets/utils/classes/ReturnManager.php | 2 +-
app/assets/utils/classes/StockManager.php | 14 +-
app/assets/utils/classes/WarehouseManager.php | 24 +-
app/assets/utils/classes_ac/EtlManager.php | 2 +-
app/assets/utils/db_auth.php | 8 +
app/cron/etl_stock_maintenance.php | 41 +++
app/ics/api/engine/approve_stock.php | 2 +-
app/include_setting_sidebar.php | 8 +
app/login/api/engine/login_confirm.php | 16 +-
app/login/api/engine/login_otp.php | 3 +
app/setting/api/engine/gl_maintenance.php | 7 +
app/setting/api/engine/stock_maintenance.php | 65 +++++
app/setting/gl_maintenance.php | 27 ++
app/setting/stock_maintenance.php | 248 ++++++++++++++++++
nodejs/scheduler.js | 26 +-
setup.php | 19 +-
20 files changed, 725 insertions(+), 62 deletions(-)
create mode 100644 app/assets/utils/classes/EtlStockManager.php
create mode 100644 app/cron/etl_stock_maintenance.php
create mode 100644 app/setting/api/engine/stock_maintenance.php
create mode 100644 app/setting/stock_maintenance.php
diff --git a/app/assets/utils/classes/CompanySettingManager.php b/app/assets/utils/classes/CompanySettingManager.php
index 5b821d3..fc64e3d 100644
--- a/app/assets/utils/classes/CompanySettingManager.php
+++ b/app/assets/utils/classes/CompanySettingManager.php
@@ -229,7 +229,7 @@ class CompanySettingManager
private function hasStockTransactions(): bool
{
- if ($this->tableHasCompanyRows('warehouse_balance')) {
+ if ($this->tableHasCompanyRows('etl_stock_summary')) {
return true;
}
diff --git a/app/assets/utils/classes/EtlStockManager.php b/app/assets/utils/classes/EtlStockManager.php
new file mode 100644
index 0000000..3bdba9d
--- /dev/null
+++ b/app/assets/utils/classes/EtlStockManager.php
@@ -0,0 +1,227 @@
+
+ * by (company_id, warehouse_id, product_sku, month). Mirrors EtlManager for GL.
+ *
+ * etl_stock_summary is maintained two ways:
+ * 1. Write-through — adjustBalance() in WarehouseManager updates it on every approve/delete.
+ * 2. Batch repair — checkAndRepair() scans td_stock_* for drift and rebuilds stale months.
+ *
+ * Gap detection is timestamp-based (identical to GL):
+ * A month is stale when any td_stock_N row (updated_at) is newer than the
+ * oldest etl_stock_summary entry for that warehouse+month (source_updated_at).
+ *
+ * checkAndRepair() — scan all warehouses, rebuild stale months, save status
+ * getGaps() — return [['warehouse_id'=>N,'period'=>'YYYY-MM'], ...]
+ * rebuildPeriod(wh_id, period) — DELETE + reinsert one (warehouse, month) slice
+ * rebuildFull(wh_id) — DELETE + rebuild all months for one warehouse
+ * getDistinctPeriods(wh_id) — distinct approved months in td_stock_N
+ * getStatus() — read etl_stock_* keys from company_setting
+ */
+class EtlStockManager
+{
+ private PDO $pdo;
+ private PDO $pdo1;
+ private int $companyId;
+
+ public function __construct(PDO $pdo, PDO $pdo1, int $company_id)
+ {
+ $this->pdo = $pdo;
+ $this->pdo1 = $pdo1;
+ $this->companyId = $company_id;
+ }
+
+ // ── Public ───────────────────────────────────────────────────────────────
+
+ public function checkAndRepair(): array
+ {
+ $gaps = $this->getGaps();
+
+ foreach ($gaps as $gap) {
+ $this->rebuildPeriod($gap['warehouse_id'], $gap['period']);
+ }
+
+ $gaps_found = count($gaps);
+ $periods_rebuilt = $gaps_found;
+ $this->saveStatus('ok', $gaps_found, $periods_rebuilt);
+
+ return ['gaps_found' => $gaps_found, 'periods_rebuilt' => $periods_rebuilt];
+ }
+
+ public function getGaps(): array
+ {
+ $stock_tables = $this->discoverStockTables();
+ $gaps = [];
+
+ foreach ($stock_tables as $table) {
+ $warehouse_id = (int)substr($table, strlen('td_stock_'));
+
+ $sth = $this->pdo->prepare("
+ SELECT DISTINCT DATE_FORMAT(s.date, '%Y-%m') AS period
+ FROM `{$table}` s
+ LEFT JOIN (
+ SELECT month, MAX(source_updated_at) AS etl_ts
+ FROM etl_stock_summary
+ WHERE company_id = :cid2 AND warehouse_id = :wh2
+ GROUP BY month
+ ) etl ON etl.month = DATE_FORMAT(s.date, '%Y-%m')
+ WHERE s.company_id = :cid
+ AND s.status = 1
+ AND (etl.month IS NULL OR s.updated_at > etl.etl_ts)
+ ORDER BY period
+ ");
+ $sth->execute([
+ ':cid' => $this->companyId,
+ ':cid2' => $this->companyId,
+ ':wh2' => $warehouse_id,
+ ]);
+
+ foreach ($sth->fetchAll(PDO::FETCH_COLUMN) as $period) {
+ $gaps[] = ['warehouse_id' => $warehouse_id, 'period' => $period];
+ }
+ }
+
+ return $gaps;
+ }
+
+ public function rebuildPeriod(int $warehouse_id, string $period): void
+ {
+ $table = 'td_stock_' . $warehouse_id;
+
+ $this->pdo->prepare(
+ "DELETE FROM etl_stock_summary
+ WHERE company_id = :cid AND warehouse_id = :wh AND month = :month"
+ )->execute([':cid' => $this->companyId, ':wh' => $warehouse_id, ':month' => $period]);
+
+ $this->pdo->prepare("
+ INSERT INTO etl_stock_summary
+ (company_id, warehouse_id, product_sku, month, total_in, total_out, source_updated_at)
+ SELECT
+ company_id,
+ :wh,
+ product_sku,
+ DATE_FORMAT(`date`, '%Y-%m') AS month,
+ ROUND(SUM(`in`), 4) AS total_in,
+ ROUND(SUM(`out`), 4) AS total_out,
+ MAX(updated_at) AS source_updated_at
+ FROM `{$table}`
+ WHERE company_id = :cid
+ AND status = 1
+ AND DATE_FORMAT(`date`, '%Y-%m') = :month
+ GROUP BY company_id, product_sku, DATE_FORMAT(`date`, '%Y-%m')
+ ")->execute([':cid' => $this->companyId, ':wh' => $warehouse_id, ':month' => $period]);
+ }
+
+ public function rebuildFull(int $warehouse_id): array
+ {
+ $table = 'td_stock_' . $warehouse_id;
+
+ $this->pdo->prepare(
+ "DELETE FROM etl_stock_summary WHERE company_id = :cid AND warehouse_id = :wh"
+ )->execute([':cid' => $this->companyId, ':wh' => $warehouse_id]);
+
+ $this->pdo->prepare("
+ INSERT INTO etl_stock_summary
+ (company_id, warehouse_id, product_sku, month, total_in, total_out, source_updated_at)
+ SELECT
+ company_id,
+ :wh,
+ product_sku,
+ DATE_FORMAT(`date`, '%Y-%m') AS month,
+ ROUND(SUM(`in`), 4) AS total_in,
+ ROUND(SUM(`out`), 4) AS total_out,
+ MAX(updated_at) AS source_updated_at
+ FROM `{$table}`
+ WHERE company_id = :cid AND status = 1
+ GROUP BY company_id, product_sku, DATE_FORMAT(`date`, '%Y-%m')
+ ")->execute([':cid' => $this->companyId, ':wh' => $warehouse_id]);
+
+ $sth = $this->pdo->prepare(
+ "SELECT COUNT(DISTINCT month) FROM etl_stock_summary
+ WHERE company_id = :cid AND warehouse_id = :wh"
+ );
+ $sth->execute([':cid' => $this->companyId, ':wh' => $warehouse_id]);
+ $periods_rebuilt = (int)$sth->fetchColumn();
+
+ $this->saveStatus('ok', 0, $periods_rebuilt);
+
+ return ['periods_rebuilt' => $periods_rebuilt];
+ }
+
+ public function getDistinctPeriods(int $warehouse_id): array
+ {
+ $table = 'td_stock_' . $warehouse_id;
+ $sth = $this->pdo->prepare(
+ "SELECT DISTINCT DATE_FORMAT(`date`, '%Y-%m') AS period
+ FROM `{$table}`
+ WHERE company_id = :cid AND status = 1
+ ORDER BY period"
+ );
+ $sth->execute([':cid' => $this->companyId]);
+ return $sth->fetchAll(PDO::FETCH_COLUMN);
+ }
+
+ public function getStatus(): array
+ {
+ $keys = [
+ 'etl_stock_last_ran',
+ 'etl_stock_last_result',
+ 'etl_stock_gaps_found',
+ 'etl_stock_periods_rebuilt',
+ ];
+ $sth = $this->pdo1->prepare(
+ "SELECT setting_key, value FROM company_setting
+ WHERE company_id = :cid AND setting_key IN ('" . implode("','", $keys) . "')"
+ );
+ $sth->execute([':cid' => $this->companyId]);
+ $rows = $sth->fetchAll(PDO::FETCH_KEY_PAIR);
+
+ $slot_hour = $this->companyId % 24;
+ $last_ran = $rows['etl_stock_last_ran'] ?? null;
+ $next_run = $last_ran
+ ? date('Y-m-d', strtotime($last_ran . ' +1 day')) . sprintf(' %02d:30', $slot_hour)
+ : date('Y-m-d') . sprintf(' %02d:30', $slot_hour);
+
+ return [
+ 'slot_hour' => $slot_hour,
+ 'last_ran' => $last_ran,
+ 'last_result' => $rows['etl_stock_last_result'] ?? null,
+ 'gaps_found' => (int)($rows['etl_stock_gaps_found'] ?? 0),
+ 'periods_rebuilt' => (int)($rows['etl_stock_periods_rebuilt'] ?? 0),
+ 'next_run' => $next_run,
+ ];
+ }
+
+ // ── Private ──────────────────────────────────────────────────────────────
+
+ private function discoverStockTables(): array
+ {
+ $sth = $this->pdo->prepare(
+ "SELECT table_name FROM information_schema.tables
+ WHERE table_schema = DATABASE() AND table_name LIKE 'td_stock_%'"
+ );
+ $sth->execute();
+ return $sth->fetchAll(PDO::FETCH_COLUMN);
+ }
+
+ private function saveStatus(string $result, int $gaps_found, int $periods_rebuilt): void
+ {
+ $now = date('Y-m-d H:i:s');
+ $data = [
+ 'etl_stock_last_ran' => $now,
+ 'etl_stock_last_result' => $result,
+ 'etl_stock_gaps_found' => (string)$gaps_found,
+ 'etl_stock_periods_rebuilt' => (string)$periods_rebuilt,
+ ];
+ $sth = $this->pdo1->prepare(
+ "INSERT INTO company_setting (company_id, setting_key, value, updated_at)
+ VALUES (:cid, :key, :val, :ts)
+ ON DUPLICATE KEY UPDATE value = VALUES(value), updated_at = VALUES(updated_at)"
+ );
+ foreach ($data as $key => $val) {
+ $sth->execute([':cid' => $this->companyId, ':key' => $key, ':val' => $val, ':ts' => $now]);
+ }
+ }
+}
diff --git a/app/assets/utils/classes/ProductManager.php b/app/assets/utils/classes/ProductManager.php
index ba384a1..130bb54 100644
--- a/app/assets/utils/classes/ProductManager.php
+++ b/app/assets/utils/classes/ProductManager.php
@@ -274,7 +274,7 @@ class ProductManager {
* Return all products with their current aggregate warehouse balance.
*
* The balance is the sum of (total_in - total_out) across all warehouses
- * from the warehouse_balance table. Products with no balance rows show 0.
+ * from the etl_stock_summary table. Products with no balance rows show 0.
*
* Used to populate the product listing page.
*
@@ -285,7 +285,7 @@ class ProductManager {
$sth = $this->pdo->prepare(
"SELECT a.*, IFNULL(SUM(b.total_in - b.total_out), 0) AS product_balance
FROM md_product a
- LEFT JOIN warehouse_balance b
+ LEFT JOIN etl_stock_summary b
ON a.company_id = b.company_id
AND a.sku = b.product_sku
WHERE a.company_id = :company_id
diff --git a/app/assets/utils/classes/ReportManager.php b/app/assets/utils/classes/ReportManager.php
index 3ddef2a..3877dbf 100644
--- a/app/assets/utils/classes/ReportManager.php
+++ b/app/assets/utils/classes/ReportManager.php
@@ -197,13 +197,13 @@ class ReportManager
/**
* Number of distinct SKUs with a positive running balance across all warehouses.
- * "In stock" means (total_in - total_out) > 0 in warehouse_balance.
+ * "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 warehouse_balance
+ FROM etl_stock_summary
WHERE company_id = :company_id
AND (total_in - total_out) > 0";
return (int) $this->fetchScalar($sql);
@@ -265,7 +265,7 @@ class ReportManager
/**
* Return aggregated total_in, total_out, and min_stock for every SKU
- * across all warehouses from warehouse_balance.
+ * across all warehouses from etl_stock_summary.
*
* Used internally by getLowStockCount and as a general balance query.
*
@@ -279,7 +279,7 @@ class ReportManager
SUM(a.total_out) AS total_out,
b.min_stock,
b.cost_price
- FROM warehouse_balance a
+ FROM etl_stock_summary a
LEFT JOIN md_product b
ON a.company_id = b.company_id
AND a.product_sku = b.sku
@@ -314,7 +314,7 @@ class ReportManager
"SELECT
ROUND(COALESCE(SUM(total_in), 0), 2) AS total_in,
ROUND(COALESCE(SUM(total_out), 0), 2) AS total_out
- FROM warehouse_balance
+ FROM etl_stock_summary
WHERE company_id = :company_id"
);
$sth->execute([':company_id' => $this->companyId]);
@@ -412,7 +412,7 @@ class ReportManager
mp.product_image,
mp.cost_price,
ROUND(SUM(wb.total_in) - SUM(wb.total_out), 2) AS balance
- FROM warehouse_balance wb
+ FROM etl_stock_summary wb
INNER JOIN md_product mp
ON wb.company_id = mp.company_id
AND wb.product_sku = mp.sku
@@ -493,7 +493,7 @@ class ReportManager
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 warehouse_balance
+ FROM etl_stock_summary
WHERE company_id = :company_id
AND month = :month"
);
@@ -517,7 +517,7 @@ class ReportManager
"SELECT wb.product_sku,
ROUND(SUM(wb.total_in) - SUM(wb.total_out), 2) AS balance,
mp.min_stock
- FROM warehouse_balance wb
+ FROM etl_stock_summary wb
INNER JOIN md_product mp
ON mp.company_id = wb.company_id
AND mp.sku = wb.product_sku
@@ -537,7 +537,7 @@ class ReportManager
* Return monthly stock_in and stock_out totals for a rolling N-month window.
*
* Produces chart-ready arrays with labels (e.g. "Apr 2025"), stock_in, and stock_out.
- * Months with no data return 0. Data is sourced from warehouse_balance (all warehouses).
+ * 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).
@@ -553,7 +553,7 @@ class ReportManager
month,
ROUND(SUM(total_in), 2) AS stock_in,
ROUND(SUM(total_out), 2) AS stock_out
- FROM warehouse_balance
+ FROM etl_stock_summary
WHERE company_id = :company_id
AND month BETWEEN :start AND :end
GROUP BY month
@@ -602,7 +602,7 @@ class ReportManager
ROUND(SUM(wb.total_out), 2) AS total_out,
p.product_name,
pc.category AS category_name
- FROM warehouse_balance wb
+ FROM etl_stock_summary wb
INNER JOIN md_product p
ON p.company_id = wb.company_id
AND p.sku = wb.product_sku
@@ -623,7 +623,7 @@ class ReportManager
}
/**
- * Return all months for which warehouse_balance data exists, newest first.
+ * Return all months for which etl_stock_summary data exists, newest first.
*
* Used to populate the month selector on the dashboard and reports pages.
*
@@ -633,7 +633,7 @@ class ReportManager
{
$sth = $this->pdo->prepare(
"SELECT DISTINCT month
- FROM warehouse_balance
+ FROM etl_stock_summary
WHERE company_id = :company_id
ORDER BY month DESC"
);
@@ -743,7 +743,7 @@ class ReportManager
}
/**
- * Total in and out balance for a specific warehouse from warehouse_balance.
+ * 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.
@@ -754,7 +754,7 @@ class ReportManager
"SELECT
ROUND(COALESCE(SUM(total_in), 0), 2) AS total_in,
ROUND(COALESCE(SUM(total_out), 0), 2) AS total_out
- FROM warehouse_balance
+ FROM etl_stock_summary
WHERE company_id = :company_id AND warehouse_id = :warehouse_id"
);
$sth->execute([':company_id' => $this->companyId, ':warehouse_id' => $warehouse_id]);
@@ -764,7 +764,7 @@ class ReportManager
/**
* Count SKUs below min_stock threshold for a specific warehouse.
*
- * Scoped to warehouse_balance rows for this warehouse only.
+ * 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.
@@ -775,7 +775,7 @@ class ReportManager
"SELECT wb.product_sku,
ROUND(SUM(wb.total_in) - SUM(wb.total_out), 2) AS balance,
mp.min_stock
- FROM warehouse_balance wb
+ FROM etl_stock_summary wb
INNER JOIN md_product mp
ON mp.company_id = wb.company_id
AND mp.sku = wb.product_sku
@@ -795,7 +795,7 @@ class ReportManager
/**
* 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 warehouse_balance)
+ * 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.
@@ -1173,7 +1173,7 @@ class ReportManager
$wh_list = $warehouses->fetchAll(PDO::FETCH_ASSOC);
// Build per-lot balance by summing approved td_stock rows across all warehouses.
- // warehouse_balance is per-product, not per-lot, so we query td_stock directly.
+ // etl_stock_summary is per-product, not per-lot, so we query td_stock directly.
$lot_balance = [];
$cid = (int) $this->companyId;
@@ -1350,7 +1350,7 @@ class ReportManager
SUM(total_in) AS total_in,
SUM(total_out) AS total_out,
COUNT(DISTINCT warehouse_id) AS total_warehouse
- FROM warehouse_balance
+ FROM etl_stock_summary
WHERE company_id = :company_id"
);
$sth->execute([':company_id' => $this->companyId]);
@@ -1364,7 +1364,7 @@ class ReportManager
* 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
+ * 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.
diff --git a/app/assets/utils/classes/ReturnManager.php b/app/assets/utils/classes/ReturnManager.php
index 1a03cad..a9fe44f 100644
--- a/app/assets/utils/classes/ReturnManager.php
+++ b/app/assets/utils/classes/ReturnManager.php
@@ -401,7 +401,7 @@ class ReturnManager {
* 1. INSERT td_stock_ in row with type='in', status=1 (force confirmed),
* source='return', source_id=return_id.
* 2. occupyRack() — place returned stock into the user-selected empty rack.
- * 3. adjustBalance() — update warehouse_balance.
+ * 3. adjustBalance() — update etl_stock_summary.
*
* After all items:
* 4. UPDATE td_return.status = 1 (confirmed).
diff --git a/app/assets/utils/classes/StockManager.php b/app/assets/utils/classes/StockManager.php
index 82b3997..5f9f227 100644
--- a/app/assets/utils/classes/StockManager.php
+++ b/app/assets/utils/classes/StockManager.php
@@ -217,7 +217,7 @@ class StockManager {
* 1. Upserts md_lot if lot_number + expiry_date are provided.
* 2. Inserts the td_stock_ row.
* 3. Calls WarehouseManager::occupyRack() to reserve the rack.
- * warehouse_balance is updated later by approveStock().
+ * etl_stock_summary is updated later by approveStock().
*
* Update flow (id > 0):
* - Updates contact_id, description, and log only.
@@ -338,7 +338,7 @@ class StockManager {
* 1. Validates the rack is occupied with the correct SKU / lot / serial.
* 2. Inserts the td_stock_ row, copying quantity and lot info from the rack.
* 3. Calls WarehouseManager::releaseRack() to free the rack slot.
- * 4. Calls WarehouseManager::adjustBalance() to update warehouse_balance.
+ * 4. Calls WarehouseManager::adjustBalance() to update etl_stock_summary.
*
* Update flow (id > 0):
* - Updates contact_id, description, and log only.
@@ -675,7 +675,7 @@ class StockManager {
// ─────────────────────────────────────────────────────────────
/**
- * Approve a draft td_stock row (status 0 → 1) and update warehouse_balance.
+ * Approve a draft td_stock row (status 0 → 1) and update etl_stock_summary.
*
* This is the single entry point for balance updates — both auto-approve
* (called immediately after save) and manual approve go through here.
@@ -729,7 +729,7 @@ class StockManager {
$id
);
$whMgmt->adjustBalance('in', $warehouse_id, $row['product_sku'], 0, (float)$row['in'],
- $id, $row['source'] ?? '', (int)($row['source_id'] ?? 0));
+ $id, $row['source'] ?? '', (int)($row['source_id'] ?? 0), $row['date'] ?? '');
} elseif ($type === 'out') {
@@ -759,7 +759,7 @@ class StockManager {
$whMgmt->releaseRack($warehouse_id, $row['zone'], $row['aisle'], $row['rack']);
}
$whMgmt->adjustBalance('out', $warehouse_id, $row['product_sku'], 0, (float)$row['out'],
- $id, $row['source'] ?? '', (int)($row['source_id'] ?? 0));
+ $id, $row['source'] ?? '', (int)($row['source_id'] ?? 0), $row['date'] ?? '');
} elseif ($type === 'transfer') {
@@ -843,7 +843,7 @@ class StockManager {
$from_row['zone'], $from_row['aisle'], $from_row['rack']
);
$whMgmt->adjustBalance('out', $from_wh_id, $from_row['product_sku'], 0, (float)$from_row['out'],
- (int)$from_row['id'], $from_row['source'] ?? '', (int)($from_row['source_id'] ?? 0));
+ (int)$from_row['id'], $from_row['source'] ?? '', (int)($from_row['source_id'] ?? 0), $from_row['date'] ?? '');
}
if ($inbound_row && $paired_wh_id) {
$whMgmt->occupyRack(
@@ -853,7 +853,7 @@ class StockManager {
$inbound_row['id']
);
$whMgmt->adjustBalance('in', $paired_wh_id, $inbound_row['product_sku'], 0, (float)$inbound_row['in'],
- (int)$inbound_row['id'], $inbound_row['source'] ?? '', (int)($inbound_row['source_id'] ?? 0));
+ (int)$inbound_row['id'], $inbound_row['source'] ?? '', (int)($inbound_row['source_id'] ?? 0), $inbound_row['date'] ?? '');
}
}
}
diff --git a/app/assets/utils/classes/WarehouseManager.php b/app/assets/utils/classes/WarehouseManager.php
index 14d7434..c242378 100644
--- a/app/assets/utils/classes/WarehouseManager.php
+++ b/app/assets/utils/classes/WarehouseManager.php
@@ -1831,7 +1831,7 @@ class WarehouseManager {
// ─────────────────────────────────────────────────────────────
/**
- * Adjust the running balance for a (warehouse, product_sku) pair in warehouse_balance.
+ * Adjust the running balance for a (warehouse, product_sku) pair in etl_stock_summary.
*
* Uses an INSERT ... ON DUPLICATE KEY UPDATE to atomically upsert the balance row,
* partitioned by month (YYYY-MM) for efficient monthly reporting queries.
@@ -1851,19 +1851,21 @@ class WarehouseManager {
*/
public function adjustBalance(
$type, $warehouse_id, $product_sku, $old_qty, $new_qty,
- int $stock_id = 0, string $source = '', int $source_id = 0
+ int $stock_id = 0, string $source = '', int $source_id = 0,
+ string $stock_date = ''
): void {
$column = $type === 'in' ? 'total_in' : 'total_out';
$delta = $new_qty - $old_qty;
- $month = date('Y-m');
+ $month = $stock_date ? date('Y-m', strtotime($stock_date)) : date('Y-m');
$this->pdo->prepare(
- "INSERT INTO warehouse_balance
- (company_id, warehouse_id, product_sku, month, `{$column}`)
+ "INSERT INTO etl_stock_summary
+ (company_id, warehouse_id, product_sku, month, `{$column}`, source_updated_at)
VALUES
- (:company_id, :warehouse_id, :product_sku, :month, :delta)
+ (:company_id, :warehouse_id, :product_sku, :month, :delta, NOW())
ON DUPLICATE KEY UPDATE
- `{$column}` = `{$column}` + VALUES(`{$column}`)"
+ `{$column}` = `{$column}` + VALUES(`{$column}`),
+ source_updated_at = NOW()"
)->execute([
':company_id' => $this->company_id,
':warehouse_id' => $warehouse_id,
@@ -2173,7 +2175,7 @@ class WarehouseManager {
);
if ((int)$row['status'] === 1) {
- $this->adjustBalance('in', $warehouse_id, $product_sku, (int)$row['in'], 0);
+ $this->adjustBalance('in', $warehouse_id, $product_sku, (int)$row['in'], 0, 0, '', 0, $row['date'] ?? '');
}
}
@@ -2235,7 +2237,7 @@ class WarehouseManager {
[(int)$row['ref_id']],
$row['date']
);
- $this->adjustBalance('out', $warehouse_id, $product_sku, (int)$row['out'], 0);
+ $this->adjustBalance('out', $warehouse_id, $product_sku, (int)$row['out'], 0, 0, '', 0, $row['date'] ?? '');
}
}
@@ -2334,8 +2336,8 @@ class WarehouseManager {
// Reverse balances on both sides
$quantity = (int)$from_row['out'];
- $this->adjustBalance('out', $from_warehouse_id, $product_sku, $quantity, 0);
- $this->adjustBalance('in', $to_warehouse, $product_sku, $quantity, 0);
+ $this->adjustBalance('out', $from_warehouse_id, $product_sku, $quantity, 0, 0, '', 0, $from_row['date'] ?? '');
+ $this->adjustBalance('in', $to_warehouse, $product_sku, $quantity, 0, 0, '', 0, $from_row['date'] ?? '');
}
}
diff --git a/app/assets/utils/classes_ac/EtlManager.php b/app/assets/utils/classes_ac/EtlManager.php
index faa1956..948de40 100644
--- a/app/assets/utils/classes_ac/EtlManager.php
+++ b/app/assets/utils/classes_ac/EtlManager.php
@@ -45,7 +45,7 @@ class EtlManager
SELECT DISTINCT g.period
FROM td_gl g
LEFT JOIN (
- SELECT period, MIN(source_updated_at) AS etl_ts
+ SELECT period, MAX(source_updated_at) AS etl_ts
FROM etl_gl_summary
WHERE company_id = :cid2
GROUP BY period
diff --git a/app/assets/utils/db_auth.php b/app/assets/utils/db_auth.php
index 210c7a0..c7fe263 100644
--- a/app/assets/utils/db_auth.php
+++ b/app/assets/utils/db_auth.php
@@ -88,6 +88,14 @@ if(!empty($_SESSION["login_company_id"])){
http_response_code(401);
exit(json_encode(['success' => 0, 'message' => 'You have been signed in from another device.', 'code' => 'signed_elsewhere']));
}
+
+ // Keep session_last_seen fresh (throttled: at most one DB write per 60 s).
+ // login_confirm.php reads this to detect when PHP GC has expired the session.
+ if (!isset($_SESSION['_last_seen_updated']) || (time() - $_SESSION['_last_seen_updated']) > 60) {
+ $pdo1->prepare("UPDATE user SET session_last_seen = NOW() WHERE user_id = :uid AND session_token = :tok")
+ ->execute([':uid' => $_SESSION['login_user_id'], ':tok' => $_SESSION['session_token']]);
+ $_SESSION['_last_seen_updated'] = time();
+ }
}
}
diff --git a/app/cron/etl_stock_maintenance.php b/app/cron/etl_stock_maintenance.php
new file mode 100644
index 0000000..d507139
--- /dev/null
+++ b/app/cron/etl_stock_maintenance.php
@@ -0,0 +1,41 @@
+ 23) {
+ http_response_code(400);
+ exit(json_encode(['success' => 0, 'message' => 'Invalid slot.']));
+}
+
+$sth = $pdo1->prepare(
+ "SELECT company_id FROM company_list WHERE company_id % 24 = :slot"
+);
+$sth->execute([':slot' => $slot]);
+$companies = $sth->fetchAll(PDO::FETCH_COLUMN);
+
+$results = [];
+foreach ($companies as $company_id) {
+ try {
+ $etl = new EtlStockManager($pdo2, $pdo1, (int)$company_id);
+ $result = $etl->checkAndRepair();
+ $results[] = [
+ 'company_id' => $company_id,
+ 'gaps_found' => $result['gaps_found'],
+ 'periods_rebuilt' => $result['periods_rebuilt'],
+ ];
+ } catch (Exception $e) {
+ $results[] = ['company_id' => $company_id, 'error' => $e->getMessage()];
+ }
+}
+
+$answer['success'] = 1;
+$answer['slot'] = $slot;
+$answer['results'] = $results;
+exit(json_encode($answer));
diff --git a/app/ics/api/engine/approve_stock.php b/app/ics/api/engine/approve_stock.php
index d181aa0..a614e12 100644
--- a/app/ics/api/engine/approve_stock.php
+++ b/app/ics/api/engine/approve_stock.php
@@ -2,7 +2,7 @@
/**
* approve_stock.php — Approve a draft td_stock row (status 0 → 1).
*
- * Flips status to 1 and calls adjustBalance() so warehouse_balance
+ * Flips status to 1 and calls adjustBalance() so etl_stock_summary
* reflects the transaction. This is the ONLY place adjustBalance() is
* called — both auto-approve (on save) and manual approve go through here.
*
diff --git a/app/include_setting_sidebar.php b/app/include_setting_sidebar.php
index e7aa966..0ddb82d 100644
--- a/app/include_setting_sidebar.php
+++ b/app/include_setting_sidebar.php
@@ -73,6 +73,14 @@
+
+
+
+ Stock Maintenance
+
+
+
Navigation
diff --git a/app/login/api/engine/login_confirm.php b/app/login/api/engine/login_confirm.php
index 95d2cf9..6cc9341 100644
--- a/app/login/api/engine/login_confirm.php
+++ b/app/login/api/engine/login_confirm.php
@@ -99,21 +99,21 @@ $_SESSION["diff"] = $otp_diff_minutes;
// If session_token is non-NULL AND was set within the last 8 hours, another
// session is active — reject. Tokens older than 8 hours are treated as
// abandoned (browser crash, PHP GC expiry, etc.) and cleared automatically.
-$sth_token = $pdo1->prepare("SELECT session_token, session_token_at FROM user WHERE user_id = :uid LIMIT 1");
+$sth_token = $pdo1->prepare("SELECT session_token, session_last_seen FROM user WHERE user_id = :uid LIMIT 1");
$sth_token->execute([':uid' => $user_id]);
-$token_row = $sth_token->fetch(PDO::FETCH_ASSOC);
+$token_row = $sth_token->fetch(PDO::FETCH_ASSOC);
$existing_token = $token_row['session_token'] ?? null;
if (!empty($existing_token)) {
- $token_age_hours = PHP_INT_MAX;
- if (!empty($token_row['session_token_at'])) {
- $token_age_hours = (time() - strtotime($token_row['session_token_at'])) / 3600;
+ $idle_seconds = PHP_INT_MAX;
+ if (!empty($token_row['session_last_seen'])) {
+ $idle_seconds = time() - strtotime($token_row['session_last_seen']);
}
- if ($token_age_hours < 8) {
+ if ($idle_seconds < (int)ini_get('session.gc_maxlifetime')) {
$answer["message"] = "This account is currently logged in on another device. Please wait for the other session to end.";
exit(json_encode($answer));
}
- // Stale token — clear it and proceed with login
- $pdo1->prepare("UPDATE user SET session_token = NULL, session_token_at = NULL WHERE user_id = :uid")
+ // PHP GC has expired this session — clear token and allow login
+ $pdo1->prepare("UPDATE user SET session_token = NULL, session_token_at = NULL, session_last_seen = NULL WHERE user_id = :uid")
->execute([':uid' => $user_id]);
}
diff --git a/app/login/api/engine/login_otp.php b/app/login/api/engine/login_otp.php
index 9bee7ad..313e2c6 100644
--- a/app/login/api/engine/login_otp.php
+++ b/app/login/api/engine/login_otp.php
@@ -361,6 +361,9 @@ if (password_verify(trim($data["password"]), $temp["password"])) {
$_SESSION["user_email"] = $user_email; // shown masked on OTP screen
$_SESSION["login_user_id"] = $user_id; // used by login_confirm.php to build the login session
$_SESSION["no_smtp"] = empty($smtp_config); // true = skip OTP step on login page
+ if (empty($smtp_config)) {
+ $_SESSION['skip_otp'] = true;
+ }
// ── Step 10: Respond ──────────────────────────────────────────────────────
$answer["success"] = 1;
diff --git a/app/setting/api/engine/gl_maintenance.php b/app/setting/api/engine/gl_maintenance.php
index ec1dfb2..4289b20 100644
--- a/app/setting/api/engine/gl_maintenance.php
+++ b/app/setting/api/engine/gl_maintenance.php
@@ -14,6 +14,13 @@ try {
exit(json_encode($answer));
}
+ if ($action === 'get_current_gaps') {
+ $gaps = $etl->getGaps();
+ $answer['output'] = ['current_gaps' => count($gaps)];
+ $answer['success'] = 1;
+ exit(json_encode($answer));
+ }
+
if ($action === 'get_periods') {
$sth = $pdo2->prepare(
"SELECT DISTINCT period FROM td_gl WHERE company_id = :cid ORDER BY period"
diff --git a/app/setting/api/engine/stock_maintenance.php b/app/setting/api/engine/stock_maintenance.php
new file mode 100644
index 0000000..3e5d9ee
--- /dev/null
+++ b/app/setting/api/engine/stock_maintenance.php
@@ -0,0 +1,65 @@
+getStatus();
+ $answer['success'] = 1;
+ exit(json_encode($answer));
+ }
+
+ if ($action === 'get_current_gaps') {
+ $gaps = $etl->getGaps();
+ $answer['output'] = ['current_gaps' => count($gaps)];
+ $answer['success'] = 1;
+ exit(json_encode($answer));
+ }
+
+ if ($action === 'get_warehouses') {
+ $sth = $pdo2->prepare(
+ "SELECT id, warehouse_name FROM md_warehouse
+ WHERE company_id = :cid AND status = 1
+ ORDER BY warehouse_name"
+ );
+ $sth->execute([':cid' => $company_id]);
+ $answer['output'] = $sth->fetchAll(PDO::FETCH_ASSOC);
+ $answer['success'] = 1;
+ exit(json_encode($answer));
+ }
+
+ if ($action === 'get_periods') {
+ $warehouse_id = (int)($data['warehouse_id'] ?? 0);
+ if (!$warehouse_id) {
+ $answer['message'] = 'warehouse_id is required.';
+ exit(json_encode($answer));
+ }
+ $answer['output'] = $etl->getDistinctPeriods($warehouse_id);
+ $answer['success'] = 1;
+ exit(json_encode($answer));
+ }
+
+ if ($action === 'rebuild_period') {
+ $warehouse_id = (int)($data['warehouse_id'] ?? 0);
+ $period = $data['period'] ?? '';
+ if (!$warehouse_id || !preg_match('/^\d{4}-\d{2}$/', $period)) {
+ $answer['message'] = 'Valid warehouse_id and period (YYYY-MM) are required.';
+ exit(json_encode($answer));
+ }
+ $etl->rebuildPeriod($warehouse_id, $period);
+ $answer['success'] = 1;
+ exit(json_encode($answer));
+ }
+
+ $answer['message'] = 'Invalid action.';
+} catch (Exception $e) {
+ $answer['message'] = $e->getMessage();
+ http_response_code(400);
+}
+
+exit(json_encode($answer));
diff --git a/app/setting/gl_maintenance.php b/app/setting/gl_maintenance.php
index 7ccfdf3..011cffb 100644
--- a/app/setting/gl_maintenance.php
+++ b/app/setting/gl_maintenance.php
@@ -61,6 +61,13 @@
Next run
—
+
+
+ Current gaps
+
+
+
+
@@ -162,6 +169,7 @@
btn.prop('disabled', false);
});
load_status();
+ load_current_gaps();
return;
}
@@ -187,7 +195,26 @@
});
});
+ function load_current_gaps() {
+ ajax_request({
+ url: server_url + 'setting/api/engine/gl_maintenance.php',
+ autoPrepare: true, checkRequired: 0, noLoading: true, queueLock: false, action: 'get_current_gaps',
+ onSuccess: function(res) {
+ var n = res.output.current_gaps;
+ $('#stat_current_gaps').html(
+ n === 0
+ ? '0 — up to date '
+ : '' + n + ' period(s) need repair '
+ );
+ },
+ onError: function() {
+ $('#stat_current_gaps').text('—');
+ }
+ });
+ }
+
load_status();
+ load_current_gaps();
+
+
+
+
+
+
+
+
+
+
+
ETL Stock Summary Maintenance
+
Manages the etl_stock_summary table used by the stock overview dashboard
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The system automatically checks and repairs the stock summary table nightly.
+ Each company is assigned a fixed time slot to distribute load across the day.
+
+
+
+
+ Scheduled slot
+ —
+
+
+ Last ran
+ —
+
+
+ Last result
+ —
+
+
+ Gaps found
+ —
+
+
+ Periods rebuilt
+ —
+
+
+ Next run
+ —
+
+
+
+ Current gaps
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Wipes and rebuilds the stock summary for a specific warehouse, one month at a time from raw stock rows.
+ Use this after a direct database fix, data import, or if dashboard figures look incorrect.
+
+
+
+ The dashboard stays usable during the rebuild — it falls back to raw data for any month not yet rebuilt.
+
+
+ Warehouse
+
+ Loading…
+
+
+
+
+ Rebuild ETL for Selected Warehouse
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+