stock aggregate table

This commit is contained in:
Thanakorn S
2026-05-25 13:30:10 +07:00
parent 293097363b
commit ba96de50a1
20 changed files with 725 additions and 62 deletions
@@ -229,7 +229,7 @@ class CompanySettingManager
private function hasStockTransactions(): bool
{
if ($this->tableHasCompanyRows('warehouse_balance')) {
if ($this->tableHasCompanyRows('etl_stock_summary')) {
return true;
}
@@ -0,0 +1,227 @@
<?php
/**
* EtlStockManager
*
* Manages etl_stock_summary — the materialized aggregate of td_stock_<warehouse_id>
* 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]);
}
}
}
+2 -2
View File
@@ -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
+21 -21
View File
@@ -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_<warehouse_id> table directly (not warehouse_balance)
* Queries the per-warehouse td_stock_<warehouse_id> table directly (not etl_stock_summary)
* for per-warehouse granularity. Produces chart-ready arrays with month labels.
*
* @param int $warehouse_id The warehouse to query.
@@ -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.
+1 -1
View File
@@ -401,7 +401,7 @@ class ReturnManager {
* 1. INSERT td_stock_<warehouse_id> 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).
+7 -7
View File
@@ -217,7 +217,7 @@ class StockManager {
* 1. Upserts md_lot if lot_number + expiry_date are provided.
* 2. Inserts the td_stock_<warehouse_id> 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_<warehouse_id> 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'] ?? '');
}
}
}
+13 -11
View File
@@ -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'] ?? '');
}
}
+1 -1
View File
@@ -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
+8
View File
@@ -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();
}
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
/**
* etl_stock_maintenance.php — Called by Node.js scheduler every hour.
* Checks and repairs etl_stock_summary for companies in the current slot.
*
* Body: { slot: 14 } (0–23, = HOUR(NOW()) from Node.js)
*/
require __DIR__ . '/../assets/utils/cron_auth.php';
require_once __DIR__ . '/../assets/utils/classes/EtlStockManager.php';
$slot = (int)($data['slot'] ?? -1);
if ($slot < 0 || $slot > 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));
+1 -1
View File
@@ -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.
*
+8
View File
@@ -73,6 +73,14 @@
</a>
</li>
<li>
<a class="nav-link <?php echo $current_page === 'stock_maintenance.php' ? 'active' : ''; ?>"
href="<?php echo $server_url?>setting/stock_maintenance.php">
<i class="ti ti-database-import"></i>
<span class="nav-text">Stock Maintenance</span>
</a>
</li>
<!-- Back -->
<li class="nav-text-space mt-2">
<small class="nav-text text-muted">Navigation</small>
+8 -8
View File
@@ -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]);
}
+3
View File
@@ -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;
@@ -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"
@@ -0,0 +1,65 @@
<?php
session_start();
require_once '../../../assets/utils/db_auth.php';
require_role($user_role, ['owner', 'admin']);
require_once '../../../assets/utils/classes/EtlStockManager.php';
$etl = new EtlStockManager($pdo2, $pdo1, $company_id);
$action = $data['action'] ?? '';
try {
if ($action === 'read') {
$answer['output'] = $etl->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));
+27
View File
@@ -61,6 +61,13 @@
<td class="text-muted small">Next run</td>
<td id="stat_next_run">—</td>
</tr>
<tr><td colspan="2"><hr class="my-2"></td></tr>
<tr>
<td class="text-muted small">Current gaps</td>
<td id="stat_current_gaps">
<span class="spinner-border spinner-border-sm text-secondary"></span>
</td>
</tr>
</tbody>
</table>
</div>
@@ -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
? '<span class="badge bg-success-subtle text-success">0 — up to date</span>'
: '<span class="badge bg-warning-subtle text-warning">' + n + ' period(s) need repair</span>'
);
},
onError: function() {
$('#stat_current_gaps').text('—');
}
});
}
load_status();
load_current_gaps();
</script>
</body>
+248
View File
@@ -0,0 +1,248 @@
<?php
session_start();
require '../config.php';
require '../include_header.php';
?>
<body>
<?php require '../include_topbar.php'; ?>
<?php require '../include_setting_sidebar.php'; ?>
<main id="content" class="content py-15">
<div class="container-fluid">
<div class="row">
<div class="col-12">
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center mb-6 gap-3">
<div>
<h1 class="fs-3 mb-1">ETL Stock Summary Maintenance</h1>
<p class="mb-0">Manages the <code>etl_stock_summary</code> table used by the stock overview dashboard</p>
</div>
</div>
</div>
</div>
<div class="row g-5">
<!-- Automatic Maintenance -->
<div class="col-12 col-lg-6">
<div class="card border-0 shadow-sm h-100" style="position:relative;">
<div class="card-header bg-transparent border-bottom">
<h6 class="mb-0"><i class="ti ti-clock me-2"></i>Automatic Maintenance</h6>
</div>
<div class="card-body">
<p class="text-muted small mb-4">
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.
</p>
<table class="table table-sm table-borderless mb-0">
<tbody>
<tr>
<td class="text-muted small" style="width:160px;">Scheduled slot</td>
<td><strong id="stat_slot">—</strong></td>
</tr>
<tr>
<td class="text-muted small">Last ran</td>
<td id="stat_last_ran">—</td>
</tr>
<tr>
<td class="text-muted small">Last result</td>
<td id="stat_last_result">—</td>
</tr>
<tr>
<td class="text-muted small">Gaps found</td>
<td id="stat_gaps_found">—</td>
</tr>
<tr>
<td class="text-muted small">Periods rebuilt</td>
<td id="stat_periods_rebuilt">—</td>
</tr>
<tr>
<td class="text-muted small">Next run</td>
<td id="stat_next_run">—</td>
</tr>
<tr><td colspan="2"><hr class="my-2"></td></tr>
<tr>
<td class="text-muted small">Current gaps</td>
<td id="stat_current_gaps">
<span class="spinner-border spinner-border-sm text-secondary"></span>
</td>
</tr>
</tbody>
</table>
</div>
<div class="card-loader-overlay" id="overlay_status">
<div class="spinner-border spinner-border-sm text-primary"></div>
</div>
</div>
</div>
<!-- Manual Rebuild -->
<div class="col-12 col-lg-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-transparent border-bottom">
<h6 class="mb-0"><i class="ti ti-database me-2"></i>Manual Full Rebuild</h6>
</div>
<div class="card-body d-flex flex-column">
<p class="text-muted small mb-3">
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.
</p>
<div class="alert alert-warning d-flex gap-2 align-items-start py-2 px-3 small mb-4">
<i class="ti ti-alert-triangle mt-1 flex-shrink-0"></i>
<span>The dashboard stays usable during the rebuild — it falls back to raw data for any month not yet rebuilt.</span>
</div>
<div class="mb-3">
<label class="form-label small text-muted">Warehouse</label>
<select id="sel_warehouse" class="form-select form-select-sm">
<option value="">Loading…</option>
</select>
</div>
<div class="mt-auto">
<button class="btn btn-danger" id="btn_rebuild_full">
<i class="ti ti-database-import me-1"></i>Rebuild ETL for Selected Warehouse
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<?php require '../include_ending.php'; ?>
<script>
var API = server_url + 'setting/api/engine/stock_maintenance.php';
function pad2(n) { return String(n).padStart(2, '0'); }
function load_status() {
$('#overlay_status').show();
ajax_request({
url: API,
autoPrepare: true, checkRequired: 0, noLoading: true, queueLock: false, action: 'read',
onSuccess: function(res) {
var d = res.output;
$('#stat_slot').text(pad2(d.slot_hour) + ':30 daily');
$('#stat_last_ran').text(d.last_ran || '—');
$('#stat_last_result').html(
d.last_result === 'ok'
? '<span class="badge bg-success-subtle text-success">OK</span>'
: d.last_result
? '<span class="badge bg-danger-subtle text-danger">' + escape_html(d.last_result) + '</span>'
: '—'
);
$('#stat_gaps_found').text(d.last_result ? d.gaps_found : '—');
$('#stat_periods_rebuilt').text(d.last_result ? d.periods_rebuilt : '—');
$('#stat_next_run').text(d.next_run || '—');
$('#overlay_status').hide();
}
});
}
function load_warehouses() {
ajax_request({
url: API,
autoPrepare: true, checkRequired: 0, noLoading: true, queueLock: false, action: 'get_warehouses',
onSuccess: function(res) {
var opts = '<option value="">Select warehouse…</option>';
$.each(res.output || [], function(_, wh) {
opts += '<option value="' + wh.id + '">' + escape_html(wh.warehouse_name) + '</option>';
});
$('#sel_warehouse').html(opts);
}
});
}
$('#btn_rebuild_full').on('click', function() {
var warehouse_id = $('#sel_warehouse').val();
var warehouse_name = $('#sel_warehouse option:selected').text();
if (!warehouse_id) { bootbox.alert('Please select a warehouse first.'); return; }
bootbox.confirm({
title: 'Rebuild ETL — ' + warehouse_name,
message: 'This will rebuild <strong>etl_stock_summary</strong> for <strong>' + escape_html(warehouse_name) + '</strong> one month at a time from raw stock rows. Continue?',
buttons: {
confirm: { label: 'Rebuild', className: 'btn-danger' },
cancel: { label: 'Cancel', className: 'btn-secondary' }
},
callback: function(confirmed) {
if (!confirmed) return;
var btn = $('#btn_rebuild_full').prop('disabled', true);
ajax_request({
url: API,
autoPrepare: true, checkRequired: 0, noLoading: true, queueLock: false,
action: 'get_periods', data: { warehouse_id: warehouse_id },
onSuccess: function(res) {
var periods = res.output || [];
var total = periods.length;
if (total === 0) {
btn.prop('disabled', false);
bootbox.alert('No approved stock periods found for this warehouse.');
return;
}
BatchOverlay.show('Rebuilding Stock ETL — ' + warehouse_name, total, { unit: 'periods', color: '#dc3545' });
function rebuild_next(i) {
if (i >= total) {
BatchOverlay.done(total + ' period(s) rebuilt successfully.', function() {
btn.prop('disabled', false);
});
load_status();
load_current_gaps();
return;
}
BatchOverlay.update(i, total, periods[i]);
ajax_request({
url: API,
autoPrepare: true, checkRequired: 0, noLoading: true, queueLock: false,
action: 'rebuild_period', data: { warehouse_id: warehouse_id, period: periods[i] },
onSuccess: function() { rebuild_next(i + 1); },
onError: function() { rebuild_next(i + 1); }
});
}
rebuild_next(0);
},
onError: function() {
BatchOverlay.hide();
btn.prop('disabled', false);
}
});
}
});
});
function load_current_gaps() {
ajax_request({
url: API,
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
? '<span class="badge bg-success-subtle text-success">0 — up to date</span>'
: '<span class="badge bg-warning-subtle text-warning">' + n + ' period(s) need repair</span>'
);
},
onError: function() {
$('#stat_current_gaps').text('—');
}
});
}
load_status();
load_warehouses();
load_current_gaps();
</script>
</body>
</html>
+25 -1
View File
@@ -64,4 +64,28 @@ cron.schedule('0 * * * *', async () => {
}
});
console.log('[scheduler] running — etl_gl_maintenance wired (hourly, slot-based)');
// ── ETL Stock Summary maintenance — runs every hour ──────────────────────────
// Checks and repairs etl_stock_summary (stock aggregation table).
// Same slot-based distribution as GL: company_id % 24.
cron.schedule('30 * * * *', async () => {
const slot = new Date().getHours();
console.log(`[scheduler] etl_stock slot=${slot}`);
try {
const result = await callPhp('/cron/etl_stock_maintenance.php', { slot });
const summary = (result.results || []).map(r =>
r.error
? `company=${r.company_id} ERROR: ${r.error}`
: `company=${r.company_id} gaps=${r.gaps_found} rebuilt=${r.periods_rebuilt}`
);
if (summary.length === 0) {
console.log(`[scheduler] etl_stock slot=${slot} — no companies`);
} else {
summary.forEach(s => console.log(`[scheduler] etl_stock ${s}`));
}
} catch (e) {
console.error('[scheduler] etl_stock_maintenance failed:', e.message);
}
});
console.log('[scheduler] running — etl_gl_maintenance + etl_stock_maintenance wired (hourly, slot-based)');
+11 -8
View File
@@ -103,8 +103,9 @@ CREATE TABLE IF NOT EXISTS `user` (
`verify_expires_at` datetime DEFAULT NULL,
`login_attempts` int(11) NOT NULL DEFAULT 0,
`locked_until` datetime DEFAULT NULL,
`session_token` varchar(64) DEFAULT NULL,
`session_token_at` datetime DEFAULT NULL,
`session_token` varchar(64) DEFAULT NULL,
`session_token_at` datetime DEFAULT NULL,
`session_last_seen` datetime DEFAULT NULL,
PRIMARY KEY (`user_id`),
UNIQUE KEY `username` (`username`),
UNIQUE KEY `email` (`email`),
@@ -533,20 +534,22 @@ CREATE TABLE IF NOT EXISTS `td_stock` (
", 'td_stock (base template)');
run($pdo, "
CREATE TABLE IF NOT EXISTS `warehouse_balance` (
CREATE TABLE IF NOT EXISTS `etl_stock_summary` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`company_id` int(11) unsigned NOT NULL DEFAULT 0,
`warehouse_id` int(11) unsigned NOT NULL DEFAULT 0,
`product_sku` varchar(255) NOT NULL DEFAULT '',
`month` varchar(10) NOT NULL,
`total_in` decimal(18,4) NOT NULL DEFAULT 0.0000,
`total_out` decimal(18,4) NOT NULL DEFAULT 0.0000,
`total_in` decimal(18,4) NOT NULL DEFAULT 0.0000,
`total_out` decimal(18,4) NOT NULL DEFAULT 0.0000,
`source_updated_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `company_id_warehouse_id_product_sku_month` (`company_id`,`warehouse_id`,`product_sku`,`month`),
KEY `company_id` (`company_id`),
KEY `product_id` (`warehouse_id`)
KEY `company_id` (`company_id`),
KEY `product_id` (`warehouse_id`),
KEY `source_updated_at` (`source_updated_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
", 'warehouse_balance');
", 'etl_stock_summary');
// ── Transactions ──────────────────────────────────────────────────────────────
run($pdo, "