gl aggregate table (ETL), cronjob by NODEJS

This commit is contained in:
Thanakorn S
2026-05-23 10:52:27 +07:00
parent 5a3bfa3435
commit 5780183b82
15 changed files with 1048 additions and 230 deletions
+322 -133
View File
@@ -2,8 +2,15 @@
/**
* FinancialReports
*
* Generates all financial statements from td_gl + td_gl_item joined with md_account.
* Read-only — no writes. All results are reusable across API endpoints and future reports.
* Generates all financial statements using a mixed ETL + raw strategy:
* - etl_gl_summary for interior whole months (fast aggregation)
* - td_gl_item for boundary months (precise date filtering)
*
* ETL is bypassed when dept_id != 0 (ETL has no department dimension)
* or when from_month == to_month (single month = only one boundary, no interior).
*
* Rule: never query ETL for the start or end month of a range — only months
* that are fully contained within the range are safe to read from the aggregate table.
*/
class FinancialReports
{
@@ -16,6 +23,215 @@ class FinancialReports
$this->companyId = $company_id;
}
// ── ETL-aware aggregate helpers ───────────────────────────────────────────
// All three return: [acc_code => ['debit' => float, 'credit' => float]]
/**
* Sum debits/credits per account for journal_date in [from_date, to_date].
* Uses ETL for interior whole months; raw for boundary months.
*/
private function aggRange(string $from_date, string $to_date, int $dept_id = 0): array
{
$from_month = substr($from_date, 0, 7);
$to_month = substr($to_date, 0, 7);
$same_month = ($from_month === $to_month);
$dept_filter = $dept_id ? 'AND i.department_id = :dept_id' : '';
$out = [];
$merge = function (array $rows) use (&$out) {
foreach ($rows as $r) {
$c = (int)$r['acc_code'];
$out[$c]['debit'] = ($out[$c]['debit'] ?? 0.0) + (float)$r['d'];
$out[$c]['credit'] = ($out[$c]['credit'] ?? 0.0) + (float)$r['c'];
}
};
if ($same_month || $dept_id !== 0) {
$sth = $this->pdo->prepare("
SELECT i.account_code AS acc_code, SUM(i.debit) AS d, SUM(i.credit) AS c
FROM td_gl_item i
JOIN td_gl g ON g.id = i.gl_id AND g.company_id = i.company_id
WHERE i.company_id = :cid
AND g.journal_date BETWEEN :fd AND :td
AND g.source_type != 'voided'
$dept_filter
GROUP BY i.account_code
");
$p = [':cid' => $this->companyId, ':fd' => $from_date, ':td' => $to_date];
if ($dept_id) $p[':dept_id'] = $dept_id;
$sth->execute($p);
$merge($sth->fetchAll(PDO::FETCH_ASSOC));
return $out;
}
// ETL: interior months only (strictly between boundaries)
$sth = $this->pdo->prepare("
SELECT acc_code, SUM(debit) AS d, SUM(credit) AS c
FROM etl_gl_summary
WHERE company_id = :cid AND period > :fm AND period < :tm
GROUP BY acc_code
");
$sth->execute([':cid' => $this->companyId, ':fm' => $from_month, ':tm' => $to_month]);
$merge($sth->fetchAll(PDO::FETCH_ASSOC));
// Raw: start boundary month (from_date → last day of from_month)
$from_month_end = date('Y-m-t', strtotime($from_date));
$sth = $this->pdo->prepare("
SELECT i.account_code AS acc_code, SUM(i.debit) AS d, SUM(i.credit) AS c
FROM td_gl_item i
JOIN td_gl g ON g.id = i.gl_id AND g.company_id = i.company_id
WHERE i.company_id = :cid
AND g.journal_date BETWEEN :fd AND :fe
AND g.source_type != 'voided'
GROUP BY i.account_code
");
$sth->execute([':cid' => $this->companyId, ':fd' => $from_date, ':fe' => $from_month_end]);
$merge($sth->fetchAll(PDO::FETCH_ASSOC));
// Raw: end boundary month (1st of to_month → to_date)
$sth = $this->pdo->prepare("
SELECT i.account_code AS acc_code, SUM(i.debit) AS d, SUM(i.credit) AS c
FROM td_gl_item i
JOIN td_gl g ON g.id = i.gl_id AND g.company_id = i.company_id
WHERE i.company_id = :cid
AND g.journal_date BETWEEN :ts AND :td
AND g.source_type != 'voided'
GROUP BY i.account_code
");
$sth->execute([':cid' => $this->companyId, ':ts' => $to_month . '-01', ':td' => $to_date]);
$merge($sth->fetchAll(PDO::FETCH_ASSOC));
return $out;
}
/**
* Sum debits/credits per account for journal_date strictly BEFORE before_date.
* Uses ETL for months prior to the month containing before_date; raw within that month.
*/
private function aggBefore(string $before_date, int $dept_id = 0): array
{
$month = substr($before_date, 0, 7);
$month_start = $month . '-01';
$dept_filter = $dept_id ? 'AND i.department_id = :dept_id' : '';
$out = [];
$merge = function (array $rows) use (&$out) {
foreach ($rows as $r) {
$c = (int)$r['acc_code'];
$out[$c]['debit'] = ($out[$c]['debit'] ?? 0.0) + (float)$r['d'];
$out[$c]['credit'] = ($out[$c]['credit'] ?? 0.0) + (float)$r['c'];
}
};
if ($dept_id !== 0) {
$sth = $this->pdo->prepare("
SELECT i.account_code AS acc_code, SUM(i.debit) AS d, SUM(i.credit) AS c
FROM td_gl_item i
JOIN td_gl g ON g.id = i.gl_id AND g.company_id = i.company_id
WHERE i.company_id = :cid
AND g.journal_date < :bd
AND g.source_type != 'voided'
$dept_filter
GROUP BY i.account_code
");
$p = [':cid' => $this->companyId, ':bd' => $before_date];
if ($dept_id) $p[':dept_id'] = $dept_id;
$sth->execute($p);
$merge($sth->fetchAll(PDO::FETCH_ASSOC));
return $out;
}
// ETL: all periods strictly before the month containing before_date
$sth = $this->pdo->prepare("
SELECT acc_code, SUM(debit) AS d, SUM(credit) AS c
FROM etl_gl_summary
WHERE company_id = :cid AND period < :month
GROUP BY acc_code
");
$sth->execute([':cid' => $this->companyId, ':month' => $month]);
$merge($sth->fetchAll(PDO::FETCH_ASSOC));
// Raw: within before_date's month, up to but not including before_date
if ($before_date > $month_start) {
$sth = $this->pdo->prepare("
SELECT i.account_code AS acc_code, SUM(i.debit) AS d, SUM(i.credit) AS c
FROM td_gl_item i
JOIN td_gl g ON g.id = i.gl_id AND g.company_id = i.company_id
WHERE i.company_id = :cid
AND g.journal_date >= :ms AND g.journal_date < :bd
AND g.source_type != 'voided'
GROUP BY i.account_code
");
$sth->execute([':cid' => $this->companyId, ':ms' => $month_start, ':bd' => $before_date]);
$merge($sth->fetchAll(PDO::FETCH_ASSOC));
}
return $out;
}
/**
* Sum debits/credits per account for all journal_date up to and including as_of_date.
* Uses ETL for months before as_of_month; raw within as_of_month.
*/
private function aggUpTo(string $as_of_date, int $dept_id = 0): array
{
$month = substr($as_of_date, 0, 7);
$month_start = $month . '-01';
$dept_filter = $dept_id ? 'AND i.department_id = :dept_id' : '';
$out = [];
$merge = function (array $rows) use (&$out) {
foreach ($rows as $r) {
$c = (int)$r['acc_code'];
$out[$c]['debit'] = ($out[$c]['debit'] ?? 0.0) + (float)$r['d'];
$out[$c]['credit'] = ($out[$c]['credit'] ?? 0.0) + (float)$r['c'];
}
};
if ($dept_id !== 0) {
$sth = $this->pdo->prepare("
SELECT i.account_code AS acc_code, SUM(i.debit) AS d, SUM(i.credit) AS c
FROM td_gl_item i
JOIN td_gl g ON g.id = i.gl_id AND g.company_id = i.company_id
WHERE i.company_id = :cid
AND g.journal_date <= :as_of
AND g.source_type != 'voided'
$dept_filter
GROUP BY i.account_code
");
$p = [':cid' => $this->companyId, ':as_of' => $as_of_date];
if ($dept_id) $p[':dept_id'] = $dept_id;
$sth->execute($p);
$merge($sth->fetchAll(PDO::FETCH_ASSOC));
return $out;
}
// ETL: all periods strictly before as_of_month
$sth = $this->pdo->prepare("
SELECT acc_code, SUM(debit) AS d, SUM(credit) AS c
FROM etl_gl_summary
WHERE company_id = :cid AND period < :month
GROUP BY acc_code
");
$sth->execute([':cid' => $this->companyId, ':month' => $month]);
$merge($sth->fetchAll(PDO::FETCH_ASSOC));
// Raw: within as_of_month up to as_of_date
$sth = $this->pdo->prepare("
SELECT i.account_code AS acc_code, SUM(i.debit) AS d, SUM(i.credit) AS c
FROM td_gl_item i
JOIN td_gl g ON g.id = i.gl_id AND g.company_id = i.company_id
WHERE i.company_id = :cid
AND g.journal_date BETWEEN :ms AND :as_of
AND g.source_type != 'voided'
GROUP BY i.account_code
");
$sth->execute([':cid' => $this->companyId, ':ms' => $month_start, ':as_of' => $as_of_date]);
$merge($sth->fetchAll(PDO::FETCH_ASSOC));
return $out;
}
// ── Trial Balance ─────────────────────────────────────────────────────────
// Returns every posting account with brought-forward balance, period movement,
// and carry-forward balance for the selected date range.
@@ -37,68 +253,49 @@ class FinancialReports
$from_date = $this->normalizeDate($from_date);
$to_date = $this->normalizeDate($to_date, true);
$opening_agg = $this->aggBefore($from_date, $dept_id);
$period_agg = $this->aggRange($from_date, $to_date, $dept_id);
$sth = $this->pdo->prepare("
SELECT
a.account_code,
a.account_name,
a.account_type,
COALESCE(SUM(CASE WHEN COALESCE(g.journal_date, DATE(g.created_at)) < :from_date THEN i.debit ELSE 0 END), 0) AS opening_debit,
COALESCE(SUM(CASE WHEN COALESCE(g.journal_date, DATE(g.created_at)) < :from_date THEN i.credit ELSE 0 END), 0) AS opening_credit,
COALESCE(SUM(CASE WHEN COALESCE(g.journal_date, DATE(g.created_at)) BETWEEN :from_date AND :to_date THEN i.debit ELSE 0 END), 0) AS period_debit,
COALESCE(SUM(CASE WHEN COALESCE(g.journal_date, DATE(g.created_at)) BETWEEN :from_date AND :to_date THEN i.credit ELSE 0 END), 0) AS period_credit
FROM md_account a
LEFT JOIN td_gl_item i ON i.company_id = a.company_id
AND i.account_code = a.account_code
AND (:dept_id = 0 OR i.department_id = :dept_id)
LEFT JOIN td_gl g ON g.id = i.gl_id
AND g.company_id = a.company_id
AND COALESCE(g.journal_date, DATE(g.created_at)) <= :to_date
WHERE a.company_id = :cid AND a.is_posting = 1 AND a.status = 1
GROUP BY a.account_code, a.account_name, a.account_type
HAVING opening_debit <> 0
OR opening_credit <> 0
OR period_debit <> 0
OR period_credit <> 0
ORDER BY a.account_code
SELECT account_code, account_name, account_type
FROM md_account
WHERE company_id = :cid AND is_posting = 1 AND status = 1
ORDER BY account_code
");
$sth->execute([
':cid' => $this->companyId,
':from_date' => $from_date,
':to_date' => $to_date,
':dept_id' => $dept_id,
]);
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
$sth->execute([':cid' => $this->companyId]);
$total_opening_debit = 0.0;
$total_opening_credit = 0.0;
$total_period_debit = 0.0;
$total_period_credit = 0.0;
$total_closing_debit = 0.0;
$total_closing_credit = 0.0;
$rows = [];
$total_opening_debit = 0.0; $total_opening_credit = 0.0;
$total_period_debit = 0.0; $total_period_credit = 0.0;
$total_closing_debit = 0.0; $total_closing_credit = 0.0;
foreach ($rows as &$row) {
$opening_balance = (float)$row['opening_debit'] - (float)$row['opening_credit'];
$period_debit = (float)$row['period_debit'];
$period_credit = (float)$row['period_credit'];
$closing_balance = $opening_balance + $period_debit - $period_credit;
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $a) {
$code = (int)$a['account_code'];
$od = $opening_agg[$code]['debit'] ?? 0.0;
$oc = $opening_agg[$code]['credit'] ?? 0.0;
$pd = $period_agg[$code]['debit'] ?? 0.0;
$pc = $period_agg[$code]['credit'] ?? 0.0;
$row['opening_balance'] = $opening_balance;
$row['opening_debit'] = $opening_balance > 0 ? $opening_balance : 0.0;
$row['opening_credit'] = $opening_balance < 0 ? abs($opening_balance) : 0.0;
$row['period_debit'] = $period_debit;
$row['period_credit'] = $period_credit;
$row['closing_balance'] = $closing_balance;
$row['closing_debit'] = $closing_balance > 0 ? $closing_balance : 0.0;
$row['closing_credit'] = $closing_balance < 0 ? abs($closing_balance) : 0.0;
if ($od == 0 && $oc == 0 && $pd == 0 && $pc == 0) continue;
$total_opening_debit += $row['opening_debit'];
$total_opening_credit += $row['opening_credit'];
$total_period_debit += $row['period_debit'];
$total_period_credit += $row['period_credit'];
$total_closing_debit += $row['closing_debit'];
$total_closing_credit += $row['closing_credit'];
$opening_balance = $od - $oc;
$closing_balance = $opening_balance + $pd - $pc;
$a['opening_debit'] = $opening_balance > 0 ? $opening_balance : 0.0;
$a['opening_credit'] = $opening_balance < 0 ? abs($opening_balance) : 0.0;
$a['period_debit'] = $pd;
$a['period_credit'] = $pc;
$a['closing_debit'] = $closing_balance > 0 ? $closing_balance : 0.0;
$a['closing_credit'] = $closing_balance < 0 ? abs($closing_balance) : 0.0;
$rows[] = $a;
$total_opening_debit += $a['opening_debit'];
$total_opening_credit += $a['opening_credit'];
$total_period_debit += $pd;
$total_period_credit += $pc;
$total_closing_debit += $a['closing_debit'];
$total_closing_credit += $a['closing_credit'];
}
unset($row);
return [
'rows' => $rows,
@@ -126,39 +323,35 @@ class FinancialReports
$from_date = $this->normalizeDate($from_date);
$to_date = $this->normalizeDate($to_date, true);
$sth = $this->pdo->prepare("
SELECT
a.account_code,
a.account_name,
a.account_type,
COALESCE(SUM(i.debit), 0) AS total_debit,
COALESCE(SUM(i.credit), 0) AS total_credit
FROM md_account a
JOIN td_gl_item i ON i.company_id = a.company_id
AND i.account_code = a.account_code
AND (:dept_id = 0 OR i.department_id = :dept_id)
JOIN td_gl g ON g.id = i.gl_id
AND g.company_id = a.company_id
AND COALESCE(g.journal_date, DATE(g.created_at)) BETWEEN :from_date AND :to_date
WHERE a.company_id = :cid
AND a.account_type IN ('revenue','expense')
AND a.is_posting = 1
GROUP BY a.account_code, a.account_name, a.account_type
ORDER BY a.account_type DESC, a.account_code
");
$sth->execute([
':cid' => $this->companyId,
':from_date' => $from_date,
':to_date' => $to_date,
':dept_id' => $dept_id,
]);
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
$agg = $this->aggRange($from_date, $to_date, $dept_id);
$sth = $this->pdo->prepare("
SELECT account_code, account_name, account_type
FROM md_account
WHERE company_id = :cid
AND account_type IN ('revenue','expense')
AND is_posting = 1
ORDER BY account_type DESC, account_code
");
$sth->execute([':cid' => $this->companyId]);
$rows = [];
$total_revenue = 0.0;
$total_expense = 0.0;
foreach ($rows as $r) {
if ($r['account_type'] === 'revenue') $total_revenue += $r['total_credit'] - $r['total_debit'];
if ($r['account_type'] === 'expense') $total_expense += $r['total_debit'] - $r['total_credit'];
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $a) {
$code = (int)$a['account_code'];
$d = $agg[$code]['debit'] ?? 0.0;
$c = $agg[$code]['credit'] ?? 0.0;
if ($d == 0 && $c == 0) continue;
$a['total_debit'] = $d;
$a['total_credit'] = $c;
$rows[] = $a;
if ($a['account_type'] === 'revenue') $total_revenue += $c - $d;
if ($a['account_type'] === 'expense') $total_expense += $d - $c;
}
return [
@@ -181,52 +374,41 @@ class FinancialReports
{
$as_of_date = $this->normalizeDate($as_of_date, true);
$sth = $this->pdo->prepare("
SELECT
a.account_code,
a.account_name,
a.account_type,
COALESCE(SUM(i.debit), 0) AS total_debit,
COALESCE(SUM(i.credit), 0) AS total_credit
FROM md_account a
JOIN td_gl_item i ON i.company_id = a.company_id
AND i.account_code = a.account_code
AND (:dept_id = 0 OR i.department_id = :dept_id)
JOIN td_gl g ON g.id = i.gl_id
AND g.company_id = a.company_id
AND COALESCE(g.journal_date, DATE(g.created_at)) <= :as_of
WHERE a.company_id = :cid
AND a.account_type IN ('asset','liability','equity')
AND a.is_posting = 1 AND a.status = 1
GROUP BY a.account_code, a.account_name, a.account_type
ORDER BY a.account_type, a.account_code
");
$sth->execute([':cid' => $this->companyId, ':as_of' => $as_of_date, ':dept_id' => $dept_id]);
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
$agg = $this->aggUpTo($as_of_date, $dept_id);
$sth = $this->pdo->prepare("
SELECT
COALESCE(SUM(CASE WHEN a.account_type = 'revenue'
THEN i.credit - i.debit ELSE 0 END), 0) AS net_revenue,
COALESCE(SUM(CASE WHEN a.account_type = 'expense'
THEN i.debit - i.credit ELSE 0 END), 0) AS net_expense
FROM md_account a
JOIN td_gl_item i ON i.company_id = a.company_id
AND i.account_code = a.account_code
AND (:dept_id = 0 OR i.department_id = :dept_id)
JOIN td_gl g ON g.id = i.gl_id
AND g.company_id = a.company_id
AND COALESCE(g.journal_date, DATE(g.created_at)) <= :as_of
WHERE a.company_id = :cid
AND a.account_type IN ('revenue','expense')
AND a.is_posting = 1
SELECT account_code, account_name, account_type
FROM md_account
WHERE company_id = :cid
AND account_type IN ('asset','liability','equity','revenue','expense')
AND is_posting = 1 AND status = 1
ORDER BY account_type, account_code
");
$sth->execute([':cid' => $this->companyId, ':as_of' => $as_of_date, ':dept_id' => $dept_id]);
$pl = $sth->fetch(PDO::FETCH_ASSOC);
$sth->execute([':cid' => $this->companyId]);
$rows = [];
$net_revenue = 0.0;
$net_expense = 0.0;
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $a) {
$code = (int)$a['account_code'];
$d = $agg[$code]['debit'] ?? 0.0;
$c = $agg[$code]['credit'] ?? 0.0;
if (in_array($a['account_type'], ['asset', 'liability', 'equity'])) {
if ($d == 0 && $c == 0) continue;
$a['total_debit'] = $d;
$a['total_credit'] = $c;
$rows[] = $a;
} else {
if ($a['account_type'] === 'revenue') $net_revenue += $c - $d;
if ($a['account_type'] === 'expense') $net_expense += $d - $c;
}
}
return [
'rows' => $rows,
'retained_earnings' => (float)($pl['net_revenue'] ?? 0) - (float)($pl['net_expense'] ?? 0),
'retained_earnings' => $net_revenue - $net_expense,
'as_of_date' => $as_of_date,
'as_of_period' => substr($as_of_date, 0, 7),
];
@@ -325,7 +507,6 @@ class FinancialReports
'revenue' => $revenue,
'expense' => $expense,
'net_profit' => $revenue - $expense,
'etl_gaps' => $this->getEtlGaps(),
];
}
@@ -448,20 +629,28 @@ class FinancialReports
];
}
// Returns periods that exist in td_gl but are missing from etl_gl_summary.
// Used by getDashboardPL() to signal which periods need a rebuild queue job.
// Returns periods where td_gl is newer than etl_gl_summary (drift detected)
// or where ETL is missing entirely. Used to signal which periods need a rebuild.
private function getEtlGaps(string $from_period = ''): array
{
$period_filter = $from_period ? "AND g.period >= :from_period" : "";
$sql = "
SELECT DISTINCT g.period
FROM td_gl g
LEFT JOIN (
SELECT DISTINCT period FROM etl_gl_summary WHERE company_id = :cid2
SELECT period, MIN(source_updated_at) AS etl_ts
FROM etl_gl_summary
WHERE company_id = :cid2
GROUP BY period
) etl ON etl.period = g.period
WHERE g.company_id = :cid
AND g.source_type NOT IN ('voided', 'reversal')
AND etl.period IS NULL
" . ($from_period ? "AND g.period >= :from_period" : "") . "
$period_filter
AND (
etl.period IS NULL
OR g.updated_at > etl.etl_ts
)
ORDER BY g.period
";
$params = [':cid' => $this->companyId, ':cid2' => $this->companyId];