diff --git a/.gitignore b/.gitignore index ea523a8..00faa53 100755 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ vendor/ # Node dependencies node_modules/ +nodejs/.env # custom files notes/ diff --git a/app/ac_dashboard/index.php b/app/ac_dashboard/index.php index ca5f276..1822968 100644 --- a/app/ac_dashboard/index.php +++ b/app/ac_dashboard/index.php @@ -180,6 +180,15 @@ + + diff --git a/app/accounting/api/engine/post_gl_entry.php b/app/accounting/api/engine/post_gl_entry.php index 8b3cc32..068baad 100644 --- a/app/accounting/api/engine/post_gl_entry.php +++ b/app/accounting/api/engine/post_gl_entry.php @@ -13,6 +13,7 @@ require '../../../assets/utils/classes_ac/posting/PaymentPosting.php'; require '../../../assets/utils/classes_ac/posting/PurchasePosting.php'; require_role($user_role, ['owner', 'admin']); +require_once '../../../assets/utils/notify_node.php'; $doc_type = trim((string)($data['doc_type'] ?? '')); $id = (int)($data['id'] ?? 0); @@ -56,6 +57,22 @@ try { $answer['success'] = 1; $answer['message'] = $existing ? 'GL entry replaced.' : 'GL entry posted.'; + $has_revenue = false; + $has_expense = false; + foreach ($built['lines'] as $line) { + $code = (int)($line['account_code'] ?? 0); + if ($code >= 4000 && $code < 5000) $has_revenue = true; + if ($code >= 5000) $has_expense = true; + } + + notify_node('gl_posted', [ + 'doc_type' => $doc_type, + 'id' => $id, + 'action' => $existing ? 'replaced' : 'posted', + 'has_revenue' => $has_revenue, + 'has_expense' => $has_expense, + ], $company_id); + } catch (Exception $e) { if ($pdo2->inTransaction()) $pdo2->rollBack(); $answer['message'] = $e->getMessage(); diff --git a/app/assets/utils/classes_ac/FinancialReports.php b/app/assets/utils/classes_ac/FinancialReports.php index f5d3921..f0375fc 100644 --- a/app/assets/utils/classes_ac/FinancialReports.php +++ b/app/assets/utils/classes_ac/FinancialReports.php @@ -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. diff --git a/app/assets/utils/classes_ac/GlManager.php b/app/assets/utils/classes_ac/GlManager.php index 68198d6..eed2628 100644 --- a/app/assets/utils/classes_ac/GlManager.php +++ b/app/assets/utils/classes_ac/GlManager.php @@ -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, + ]); + } + } } diff --git a/app/assets/utils/notify_node.php b/app/assets/utils/notify_node.php new file mode 100644 index 0000000..7fc3716 --- /dev/null +++ b/app/assets/utils/notify_node.php @@ -0,0 +1,36 @@ + $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); +} diff --git a/app/finance/api/engine/manage_payment.php b/app/finance/api/engine/manage_payment.php index 2fc1112..5c6dc1f 100644 --- a/app/finance/api/engine/manage_payment.php +++ b/app/finance/api/engine/manage_payment.php @@ -52,6 +52,8 @@ $answer['new_id'] = $new_id; $answer['message'] = 'Payment posted.'; (new UsageGuard($pdo1, $company_id, $packages))->increment(); + require_once '../../../assets/utils/notify_node.php'; + notify_node('payment_posted', ['id' => $new_id], $company_id); exit(json_encode($answer)); } diff --git a/app/finance/api/engine/manage_receipt.php b/app/finance/api/engine/manage_receipt.php index 119ce15..5467322 100644 --- a/app/finance/api/engine/manage_receipt.php +++ b/app/finance/api/engine/manage_receipt.php @@ -52,6 +52,8 @@ $answer['new_id'] = $new_id; $answer['message'] = 'Receipt posted.'; (new UsageGuard($pdo1, $company_id, $packages))->increment(); + require_once '../../../assets/utils/notify_node.php'; + notify_node('receipt_posted', ['id' => $new_id], $company_id); exit(json_encode($answer)); } diff --git a/app/include_ending.php b/app/include_ending.php index c03be32..a58eb74 100644 --- a/app/include_ending.php +++ b/app/include_ending.php @@ -7,6 +7,30 @@ + + + + +