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 + +
  • +