gl aggregate table (ETL), cronjob by NODEJS
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* BatchOverlay — shared full-screen progress overlay.
|
||||
*
|
||||
* Usage:
|
||||
* BatchOverlay.show(title, total, options)
|
||||
* options: { unit: 'documents', color: '#dc3545' }
|
||||
*
|
||||
* BatchOverlay.update(done, total, currentItem)
|
||||
* Advances the bar and ETA. currentItem shown in the sub-line.
|
||||
*
|
||||
* BatchOverlay.done(message, onClose)
|
||||
* Switches to "complete" state — green checkmark + Done button.
|
||||
* onClose fires when the user dismisses.
|
||||
*
|
||||
* BatchOverlay.hide()
|
||||
* Force-hides (use for error bail-outs before done is reached).
|
||||
*/
|
||||
var BatchOverlay = (function () {
|
||||
|
||||
var _startMs = 0;
|
||||
var _onClose = null;
|
||||
var _unit = 'items';
|
||||
var _color = '#0d6efd';
|
||||
var _injected = false;
|
||||
|
||||
var _HTML = '<div id="bos-wrap" style="display:none;position:fixed;inset:0;z-index:9999;' +
|
||||
'background:rgba(0,0,0,0.55);align-items:center;justify-content:center;">' +
|
||||
'<div class="card shadow-lg p-4 text-center" style="min-width:380px;max-width:480px;width:90%;">' +
|
||||
'<div id="bos-spinner" class="spinner-border mx-auto mb-3" role="status" style="width:2.5rem;height:2.5rem;"></div>' +
|
||||
'<div id="bos-icon" class="mx-auto mb-3" style="display:none;font-size:2.5rem;color:#198754;"><i class="ti ti-circle-check"></i></div>' +
|
||||
'<div class="fw-semibold fs-6 mb-1" id="bos-title">Processing…</div>' +
|
||||
'<div class="text-muted small mb-4" id="bos-sub"></div>' +
|
||||
'<div id="bos-pct" style="font-size:3rem;font-weight:700;line-height:1;letter-spacing:-1px;">0%</div>' +
|
||||
'<div class="text-muted small mb-3" id="bos-count"></div>' +
|
||||
'<div class="progress mb-2" style="height:10px;border-radius:6px;">' +
|
||||
'<div id="bos-bar" class="progress-bar progress-bar-striped progress-bar-animated"' +
|
||||
' role="progressbar" style="width:0%;border-radius:6px;"' +
|
||||
' aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"></div>' +
|
||||
'</div>' +
|
||||
'<div class="text-muted small" id="bos-eta"> </div>' +
|
||||
'<button id="bos-close" class="btn btn-success mt-3" style="display:none;">' +
|
||||
'<i class="ti ti-check me-1"></i>Done' +
|
||||
'</button>' +
|
||||
'</div></div>';
|
||||
|
||||
function _inject() {
|
||||
if (_injected) return;
|
||||
$('body').append(_HTML);
|
||||
$('#bos-close').on('click', function () {
|
||||
_resetState();
|
||||
$('#bos-wrap').hide();
|
||||
if (_onClose) { _onClose(); _onClose = null; }
|
||||
});
|
||||
_injected = true;
|
||||
}
|
||||
|
||||
function _resetState() {
|
||||
$('#bos-spinner').show().css('color', _color);
|
||||
$('#bos-icon').hide();
|
||||
$('#bos-pct').css('color', _color);
|
||||
$('#bos-bar')
|
||||
.addClass('progress-bar-striped progress-bar-animated')
|
||||
.removeClass('bg-success')
|
||||
.css('background-color', _color)
|
||||
.css('width', '0%').attr('aria-valuenow', 0);
|
||||
$('#bos-close').hide();
|
||||
$('#bos-eta').html(' ');
|
||||
}
|
||||
|
||||
function show(title, total, options) {
|
||||
options = options || {};
|
||||
_unit = options.unit || 'items';
|
||||
_color = options.color || '#0d6efd';
|
||||
_onClose = options.onClose || null;
|
||||
_startMs = Date.now();
|
||||
|
||||
_inject();
|
||||
_resetState();
|
||||
|
||||
$('#bos-title').text(title);
|
||||
$('#bos-sub').text('Starting…');
|
||||
$('#bos-pct').text('0%');
|
||||
$('#bos-count').text('0 / ' + total + ' ' + _unit);
|
||||
$('#bos-wrap').css('display', 'flex');
|
||||
}
|
||||
|
||||
function update(done, total, currentItem) {
|
||||
var pct = Math.round(done / total * 100);
|
||||
var elapsed = (Date.now() - _startMs) / 1000;
|
||||
var eta = ' ';
|
||||
if (done > 0) {
|
||||
var remaining = Math.round((elapsed / done) * (total - done));
|
||||
eta = remaining > 0 ? ('~' + remaining + 's remaining') : 'almost done…';
|
||||
}
|
||||
$('#bos-pct').text(pct + '%');
|
||||
$('#bos-bar').css('width', pct + '%').attr('aria-valuenow', pct);
|
||||
$('#bos-count').text(done + ' / ' + total + ' ' + _unit);
|
||||
if (currentItem !== undefined) $('#bos-sub').text('Processing ' + currentItem + '…');
|
||||
$('#bos-eta').html(eta);
|
||||
}
|
||||
|
||||
function done(message, onClose) {
|
||||
_onClose = onClose || null;
|
||||
$('#bos-spinner').hide();
|
||||
$('#bos-icon').show();
|
||||
$('#bos-pct').text('100%').css('color', '#198754');
|
||||
$('#bos-bar')
|
||||
.removeClass('progress-bar-striped progress-bar-animated')
|
||||
.css('background-color', '').addClass('bg-success')
|
||||
.css('width', '100%').attr('aria-valuenow', 100);
|
||||
$('#bos-sub').text(message || 'Complete.');
|
||||
$('#bos-eta').html(' ');
|
||||
$('#bos-close').show();
|
||||
}
|
||||
|
||||
function hide() {
|
||||
if (_injected) $('#bos-wrap').hide();
|
||||
}
|
||||
|
||||
return { show: show, update: update, done: done, hide: hide };
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
/**
|
||||
* EtlManager
|
||||
*
|
||||
* Manages etl_gl_summary — the materialized aggregation of td_gl_item by period.
|
||||
* Used for fast dashboard queries instead of scanning raw GL rows.
|
||||
*
|
||||
* rebuildFull() — wipe and rebuild all periods for this company (manual trigger)
|
||||
* rebuildPeriod() — rebuild a single period (nightly scheduler, gap repair)
|
||||
* getStatus() — return last run info from company_setting
|
||||
*/
|
||||
class EtlManager
|
||||
{
|
||||
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 $period) {
|
||||
$this->rebuildPeriod($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
|
||||
{
|
||||
$sth = $this->pdo->prepare("
|
||||
SELECT DISTINCT g.period
|
||||
FROM td_gl g
|
||||
LEFT JOIN (
|
||||
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
|
||||
OR g.updated_at > etl.etl_ts
|
||||
)
|
||||
ORDER BY g.period
|
||||
");
|
||||
$sth->execute([':cid' => $this->companyId, ':cid2' => $this->companyId]);
|
||||
return $sth->fetchAll(PDO::FETCH_COLUMN);
|
||||
}
|
||||
|
||||
public function rebuildFull(): array
|
||||
{
|
||||
$this->pdo->prepare(
|
||||
"DELETE FROM etl_gl_summary WHERE company_id = :cid"
|
||||
)->execute([':cid' => $this->companyId]);
|
||||
|
||||
$this->pdo->prepare("
|
||||
INSERT INTO etl_gl_summary
|
||||
(company_id, acc_code, period, debit, credit, count, source_updated_at, updated_at)
|
||||
SELECT
|
||||
i.company_id,
|
||||
i.account_code,
|
||||
g.period,
|
||||
SUM(i.debit),
|
||||
SUM(i.credit),
|
||||
COUNT(*),
|
||||
MAX(g.updated_at),
|
||||
NOW()
|
||||
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.source_type != 'voided'
|
||||
GROUP BY i.company_id, i.account_code, g.period
|
||||
")->execute([':cid' => $this->companyId]);
|
||||
|
||||
$periods_rebuilt = (int)$this->pdo->query(
|
||||
"SELECT COUNT(DISTINCT period) FROM etl_gl_summary WHERE company_id = {$this->companyId}"
|
||||
)->fetchColumn();
|
||||
|
||||
$this->saveStatus('ok', 0, $periods_rebuilt);
|
||||
|
||||
return ['periods_rebuilt' => $periods_rebuilt];
|
||||
}
|
||||
|
||||
public function rebuildPeriod(string $period): void
|
||||
{
|
||||
$this->pdo->prepare(
|
||||
"DELETE FROM etl_gl_summary WHERE company_id = :cid AND period = :period"
|
||||
)->execute([':cid' => $this->companyId, ':period' => $period]);
|
||||
|
||||
$this->pdo->prepare("
|
||||
INSERT INTO etl_gl_summary
|
||||
(company_id, acc_code, period, debit, credit, count, source_updated_at, updated_at)
|
||||
SELECT
|
||||
i.company_id,
|
||||
i.account_code,
|
||||
g.period,
|
||||
SUM(i.debit),
|
||||
SUM(i.credit),
|
||||
COUNT(*),
|
||||
MAX(g.updated_at),
|
||||
NOW()
|
||||
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.period = :period
|
||||
AND g.source_type != 'voided'
|
||||
GROUP BY i.company_id, i.account_code, g.period
|
||||
")->execute([':cid' => $this->companyId, ':period' => $period]);
|
||||
}
|
||||
|
||||
public function getStatus(): array
|
||||
{
|
||||
$keys = ['etl_last_ran', 'etl_last_result', 'etl_gaps_found', 'etl_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_last_ran'] ?? null;
|
||||
$next_run = $last_ran
|
||||
? date('Y-m-d', strtotime($last_ran . ' +1 day')) . sprintf(' %02d:00', $slot_hour)
|
||||
: date('Y-m-d') . sprintf(' %02d:00', $slot_hour);
|
||||
|
||||
return [
|
||||
'slot_hour' => $slot_hour,
|
||||
'last_ran' => $last_ran,
|
||||
'last_result' => $rows['etl_last_result'] ?? null,
|
||||
'gaps_found' => (int)($rows['etl_gaps_found'] ?? 0),
|
||||
'periods_rebuilt' => (int)($rows['etl_periods_rebuilt'] ?? 0),
|
||||
'next_run' => $next_run,
|
||||
];
|
||||
}
|
||||
|
||||
// ── Private ──────────────────────────────────────────────────────────────
|
||||
|
||||
private function saveStatus(string $result, int $gaps_found, int $periods_rebuilt): void
|
||||
{
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$data = [
|
||||
'etl_last_ran' => $now,
|
||||
'etl_last_result' => $result,
|
||||
'etl_gaps_found' => (string)$gaps_found,
|
||||
'etl_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,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];
|
||||
|
||||
@@ -71,7 +71,7 @@ class GlManager
|
||||
':updated_at' => $now,
|
||||
]);
|
||||
$this->insertLines((int)$this->pdo->lastInsertId(), $lines);
|
||||
$this->upsertEtl($lines, $period);
|
||||
$this->upsertEtl($lines, $period, 1, $now);
|
||||
}
|
||||
|
||||
public function replaceManual(int $gl_id, string $reference, string $description, string $journal_date, string $period, array $lines): void
|
||||
@@ -126,8 +126,8 @@ class GlManager
|
||||
]);
|
||||
|
||||
$this->insertLines($gl_id, $lines);
|
||||
$this->upsertEtl($old_lines, $old_period, -1);
|
||||
$this->upsertEtl($lines, $period);
|
||||
$this->upsertEtl($old_lines, $old_period, -1, $now);
|
||||
$this->upsertEtl($lines, $period, 1, $now);
|
||||
}
|
||||
|
||||
public function postManual(string $reference, string $description, string $journal_date, string $period, array $lines): int
|
||||
@@ -154,7 +154,7 @@ class GlManager
|
||||
]);
|
||||
$gl_id = (int)$this->pdo->lastInsertId();
|
||||
$this->insertLines($gl_id, $lines);
|
||||
$this->upsertEtl($lines, $period);
|
||||
$this->upsertEtl($lines, $period, 1, $now);
|
||||
return $gl_id;
|
||||
}
|
||||
|
||||
@@ -163,7 +163,7 @@ class GlManager
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id, current_version, formula_id, history, journal_date
|
||||
"SELECT id, current_version, formula_id, history, journal_date, period
|
||||
FROM td_gl
|
||||
WHERE company_id = :cid
|
||||
AND source_type = :source_type
|
||||
@@ -220,8 +220,8 @@ class GlManager
|
||||
]);
|
||||
|
||||
$this->insertLines($gl_id, $lines);
|
||||
$this->upsertEtl($old_lines, $old_period, -1);
|
||||
$this->upsertEtl($lines, $period);
|
||||
$this->upsertEtl($old_lines, $old_period, -1, $now);
|
||||
$this->upsertEtl($lines, $period, 1, $now);
|
||||
}
|
||||
|
||||
public function delete(string $source_type, int $source_id): void
|
||||
@@ -278,7 +278,7 @@ class GlManager
|
||||
]);
|
||||
$reversal_gl_id = (int)$this->pdo->lastInsertId();
|
||||
$this->insertLines($reversal_gl_id, $reversal_lines);
|
||||
$this->upsertEtl($reversal_lines, $gl['period']);
|
||||
$this->upsertEtl($reversal_lines, $gl['period'], 1, $now);
|
||||
}
|
||||
|
||||
// Mark original as voided — keeps audit trail; getBySource() won't match 'voided' source_type
|
||||
@@ -342,20 +342,22 @@ class GlManager
|
||||
}
|
||||
|
||||
// sign=1 to add, sign=-1 to subtract (used when replacing old lines)
|
||||
private function upsertEtl(array $lines, string $period, int $sign = 1): void
|
||||
private function upsertEtl(array $lines, string $period, int $sign = 1, string $now = ''): void
|
||||
{
|
||||
if (empty($lines) || empty($period)) return;
|
||||
if (!$now) $now = date('Y-m-d H:i:s');
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"INSERT INTO etl_gl_summary
|
||||
(company_id, acc_code, period, debit, credit, count, updated_at)
|
||||
(company_id, acc_code, period, debit, credit, count, source_updated_at, updated_at)
|
||||
VALUES
|
||||
(:cid, :acc, :period, :debit, :credit, :cnt, NOW())
|
||||
(:cid, :acc, :period, :debit, :credit, :cnt, :src_ts, :src_ts)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
debit = debit + VALUES(debit),
|
||||
credit = credit + VALUES(credit),
|
||||
count = count + VALUES(count),
|
||||
updated_at = NOW()"
|
||||
debit = debit + VALUES(debit),
|
||||
credit = credit + VALUES(credit),
|
||||
count = count + VALUES(count),
|
||||
source_updated_at = GREATEST(COALESCE(source_updated_at, VALUES(source_updated_at)), VALUES(source_updated_at)),
|
||||
updated_at = VALUES(updated_at)"
|
||||
);
|
||||
foreach ($lines as $line) {
|
||||
$sth->execute([
|
||||
@@ -365,6 +367,7 @@ class GlManager
|
||||
':debit' => (float)($line['debit'] ?? 0) * $sign,
|
||||
':credit' => (float)($line['credit'] ?? 0) * $sign,
|
||||
':cnt' => $sign,
|
||||
':src_ts' => $now,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
/**
|
||||
* cron_auth.php — Secret-based auth for cron endpoints called by Node.js scheduler.
|
||||
* No session required. Validates x-cron-secret header against NODE_EMIT_SECRET.
|
||||
* Sets up $pdo1, $pdo2, and $answer identical to db_auth.php.
|
||||
*/
|
||||
ini_set('display_errors', 0);
|
||||
ini_set('log_errors', 1);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
require_once __DIR__ . '/../../config.php';
|
||||
require_once __DIR__ . '/../../dbconn.php';
|
||||
|
||||
$secret = $_SERVER['HTTP_X_CRON_SECRET'] ?? '';
|
||||
if (!defined('NODE_EMIT_SECRET') || $secret !== NODE_EMIT_SECRET) {
|
||||
http_response_code(403);
|
||||
exit(json_encode(['success' => 0, 'message' => 'Forbidden']));
|
||||
}
|
||||
|
||||
$data = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||
$answer = ['success' => 0];
|
||||
Reference in New Issue
Block a user