stock aggregate table
This commit is contained in:
@@ -229,7 +229,7 @@ class CompanySettingManager
|
|||||||
|
|
||||||
private function hasStockTransactions(): bool
|
private function hasStockTransactions(): bool
|
||||||
{
|
{
|
||||||
if ($this->tableHasCompanyRows('warehouse_balance')) {
|
if ($this->tableHasCompanyRows('etl_stock_summary')) {
|
||||||
return true;
|
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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -274,7 +274,7 @@ class ProductManager {
|
|||||||
* Return all products with their current aggregate warehouse balance.
|
* Return all products with their current aggregate warehouse balance.
|
||||||
*
|
*
|
||||||
* The balance is the sum of (total_in - total_out) across all warehouses
|
* 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.
|
* Used to populate the product listing page.
|
||||||
*
|
*
|
||||||
@@ -285,7 +285,7 @@ class ProductManager {
|
|||||||
$sth = $this->pdo->prepare(
|
$sth = $this->pdo->prepare(
|
||||||
"SELECT a.*, IFNULL(SUM(b.total_in - b.total_out), 0) AS product_balance
|
"SELECT a.*, IFNULL(SUM(b.total_in - b.total_out), 0) AS product_balance
|
||||||
FROM md_product a
|
FROM md_product a
|
||||||
LEFT JOIN warehouse_balance b
|
LEFT JOIN etl_stock_summary b
|
||||||
ON a.company_id = b.company_id
|
ON a.company_id = b.company_id
|
||||||
AND a.sku = b.product_sku
|
AND a.sku = b.product_sku
|
||||||
WHERE a.company_id = :company_id
|
WHERE a.company_id = :company_id
|
||||||
|
|||||||
@@ -197,13 +197,13 @@ class ReportManager
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Number of distinct SKUs with a positive running balance across all warehouses.
|
* 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.
|
* Used by the reports_stats dashboard tile.
|
||||||
*/
|
*/
|
||||||
public function getTotalProductInStock(): int
|
public function getTotalProductInStock(): int
|
||||||
{
|
{
|
||||||
$sql = "SELECT COUNT(DISTINCT product_sku)
|
$sql = "SELECT COUNT(DISTINCT product_sku)
|
||||||
FROM warehouse_balance
|
FROM etl_stock_summary
|
||||||
WHERE company_id = :company_id
|
WHERE company_id = :company_id
|
||||||
AND (total_in - total_out) > 0";
|
AND (total_in - total_out) > 0";
|
||||||
return (int) $this->fetchScalar($sql);
|
return (int) $this->fetchScalar($sql);
|
||||||
@@ -265,7 +265,7 @@ class ReportManager
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Return aggregated total_in, total_out, and min_stock for every SKU
|
* 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.
|
* Used internally by getLowStockCount and as a general balance query.
|
||||||
*
|
*
|
||||||
@@ -279,7 +279,7 @@ class ReportManager
|
|||||||
SUM(a.total_out) AS total_out,
|
SUM(a.total_out) AS total_out,
|
||||||
b.min_stock,
|
b.min_stock,
|
||||||
b.cost_price
|
b.cost_price
|
||||||
FROM warehouse_balance a
|
FROM etl_stock_summary a
|
||||||
LEFT JOIN md_product b
|
LEFT JOIN md_product b
|
||||||
ON a.company_id = b.company_id
|
ON a.company_id = b.company_id
|
||||||
AND a.product_sku = b.sku
|
AND a.product_sku = b.sku
|
||||||
@@ -314,7 +314,7 @@ class ReportManager
|
|||||||
"SELECT
|
"SELECT
|
||||||
ROUND(COALESCE(SUM(total_in), 0), 2) AS total_in,
|
ROUND(COALESCE(SUM(total_in), 0), 2) AS total_in,
|
||||||
ROUND(COALESCE(SUM(total_out), 0), 2) AS total_out
|
ROUND(COALESCE(SUM(total_out), 0), 2) AS total_out
|
||||||
FROM warehouse_balance
|
FROM etl_stock_summary
|
||||||
WHERE company_id = :company_id"
|
WHERE company_id = :company_id"
|
||||||
);
|
);
|
||||||
$sth->execute([':company_id' => $this->companyId]);
|
$sth->execute([':company_id' => $this->companyId]);
|
||||||
@@ -412,7 +412,7 @@ class ReportManager
|
|||||||
mp.product_image,
|
mp.product_image,
|
||||||
mp.cost_price,
|
mp.cost_price,
|
||||||
ROUND(SUM(wb.total_in) - SUM(wb.total_out), 2) AS balance
|
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
|
INNER JOIN md_product mp
|
||||||
ON wb.company_id = mp.company_id
|
ON wb.company_id = mp.company_id
|
||||||
AND wb.product_sku = mp.sku
|
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_in), 0), 2) AS total_in,
|
||||||
ROUND(COALESCE(SUM(total_out), 0), 2) AS total_out,
|
ROUND(COALESCE(SUM(total_out), 0), 2) AS total_out,
|
||||||
COUNT(DISTINCT product_sku) AS active_products
|
COUNT(DISTINCT product_sku) AS active_products
|
||||||
FROM warehouse_balance
|
FROM etl_stock_summary
|
||||||
WHERE company_id = :company_id
|
WHERE company_id = :company_id
|
||||||
AND month = :month"
|
AND month = :month"
|
||||||
);
|
);
|
||||||
@@ -517,7 +517,7 @@ class ReportManager
|
|||||||
"SELECT wb.product_sku,
|
"SELECT wb.product_sku,
|
||||||
ROUND(SUM(wb.total_in) - SUM(wb.total_out), 2) AS balance,
|
ROUND(SUM(wb.total_in) - SUM(wb.total_out), 2) AS balance,
|
||||||
mp.min_stock
|
mp.min_stock
|
||||||
FROM warehouse_balance wb
|
FROM etl_stock_summary wb
|
||||||
INNER JOIN md_product mp
|
INNER JOIN md_product mp
|
||||||
ON mp.company_id = wb.company_id
|
ON mp.company_id = wb.company_id
|
||||||
AND mp.sku = wb.product_sku
|
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.
|
* 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.
|
* 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).
|
* @param int $months Number of months to include (default 12).
|
||||||
* @return array Keys: labels (array), stock_in (array), stock_out (array).
|
* @return array Keys: labels (array), stock_in (array), stock_out (array).
|
||||||
@@ -553,7 +553,7 @@ class ReportManager
|
|||||||
month,
|
month,
|
||||||
ROUND(SUM(total_in), 2) AS stock_in,
|
ROUND(SUM(total_in), 2) AS stock_in,
|
||||||
ROUND(SUM(total_out), 2) AS stock_out
|
ROUND(SUM(total_out), 2) AS stock_out
|
||||||
FROM warehouse_balance
|
FROM etl_stock_summary
|
||||||
WHERE company_id = :company_id
|
WHERE company_id = :company_id
|
||||||
AND month BETWEEN :start AND :end
|
AND month BETWEEN :start AND :end
|
||||||
GROUP BY month
|
GROUP BY month
|
||||||
@@ -602,7 +602,7 @@ class ReportManager
|
|||||||
ROUND(SUM(wb.total_out), 2) AS total_out,
|
ROUND(SUM(wb.total_out), 2) AS total_out,
|
||||||
p.product_name,
|
p.product_name,
|
||||||
pc.category AS category_name
|
pc.category AS category_name
|
||||||
FROM warehouse_balance wb
|
FROM etl_stock_summary wb
|
||||||
INNER JOIN md_product p
|
INNER JOIN md_product p
|
||||||
ON p.company_id = wb.company_id
|
ON p.company_id = wb.company_id
|
||||||
AND p.sku = wb.product_sku
|
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.
|
* Used to populate the month selector on the dashboard and reports pages.
|
||||||
*
|
*
|
||||||
@@ -633,7 +633,7 @@ class ReportManager
|
|||||||
{
|
{
|
||||||
$sth = $this->pdo->prepare(
|
$sth = $this->pdo->prepare(
|
||||||
"SELECT DISTINCT month
|
"SELECT DISTINCT month
|
||||||
FROM warehouse_balance
|
FROM etl_stock_summary
|
||||||
WHERE company_id = :company_id
|
WHERE company_id = :company_id
|
||||||
ORDER BY month DESC"
|
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.
|
* @param int $warehouse_id The warehouse to query.
|
||||||
* @return array Keys: total_in, total_out.
|
* @return array Keys: total_in, total_out.
|
||||||
@@ -754,7 +754,7 @@ class ReportManager
|
|||||||
"SELECT
|
"SELECT
|
||||||
ROUND(COALESCE(SUM(total_in), 0), 2) AS total_in,
|
ROUND(COALESCE(SUM(total_in), 0), 2) AS total_in,
|
||||||
ROUND(COALESCE(SUM(total_out), 0), 2) AS total_out
|
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"
|
WHERE company_id = :company_id AND warehouse_id = :warehouse_id"
|
||||||
);
|
);
|
||||||
$sth->execute([':company_id' => $this->companyId, ':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.
|
* 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.
|
* @param int $warehouse_id The warehouse to query.
|
||||||
* @return int Number of SKUs below min_stock.
|
* @return int Number of SKUs below min_stock.
|
||||||
@@ -775,7 +775,7 @@ class ReportManager
|
|||||||
"SELECT wb.product_sku,
|
"SELECT wb.product_sku,
|
||||||
ROUND(SUM(wb.total_in) - SUM(wb.total_out), 2) AS balance,
|
ROUND(SUM(wb.total_in) - SUM(wb.total_out), 2) AS balance,
|
||||||
mp.min_stock
|
mp.min_stock
|
||||||
FROM warehouse_balance wb
|
FROM etl_stock_summary wb
|
||||||
INNER JOIN md_product mp
|
INNER JOIN md_product mp
|
||||||
ON mp.company_id = wb.company_id
|
ON mp.company_id = wb.company_id
|
||||||
AND mp.sku = wb.product_sku
|
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.
|
* 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.
|
* for per-warehouse granularity. Produces chart-ready arrays with month labels.
|
||||||
*
|
*
|
||||||
* @param int $warehouse_id The warehouse to query.
|
* @param int $warehouse_id The warehouse to query.
|
||||||
@@ -1173,7 +1173,7 @@ class ReportManager
|
|||||||
$wh_list = $warehouses->fetchAll(PDO::FETCH_ASSOC);
|
$wh_list = $warehouses->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
// Build per-lot balance by summing approved td_stock rows across all warehouses.
|
// 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 = [];
|
$lot_balance = [];
|
||||||
$cid = (int) $this->companyId;
|
$cid = (int) $this->companyId;
|
||||||
|
|
||||||
@@ -1350,7 +1350,7 @@ class ReportManager
|
|||||||
SUM(total_in) AS total_in,
|
SUM(total_in) AS total_in,
|
||||||
SUM(total_out) AS total_out,
|
SUM(total_out) AS total_out,
|
||||||
COUNT(DISTINCT warehouse_id) AS total_warehouse
|
COUNT(DISTINCT warehouse_id) AS total_warehouse
|
||||||
FROM warehouse_balance
|
FROM etl_stock_summary
|
||||||
WHERE company_id = :company_id"
|
WHERE company_id = :company_id"
|
||||||
);
|
);
|
||||||
$sth->execute([':company_id' => $this->companyId]);
|
$sth->execute([':company_id' => $this->companyId]);
|
||||||
@@ -1364,7 +1364,7 @@ class ReportManager
|
|||||||
* Each row is one individual transaction — no grouping. Full datetime is
|
* Each row is one individual transaction — no grouping. Full datetime is
|
||||||
* returned as stored in the td_stock_* table.
|
* 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.
|
* strictly before from_month, giving the cumulative net before the range.
|
||||||
*
|
*
|
||||||
* Used by reports/stock_movement.php.
|
* Used by reports/stock_movement.php.
|
||||||
|
|||||||
@@ -401,7 +401,7 @@ class ReturnManager {
|
|||||||
* 1. INSERT td_stock_<warehouse_id> in row with type='in', status=1 (force confirmed),
|
* 1. INSERT td_stock_<warehouse_id> in row with type='in', status=1 (force confirmed),
|
||||||
* source='return', source_id=return_id.
|
* source='return', source_id=return_id.
|
||||||
* 2. occupyRack() — place returned stock into the user-selected empty rack.
|
* 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:
|
* After all items:
|
||||||
* 4. UPDATE td_return.status = 1 (confirmed).
|
* 4. UPDATE td_return.status = 1 (confirmed).
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ class StockManager {
|
|||||||
* 1. Upserts md_lot if lot_number + expiry_date are provided.
|
* 1. Upserts md_lot if lot_number + expiry_date are provided.
|
||||||
* 2. Inserts the td_stock_<warehouse_id> row.
|
* 2. Inserts the td_stock_<warehouse_id> row.
|
||||||
* 3. Calls WarehouseManager::occupyRack() to reserve the rack.
|
* 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):
|
* Update flow (id > 0):
|
||||||
* - Updates contact_id, description, and log only.
|
* - 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.
|
* 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.
|
* 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.
|
* 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):
|
* Update flow (id > 0):
|
||||||
* - Updates contact_id, description, and log only.
|
* - 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
|
* This is the single entry point for balance updates — both auto-approve
|
||||||
* (called immediately after save) and manual approve go through here.
|
* (called immediately after save) and manual approve go through here.
|
||||||
@@ -729,7 +729,7 @@ class StockManager {
|
|||||||
$id
|
$id
|
||||||
);
|
);
|
||||||
$whMgmt->adjustBalance('in', $warehouse_id, $row['product_sku'], 0, (float)$row['in'],
|
$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') {
|
} elseif ($type === 'out') {
|
||||||
|
|
||||||
@@ -759,7 +759,7 @@ class StockManager {
|
|||||||
$whMgmt->releaseRack($warehouse_id, $row['zone'], $row['aisle'], $row['rack']);
|
$whMgmt->releaseRack($warehouse_id, $row['zone'], $row['aisle'], $row['rack']);
|
||||||
}
|
}
|
||||||
$whMgmt->adjustBalance('out', $warehouse_id, $row['product_sku'], 0, (float)$row['out'],
|
$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') {
|
} elseif ($type === 'transfer') {
|
||||||
|
|
||||||
@@ -843,7 +843,7 @@ class StockManager {
|
|||||||
$from_row['zone'], $from_row['aisle'], $from_row['rack']
|
$from_row['zone'], $from_row['aisle'], $from_row['rack']
|
||||||
);
|
);
|
||||||
$whMgmt->adjustBalance('out', $from_wh_id, $from_row['product_sku'], 0, (float)$from_row['out'],
|
$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) {
|
if ($inbound_row && $paired_wh_id) {
|
||||||
$whMgmt->occupyRack(
|
$whMgmt->occupyRack(
|
||||||
@@ -853,7 +853,7 @@ class StockManager {
|
|||||||
$inbound_row['id']
|
$inbound_row['id']
|
||||||
);
|
);
|
||||||
$whMgmt->adjustBalance('in', $paired_wh_id, $inbound_row['product_sku'], 0, (float)$inbound_row['in'],
|
$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'] ?? '');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
* Uses an INSERT ... ON DUPLICATE KEY UPDATE to atomically upsert the balance row,
|
||||||
* partitioned by month (YYYY-MM) for efficient monthly reporting queries.
|
* partitioned by month (YYYY-MM) for efficient monthly reporting queries.
|
||||||
@@ -1851,19 +1851,21 @@ class WarehouseManager {
|
|||||||
*/
|
*/
|
||||||
public function adjustBalance(
|
public function adjustBalance(
|
||||||
$type, $warehouse_id, $product_sku, $old_qty, $new_qty,
|
$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 {
|
): void {
|
||||||
$column = $type === 'in' ? 'total_in' : 'total_out';
|
$column = $type === 'in' ? 'total_in' : 'total_out';
|
||||||
$delta = $new_qty - $old_qty;
|
$delta = $new_qty - $old_qty;
|
||||||
$month = date('Y-m');
|
$month = $stock_date ? date('Y-m', strtotime($stock_date)) : date('Y-m');
|
||||||
|
|
||||||
$this->pdo->prepare(
|
$this->pdo->prepare(
|
||||||
"INSERT INTO warehouse_balance
|
"INSERT INTO etl_stock_summary
|
||||||
(company_id, warehouse_id, product_sku, month, `{$column}`)
|
(company_id, warehouse_id, product_sku, month, `{$column}`, source_updated_at)
|
||||||
VALUES
|
VALUES
|
||||||
(:company_id, :warehouse_id, :product_sku, :month, :delta)
|
(:company_id, :warehouse_id, :product_sku, :month, :delta, NOW())
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
`{$column}` = `{$column}` + VALUES(`{$column}`)"
|
`{$column}` = `{$column}` + VALUES(`{$column}`),
|
||||||
|
source_updated_at = NOW()"
|
||||||
)->execute([
|
)->execute([
|
||||||
':company_id' => $this->company_id,
|
':company_id' => $this->company_id,
|
||||||
':warehouse_id' => $warehouse_id,
|
':warehouse_id' => $warehouse_id,
|
||||||
@@ -2173,7 +2175,7 @@ class WarehouseManager {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if ((int)$row['status'] === 1) {
|
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']],
|
[(int)$row['ref_id']],
|
||||||
$row['date']
|
$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
|
// Reverse balances on both sides
|
||||||
$quantity = (int)$from_row['out'];
|
$quantity = (int)$from_row['out'];
|
||||||
$this->adjustBalance('out', $from_warehouse_id, $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);
|
$this->adjustBalance('in', $to_warehouse, $product_sku, $quantity, 0, 0, '', 0, $from_row['date'] ?? '');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ class EtlManager
|
|||||||
SELECT DISTINCT g.period
|
SELECT DISTINCT g.period
|
||||||
FROM td_gl g
|
FROM td_gl g
|
||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
SELECT period, MIN(source_updated_at) AS etl_ts
|
SELECT period, MAX(source_updated_at) AS etl_ts
|
||||||
FROM etl_gl_summary
|
FROM etl_gl_summary
|
||||||
WHERE company_id = :cid2
|
WHERE company_id = :cid2
|
||||||
GROUP BY period
|
GROUP BY period
|
||||||
|
|||||||
@@ -88,6 +88,14 @@ if(!empty($_SESSION["login_company_id"])){
|
|||||||
http_response_code(401);
|
http_response_code(401);
|
||||||
exit(json_encode(['success' => 0, 'message' => 'You have been signed in from another device.', 'code' => 'signed_elsewhere']));
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
/**
|
/**
|
||||||
* approve_stock.php — Approve a draft td_stock row (status 0 → 1).
|
* 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
|
* reflects the transaction. This is the ONLY place adjustBalance() is
|
||||||
* called — both auto-approve (on save) and manual approve go through here.
|
* called — both auto-approve (on save) and manual approve go through here.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -73,6 +73,14 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</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 -->
|
<!-- Back -->
|
||||||
<li class="nav-text-space mt-2">
|
<li class="nav-text-space mt-2">
|
||||||
<small class="nav-text text-muted">Navigation</small>
|
<small class="nav-text text-muted">Navigation</small>
|
||||||
|
|||||||
@@ -99,21 +99,21 @@ $_SESSION["diff"] = $otp_diff_minutes;
|
|||||||
// If session_token is non-NULL AND was set within the last 8 hours, another
|
// 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
|
// session is active — reject. Tokens older than 8 hours are treated as
|
||||||
// abandoned (browser crash, PHP GC expiry, etc.) and cleared automatically.
|
// 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]);
|
$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;
|
$existing_token = $token_row['session_token'] ?? null;
|
||||||
if (!empty($existing_token)) {
|
if (!empty($existing_token)) {
|
||||||
$token_age_hours = PHP_INT_MAX;
|
$idle_seconds = PHP_INT_MAX;
|
||||||
if (!empty($token_row['session_token_at'])) {
|
if (!empty($token_row['session_last_seen'])) {
|
||||||
$token_age_hours = (time() - strtotime($token_row['session_token_at'])) / 3600;
|
$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.";
|
$answer["message"] = "This account is currently logged in on another device. Please wait for the other session to end.";
|
||||||
exit(json_encode($answer));
|
exit(json_encode($answer));
|
||||||
}
|
}
|
||||||
// Stale token — clear it and proceed with login
|
// PHP GC has expired this session — clear token and allow login
|
||||||
$pdo1->prepare("UPDATE user SET session_token = NULL, session_token_at = NULL WHERE user_id = :uid")
|
$pdo1->prepare("UPDATE user SET session_token = NULL, session_token_at = NULL, session_last_seen = NULL WHERE user_id = :uid")
|
||||||
->execute([':uid' => $user_id]);
|
->execute([':uid' => $user_id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -361,6 +361,9 @@ if (password_verify(trim($data["password"]), $temp["password"])) {
|
|||||||
$_SESSION["user_email"] = $user_email; // shown masked on OTP screen
|
$_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["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
|
$_SESSION["no_smtp"] = empty($smtp_config); // true = skip OTP step on login page
|
||||||
|
if (empty($smtp_config)) {
|
||||||
|
$_SESSION['skip_otp'] = true;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Step 10: Respond ──────────────────────────────────────────────────────
|
// ── Step 10: Respond ──────────────────────────────────────────────────────
|
||||||
$answer["success"] = 1;
|
$answer["success"] = 1;
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ try {
|
|||||||
exit(json_encode($answer));
|
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') {
|
if ($action === 'get_periods') {
|
||||||
$sth = $pdo2->prepare(
|
$sth = $pdo2->prepare(
|
||||||
"SELECT DISTINCT period FROM td_gl WHERE company_id = :cid ORDER BY period"
|
"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));
|
||||||
@@ -61,6 +61,13 @@
|
|||||||
<td class="text-muted small">Next run</td>
|
<td class="text-muted small">Next run</td>
|
||||||
<td id="stat_next_run">—</td>
|
<td id="stat_next_run">—</td>
|
||||||
</tr>
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -162,6 +169,7 @@
|
|||||||
btn.prop('disabled', false);
|
btn.prop('disabled', false);
|
||||||
});
|
});
|
||||||
load_status();
|
load_status();
|
||||||
|
load_current_gaps();
|
||||||
return;
|
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_status();
|
||||||
|
load_current_gaps();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -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
@@ -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)');
|
||||||
|
|||||||
@@ -103,8 +103,9 @@ CREATE TABLE IF NOT EXISTS `user` (
|
|||||||
`verify_expires_at` datetime DEFAULT NULL,
|
`verify_expires_at` datetime DEFAULT NULL,
|
||||||
`login_attempts` int(11) NOT NULL DEFAULT 0,
|
`login_attempts` int(11) NOT NULL DEFAULT 0,
|
||||||
`locked_until` datetime DEFAULT NULL,
|
`locked_until` datetime DEFAULT NULL,
|
||||||
`session_token` varchar(64) DEFAULT NULL,
|
`session_token` varchar(64) DEFAULT NULL,
|
||||||
`session_token_at` datetime DEFAULT NULL,
|
`session_token_at` datetime DEFAULT NULL,
|
||||||
|
`session_last_seen` datetime DEFAULT NULL,
|
||||||
PRIMARY KEY (`user_id`),
|
PRIMARY KEY (`user_id`),
|
||||||
UNIQUE KEY `username` (`username`),
|
UNIQUE KEY `username` (`username`),
|
||||||
UNIQUE KEY `email` (`email`),
|
UNIQUE KEY `email` (`email`),
|
||||||
@@ -533,20 +534,22 @@ CREATE TABLE IF NOT EXISTS `td_stock` (
|
|||||||
", 'td_stock (base template)');
|
", 'td_stock (base template)');
|
||||||
|
|
||||||
run($pdo, "
|
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,
|
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
|
||||||
`company_id` int(11) unsigned NOT NULL DEFAULT 0,
|
`company_id` int(11) unsigned NOT NULL DEFAULT 0,
|
||||||
`warehouse_id` int(11) unsigned NOT NULL DEFAULT 0,
|
`warehouse_id` int(11) unsigned NOT NULL DEFAULT 0,
|
||||||
`product_sku` varchar(255) NOT NULL DEFAULT '',
|
`product_sku` varchar(255) NOT NULL DEFAULT '',
|
||||||
`month` varchar(10) NOT NULL,
|
`month` varchar(10) NOT NULL,
|
||||||
`total_in` 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,
|
`total_out` decimal(18,4) NOT NULL DEFAULT 0.0000,
|
||||||
|
`source_updated_at` datetime DEFAULT NULL,
|
||||||
PRIMARY KEY (`id`),
|
PRIMARY KEY (`id`),
|
||||||
UNIQUE KEY `company_id_warehouse_id_product_sku_month` (`company_id`,`warehouse_id`,`product_sku`,`month`),
|
UNIQUE KEY `company_id_warehouse_id_product_sku_month` (`company_id`,`warehouse_id`,`product_sku`,`month`),
|
||||||
KEY `company_id` (`company_id`),
|
KEY `company_id` (`company_id`),
|
||||||
KEY `product_id` (`warehouse_id`)
|
KEY `product_id` (`warehouse_id`),
|
||||||
|
KEY `source_updated_at` (`source_updated_at`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
|
||||||
", 'warehouse_balance');
|
", 'etl_stock_summary');
|
||||||
|
|
||||||
// ── Transactions ──────────────────────────────────────────────────────────────
|
// ── Transactions ──────────────────────────────────────────────────────────────
|
||||||
run($pdo, "
|
run($pdo, "
|
||||||
|
|||||||
Reference in New Issue
Block a user