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
+6 -72
View File
@@ -69,35 +69,6 @@
<?php require '../include_ending.php'; ?> <?php require '../include_ending.php'; ?>
<!-- Batch processing overlay -->
<div id="batch-overlay" 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 class="spinner-border text-primary mx-auto mb-3" role="status" style="width:2.5rem; height:2.5rem;"></div>
<div class="fw-semibold fs-6 mb-1" id="batch-overlay-title">Processing…</div>
<div class="text-muted small mb-4" id="batch-overlay-sub"></div>
<!-- Large percentage number -->
<div id="batch-overlay-pct"
style="font-size:3rem; font-weight:700; line-height:1; letter-spacing:-1px; color:#0d6efd;">
0%
</div>
<div class="text-muted small mb-3" id="batch-overlay-count">0 / 0 documents</div>
<!-- Progress bar (pure visual fill, no text inside) -->
<div class="progress mb-2" style="height:10px; border-radius:6px;">
<div id="batch-overlay-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="batch-overlay-eta">&nbsp;</div>
</div>
</div>
<script> <script>
// (batch_process lives in custom.js) // (batch_process lives in custom.js)
@@ -247,37 +218,14 @@
return '<span class="badge bg-success-subtle text-success">Mapped</span>'; return '<span class="badge bg-success-subtle text-success">Mapped</span>';
} }
// ── batch overlay helpers ───────────────────────────────────────────────── // ── batch overlay helpers — delegated to shared BatchOverlay ─────────────
var _batch_start_ms = 0;
function show_batch_overlay(label, total) { function show_batch_overlay(label, total) {
_batch_start_ms = Date.now(); BatchOverlay.show('Batch GL Posting — ' + label, total, { unit: 'documents' });
$('#batch-overlay-title').text('Batch GL Posting — ' + label);
$('#batch-overlay-sub').text('Starting…');
$('#batch-overlay-pct').text('0%');
$('#batch-overlay-bar').css('width', '0%').attr('aria-valuenow', 0);
$('#batch-overlay-count').text('0 / ' + total + ' documents');
$('#batch-overlay-eta').html('&nbsp;');
$('#batch-overlay').css('display', 'flex');
} }
function update_batch_overlay(done, total) { function update_batch_overlay(done, total) {
var pct = Math.round(done / total * 100); BatchOverlay.update(done, total, 'document ' + done + ' of ' + total);
var elapsed = (Date.now() - _batch_start_ms) / 1000;
var eta_txt = '&nbsp;';
if (done > 0) {
var remaining = Math.round((elapsed / done) * (total - done));
eta_txt = remaining > 0 ? ('~' + remaining + 's remaining') : 'almost done…';
}
$('#batch-overlay-pct').text(pct + '%');
$('#batch-overlay-bar').css('width', pct + '%').attr('aria-valuenow', pct);
$('#batch-overlay-count').text(done + ' / ' + total + ' documents');
$('#batch-overlay-sub').text('Processing document ' + done + ' of ' + total + '…');
$('#batch-overlay-eta').html(eta_txt);
}
function hide_batch_overlay() {
$('#batch-overlay').hide();
} }
// ── run batch over all loaded IDs ───────────────────────────────────────── // ── run batch over all loaded IDs ─────────────────────────────────────────
@@ -323,15 +271,11 @@
function _start_gl_batch(doc_type, ids) { function _start_gl_batch(doc_type, ids) {
var formula_id = $('#gl-formula-' + doc_type).val(); var formula_id = $('#gl-formula-' + doc_type).val();
var $prog = $('#gl-progress-' + doc_type);
var $bar = $prog.find('.progress-bar');
var $msg = $('#gl-msg-' + doc_type); var $msg = $('#gl-msg-' + doc_type);
set_btn_state('#gl-btn-load-' + doc_type, false); set_btn_state('#gl-btn-load-' + doc_type, false);
set_btn_state('#gl-btn-run-' + doc_type, false); set_btn_state('#gl-btn-run-' + doc_type, false);
$msg.addClass('d-none').empty(); $msg.addClass('d-none').empty();
$prog.removeClass('d-none');
$bar.css('width', '0%').text('0%');
show_batch_overlay(DOC_LABELS[doc_type] || doc_type, ids.length); show_batch_overlay(DOC_LABELS[doc_type] || doc_type, ids.length);
@@ -351,28 +295,24 @@
}; };
}, },
function(done, total) { function(done, total) {
var pct = Math.round(done / total * 100);
$bar.css('width', pct + '%').attr('aria-valuenow', pct).text(pct + '%');
update_batch_overlay(done, total); update_batch_overlay(done, total);
}, },
function(failed) { function(failed) {
// Always release DB lock — whether succeeded or failed // Always release DB lock — whether succeeded or failed
release_op_lock('gl_post'); release_op_lock('gl_post');
hide_batch_overlay();
$prog.addClass('d-none');
var succeeded = ids.length - failed.length; var succeeded = ids.length - failed.length;
if (failed.length === 0) { if (failed.length === 0) {
_gl_failed[doc_type] = []; _gl_failed[doc_type] = [];
$msg.removeClass('d-none alert-danger').addClass('alert alert-success') BatchOverlay.done(succeeded + ' document(s) posted successfully.');
.text('Done. ' + succeeded + ' document(s) posted successfully.');
} else { } else {
_gl_failed[doc_type] = failed; _gl_failed[doc_type] = failed;
var nums = failed.map(function(f) { return f.item; }).join(', '); var nums = failed.map(function(f) { return f.item; }).join(', ');
var detail = failed.map(function(f) { var detail = failed.map(function(f) {
return escape_html(f.item + (f.error ? ': ' + f.error : '')); return escape_html(f.item + (f.error ? ': ' + f.error : ''));
}).join('<br>'); }).join('<br>');
BatchOverlay.done(failed.length + ' failed · ' + succeeded + ' succeeded.', function() {
$msg.removeClass('d-none alert-success').addClass('alert alert-danger') $msg.removeClass('d-none alert-success').addClass('alert alert-danger')
.html('<strong>' + failed.length + ' failed</strong> after 3 retries (IDs: ' .html('<strong>' + failed.length + ' failed</strong> after 3 retries (IDs: '
+ escape_html(nums) + '). ' + succeeded + ' succeeded.' + escape_html(nums) + '). ' + succeeded + ' succeeded.'
@@ -380,6 +320,7 @@
+ ' <button class="btn btn-sm btn-warning ms-2"' + ' <button class="btn btn-sm btn-warning ms-2"'
+ ' onclick="retry_gl_batch(\'' + doc_type + '\')">' + ' onclick="retry_gl_batch(\'' + doc_type + '\')">'
+ '<i class="ti ti-refresh me-1"></i>Retry Failed</button>'); + '<i class="ti ti-refresh me-1"></i>Retry Failed</button>');
});
} }
// Log the batch action // Log the batch action
@@ -487,13 +428,6 @@ function gl_tab_html(string $doc_type, string $label): string {
<!-- Summary badge --> <!-- Summary badge -->
<div id="gl-badge-{$doc_type}" class="text-muted small mb-2"></div> <div id="gl-badge-{$doc_type}" class="text-muted small mb-2"></div>
<!-- Progress bar -->
<div id="gl-progress-{$doc_type}" class="progress mb-3 d-none" style="height:22px;">
<div class="progress-bar progress-bar-striped progress-bar-animated"
role="progressbar" style="width:0%" aria-valuenow="0"
aria-valuemin="0" aria-valuemax="100">0 / 0</div>
</div>
<!-- Result message --> <!-- Result message -->
<div id="gl-msg-{$doc_type}" class="d-none mb-3"></div> <div id="gl-msg-{$doc_type}" class="d-none mb-3"></div>
+122
View File
@@ -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">&nbsp;</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('&nbsp;');
}
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 = '&nbsp;';
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('&nbsp;');
$('#bos-close').show();
}
function hide() {
if (_injected) $('#bos-wrap').hide();
}
return { show: show, update: update, done: done, hide: hide };
})();
+172
View File
@@ -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]);
}
}
}
+322 -133
View File
@@ -2,8 +2,15 @@
/** /**
* FinancialReports * FinancialReports
* *
* Generates all financial statements from td_gl + td_gl_item joined with md_account. * Generates all financial statements using a mixed ETL + raw strategy:
* Read-only — no writes. All results are reusable across API endpoints and future reports. * - 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 class FinancialReports
{ {
@@ -16,6 +23,215 @@ class FinancialReports
$this->companyId = $company_id; $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 ───────────────────────────────────────────────────────── // ── Trial Balance ─────────────────────────────────────────────────────────
// Returns every posting account with brought-forward balance, period movement, // Returns every posting account with brought-forward balance, period movement,
// and carry-forward balance for the selected date range. // and carry-forward balance for the selected date range.
@@ -37,68 +253,49 @@ class FinancialReports
$from_date = $this->normalizeDate($from_date); $from_date = $this->normalizeDate($from_date);
$to_date = $this->normalizeDate($to_date, true); $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(" $sth = $this->pdo->prepare("
SELECT SELECT account_code, account_name, account_type
a.account_code, FROM md_account
a.account_name, WHERE company_id = :cid AND is_posting = 1 AND status = 1
a.account_type, ORDER BY account_code
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
"); ");
$sth->execute([ $sth->execute([':cid' => $this->companyId]);
':cid' => $this->companyId,
':from_date' => $from_date,
':to_date' => $to_date,
':dept_id' => $dept_id,
]);
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
$total_opening_debit = 0.0; $rows = [];
$total_opening_credit = 0.0; $total_opening_debit = 0.0; $total_opening_credit = 0.0;
$total_period_debit = 0.0; $total_period_debit = 0.0; $total_period_credit = 0.0;
$total_period_credit = 0.0; $total_closing_debit = 0.0; $total_closing_credit = 0.0;
$total_closing_debit = 0.0;
$total_closing_credit = 0.0;
foreach ($rows as &$row) { foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $a) {
$opening_balance = (float)$row['opening_debit'] - (float)$row['opening_credit']; $code = (int)$a['account_code'];
$period_debit = (float)$row['period_debit']; $od = $opening_agg[$code]['debit'] ?? 0.0;
$period_credit = (float)$row['period_credit']; $oc = $opening_agg[$code]['credit'] ?? 0.0;
$closing_balance = $opening_balance + $period_debit - $period_credit; $pd = $period_agg[$code]['debit'] ?? 0.0;
$pc = $period_agg[$code]['credit'] ?? 0.0;
$row['opening_balance'] = $opening_balance; if ($od == 0 && $oc == 0 && $pd == 0 && $pc == 0) continue;
$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;
$total_opening_debit += $row['opening_debit']; $opening_balance = $od - $oc;
$total_opening_credit += $row['opening_credit']; $closing_balance = $opening_balance + $pd - $pc;
$total_period_debit += $row['period_debit'];
$total_period_credit += $row['period_credit']; $a['opening_debit'] = $opening_balance > 0 ? $opening_balance : 0.0;
$total_closing_debit += $row['closing_debit']; $a['opening_credit'] = $opening_balance < 0 ? abs($opening_balance) : 0.0;
$total_closing_credit += $row['closing_credit']; $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 [ return [
'rows' => $rows, 'rows' => $rows,
@@ -126,39 +323,35 @@ class FinancialReports
$from_date = $this->normalizeDate($from_date); $from_date = $this->normalizeDate($from_date);
$to_date = $this->normalizeDate($to_date, true); $to_date = $this->normalizeDate($to_date, true);
$sth = $this->pdo->prepare(" $agg = $this->aggRange($from_date, $to_date, $dept_id);
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);
$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_revenue = 0.0;
$total_expense = 0.0; $total_expense = 0.0;
foreach ($rows as $r) {
if ($r['account_type'] === 'revenue') $total_revenue += $r['total_credit'] - $r['total_debit']; foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $a) {
if ($r['account_type'] === 'expense') $total_expense += $r['total_debit'] - $r['total_credit']; $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 [ return [
@@ -181,52 +374,41 @@ class FinancialReports
{ {
$as_of_date = $this->normalizeDate($as_of_date, true); $as_of_date = $this->normalizeDate($as_of_date, true);
$sth = $this->pdo->prepare(" $agg = $this->aggUpTo($as_of_date, $dept_id);
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);
$sth = $this->pdo->prepare(" $sth = $this->pdo->prepare("
SELECT SELECT account_code, account_name, account_type
COALESCE(SUM(CASE WHEN a.account_type = 'revenue' FROM md_account
THEN i.credit - i.debit ELSE 0 END), 0) AS net_revenue, WHERE company_id = :cid
COALESCE(SUM(CASE WHEN a.account_type = 'expense' AND account_type IN ('asset','liability','equity','revenue','expense')
THEN i.debit - i.credit ELSE 0 END), 0) AS net_expense AND is_posting = 1 AND status = 1
FROM md_account a ORDER BY account_type, account_code
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
"); ");
$sth->execute([':cid' => $this->companyId, ':as_of' => $as_of_date, ':dept_id' => $dept_id]); $sth->execute([':cid' => $this->companyId]);
$pl = $sth->fetch(PDO::FETCH_ASSOC);
$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 [ return [
'rows' => $rows, '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_date' => $as_of_date,
'as_of_period' => substr($as_of_date, 0, 7), 'as_of_period' => substr($as_of_date, 0, 7),
]; ];
@@ -325,7 +507,6 @@ class FinancialReports
'revenue' => $revenue, 'revenue' => $revenue,
'expense' => $expense, 'expense' => $expense,
'net_profit' => $revenue - $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. // Returns periods where td_gl is newer than etl_gl_summary (drift detected)
// Used by getDashboardPL() to signal which periods need a rebuild queue job. // or where ETL is missing entirely. Used to signal which periods need a rebuild.
private function getEtlGaps(string $from_period = ''): array private function getEtlGaps(string $from_period = ''): array
{ {
$period_filter = $from_period ? "AND g.period >= :from_period" : "";
$sql = " $sql = "
SELECT DISTINCT g.period SELECT DISTINCT g.period
FROM td_gl g FROM td_gl g
LEFT JOIN ( 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 ) etl ON etl.period = g.period
WHERE g.company_id = :cid WHERE g.company_id = :cid
AND g.source_type NOT IN ('voided', 'reversal') AND g.source_type NOT IN ('voided', 'reversal')
AND etl.period IS NULL $period_filter
" . ($from_period ? "AND g.period >= :from_period" : "") . " AND (
etl.period IS NULL
OR g.updated_at > etl.etl_ts
)
ORDER BY g.period ORDER BY g.period
"; ";
$params = [':cid' => $this->companyId, ':cid2' => $this->companyId]; $params = [':cid' => $this->companyId, ':cid2' => $this->companyId];
+15 -12
View File
@@ -71,7 +71,7 @@ class GlManager
':updated_at' => $now, ':updated_at' => $now,
]); ]);
$this->insertLines((int)$this->pdo->lastInsertId(), $lines); $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 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->insertLines($gl_id, $lines);
$this->upsertEtl($old_lines, $old_period, -1); $this->upsertEtl($old_lines, $old_period, -1, $now);
$this->upsertEtl($lines, $period); $this->upsertEtl($lines, $period, 1, $now);
} }
public function postManual(string $reference, string $description, string $journal_date, string $period, array $lines): int 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(); $gl_id = (int)$this->pdo->lastInsertId();
$this->insertLines($gl_id, $lines); $this->insertLines($gl_id, $lines);
$this->upsertEtl($lines, $period); $this->upsertEtl($lines, $period, 1, $now);
return $gl_id; return $gl_id;
} }
@@ -163,7 +163,7 @@ class GlManager
$now = date('Y-m-d H:i:s'); $now = date('Y-m-d H:i:s');
$sth = $this->pdo->prepare( $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 FROM td_gl
WHERE company_id = :cid WHERE company_id = :cid
AND source_type = :source_type AND source_type = :source_type
@@ -220,8 +220,8 @@ class GlManager
]); ]);
$this->insertLines($gl_id, $lines); $this->insertLines($gl_id, $lines);
$this->upsertEtl($old_lines, $old_period, -1); $this->upsertEtl($old_lines, $old_period, -1, $now);
$this->upsertEtl($lines, $period); $this->upsertEtl($lines, $period, 1, $now);
} }
public function delete(string $source_type, int $source_id): void public function delete(string $source_type, int $source_id): void
@@ -278,7 +278,7 @@ class GlManager
]); ]);
$reversal_gl_id = (int)$this->pdo->lastInsertId(); $reversal_gl_id = (int)$this->pdo->lastInsertId();
$this->insertLines($reversal_gl_id, $reversal_lines); $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 // 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) // 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 (empty($lines) || empty($period)) return;
if (!$now) $now = date('Y-m-d H:i:s');
$sth = $this->pdo->prepare( $sth = $this->pdo->prepare(
"INSERT INTO etl_gl_summary "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 VALUES
(:cid, :acc, :period, :debit, :credit, :cnt, NOW()) (:cid, :acc, :period, :debit, :credit, :cnt, :src_ts, :src_ts)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
debit = debit + VALUES(debit), debit = debit + VALUES(debit),
credit = credit + VALUES(credit), credit = credit + VALUES(credit),
count = count + VALUES(count), count = count + VALUES(count),
updated_at = NOW()" 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) { foreach ($lines as $line) {
$sth->execute([ $sth->execute([
@@ -365,6 +367,7 @@ class GlManager
':debit' => (float)($line['debit'] ?? 0) * $sign, ':debit' => (float)($line['debit'] ?? 0) * $sign,
':credit' => (float)($line['credit'] ?? 0) * $sign, ':credit' => (float)($line['credit'] ?? 0) * $sign,
':cnt' => $sign, ':cnt' => $sign,
':src_ts' => $now,
]); ]);
} }
} }
+21
View File
@@ -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];
+43
View File
@@ -0,0 +1,43 @@
<?php
/**
* etl_gl_maintenance.php — Called by Node.js scheduler every hour.
* Checks and repairs etl_gl_summary (GL summary) for companies in the current slot.
* For stock ETL maintenance use etl_stock_maintenance.php (not yet implemented).
*
* Body: { slot: 14 } (0–23, = HOUR(NOW()) from Node.js)
*/
require '../../assets/utils/cron_auth.php';
require_once '../../assets/utils/classes_ac/EtlManager.php';
$slot = (int)($data['slot'] ?? -1);
if ($slot < 0 || $slot > 23) {
http_response_code(400);
exit(json_encode(['success' => 0, 'message' => 'Invalid slot.']));
}
// Fetch companies whose slot matches this hour
$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 EtlManager($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));
+1
View File
@@ -67,6 +67,7 @@ header('Referrer-Policy: strict-origin-when-cross-origin');
<script type="module" src="<?php echo $server_url?>assets/js/main.js"></script> <script type="module" src="<?php echo $server_url?>assets/js/main.js"></script>
<link rel="stylesheet" href="<?php echo $server_url?>assets/css/custom.css"> <link rel="stylesheet" href="<?php echo $server_url?>assets/css/custom.css">
<script src="<?php echo $server_url?>assets/js/custom.js"></script> <script src="<?php echo $server_url?>assets/js/custom.js"></script>
<script src="<?php echo $server_url?>assets/js/batch_overlay.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/html5-qrcode/2.3.8/html5-qrcode.min.js" <script src="https://cdnjs.cloudflare.com/ajax/libs/html5-qrcode/2.3.8/html5-qrcode.min.js"
crossorigin="anonymous" referrerpolicy="no-referrer"></script> crossorigin="anonymous" referrerpolicy="no-referrer"></script>
+12
View File
@@ -61,6 +61,18 @@
</a> </a>
</li> </li>
<li class="nav-text-space">
<small class="nav-text text-muted">Maintenance</small>
</li>
<li>
<a class="nav-link <?php echo $current_page === 'gl_maintenance.php' ? 'active' : ''; ?>"
href="<?php echo $server_url?>setting/gl_maintenance.php">
<i class="ti ti-database-cog"></i>
<span class="nav-text">GL 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>
+44
View File
@@ -0,0 +1,44 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require_role($user_role, ['owner', 'admin']);
require_once '../../../assets/utils/classes_ac/EtlManager.php';
$etl = new EtlManager($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_periods') {
$sth = $pdo2->prepare(
"SELECT DISTINCT period FROM td_gl WHERE company_id = :cid ORDER BY period"
);
$sth->execute([':cid' => $company_id]);
$answer['output'] = $sth->fetchAll(PDO::FETCH_COLUMN);
$answer['success'] = 1;
exit(json_encode($answer));
}
if ($action === 'rebuild_period') {
$period = $data['period'] ?? '';
if (!preg_match('/^\d{4}-\d{2}$/', $period)) {
$answer['message'] = 'Invalid period.';
exit(json_encode($answer));
}
$etl->rebuildPeriod($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));
+194
View File
@@ -0,0 +1,194 @@
<?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 GL Summary Maintenance</h1>
<p class="mb-0">Manages the <code>etl_gl_summary</code> table used by the accounting reports</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 GL 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>
</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 entire GL summary table from raw journal entries.
Use this after a direct database fix, data migration, 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>Rebuilds one month at a time. The dashboard stays usable during the rebuild — it falls back to raw data for any period not yet in the summary.</span>
</div>
<div class="mt-auto">
<button class="btn btn-danger" id="btn_rebuild_full">
<i class="ti ti-database-import me-1"></i>Rebuild Full ETL Table
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<?php require '../include_ending.php'; ?>
<script>
function load_status() {
$('#overlay_status').show();
ajax_request({
url: server_url + 'setting/api/engine/gl_maintenance.php',
autoPrepare: true, checkRequired: 0, noLoading: true, queueLock: false, action: 'read',
onSuccess: function(res) {
var d = res.output;
$('#stat_slot').text(pad2(d.slot_hour) + ':00 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 pad2(n) { return String(n).padStart(2, '0'); }
$('#btn_rebuild_full').on('click', function() {
bootbox.confirm({
title: 'Rebuild Full ETL Table',
message: 'This will rebuild the GL summary table one month at a time from raw journal entries. 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: server_url + 'setting/api/engine/gl_maintenance.php',
autoPrepare: true, checkRequired: 0, noLoading: true, queueLock: false, action: 'get_periods',
onSuccess: function(res) {
var periods = res.output || [];
var total = periods.length;
if (total === 0) {
btn.prop('disabled', false);
bootbox.alert('No GL periods found.');
return;
}
BatchOverlay.show('Rebuilding GL Summary', 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();
return;
}
BatchOverlay.update(i, total, periods[i]);
ajax_request({
url: server_url + 'setting/api/engine/gl_maintenance.php',
autoPrepare: true, checkRequired: 0, noLoading: true, queueLock: false,
action: 'rebuild_period', data: { 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);
}
});
}
});
});
load_status();
</script>
</body>
</html>
+5
View File
@@ -5,5 +5,10 @@
PORT=3000 PORT=3000
# Secret key — PHP sends this in x-emit-secret header to authenticate /emit calls # Secret key — PHP sends this in x-emit-secret header to authenticate /emit calls
# Also used by Node.js scheduler as x-cron-secret when calling PHP cron endpoints
# Use a long random string in production # Use a long random string in production
EMIT_SECRET= EMIT_SECRET=
# PHP internal base URL (scheduler → PHP cron endpoints)
PHP_BASE_URL=http://127.0.0.1
PHP_WEBROOT=/mn3wms/app
+10
View File
@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"dotenv": "^16.0.0", "dotenv": "^16.0.0",
"express": "^4.18.0", "express": "^4.18.0",
"node-cron": "^4.2.1",
"socket.io": "^4.7.0" "socket.io": "^4.7.0"
} }
}, },
@@ -651,6 +652,15 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/node-cron": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
"integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==",
"license": "ISC",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/object-assign": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+3 -2
View File
@@ -7,8 +7,9 @@
"start": "node server.js" "start": "node server.js"
}, },
"dependencies": { "dependencies": {
"dotenv": "^16.0.0",
"express": "^4.18.0", "express": "^4.18.0",
"socket.io": "^4.7.0", "node-cron": "^4.2.1",
"dotenv": "^16.0.0" "socket.io": "^4.7.0"
} }
} }
+67
View File
@@ -0,0 +1,67 @@
require('dotenv').config();
const cron = require('node-cron');
const http = require('http');
const PHP_BASE = process.env.PHP_BASE_URL || 'http://127.0.0.1';
const PHP_WEBROOT = process.env.PHP_WEBROOT || '/mn3wms/app';
const SECRET = process.env.EMIT_SECRET || '';
function callPhp(path, body) {
return new Promise((resolve, reject) => {
const payload = JSON.stringify(body);
const url = new URL(PHP_BASE + PHP_WEBROOT + path);
const options = {
hostname: url.hostname,
port: url.port || 80,
path: url.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload),
'x-cron-secret': SECRET,
},
};
const req = http.request(options, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
try { resolve(JSON.parse(data)); }
catch (e) { reject(new Error('Bad JSON: ' + data.slice(0, 100))); }
});
});
req.on('error', reject);
req.write(payload);
req.end();
});
}
// ── ETL GL Summary maintenance — runs every hour ─────────────────────────────
// Checks and repairs etl_gl_summary (GL aggregation table).
// Each company has a fixed slot = company_id % 24 — load is spread across 24 hours.
// For etl_stock_summary add a separate schedule below when ready.
cron.schedule('0 * * * *', async () => {
const slot = new Date().getHours();
console.log(`[scheduler] etl_gl slot=${slot}`);
try {
const result = await callPhp('/cron/etl_gl_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_gl slot=${slot} — no companies`);
} else {
summary.forEach(s => console.log(`[scheduler] etl_gl ${s}`));
}
} catch (e) {
console.error('[scheduler] etl_gl_maintenance failed:', e.message);
}
});
console.log('[scheduler] running — etl_gl_maintenance wired (hourly, slot-based)');