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'] ?? '');
}
}