NODE JS introduction: socket polling

This commit is contained in:
Thanakorn S
2026-05-22 17:01:59 +07:00
parent 648efee991
commit 5a3bfa3435
17 changed files with 1520 additions and 2135 deletions
@@ -234,25 +234,50 @@ class FinancialReports
// ── Monthly Trend ─────────────────────────────────────────────────────────
// Revenue vs expense per period for the last N months (defaults to 6).
// Returns rows: [{period, revenue, expense}]
// Reads from etl_gl_summary; falls back to td_gl_item for periods not yet in ETL.
public function getMonthlyTrend(int $months = 6): array
{
$from = date('Y-m', strtotime('-' . ($months - 1) . ' months'));
$sth = $this->pdo->prepare("
SELECT
g.period,
SUM(CASE WHEN a.account_type = 'revenue' THEN i.credit - i.debit ELSE 0 END) AS revenue,
SUM(CASE WHEN a.account_type = 'expense' THEN i.debit - i.credit ELSE 0 END) AS expense
FROM td_gl g
JOIN td_gl_item i ON i.gl_id = g.id AND i.company_id = g.company_id
JOIN md_account a ON a.company_id = i.company_id AND a.account_code = i.account_code
WHERE g.company_id = :cid AND g.period >= :from_period
GROUP BY g.period
ORDER BY g.period ASC
$sth = $this->pdo->prepare("
SELECT period, acc_code, SUM(debit) AS d, SUM(credit) AS c
FROM etl_gl_summary
WHERE company_id = :cid AND acc_code >= 4000 AND period >= :from
GROUP BY period, acc_code
UNION ALL
SELECT g.period, 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
LEFT JOIN (
SELECT DISTINCT period FROM etl_gl_summary WHERE company_id = :cid2
) etl ON etl.period = g.period
WHERE i.company_id = :cid3
AND i.account_code >= 4000
AND g.source_type != 'voided'
AND g.period >= :from2
AND etl.period IS NULL
GROUP BY g.period, i.account_code
");
$sth->execute([':cid' => $this->companyId, ':from_period' => $from]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
$sth->execute([
':cid' => $this->companyId, ':from' => $from,
':cid2' => $this->companyId,
':cid3' => $this->companyId, ':from2' => $from,
]);
$by_period = [];
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
$p = $row['period'];
$code = (int)$row['acc_code'];
if (!isset($by_period[$p])) $by_period[$p] = ['period' => $p, 'revenue' => 0.0, 'expense' => 0.0];
if ($code >= 4000 && $code < 5000) $by_period[$p]['revenue'] += (float)$row['c'] - (float)$row['d'];
else $by_period[$p]['expense'] += (float)$row['d'] - (float)$row['c'];
}
ksort($by_period);
return array_values($by_period);
}
// ── Dashboard focused methods ─────────────────────────────────────────────
@@ -260,29 +285,48 @@ class FinancialReports
public function getDashboardPL(): array
{
// ETL first — all periods present in etl_gl_summary
// Fallback — periods in td_gl but missing from ETL (gap fill)
$sth = $this->pdo->prepare("
SELECT
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
JOIN td_gl g ON g.company_id = i.company_id AND g.id = i.gl_id
WHERE a.company_id = :cid
AND a.account_type IN ('revenue', 'expense')
AND a.is_posting = 1
GROUP BY a.account_type
SELECT acc_code, SUM(debit) AS d, SUM(credit) AS c
FROM etl_gl_summary
WHERE company_id = :cid AND acc_code >= 4000
GROUP BY acc_code
UNION ALL
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
LEFT JOIN (
SELECT DISTINCT period FROM etl_gl_summary WHERE company_id = :cid2
) etl ON etl.period = g.period
WHERE i.company_id = :cid3
AND i.account_code >= 4000
AND g.source_type != 'voided'
AND etl.period IS NULL
GROUP BY i.account_code
");
$sth->execute([':cid' => $this->companyId]);
$sth->execute([
':cid' => $this->companyId,
':cid2' => $this->companyId,
':cid3' => $this->companyId,
]);
$revenue = 0.0;
$expense = 0.0;
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
if ($row['account_type'] === 'revenue') $revenue += (float)$row['total_credit'] - (float)$row['total_debit'];
if ($row['account_type'] === 'expense') $expense += (float)$row['total_debit'] - (float)$row['total_credit'];
$code = (int)$row['acc_code'];
if ($code >= 4000 && $code < 5000) $revenue += (float)$row['c'] - (float)$row['d'];
else $expense += (float)$row['d'] - (float)$row['c'];
}
return ['revenue' => $revenue, 'expense' => $expense, 'net_profit' => $revenue - $expense];
return [
'revenue' => $revenue,
'expense' => $expense,
'net_profit' => $revenue - $expense,
'etl_gaps' => $this->getEtlGaps(),
];
}
public function getDashboardJournalCount(): int
@@ -404,6 +448,30 @@ 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.
private function getEtlGaps(string $from_period = ''): array
{
$sql = "
SELECT DISTINCT g.period
FROM td_gl g
LEFT JOIN (
SELECT DISTINCT period FROM etl_gl_summary WHERE company_id = :cid2
) 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" : "") . "
ORDER BY g.period
";
$params = [':cid' => $this->companyId, ':cid2' => $this->companyId];
if ($from_period) $params[':from_period'] = $from_period;
$sth = $this->pdo->prepare($sql);
$sth->execute($params);
return $sth->fetchAll(PDO::FETCH_COLUMN);
}
// ── GL Movement ───────────────────────────────────────────────────────────
// Per-account ledger: opening balance + chronological lines with running balance.
+44 -2
View File
@@ -71,6 +71,7 @@ class GlManager
':updated_at' => $now,
]);
$this->insertLines((int)$this->pdo->lastInsertId(), $lines);
$this->upsertEtl($lines, $period);
}
public function replaceManual(int $gl_id, string $reference, string $description, string $journal_date, string $period, array $lines): void
@@ -99,6 +100,9 @@ class GlManager
'lines' => $this->getLines($gl_id),
];
$old_lines = $history[count($history) - 1]['lines']; // captured just above
$old_period = $gl['period'];
$this->pdo->prepare(
"DELETE FROM td_gl_item WHERE company_id = :cid AND gl_id = :gl_id"
)->execute([':cid' => $this->companyId, ':gl_id' => $gl_id]);
@@ -122,6 +126,8 @@ class GlManager
]);
$this->insertLines($gl_id, $lines);
$this->upsertEtl($old_lines, $old_period, -1);
$this->upsertEtl($lines, $period);
}
public function postManual(string $reference, string $description, string $journal_date, string $period, array $lines): int
@@ -148,6 +154,7 @@ class GlManager
]);
$gl_id = (int)$this->pdo->lastInsertId();
$this->insertLines($gl_id, $lines);
$this->upsertEtl($lines, $period);
return $gl_id;
}
@@ -179,11 +186,14 @@ class GlManager
$history = json_decode($gl['history'], true) ?: [];
// Snapshot current lines into history
$old_lines = $this->getLines($gl_id);
$old_period = $gl['period'];
$history[] = [
'version' => $version,
'formula_id' => (int)$gl['formula_id'],
'replaced_at' => $now,
'lines' => $this->getLines($gl_id),
'lines' => $old_lines,
];
$this->pdo->prepare(
@@ -210,6 +220,8 @@ class GlManager
]);
$this->insertLines($gl_id, $lines);
$this->upsertEtl($old_lines, $old_period, -1);
$this->upsertEtl($lines, $period);
}
public function delete(string $source_type, int $source_id): void
@@ -264,7 +276,9 @@ class GlManager
':created_at' => $now,
':updated_at' => $now,
]);
$this->insertLines((int)$this->pdo->lastInsertId(), $reversal_lines);
$reversal_gl_id = (int)$this->pdo->lastInsertId();
$this->insertLines($reversal_gl_id, $reversal_lines);
$this->upsertEtl($reversal_lines, $gl['period']);
}
// Mark original as voided — keeps audit trail; getBySource() won't match 'voided' source_type
@@ -326,4 +340,32 @@ 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
{
if (empty($lines) || empty($period)) return;
$sth = $this->pdo->prepare(
"INSERT INTO etl_gl_summary
(company_id, acc_code, period, debit, credit, count, updated_at)
VALUES
(:cid, :acc, :period, :debit, :credit, :cnt, NOW())
ON DUPLICATE KEY UPDATE
debit = debit + VALUES(debit),
credit = credit + VALUES(credit),
count = count + VALUES(count),
updated_at = NOW()"
);
foreach ($lines as $line) {
$sth->execute([
':cid' => $this->companyId,
':acc' => (int)($line['account_code'] ?? 0),
':period' => $period,
':debit' => (float)($line['debit'] ?? 0) * $sign,
':credit' => (float)($line['credit'] ?? 0) * $sign,
':cnt' => $sign,
]);
}
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
/**
* notify_node — fire-and-forget event push to the Node.js real-time server.
*
* Call this AFTER a successful DB commit. It never throws — if Node is down
* the request times out silently in 1 second.
*
* @param string $event Event name, e.g. 'gl_posted'
* @param array $data Payload sent to the browser
* @param int $company_id Scopes the broadcast to one company's room
*/
function notify_node(string $event, array $data, int $company_id): void
{
if ($company_id <= 0) return;
$body = json_encode([
'event' => $event,
'data' => $data,
'company_id' => $company_id,
]);
$ch = curl_init(defined('NODE_EMIT_URL') ? NODE_EMIT_URL : 'http://127.0.0.1:3000/emit');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'x-emit-secret: ' . (defined('NODE_EMIT_SECRET') ? NODE_EMIT_SECRET : ''),
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 1, // never block PHP
CURLOPT_CONNECTTIMEOUT => 1,
]);
curl_exec($ch);
curl_close($ch);
}