NODE JS introduction: socket polling
This commit is contained in:
@@ -11,6 +11,7 @@ vendor/
|
|||||||
|
|
||||||
# Node dependencies
|
# Node dependencies
|
||||||
node_modules/
|
node_modules/
|
||||||
|
nodejs/.env
|
||||||
|
|
||||||
# custom files
|
# custom files
|
||||||
notes/
|
notes/
|
||||||
|
|||||||
+105
-28
@@ -180,6 +180,15 @@
|
|||||||
<?php require '../include_ending.php'; ?>
|
<?php require '../include_ending.php'; ?>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
|
||||||
|
<style>
|
||||||
|
/* Brief yellow flash when a card updates silently via WebSocket */
|
||||||
|
@keyframes card-flash {
|
||||||
|
0% { box-shadow: 0 0 0 3px rgba(250,204,21,0.8); }
|
||||||
|
100% { box-shadow: none; }
|
||||||
|
}
|
||||||
|
.card-flash { animation: card-flash 1s ease-out; }
|
||||||
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
var trend_chart_instance = null;
|
var trend_chart_instance = null;
|
||||||
|
|
||||||
@@ -207,26 +216,33 @@
|
|||||||
return Number(n).toLocaleString(undefined, {minimumFractionDigits:2, maximumFractionDigits:2});
|
return Number(n).toLocaleString(undefined, {minimumFractionDigits:2, maximumFractionDigits:2});
|
||||||
}
|
}
|
||||||
|
|
||||||
function show_overlays() {
|
// Flash a card briefly to signal a silent update happened
|
||||||
$('#overlay_revenue, #overlay_expense, #overlay_net, #overlay_journals, #overlay_lock, #overlay_trend, #overlay_source, #overlay_recent').show();
|
function flash_card(el) {
|
||||||
|
if (!el) return;
|
||||||
|
el.classList.remove('card-flash');
|
||||||
|
void el.offsetWidth; // force reflow so animation restarts
|
||||||
|
el.classList.add('card-flash');
|
||||||
|
setTimeout(function() { el.classList.remove('card-flash'); }, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function set_dashboard_loading() {
|
// ── Individual load functions ─────────────────────────────────────────────
|
||||||
show_overlays();
|
// Each accepts a `silent` flag:
|
||||||
['stat_revenue', 'stat_expense', 'stat_net', 'stat_journals'].forEach(function(id) {
|
// false (default) — show overlay spinner, blank value while loading
|
||||||
document.getElementById(id).textContent = '—';
|
// true — keep existing value visible, flash card on update
|
||||||
});
|
|
||||||
document.getElementById('posting_window_banner').textContent = 'Loading...';
|
|
||||||
document.getElementById('by_source_list').innerHTML =
|
|
||||||
'<div class="text-center text-muted small py-4">Loading...</div>';
|
|
||||||
document.getElementById('recent_tbody').innerHTML =
|
|
||||||
'<tr><td colspan="7" class="text-center text-muted py-4 small">Loading...</td></tr>';
|
|
||||||
}
|
|
||||||
|
|
||||||
function load_dashboard() {
|
// load_pl(silent, flash_revenue, flash_expense)
|
||||||
set_dashboard_loading();
|
// One DB query — but only flash cards that actually changed.
|
||||||
|
// flash_revenue / flash_expense default to true when not passed.
|
||||||
|
function load_pl(silent, flash_revenue, flash_expense) {
|
||||||
|
if (flash_revenue === undefined) flash_revenue = true;
|
||||||
|
if (flash_expense === undefined) flash_expense = true;
|
||||||
|
|
||||||
// Revenue, Expenses, Net Profit
|
if (!silent) {
|
||||||
|
$('#overlay_revenue, #overlay_expense, #overlay_net').show();
|
||||||
|
['stat_revenue', 'stat_expense', 'stat_net'].forEach(function(id) {
|
||||||
|
document.getElementById(id).textContent = '—';
|
||||||
|
});
|
||||||
|
}
|
||||||
ajax_request({
|
ajax_request({
|
||||||
url: server_url + 'ac_dashboard/api/engine/pl.php',
|
url: server_url + 'ac_dashboard/api/engine/pl.php',
|
||||||
autoPrepare: true, checkRequired: 0, noLoading: true, queueLock: false, action: 'read',
|
autoPrepare: true, checkRequired: 0, noLoading: true, queueLock: false, action: 'read',
|
||||||
@@ -252,48 +268,89 @@
|
|||||||
if (sub) sub.className = net >= 0 ? 'text-success mb-0 small' : 'text-danger mb-0 small';
|
if (sub) sub.className = net >= 0 ? 'text-success mb-0 small' : 'text-danger mb-0 small';
|
||||||
}
|
}
|
||||||
$('#overlay_net').hide();
|
$('#overlay_net').hide();
|
||||||
|
|
||||||
|
// Flash only cards that actually changed
|
||||||
|
if (silent) {
|
||||||
|
if (flash_revenue) flash_card(document.getElementById('stat_revenue').closest('.card'));
|
||||||
|
if (flash_expense) flash_card(document.getElementById('stat_expense').closest('.card'));
|
||||||
|
flash_card(net_el.closest('.card')); // net profit always updates if either changed
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Journal Entries count
|
function load_journals(silent) {
|
||||||
|
if (!silent) {
|
||||||
|
$('#overlay_journals').show();
|
||||||
|
document.getElementById('stat_journals').textContent = '—';
|
||||||
|
}
|
||||||
ajax_request({
|
ajax_request({
|
||||||
url: server_url + 'ac_dashboard/api/engine/journals.php',
|
url: server_url + 'ac_dashboard/api/engine/journals.php',
|
||||||
autoPrepare: true, checkRequired: 0, noLoading: true, queueLock: false, action: 'read',
|
autoPrepare: true, checkRequired: 0, noLoading: true, queueLock: false, action: 'read',
|
||||||
onSuccess: function(res) {
|
onSuccess: function(res) {
|
||||||
document.getElementById('stat_journals').textContent = res.output.journal_count;
|
document.getElementById('stat_journals').textContent = res.output.journal_count;
|
||||||
$('#overlay_journals').hide();
|
$('#overlay_journals').hide();
|
||||||
|
if (silent) flash_card(document.getElementById('stat_journals').closest('.card'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Posting window banner
|
function load_posting_window() {
|
||||||
|
$('#overlay_lock').show();
|
||||||
ajax_request({
|
ajax_request({
|
||||||
url: server_url + 'ac_dashboard/api/engine/posting_window.php',
|
url: server_url + 'ac_dashboard/api/engine/posting_window.php',
|
||||||
autoPrepare: true, checkRequired: 0, noLoading: true, queueLock: false, action: 'read',
|
autoPrepare: true, checkRequired: 0, noLoading: true, queueLock: false, action: 'read',
|
||||||
onSuccess: function(res) { render_posting_window(res.output); }
|
onSuccess: function(res) { render_posting_window(res.output); }
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Revenue vs Expenses trend chart
|
function load_trend(silent) {
|
||||||
|
if (!silent) $('#overlay_trend').show();
|
||||||
ajax_request({
|
ajax_request({
|
||||||
url: server_url + 'ac_dashboard/api/engine/trend.php',
|
url: server_url + 'ac_dashboard/api/engine/trend.php',
|
||||||
autoPrepare: true, checkRequired: 0, noLoading: true, queueLock: false, action: 'read',
|
autoPrepare: true, checkRequired: 0, noLoading: true, queueLock: false, action: 'read',
|
||||||
onSuccess: function(res) { render_trend(res.output.trend); }
|
onSuccess: function(res) { render_trend(res.output.trend, silent); }
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Entries by type breakdown
|
function load_by_source(silent) {
|
||||||
|
if (!silent) {
|
||||||
|
$('#overlay_source').show();
|
||||||
|
document.getElementById('by_source_list').innerHTML =
|
||||||
|
'<div class="text-center text-muted small py-4">Loading...</div>';
|
||||||
|
}
|
||||||
ajax_request({
|
ajax_request({
|
||||||
url: server_url + 'ac_dashboard/api/engine/by_source.php',
|
url: server_url + 'ac_dashboard/api/engine/by_source.php',
|
||||||
autoPrepare: true, checkRequired: 0, noLoading: true, queueLock: false, action: 'read',
|
autoPrepare: true, checkRequired: 0, noLoading: true, queueLock: false, action: 'read',
|
||||||
onSuccess: function(res) { render_by_source(res.output.by_source); }
|
onSuccess: function(res) { render_by_source(res.output.by_source, silent); }
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Recent journal entries table
|
function load_recent(silent) {
|
||||||
|
if (!silent) {
|
||||||
|
$('#overlay_recent').show();
|
||||||
|
document.getElementById('recent_tbody').innerHTML =
|
||||||
|
'<tr><td colspan="7" class="text-center text-muted py-4 small">Loading...</td></tr>';
|
||||||
|
}
|
||||||
ajax_request({
|
ajax_request({
|
||||||
url: server_url + 'ac_dashboard/api/engine/recent.php',
|
url: server_url + 'ac_dashboard/api/engine/recent.php',
|
||||||
autoPrepare: true, checkRequired: 0, noLoading: true, queueLock: false, action: 'read',
|
autoPrepare: true, checkRequired: 0, noLoading: true, queueLock: false, action: 'read',
|
||||||
onSuccess: function(res) { render_recent(res.output.recent); }
|
onSuccess: function(res) { render_recent(res.output.recent, silent); }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Full dashboard load (first load) ─────────────────────────────────────
|
||||||
|
function load_dashboard() {
|
||||||
|
load_pl(false);
|
||||||
|
load_journals(false);
|
||||||
|
load_posting_window();
|
||||||
|
load_trend(false);
|
||||||
|
load_by_source(false);
|
||||||
|
load_recent(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render functions ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
function render_posting_window(window_data) {
|
function render_posting_window(window_data) {
|
||||||
var el = document.getElementById('posting_window_banner');
|
var el = document.getElementById('posting_window_banner');
|
||||||
var from = window_data.open_from || '';
|
var from = window_data.open_from || '';
|
||||||
@@ -308,8 +365,7 @@
|
|||||||
$('#overlay_lock').hide();
|
$('#overlay_lock').hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
function render_trend(trend) {
|
function render_trend(trend, silent) {
|
||||||
// Build exactly 6 month labels (last 6 months ending today), fill zeros for missing
|
|
||||||
var labels = [], revenues = [], expenses = [];
|
var labels = [], revenues = [], expenses = [];
|
||||||
var idx = {};
|
var idx = {};
|
||||||
trend.forEach(function(r){ idx[r.period] = r; });
|
trend.forEach(function(r){ idx[r.period] = r; });
|
||||||
@@ -345,9 +401,10 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
$('#overlay_trend').hide();
|
$('#overlay_trend').hide();
|
||||||
|
if (silent) flash_card(document.getElementById('trend_chart').closest('.card'));
|
||||||
}
|
}
|
||||||
|
|
||||||
function render_by_source(rows) {
|
function render_by_source(rows, silent) {
|
||||||
var el = document.getElementById('by_source_list');
|
var el = document.getElementById('by_source_list');
|
||||||
if (!rows || rows.length === 0) {
|
if (!rows || rows.length === 0) {
|
||||||
el.innerHTML = '<div class="text-center text-muted small py-4">No entries found.</div>';
|
el.innerHTML = '<div class="text-center text-muted small py-4">No entries found.</div>';
|
||||||
@@ -371,9 +428,10 @@
|
|||||||
});
|
});
|
||||||
el.innerHTML = html;
|
el.innerHTML = html;
|
||||||
$('#overlay_source').hide();
|
$('#overlay_source').hide();
|
||||||
|
if (silent) flash_card(el.closest('.card'));
|
||||||
}
|
}
|
||||||
|
|
||||||
function render_recent(rows) {
|
function render_recent(rows, silent) {
|
||||||
var tbody = document.getElementById('recent_tbody');
|
var tbody = document.getElementById('recent_tbody');
|
||||||
if (!rows || rows.length === 0) {
|
if (!rows || rows.length === 0) {
|
||||||
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-4 small">No journal entries found.</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-4 small">No journal entries found.</td></tr>';
|
||||||
@@ -397,9 +455,28 @@
|
|||||||
});
|
});
|
||||||
tbody.innerHTML = html;
|
tbody.innerHTML = html;
|
||||||
$('#overlay_recent').hide();
|
$('#overlay_recent').hide();
|
||||||
|
if (silent) flash_card(tbody.closest('.card'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Initial load ──────────────────────────────────────────────────────────
|
||||||
load_dashboard();
|
load_dashboard();
|
||||||
|
|
||||||
|
// ── Real-time: targeted silent updates per event payload ──────────────────
|
||||||
|
if (window._socket) {
|
||||||
|
window._socket.on('gl_posted', function(data) {
|
||||||
|
console.log('[dashboard] gl_posted', data);
|
||||||
|
|
||||||
|
if (data.has_revenue || data.has_expense) {
|
||||||
|
load_pl(true, data.has_revenue, data.has_expense); // only affected side flashes
|
||||||
|
load_trend(true); // trend = revenue vs expense chart, only relevant when those change
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always update regardless of which accounts were hit
|
||||||
|
load_journals(true);
|
||||||
|
load_by_source(true);
|
||||||
|
load_recent(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ require '../../../assets/utils/classes_ac/posting/PaymentPosting.php';
|
|||||||
require '../../../assets/utils/classes_ac/posting/PurchasePosting.php';
|
require '../../../assets/utils/classes_ac/posting/PurchasePosting.php';
|
||||||
|
|
||||||
require_role($user_role, ['owner', 'admin']);
|
require_role($user_role, ['owner', 'admin']);
|
||||||
|
require_once '../../../assets/utils/notify_node.php';
|
||||||
|
|
||||||
$doc_type = trim((string)($data['doc_type'] ?? ''));
|
$doc_type = trim((string)($data['doc_type'] ?? ''));
|
||||||
$id = (int)($data['id'] ?? 0);
|
$id = (int)($data['id'] ?? 0);
|
||||||
@@ -56,6 +57,22 @@ try {
|
|||||||
$answer['success'] = 1;
|
$answer['success'] = 1;
|
||||||
$answer['message'] = $existing ? 'GL entry replaced.' : 'GL entry posted.';
|
$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) {
|
} catch (Exception $e) {
|
||||||
if ($pdo2->inTransaction()) $pdo2->rollBack();
|
if ($pdo2->inTransaction()) $pdo2->rollBack();
|
||||||
$answer['message'] = $e->getMessage();
|
$answer['message'] = $e->getMessage();
|
||||||
|
|||||||
@@ -234,25 +234,50 @@ class FinancialReports
|
|||||||
|
|
||||||
// ── Monthly Trend ─────────────────────────────────────────────────────────
|
// ── Monthly Trend ─────────────────────────────────────────────────────────
|
||||||
// Revenue vs expense per period for the last N months (defaults to 6).
|
// 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
|
public function getMonthlyTrend(int $months = 6): array
|
||||||
{
|
{
|
||||||
$from = date('Y-m', strtotime('-' . ($months - 1) . ' months'));
|
$from = date('Y-m', strtotime('-' . ($months - 1) . ' months'));
|
||||||
$sth = $this->pdo->prepare("
|
|
||||||
SELECT
|
$sth = $this->pdo->prepare("
|
||||||
g.period,
|
SELECT period, acc_code, SUM(debit) AS d, SUM(credit) AS c
|
||||||
SUM(CASE WHEN a.account_type = 'revenue' THEN i.credit - i.debit ELSE 0 END) AS revenue,
|
FROM etl_gl_summary
|
||||||
SUM(CASE WHEN a.account_type = 'expense' THEN i.debit - i.credit ELSE 0 END) AS expense
|
WHERE company_id = :cid AND acc_code >= 4000 AND period >= :from
|
||||||
FROM td_gl g
|
GROUP BY period, acc_code
|
||||||
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
|
UNION ALL
|
||||||
WHERE g.company_id = :cid AND g.period >= :from_period
|
|
||||||
GROUP BY g.period
|
SELECT g.period, i.account_code AS acc_code, SUM(i.debit) AS d, SUM(i.credit) AS c
|
||||||
ORDER BY g.period ASC
|
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]);
|
$sth->execute([
|
||||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
':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 ─────────────────────────────────────────────
|
// ── Dashboard focused methods ─────────────────────────────────────────────
|
||||||
@@ -260,29 +285,48 @@ class FinancialReports
|
|||||||
|
|
||||||
public function getDashboardPL(): array
|
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("
|
$sth = $this->pdo->prepare("
|
||||||
SELECT
|
SELECT acc_code, SUM(debit) AS d, SUM(credit) AS c
|
||||||
a.account_type,
|
FROM etl_gl_summary
|
||||||
COALESCE(SUM(i.debit), 0) AS total_debit,
|
WHERE company_id = :cid AND acc_code >= 4000
|
||||||
COALESCE(SUM(i.credit), 0) AS total_credit
|
GROUP BY acc_code
|
||||||
FROM md_account a
|
|
||||||
JOIN td_gl_item i ON i.company_id = a.company_id AND i.account_code = a.account_code
|
UNION ALL
|
||||||
JOIN td_gl g ON g.company_id = i.company_id AND g.id = i.gl_id
|
|
||||||
WHERE a.company_id = :cid
|
SELECT i.account_code AS acc_code, SUM(i.debit) AS d, SUM(i.credit) AS c
|
||||||
AND a.account_type IN ('revenue', 'expense')
|
FROM td_gl_item i
|
||||||
AND a.is_posting = 1
|
JOIN td_gl g ON g.id = i.gl_id AND g.company_id = i.company_id
|
||||||
GROUP BY a.account_type
|
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;
|
$revenue = 0.0;
|
||||||
$expense = 0.0;
|
$expense = 0.0;
|
||||||
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||||
if ($row['account_type'] === 'revenue') $revenue += (float)$row['total_credit'] - (float)$row['total_debit'];
|
$code = (int)$row['acc_code'];
|
||||||
if ($row['account_type'] === 'expense') $expense += (float)$row['total_debit'] - (float)$row['total_credit'];
|
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
|
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 ───────────────────────────────────────────────────────────
|
// ── GL Movement ───────────────────────────────────────────────────────────
|
||||||
// Per-account ledger: opening balance + chronological lines with running balance.
|
// Per-account ledger: opening balance + chronological lines with running balance.
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +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);
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -99,6 +100,9 @@ class GlManager
|
|||||||
'lines' => $this->getLines($gl_id),
|
'lines' => $this->getLines($gl_id),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
$old_lines = $history[count($history) - 1]['lines']; // captured just above
|
||||||
|
$old_period = $gl['period'];
|
||||||
|
|
||||||
$this->pdo->prepare(
|
$this->pdo->prepare(
|
||||||
"DELETE FROM td_gl_item WHERE company_id = :cid AND gl_id = :gl_id"
|
"DELETE FROM td_gl_item WHERE company_id = :cid AND gl_id = :gl_id"
|
||||||
)->execute([':cid' => $this->companyId, ':gl_id' => $gl_id]);
|
)->execute([':cid' => $this->companyId, ':gl_id' => $gl_id]);
|
||||||
@@ -122,6 +126,8 @@ class GlManager
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$this->insertLines($gl_id, $lines);
|
$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
|
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();
|
$gl_id = (int)$this->pdo->lastInsertId();
|
||||||
$this->insertLines($gl_id, $lines);
|
$this->insertLines($gl_id, $lines);
|
||||||
|
$this->upsertEtl($lines, $period);
|
||||||
return $gl_id;
|
return $gl_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,11 +186,14 @@ class GlManager
|
|||||||
$history = json_decode($gl['history'], true) ?: [];
|
$history = json_decode($gl['history'], true) ?: [];
|
||||||
|
|
||||||
// Snapshot current lines into history
|
// Snapshot current lines into history
|
||||||
|
$old_lines = $this->getLines($gl_id);
|
||||||
|
$old_period = $gl['period'];
|
||||||
|
|
||||||
$history[] = [
|
$history[] = [
|
||||||
'version' => $version,
|
'version' => $version,
|
||||||
'formula_id' => (int)$gl['formula_id'],
|
'formula_id' => (int)$gl['formula_id'],
|
||||||
'replaced_at' => $now,
|
'replaced_at' => $now,
|
||||||
'lines' => $this->getLines($gl_id),
|
'lines' => $old_lines,
|
||||||
];
|
];
|
||||||
|
|
||||||
$this->pdo->prepare(
|
$this->pdo->prepare(
|
||||||
@@ -210,6 +220,8 @@ class GlManager
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$this->insertLines($gl_id, $lines);
|
$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
|
public function delete(string $source_type, int $source_id): void
|
||||||
@@ -264,7 +276,9 @@ class GlManager
|
|||||||
':created_at' => $now,
|
':created_at' => $now,
|
||||||
':updated_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
|
// 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -52,6 +52,8 @@
|
|||||||
$answer['new_id'] = $new_id;
|
$answer['new_id'] = $new_id;
|
||||||
$answer['message'] = 'Payment posted.';
|
$answer['message'] = 'Payment posted.';
|
||||||
(new UsageGuard($pdo1, $company_id, $packages))->increment();
|
(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));
|
exit(json_encode($answer));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,8 @@
|
|||||||
$answer['new_id'] = $new_id;
|
$answer['new_id'] = $new_id;
|
||||||
$answer['message'] = 'Receipt posted.';
|
$answer['message'] = 'Receipt posted.';
|
||||||
(new UsageGuard($pdo1, $company_id, $packages))->increment();
|
(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));
|
exit(json_encode($answer));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,30 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Real-time WebSocket connection ──────────────────────────────────────── -->
|
||||||
|
<!-- Socket.io client is served by the Node.js server itself -->
|
||||||
|
<script src="<?php echo NODE_PUBLIC_URL; ?>/socket.io/socket.io.js"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
// company_id is set in include_topbar.php as a JS global
|
||||||
|
if (typeof company_id === 'undefined' || !company_id) return;
|
||||||
|
|
||||||
|
window._socket = io('<?php echo NODE_PUBLIC_URL; ?>', {
|
||||||
|
query: { company_id: company_id },
|
||||||
|
reconnection: true,
|
||||||
|
reconnectionDelay: 2000,
|
||||||
|
});
|
||||||
|
|
||||||
|
window._socket.on('connect', function () {
|
||||||
|
console.log('[socket] connected', window._socket.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
window._socket.on('disconnect', function () {
|
||||||
|
console.log('[socket] disconnected');
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
<!-- Stock rows modal — used by: order/order.php, order/return.php, po/po.php, po/supplier_returns.php (via show_stock_rows() in custom.js) -->
|
<!-- Stock rows modal — used by: order/order.php, order/return.php, po/po.php, po/supplier_returns.php (via show_stock_rows() in custom.js) -->
|
||||||
<div class="modal fade" id="stock_rows_modal" tabindex="-1">
|
<div class="modal fade" id="stock_rows_modal" tabindex="-1">
|
||||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||||
|
|||||||
@@ -255,7 +255,8 @@ $_usage_full = $_usage_max_pct >= 100;
|
|||||||
// Global Variables
|
// Global Variables
|
||||||
var server_url = '<?php echo $server_url; ?>';
|
var server_url = '<?php echo $server_url; ?>';
|
||||||
var prop_limit = '<?php echo $prop['limit']; ?>';
|
var prop_limit = '<?php echo $prop['limit']; ?>';
|
||||||
var user_role = document.getElementById('session-context')?.dataset.role || 'viewer';
|
var user_role = document.getElementById('session-context')?.dataset.role || 'viewer';
|
||||||
|
var company_id = parseInt(document.getElementById('session-context')?.dataset.companyId || '0', 10);
|
||||||
|
|
||||||
// ── Log out ───────────────────────────────────────────────────────────────────
|
// ── Log out ───────────────────────────────────────────────────────────────────
|
||||||
function log_out() {
|
function log_out() {
|
||||||
|
|||||||
+2
-5
@@ -1,7 +1,4 @@
|
|||||||
{
|
{
|
||||||
"name": "wms/wms",
|
"name": "wms/mn3wms",
|
||||||
"description": "WMS",
|
"description": "MN3 WMS"
|
||||||
"require-dev": {
|
|
||||||
"phpunit/phpunit": "^10"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
-1688
File diff suppressed because it is too large
Load Diff
@@ -1,386 +1,9 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* Document Flow Test Suite — T19 to T27
|
* Compatibility entry point for the previous docs/tests/doc_flow_test.php path.
|
||||||
* Run: php tests/doc_flow_test.php
|
*
|
||||||
* DB: wms_codex_test / wms2_codex_test
|
* The current document-flow risk area is soft-delete compliance, so this wrapper
|
||||||
|
* delegates to the focused soft-delete suite.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// ── Bootstrap ────────────────────────────────────────────────────────────────
|
require __DIR__ . '/soft_delete_test.php';
|
||||||
define('APP_ROOT', __DIR__ . '/../app');
|
|
||||||
|
|
||||||
// Load config for helper classes (packages array etc.) but override DB names
|
|
||||||
require APP_ROOT . '/config.php';
|
|
||||||
$db_database = 'wms_codex_test';
|
|
||||||
$db_database2 = 'wms2_codex_test';
|
|
||||||
require APP_ROOT . '/dbconn.php';
|
|
||||||
require APP_ROOT . '/assets/utils/db_helpers.php';
|
|
||||||
require APP_ROOT . '/assets/utils/classes/CompanySettingManager.php';
|
|
||||||
require APP_ROOT . '/assets/utils/classes_ac/PostingWindowGuard.php';
|
|
||||||
require APP_ROOT . '/assets/utils/classes/WarehouseManager.php';
|
|
||||||
require APP_ROOT . '/assets/utils/classes/StockManager.php';
|
|
||||||
require APP_ROOT . '/assets/utils/classes/OrderManager.php';
|
|
||||||
require APP_ROOT . '/assets/utils/classes/PurchaseOrderManager.php';
|
|
||||||
require APP_ROOT . '/assets/utils/classes/QuotationManager.php';
|
|
||||||
require APP_ROOT . '/assets/utils/classes/InvoiceManager.php';
|
|
||||||
|
|
||||||
// Fake session so logging arrays don't fail
|
|
||||||
$_SESSION['login_user_id'] = 501;
|
|
||||||
$_SESSION['otpTime'] = time();
|
|
||||||
|
|
||||||
// Test constants
|
|
||||||
const COMPANY_ID = 1001;
|
|
||||||
const WH_ID = 4;
|
|
||||||
const ZONE = '99';
|
|
||||||
const AISLE = '99';
|
|
||||||
const RACK = '99';
|
|
||||||
const SKU = 'SKU-CODEX-001';
|
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
||||||
$pass = 0;
|
|
||||||
$fail = 0;
|
|
||||||
$results = [];
|
|
||||||
|
|
||||||
function ok(string $label, bool $condition, string $detail = ''): void {
|
|
||||||
global $pass, $fail, $results;
|
|
||||||
$status = $condition ? 'PASS' : 'FAIL';
|
|
||||||
$condition ? $pass++ : $fail++;
|
|
||||||
$results[] = [$status, $label, $detail];
|
|
||||||
echo ($condition ? "\033[32m[PASS]\033[0m" : "\033[31m[FAIL]\033[0m")
|
|
||||||
. " {$label}" . ($detail ? " — {$detail}" : '') . PHP_EOL;
|
|
||||||
}
|
|
||||||
|
|
||||||
function logging(): array {
|
|
||||||
return ['user_id' => 501, 'dt' => date('Y-m-d H:i:s'), 'login' => date('Y-m-d H:i:s')];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Reset ─────────────────────────────────────────────────────────────────────
|
|
||||||
echo PHP_EOL . "=== RESET ===" . PHP_EOL;
|
|
||||||
$p1 = $pdo1;
|
|
||||||
$p2 = $pdo2;
|
|
||||||
|
|
||||||
// wms_codex_test
|
|
||||||
$p1->exec("DELETE FROM company_usage WHERE company_id = " . COMPANY_ID);
|
|
||||||
|
|
||||||
// wms2_codex_test
|
|
||||||
$p2->exec("DELETE FROM td_invoice_item WHERE company_id = " . COMPANY_ID);
|
|
||||||
$p2->exec("DELETE FROM td_invoice WHERE company_id = " . COMPANY_ID);
|
|
||||||
$p2->exec("DELETE FROM td_order_item WHERE company_id = " . COMPANY_ID);
|
|
||||||
$p2->exec("DELETE FROM td_order WHERE company_id = " . COMPANY_ID);
|
|
||||||
$p2->exec("DELETE FROM td_purchase_order_item WHERE company_id = " . COMPANY_ID);
|
|
||||||
$p2->exec("DELETE FROM td_purchase_order WHERE company_id = " . COMPANY_ID);
|
|
||||||
$p2->exec("DELETE FROM td_quotation_item WHERE company_id = " . COMPANY_ID);
|
|
||||||
$p2->exec("DELETE FROM td_quotation WHERE company_id = " . COMPANY_ID);
|
|
||||||
$p2->exec("DELETE FROM td_stock_" . WH_ID . " WHERE company_id = " . COMPANY_ID);
|
|
||||||
$p2->exec("DELETE FROM warehouse_balance WHERE company_id = " . COMPANY_ID);
|
|
||||||
$p2->exec("UPDATE md_rack SET product_sku = NULL, td_stock_id = 0 WHERE company_id = " . COMPANY_ID);
|
|
||||||
echo "State reset complete." . PHP_EOL . PHP_EOL;
|
|
||||||
|
|
||||||
// ── T19: Stock In — Create ────────────────────────────────────────────────────
|
|
||||||
echo "=== T19: Stock In Create ===" . PHP_EOL;
|
|
||||||
$stock_in_id = 0;
|
|
||||||
try {
|
|
||||||
$data = [
|
|
||||||
'warehouse' => WH_ID,
|
|
||||||
'product_sku' => SKU,
|
|
||||||
'quantity' => 10,
|
|
||||||
'price' => 5.00,
|
|
||||||
'zone' => ZONE,
|
|
||||||
'aisle' => AISLE,
|
|
||||||
'rack' => RACK,
|
|
||||||
'contact_id' => 0,
|
|
||||||
'description' => 'Test stock in',
|
|
||||||
'lot_number' => '',
|
|
||||||
'serial_number' => '',
|
|
||||||
];
|
|
||||||
dbTransaction($p2, function($pdo) use ($data, &$stock_in_id) {
|
|
||||||
$stock = new StockManager($pdo, COMPANY_ID);
|
|
||||||
$stock_in_id = $stock->saveStockIn($data, logging(), bin2hex(random_bytes(16)));
|
|
||||||
});
|
|
||||||
ok('T19a: stock_in_id > 0', $stock_in_id > 0, "id={$stock_in_id}");
|
|
||||||
|
|
||||||
// Verify rack occupied
|
|
||||||
$rack = $p2->prepare("SELECT product_sku, td_stock_id FROM md_rack WHERE company_id=? AND zone=? AND aisle=? AND rack=?");
|
|
||||||
$rack->execute([COMPANY_ID, ZONE, AISLE, RACK]);
|
|
||||||
$rrow = $rack->fetch(PDO::FETCH_ASSOC);
|
|
||||||
ok('T19b: rack occupied with correct SKU', $rrow['product_sku'] === SKU, "sku={$rrow['product_sku']}");
|
|
||||||
ok('T19c: rack td_stock_id matches', (int)$rrow['td_stock_id'] === $stock_in_id, "td_stock_id={$rrow['td_stock_id']}");
|
|
||||||
} catch (Exception $e) {
|
|
||||||
ok('T19: Stock In', false, $e->getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── T20: Stock In — Approve ───────────────────────────────────────────────────
|
|
||||||
echo PHP_EOL . "=== T20: Stock In Approve ===" . PHP_EOL;
|
|
||||||
try {
|
|
||||||
dbTransaction($p2, function($pdo) use ($stock_in_id) {
|
|
||||||
$stock = new StockManager($pdo, COMPANY_ID);
|
|
||||||
$whMgmt = new WarehouseManager($pdo, COMPANY_ID);
|
|
||||||
$stock->approveStock($stock_in_id, WH_ID, 'in', $whMgmt);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check warehouse_balance
|
|
||||||
$sth = $p2->prepare("SELECT total_in FROM warehouse_balance WHERE company_id=? AND warehouse_id=? AND product_sku=?");
|
|
||||||
$sth->execute([COMPANY_ID, WH_ID, SKU]);
|
|
||||||
$bal = $sth->fetch(PDO::FETCH_ASSOC);
|
|
||||||
ok('T20a: warehouse_balance updated', !empty($bal), "total_in=" . ($bal['total_in'] ?? 'null'));
|
|
||||||
ok('T20b: total_in = 10', (float)($bal['total_in'] ?? 0) === 10.0, (string)($bal['total_in'] ?? 0));
|
|
||||||
|
|
||||||
// Check stock row status = 1
|
|
||||||
$sth2 = $p2->prepare("SELECT status FROM td_stock_" . WH_ID . " WHERE id=? AND company_id=?");
|
|
||||||
$sth2->execute([$stock_in_id, COMPANY_ID]);
|
|
||||||
$srow = $sth2->fetch(PDO::FETCH_ASSOC);
|
|
||||||
ok('T20c: stock status = 1 after approve', (int)$srow['status'] === 1, "status={$srow['status']}");
|
|
||||||
} catch (Exception $e) {
|
|
||||||
ok('T20: Stock Approve', false, $e->getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── T21: Sales Order — Create Draft ──────────────────────────────────────────
|
|
||||||
echo PHP_EOL . "=== T21: Sales Order Create ===" . PHP_EOL;
|
|
||||||
$order_id = 0;
|
|
||||||
try {
|
|
||||||
$qty = 2.0;
|
|
||||||
$uprice = 10.0;
|
|
||||||
$total = $qty * $uprice;
|
|
||||||
$data = [
|
|
||||||
'contact_id' => 0,
|
|
||||||
'department_id'=> 0,
|
|
||||||
'order_date' => date('Y-m-d'),
|
|
||||||
'discount' => 0,
|
|
||||||
'tax_adjustment' => 0,
|
|
||||||
'shipping_fee' => 0,
|
|
||||||
'notes' => 'Test order',
|
|
||||||
'items' => [[
|
|
||||||
'product_sku' => SKU,
|
|
||||||
'product_name' => 'Test Product 001',
|
|
||||||
'quantity' => $qty,
|
|
||||||
'unit_price' => $uprice,
|
|
||||||
'total_price' => $total,
|
|
||||||
'tax_amount' => 0,
|
|
||||||
'tax_rate' => 0,
|
|
||||||
'warehouse_id' => WH_ID,
|
|
||||||
'lot_number' => '',
|
|
||||||
]],
|
|
||||||
];
|
|
||||||
$om = new OrderManager($p2, COMPANY_ID);
|
|
||||||
$order_id = $om->saveOrder($data, logging());
|
|
||||||
ok('T21a: order_id > 0', $order_id > 0, "id={$order_id}");
|
|
||||||
|
|
||||||
$sth = $p2->prepare("SELECT status, grand_total FROM td_order WHERE id=? AND company_id=?");
|
|
||||||
$sth->execute([$order_id, COMPANY_ID]);
|
|
||||||
$orow = $sth->fetch(PDO::FETCH_ASSOC);
|
|
||||||
ok('T21b: order status = 0 (draft)', (int)$orow['status'] === 0, "status={$orow['status']}");
|
|
||||||
ok('T21c: grand_total = 20.00', (float)$orow['grand_total'] === 20.0, "total={$orow['grand_total']}");
|
|
||||||
|
|
||||||
$sth2 = $p2->prepare("SELECT COUNT(*) FROM td_order_item WHERE order_id=? AND company_id=?");
|
|
||||||
$sth2->execute([$order_id, COMPANY_ID]);
|
|
||||||
ok('T21d: 1 order item created', (int)$sth2->fetchColumn() === 1);
|
|
||||||
} catch (Exception $e) {
|
|
||||||
ok('T21: Sales Order Create', false, $e->getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── T22: Sales Order — Confirm ───────────────────────────────────────────────
|
|
||||||
echo PHP_EOL . "=== T22: Sales Order Confirm ===" . PHP_EOL;
|
|
||||||
try {
|
|
||||||
$uuid = bin2hex(random_bytes(16));
|
|
||||||
dbTransaction($p2, function($pdo) use ($order_id, $uuid) {
|
|
||||||
$om = new OrderManager($pdo, COMPANY_ID);
|
|
||||||
$om->confirmOrder($order_id, $uuid, logging());
|
|
||||||
});
|
|
||||||
|
|
||||||
$sth = $p2->prepare("SELECT status FROM td_order WHERE id=? AND company_id=?");
|
|
||||||
$sth->execute([$order_id, COMPANY_ID]);
|
|
||||||
$orow = $sth->fetch(PDO::FETCH_ASSOC);
|
|
||||||
ok('T22a: order status = 1 (confirmed)', (int)$orow['status'] === 1, "status={$orow['status']}");
|
|
||||||
|
|
||||||
// Check a stock-out draft row was created
|
|
||||||
$sth2 = $p2->prepare("SELECT COUNT(*) FROM td_stock_" . WH_ID . " WHERE company_id=? AND source='order' AND source_id=? AND `out`>0");
|
|
||||||
$sth2->execute([COMPANY_ID, $order_id]);
|
|
||||||
ok('T22b: draft stock-out row created', (int)$sth2->fetchColumn() === 1);
|
|
||||||
} catch (Exception $e) {
|
|
||||||
ok('T22: Sales Order Confirm', false, $e->getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── T23: Invoice — Create from Order ─────────────────────────────────────────
|
|
||||||
echo PHP_EOL . "=== T23: Invoice from Confirmed Order ===" . PHP_EOL;
|
|
||||||
$invoice_id = 0;
|
|
||||||
try {
|
|
||||||
$im = new InvoiceManager($p2, COMPANY_ID);
|
|
||||||
dbTransaction($p2, function($pdo) use ($order_id, &$invoice_id) {
|
|
||||||
$im2 = new InvoiceManager($pdo, COMPANY_ID);
|
|
||||||
$invoice_id = $im2->createFromOrder($order_id, logging());
|
|
||||||
});
|
|
||||||
ok('T23a: invoice_id > 0', $invoice_id > 0, "id={$invoice_id}");
|
|
||||||
|
|
||||||
$sth = $p2->prepare("SELECT status, doc_type, grand_total FROM td_invoice WHERE id=? AND company_id=?");
|
|
||||||
$sth->execute([$invoice_id, COMPANY_ID]);
|
|
||||||
$inv = $sth->fetch(PDO::FETCH_ASSOC);
|
|
||||||
ok('T23b: doc_type = invoice', $inv['doc_type'] === 'invoice', "doc_type={$inv['doc_type']}");
|
|
||||||
ok('T23c: invoice status = 0 (draft)', (int)$inv['status'] === 0, "status={$inv['status']}");
|
|
||||||
ok('T23d: grand_total = 20.00', (float)$inv['grand_total'] === 20.0, "total={$inv['grand_total']}");
|
|
||||||
|
|
||||||
$sth2 = $p2->prepare("SELECT COUNT(*) FROM td_invoice_item WHERE invoice_id=? AND company_id=?");
|
|
||||||
$sth2->execute([$invoice_id, COMPANY_ID]);
|
|
||||||
ok('T23e: invoice items copied', (int)$sth2->fetchColumn() >= 1);
|
|
||||||
|
|
||||||
// Block duplicate invoice
|
|
||||||
$blocked = false;
|
|
||||||
try {
|
|
||||||
dbTransaction($p2, function($pdo) use ($order_id) {
|
|
||||||
(new InvoiceManager($pdo, COMPANY_ID))->createFromOrder($order_id, logging());
|
|
||||||
});
|
|
||||||
} catch (Exception $e2) {
|
|
||||||
$blocked = str_contains($e2->getMessage(), 'already exists');
|
|
||||||
}
|
|
||||||
ok('T23f: duplicate invoice blocked', $blocked);
|
|
||||||
} catch (Exception $e) {
|
|
||||||
ok('T23: Invoice from Order', false, $e->getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── T24: Purchase Order — Create ─────────────────────────────────────────────
|
|
||||||
echo PHP_EOL . "=== T24: PO Create ===" . PHP_EOL;
|
|
||||||
$po_id = 0;
|
|
||||||
try {
|
|
||||||
$qty = 5.0;
|
|
||||||
$uprice = 4.50;
|
|
||||||
$total = $qty * $uprice;
|
|
||||||
$data = [
|
|
||||||
'contact_id' => 0,
|
|
||||||
'department_id' => 0,
|
|
||||||
'po_date' => date('Y-m-d'),
|
|
||||||
'expected_date' => date('Y-m-d', strtotime('+7 days')),
|
|
||||||
'warehouse_id' => WH_ID,
|
|
||||||
'discount' => 0,
|
|
||||||
'tax_adjustment'=> 0,
|
|
||||||
'shipping_fee' => 0,
|
|
||||||
'notes' => 'Test PO',
|
|
||||||
'items' => [[
|
|
||||||
'product_sku' => SKU,
|
|
||||||
'product_name' => 'Test Product 001',
|
|
||||||
'quantity' => $qty,
|
|
||||||
'unit_price' => $uprice,
|
|
||||||
'total_price' => $total,
|
|
||||||
'tax_amount' => 0,
|
|
||||||
'tax_rate' => 0,
|
|
||||||
]],
|
|
||||||
];
|
|
||||||
$pom = new PurchaseOrderManager($p2, COMPANY_ID);
|
|
||||||
$po_id = $pom->savePo($data, logging());
|
|
||||||
ok('T24a: po_id > 0', $po_id > 0, "id={$po_id}");
|
|
||||||
|
|
||||||
$sth = $p2->prepare("SELECT status, grand_total, expected_date FROM td_purchase_order WHERE id=? AND company_id=?");
|
|
||||||
$sth->execute([$po_id, COMPANY_ID]);
|
|
||||||
$porow = $sth->fetch(PDO::FETCH_ASSOC);
|
|
||||||
ok('T24b: PO status = 0 (draft)', (int)$porow['status'] === 0, "status={$porow['status']}");
|
|
||||||
ok('T24c: grand_total = 22.50', (float)$porow['grand_total'] === 22.5, "total={$porow['grand_total']}");
|
|
||||||
ok('T24d: expected_date saved', !empty($porow['expected_date']), "expected_date={$porow['expected_date']}");
|
|
||||||
|
|
||||||
$sth2 = $p2->prepare("SELECT COUNT(*) FROM td_purchase_order_item WHERE order_id=? AND company_id=?");
|
|
||||||
$sth2->execute([$po_id, COMPANY_ID]);
|
|
||||||
ok('T24e: 1 PO item created', (int)$sth2->fetchColumn() === 1);
|
|
||||||
} catch (Exception $e) {
|
|
||||||
ok('T24: PO Create', false, $e->getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── T25: PO Confirm ──────────────────────────────────────────────────────────
|
|
||||||
echo PHP_EOL . "=== T25: PO Confirm ===" . PHP_EOL;
|
|
||||||
try {
|
|
||||||
$pom = new PurchaseOrderManager($p2, COMPANY_ID);
|
|
||||||
$pom->confirmPo($po_id, logging());
|
|
||||||
|
|
||||||
$sth = $p2->prepare("SELECT status FROM td_purchase_order WHERE id=? AND company_id=?");
|
|
||||||
$sth->execute([$po_id, COMPANY_ID]);
|
|
||||||
$porow = $sth->fetch(PDO::FETCH_ASSOC);
|
|
||||||
ok('T25a: PO status = 1 (confirmed)', (int)$porow['status'] === 1, "status={$porow['status']}");
|
|
||||||
|
|
||||||
// Confirm again should throw
|
|
||||||
$blocked = false;
|
|
||||||
try { $pom->confirmPo($po_id, logging()); } catch (Exception $e2) { $blocked = true; }
|
|
||||||
ok('T25b: double-confirm blocked', $blocked);
|
|
||||||
} catch (Exception $e) {
|
|
||||||
ok('T25: PO Confirm', false, $e->getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── T26: Quotation Create ────────────────────────────────────────────────────
|
|
||||||
echo PHP_EOL . "=== T26: Quotation Create ===" . PHP_EOL;
|
|
||||||
$quotation_id = 0;
|
|
||||||
try {
|
|
||||||
$qty = 3.0;
|
|
||||||
$uprice = 10.0;
|
|
||||||
$total = $qty * $uprice;
|
|
||||||
$data = [
|
|
||||||
'contact_id' => 0,
|
|
||||||
'department_id' => 0,
|
|
||||||
'quotation_date' => date('Y-m-d'),
|
|
||||||
'valid_until' => date('Y-m-d', strtotime('+30 days')),
|
|
||||||
'discount' => 0,
|
|
||||||
'notes' => 'Test quotation',
|
|
||||||
'items' => [[
|
|
||||||
'product_sku' => SKU,
|
|
||||||
'product_name' => 'Test Product 001',
|
|
||||||
'quantity' => $qty,
|
|
||||||
'unit_price' => $uprice,
|
|
||||||
'total_price' => $total,
|
|
||||||
'tax_amount' => 0,
|
|
||||||
'tax_rate' => 0,
|
|
||||||
]],
|
|
||||||
];
|
|
||||||
$qm = new QuotationManager($p2, COMPANY_ID);
|
|
||||||
$quotation_id = $qm->save($data, logging());
|
|
||||||
ok('T26a: quotation_id > 0', $quotation_id > 0, "id={$quotation_id}");
|
|
||||||
|
|
||||||
$sth = $p2->prepare("SELECT status, grand_total, quotation_date, valid_until FROM td_quotation WHERE id=? AND company_id=?");
|
|
||||||
$sth->execute([$quotation_id, COMPANY_ID]);
|
|
||||||
$qrow = $sth->fetch(PDO::FETCH_ASSOC);
|
|
||||||
ok('T26b: quotation status = 0 (draft)', (int)$qrow['status'] === 0, "status={$qrow['status']}");
|
|
||||||
ok('T26c: grand_total = 30.00', (float)$qrow['grand_total'] === 30.0, "total={$qrow['grand_total']}");
|
|
||||||
ok('T26d: quotation_date saved', !empty($qrow['quotation_date']), "qdate={$qrow['quotation_date']}");
|
|
||||||
ok('T26e: valid_until saved', !empty($qrow['valid_until']), "valid_until={$qrow['valid_until']}");
|
|
||||||
|
|
||||||
$sth2 = $p2->prepare("SELECT COUNT(*) FROM td_quotation_item WHERE quotation_id=? AND company_id=?");
|
|
||||||
$sth2->execute([$quotation_id, COMPANY_ID]);
|
|
||||||
ok('T26f: 1 quotation item created', (int)$sth2->fetchColumn() === 1);
|
|
||||||
} catch (Exception $e) {
|
|
||||||
ok('T26: Quotation Create', false, $e->getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── T27: Usage increment grep check ──────────────────────────────────────────
|
|
||||||
echo PHP_EOL . "=== T27: Usage Increment Wiring ===" . PHP_EOL;
|
|
||||||
$increment_files = [
|
|
||||||
'ics/api/engine/manage_stock_in.php',
|
|
||||||
'ics/api/engine/manage_stock_out.php',
|
|
||||||
'ics/api/engine/manage_stock_transfer.php',
|
|
||||||
'order/api/engine/manage_order.php',
|
|
||||||
'order/api/engine/manage_invoice.php',
|
|
||||||
'po/api/engine/manage_po.php',
|
|
||||||
'revenue/api/engine/manage_order.php',
|
|
||||||
'revenue/api/engine/manage_quotation.php',
|
|
||||||
'finance/api/engine/manage_receipt.php',
|
|
||||||
'finance/api/engine/manage_payment.php',
|
|
||||||
];
|
|
||||||
$missing = [];
|
|
||||||
foreach ($increment_files as $rel) {
|
|
||||||
$path = APP_ROOT . '/' . $rel;
|
|
||||||
if (!file_exists($path)) {
|
|
||||||
$missing[] = $rel . ' (FILE MISSING)';
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$src = file_get_contents($path);
|
|
||||||
if (!str_contains($src, '->increment()')) {
|
|
||||||
$missing[] = $rel;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ok('T27: all 10 engine files have increment()', empty($missing),
|
|
||||||
empty($missing) ? '10/10 found' : 'Missing: ' . implode(', ', $missing));
|
|
||||||
|
|
||||||
// ── Summary ───────────────────────────────────────────────────────────────────
|
|
||||||
echo PHP_EOL . "=== SUMMARY ===" . PHP_EOL;
|
|
||||||
$total = $pass + $fail;
|
|
||||||
echo "Passed: {$pass}/{$total}" . PHP_EOL;
|
|
||||||
|
|
||||||
if ($fail > 0) {
|
|
||||||
echo PHP_EOL . "FAILED TESTS:" . PHP_EOL;
|
|
||||||
foreach ($results as [$s, $l, $d]) {
|
|
||||||
if ($s === 'FAIL') echo " - {$l}" . ($d ? ": {$d}" : '') . PHP_EOL;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
exit($fail === 0 ? 0 : 1);
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Node.js real-time server config
|
||||||
|
# Copy this file to .env and fill in the values
|
||||||
|
|
||||||
|
# Port the Node.js server listens on
|
||||||
|
PORT=3000
|
||||||
|
|
||||||
|
# Secret key — PHP sends this in x-emit-secret header to authenticate /emit calls
|
||||||
|
# Use a long random string in production
|
||||||
|
EMIT_SECRET=
|
||||||
Generated
+1099
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "mn3wms-realtime",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Real-time event server for MN3 WMS",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.0",
|
||||||
|
"socket.io": "^4.7.0",
|
||||||
|
"dotenv": "^16.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const { createServer } = require('http');
|
||||||
|
const { Server } = require('socket.io');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const httpServer = createServer(app);
|
||||||
|
const io = new Server(httpServer, {
|
||||||
|
cors: { origin: '*' }
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// ── /emit ─────────────────────────────────────────────────────────────────────
|
||||||
|
// PHP calls this after any significant action.
|
||||||
|
// Body: { event, data, company_id }
|
||||||
|
//
|
||||||
|
app.post('/emit', (req, res) => {
|
||||||
|
const secret = req.headers['x-emit-secret'];
|
||||||
|
if (secret !== process.env.EMIT_SECRET) {
|
||||||
|
return res.status(403).json({ ok: false, message: 'Forbidden' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { event, data, company_id } = req.body;
|
||||||
|
if (!event || !company_id) {
|
||||||
|
return res.status(400).json({ ok: false, message: 'event and company_id required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast only to the room for this company
|
||||||
|
io.to(`company_${company_id}`).emit(event, data);
|
||||||
|
|
||||||
|
console.log(`[emit] company=${company_id} event=${event}`, data);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── WebSocket connections ─────────────────────────────────────────────────────
|
||||||
|
// Each browser tab connects here on page load.
|
||||||
|
// It joins a room named company_<id> so events stay isolated per company.
|
||||||
|
//
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
const company_id = socket.handshake.query.company_id;
|
||||||
|
|
||||||
|
if (!company_id) {
|
||||||
|
socket.disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.join(`company_${company_id}`);
|
||||||
|
console.log(`[connect] socket=${socket.id} company=${company_id}`);
|
||||||
|
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
console.log(`[disconnect] socket=${socket.id}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Start ─────────────────────────────────────────────────────────────────────
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
httpServer.listen(PORT, () => {
|
||||||
|
console.log(`Node.js real-time server running on port ${PORT}`);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user