Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6765054950 | ||
|
|
075d80ad40 | ||
|
|
a859cfda3e | ||
|
|
37d2f8e725 | ||
|
|
39201df36e | ||
|
|
3045c4a8ef | ||
|
|
4c4522169c | ||
|
|
234814a23c | ||
|
|
0eab2a3b96 | ||
|
|
618045540a | ||
|
|
7b294f70da | ||
|
|
356d308907 | ||
|
|
9c7a4a139d | ||
|
|
b634372b22 | ||
|
|
2a6441c6a9 | ||
|
|
987cf7ded9 | ||
|
|
ea94b6a6ae | ||
|
|
dbbc89f8f2 | ||
|
|
82f66b0c41 | ||
|
|
8905b5bf35 | ||
|
|
8916d9180d | ||
|
|
ebccca4989 | ||
|
|
aee797b998 | ||
|
|
5ff1a60c7d | ||
|
|
5bfaf6f8c3 | ||
|
|
17a62e50e4 | ||
|
|
1b34482216 | ||
|
|
1c5236d8e0 | ||
|
|
6ca4c91865 | ||
|
|
632c039790 | ||
|
|
1a952b42dd | ||
|
|
9e200d31fe | ||
|
|
0815ae3292 | ||
|
|
5c0166ae73 | ||
|
|
3c9475f3fa | ||
|
|
8027ab569e | ||
|
|
1237888ab9 | ||
|
|
45a78a3fed | ||
|
|
5ca6b49fd0 | ||
|
|
54f3f11fd2 | ||
|
|
5affae1fc5 | ||
|
|
4bb378a905 | ||
|
|
15618f4955 | ||
|
|
c0de84a575 | ||
|
|
5780183b82 | ||
|
|
5a3bfa3435 | ||
|
|
648efee991 | ||
|
|
738f600fe8 | ||
|
|
7379ac9e4a | ||
|
|
cf25732da0 | ||
|
|
f2b87cfd0f | ||
|
|
86b1aa9d62 | ||
|
|
e1135d2bce |
@@ -0,0 +1,17 @@
|
||||
# Copy to .env and fill in real values before running: docker compose up -d --build
|
||||
|
||||
# MariaDB root password (also used by the app's db_pass)
|
||||
DB_ROOT_PASSWORD=
|
||||
|
||||
# Public IP or domain the browser uses to reach this server (Socket.IO client URL)
|
||||
PUBLIC_HOST=
|
||||
|
||||
# Shared secret between PHP and Node (must be a long random string)
|
||||
EMIT_SECRET=
|
||||
|
||||
# SMTP (Gmail) used for outgoing mail
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
|
||||
# Port to expose the web app on (default 80)
|
||||
HTTP_PORT=80
|
||||
@@ -2,18 +2,17 @@
|
||||
app/config.php
|
||||
app/uploads
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# PHP dependencies
|
||||
vendor/
|
||||
|
||||
# Node dependencies
|
||||
node_modules/
|
||||
nodejs/.env
|
||||
|
||||
# Docker deploy secrets
|
||||
/.env
|
||||
|
||||
# zxcvbn dev dependencies (not needed at runtime)
|
||||
lib/zxcvbn-php-master/vendor/sebastian/
|
||||
|
||||
# custom files
|
||||
notes/
|
||||
docs/
|
||||
.claude/
|
||||
SESSION.php
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes/UsageGuard.php';
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_once '../../../assets/utils/classes/UsageGuard.php';
|
||||
|
||||
(new UsageGuard($pdo1, $company_id, $packages))->assertFeatureAccessible('dashboard');
|
||||
require '../../../assets/utils/classes_ac/FinancialReports.php';
|
||||
require_once '../../../assets/utils/classes_ac/FinancialReports.php';
|
||||
|
||||
$reports = new FinancialReports($pdo2, $company_id);
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes/UsageGuard.php';
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_once '../../../assets/utils/classes/UsageGuard.php';
|
||||
|
||||
(new UsageGuard($pdo1, $company_id, $packages))->assertFeatureAccessible('dashboard');
|
||||
require '../../../assets/utils/classes_ac/FinancialReports.php';
|
||||
require_once '../../../assets/utils/classes_ac/FinancialReports.php';
|
||||
|
||||
$reports = new FinancialReports($pdo2, $company_id);
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes/UsageGuard.php';
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_once '../../../assets/utils/classes/UsageGuard.php';
|
||||
|
||||
(new UsageGuard($pdo1, $company_id, $packages))->assertFeatureAccessible('dashboard');
|
||||
require '../../../assets/utils/classes_ac/FinancialReports.php';
|
||||
require_once '../../../assets/utils/classes_ac/FinancialReports.php';
|
||||
|
||||
$reports = new FinancialReports($pdo2, $company_id);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes/CompanySettingManager.php';
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_once '../../../assets/utils/classes/CompanySettingManager.php';
|
||||
|
||||
$settings = new CompanySettingManager($pdo1, $company_id);
|
||||
$open_from = $settings->get('posting_open_from') ?: $settings->get('gl_open_from') ?: '';
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes/UsageGuard.php';
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_once '../../../assets/utils/classes/UsageGuard.php';
|
||||
|
||||
(new UsageGuard($pdo1, $company_id, $packages))->assertFeatureAccessible('dashboard');
|
||||
require '../../../assets/utils/classes_ac/FinancialReports.php';
|
||||
require_once '../../../assets/utils/classes_ac/FinancialReports.php';
|
||||
|
||||
$limit = max(1, min(50, (int)($data['limit'] ?? 10)));
|
||||
$reports = new FinancialReports($pdo2, $company_id);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes/UsageGuard.php';
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_once '../../../assets/utils/classes/UsageGuard.php';
|
||||
|
||||
(new UsageGuard($pdo1, $company_id, $packages))->assertFeatureAccessible('dashboard');
|
||||
require '../../../assets/utils/classes_ac/FinancialReports.php';
|
||||
require_once '../../../assets/utils/classes_ac/FinancialReports.php';
|
||||
|
||||
$months = max(1, min(24, (int)($data['months'] ?? 6)));
|
||||
$reports = new FinancialReports($pdo2, $company_id);
|
||||
|
||||
@@ -180,6 +180,15 @@
|
||||
<?php require '../include_ending.php'; ?>
|
||||
|
||||
<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>
|
||||
var trend_chart_instance = null;
|
||||
|
||||
@@ -207,26 +216,33 @@
|
||||
return Number(n).toLocaleString(undefined, {minimumFractionDigits:2, maximumFractionDigits:2});
|
||||
}
|
||||
|
||||
function show_overlays() {
|
||||
$('#overlay_revenue, #overlay_expense, #overlay_net, #overlay_journals, #overlay_lock, #overlay_trend, #overlay_source, #overlay_recent').show();
|
||||
// Flash a card briefly to signal a silent update happened
|
||||
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() {
|
||||
show_overlays();
|
||||
['stat_revenue', 'stat_expense', 'stat_net', 'stat_journals'].forEach(function(id) {
|
||||
document.getElementById(id).textContent = '—';
|
||||
});
|
||||
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>';
|
||||
}
|
||||
// ── Individual load functions ─────────────────────────────────────────────
|
||||
// Each accepts a `silent` flag:
|
||||
// false (default) — show overlay spinner, blank value while loading
|
||||
// true — keep existing value visible, flash card on update
|
||||
|
||||
function load_dashboard() {
|
||||
set_dashboard_loading();
|
||||
// load_pl(silent, flash_revenue, flash_expense)
|
||||
// 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({
|
||||
url: server_url + 'ac_dashboard/api/engine/pl.php',
|
||||
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';
|
||||
}
|
||||
$('#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({
|
||||
url: server_url + 'ac_dashboard/api/engine/journals.php',
|
||||
autoPrepare: true, checkRequired: 0, noLoading: true, queueLock: false, action: 'read',
|
||||
onSuccess: function(res) {
|
||||
document.getElementById('stat_journals').textContent = res.output.journal_count;
|
||||
$('#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({
|
||||
url: server_url + 'ac_dashboard/api/engine/posting_window.php',
|
||||
autoPrepare: true, checkRequired: 0, noLoading: true, queueLock: false, action: 'read',
|
||||
onSuccess: function(res) { render_posting_window(res.output); }
|
||||
});
|
||||
}
|
||||
|
||||
// Revenue vs Expenses trend chart
|
||||
function load_trend(silent) {
|
||||
if (!silent) $('#overlay_trend').show();
|
||||
ajax_request({
|
||||
url: server_url + 'ac_dashboard/api/engine/trend.php',
|
||||
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({
|
||||
url: server_url + 'ac_dashboard/api/engine/by_source.php',
|
||||
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({
|
||||
url: server_url + 'ac_dashboard/api/engine/recent.php',
|
||||
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) {
|
||||
var el = document.getElementById('posting_window_banner');
|
||||
var from = window_data.open_from || '';
|
||||
@@ -308,8 +365,7 @@
|
||||
$('#overlay_lock').hide();
|
||||
}
|
||||
|
||||
function render_trend(trend) {
|
||||
// Build exactly 6 month labels (last 6 months ending today), fill zeros for missing
|
||||
function render_trend(trend, silent) {
|
||||
var labels = [], revenues = [], expenses = [];
|
||||
var idx = {};
|
||||
trend.forEach(function(r){ idx[r.period] = r; });
|
||||
@@ -345,9 +401,10 @@
|
||||
}
|
||||
});
|
||||
$('#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');
|
||||
if (!rows || rows.length === 0) {
|
||||
el.innerHTML = '<div class="text-center text-muted small py-4">No entries found.</div>';
|
||||
@@ -371,9 +428,10 @@
|
||||
});
|
||||
el.innerHTML = html;
|
||||
$('#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');
|
||||
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>';
|
||||
@@ -397,9 +455,28 @@
|
||||
});
|
||||
tbody.innerHTML = html;
|
||||
$('#overlay_recent').hide();
|
||||
if (silent) flash_card(tbody.closest('.card'));
|
||||
}
|
||||
|
||||
// ── Initial load ──────────────────────────────────────────────────────────
|
||||
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>
|
||||
|
||||
</body>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes_ac/ChartOfAccounts.php';
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_once '../../../assets/utils/classes_ac/ChartOfAccounts.php';
|
||||
|
||||
$coa = new ChartOfAccounts($pdo2, $company_id);
|
||||
$answer['output'] = $coa->getAll();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_role($user_role, ['owner', 'admin', 'staff']);
|
||||
require '../../../assets/utils/classes_ac/AccountFormulaManager.php';
|
||||
require_once '../../../assets/utils/classes_ac/AccountFormulaManager.php';
|
||||
|
||||
try {
|
||||
$mgr = new AccountFormulaManager($pdo2, $company_id);
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes_ac/ChartOfAccounts.php';
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_once '../../../assets/utils/classes/UsageGuard.php';
|
||||
require_once '../../../assets/utils/classes_ac/ChartOfAccounts.php';
|
||||
|
||||
(new UsageGuard($pdo1, $company_id, $packages))->assertFeatureAccessible('dashboard');
|
||||
|
||||
$coa = new ChartOfAccounts($pdo2, $company_id);
|
||||
$stats = $coa->getStats();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes/OperationLockManager.php';
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_once '../../../assets/utils/classes/OperationLockManager.php';
|
||||
|
||||
try {
|
||||
$lock_manager = new OperationLockManager($pdo2, $company_id, $user_id);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes_ac/DepartmentManager.php';
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_once '../../../assets/utils/classes_ac/DepartmentManager.php';
|
||||
|
||||
$dept = new DepartmentManager($pdo2, $company_id);
|
||||
$answer['output'] = $dept->getAll();
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes_ac/DepartmentManager.php';
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_once '../../../assets/utils/classes/UsageGuard.php';
|
||||
require_once '../../../assets/utils/classes_ac/DepartmentManager.php';
|
||||
|
||||
(new UsageGuard($pdo1, $company_id, $packages))->assertFeatureAccessible('dashboard');
|
||||
|
||||
$dept = new DepartmentManager($pdo2, $company_id);
|
||||
$stats = $dept->getStats();
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes/UsageGuard.php';
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_once '../../../assets/utils/classes/UsageGuard.php';
|
||||
|
||||
(new UsageGuard($pdo1, $company_id, $packages))->assertFeatureAccessible('reports');
|
||||
require '../../../assets/utils/classes_ac/FinancialReports.php';
|
||||
require_once '../../../assets/utils/classes_ac/FinancialReports.php';
|
||||
|
||||
$as_of_date = trim((string)($data['as_of_date'] ?? ($data['as_of_period'] ?? date('Y-m-d'))));
|
||||
$dept_id = (int)($data['department_id'] ?? 0);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
session_start();
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_once '../../../assets/utils/classes_ac/GlManager.php';
|
||||
require_once '../../../assets/utils/classes_ac/GlQueryManager.php';
|
||||
|
||||
$doc_type = trim((string)($data['doc_type'] ?? ''));
|
||||
$source_id = (int)($data['source_id'] ?? 0);
|
||||
|
||||
if (!$doc_type || $source_id <= 0) {
|
||||
$answer['message'] = 'doc_type and source_id required.';
|
||||
exit(json_encode($answer));
|
||||
}
|
||||
|
||||
try {
|
||||
$gl = new GlManager($pdo2, $company_id);
|
||||
$entry = $gl->getBySource($doc_type, $source_id);
|
||||
|
||||
if (!$entry) {
|
||||
$answer['success'] = 1;
|
||||
$answer['output'] = null;
|
||||
exit(json_encode($answer));
|
||||
}
|
||||
|
||||
$query = new GlQueryManager($pdo2, $company_id);
|
||||
$detail = $query->getJournalDetail((int)$entry['id']);
|
||||
|
||||
$answer['success'] = 1;
|
||||
$answer['output'] = $detail;
|
||||
} catch (Exception $e) {
|
||||
$answer['message'] = $e->getMessage();
|
||||
}
|
||||
|
||||
exit(json_encode($answer));
|
||||
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes_ac/GlQueryManager.php';
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_once '../../../assets/utils/classes_ac/GlQueryManager.php';
|
||||
|
||||
try {
|
||||
$gl_query = new GlQueryManager($pdo2, $company_id);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes/UsageGuard.php';
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_once '../../../assets/utils/classes/UsageGuard.php';
|
||||
|
||||
(new UsageGuard($pdo1, $company_id, $packages))->assertFeatureAccessible('reports');
|
||||
require '../../../assets/utils/classes_ac/FinancialReports.php';
|
||||
require_once '../../../assets/utils/classes_ac/FinancialReports.php';
|
||||
|
||||
$account_code = trim((string)($data['account_code'] ?? ''));
|
||||
$from_date = trim((string)($data['from_date'] ?? ($data['from_period'] ?? date('Y-m-d'))));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes_ac/GlQueryManager.php';
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_once '../../../assets/utils/classes_ac/GlQueryManager.php';
|
||||
|
||||
try {
|
||||
$gl_query = new GlQueryManager($pdo2, $company_id);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes_ac/GlQueryManager.php';
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_once '../../../assets/utils/classes_ac/GlQueryManager.php';
|
||||
|
||||
try {
|
||||
$gl_query = new GlQueryManager($pdo2, $company_id);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes/UsageGuard.php';
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_once '../../../assets/utils/classes/UsageGuard.php';
|
||||
|
||||
(new UsageGuard($pdo1, $company_id, $packages))->assertFeatureAccessible('reports');
|
||||
require '../../../assets/utils/classes_ac/FinancialReports.php';
|
||||
require_once '../../../assets/utils/classes_ac/FinancialReports.php';
|
||||
|
||||
$from_date = trim((string)($data['from_date'] ?? ($data['from_period'] ?? date('Y-m-d'))));
|
||||
$to_date = trim((string)($data['to_date'] ?? ($data['to_period'] ?? $from_date)));
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes/UsageGuard.php';
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_once '../../../assets/utils/classes/UsageGuard.php';
|
||||
|
||||
(new UsageGuard($pdo1, $company_id, $packages))->assertFeatureAccessible('reports');
|
||||
require '../../../assets/utils/classes_ac/FinancialReports.php';
|
||||
require_once '../../../assets/utils/classes_ac/FinancialReports.php';
|
||||
|
||||
$from_date = trim((string)($data['from_date'] ?? ($data['from_period'] ?? date('Y-m-d'))));
|
||||
$to_date = trim((string)($data['to_date'] ?? ($data['to_period'] ?? $from_date)));
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes/UsageGuard.php';
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_once '../../../assets/utils/classes/UsageGuard.php';
|
||||
|
||||
(new UsageGuard($pdo1, $company_id, $packages))->assertFeatureAccessible('reports');
|
||||
require '../../../assets/utils/classes_ac/TaxReportManager.php';
|
||||
require_once '../../../assets/utils/classes_ac/TaxReportManager.php';
|
||||
|
||||
$from_date = trim((string)($data['from_date'] ?? ($data['from_period'] ?? date('Y-m-d'))));
|
||||
$to_date = trim((string)($data['to_date'] ?? ($data['to_period'] ?? $from_date)));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes/BatchActionManager.php';
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_once '../../../assets/utils/classes/BatchActionManager.php';
|
||||
|
||||
try {
|
||||
$batch_action = new BatchActionManager($pdo2, $company_id, $user_id);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes_ac/ChartOfAccounts.php';
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_once '../../../assets/utils/classes_ac/ChartOfAccounts.php';
|
||||
|
||||
require_role($user_role, ['owner', 'admin']);
|
||||
|
||||
if (empty($data['account_code']) || empty($data['account_name']) || empty($data['account_type'])) {
|
||||
$answer['message'] = 'Account code, name, and type are required';
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes_ac/DepartmentManager.php';
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_once '../../../assets/utils/classes_ac/DepartmentManager.php';
|
||||
|
||||
require_role($user_role, ['owner', 'admin']);
|
||||
|
||||
if (empty($data['dept_code']) || empty($data['dept_name'])) {
|
||||
$answer['message'] = 'Department code and name are required';
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes_ac/PostingWindowGuard.php';
|
||||
require '../../../assets/utils/classes_ac/GlManager.php';
|
||||
require '../../../assets/utils/classes_ac/posting/BasePosting.php';
|
||||
require '../../../assets/utils/classes_ac/posting/InvoicePosting.php';
|
||||
require '../../../assets/utils/classes_ac/posting/CreditNotePosting.php';
|
||||
require '../../../assets/utils/classes_ac/posting/PurchaseInvoicePosting.php';
|
||||
require '../../../assets/utils/classes_ac/posting/SupplierCreditNotePosting.php';
|
||||
require '../../../assets/utils/classes_ac/posting/ReceiptPosting.php';
|
||||
require '../../../assets/utils/classes_ac/posting/PaymentPosting.php';
|
||||
require '../../../assets/utils/classes_ac/posting/PurchasePosting.php';
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_once '../../../assets/utils/classes_ac/PostingWindowGuard.php';
|
||||
require_once '../../../assets/utils/classes_ac/GlManager.php';
|
||||
require_once '../../../assets/utils/classes_ac/posting/BasePosting.php';
|
||||
require_once '../../../assets/utils/classes_ac/posting/InvoicePosting.php';
|
||||
require_once '../../../assets/utils/classes_ac/posting/CreditNotePosting.php';
|
||||
require_once '../../../assets/utils/classes_ac/posting/PurchaseInvoicePosting.php';
|
||||
require_once '../../../assets/utils/classes_ac/posting/SupplierCreditNotePosting.php';
|
||||
require_once '../../../assets/utils/classes_ac/posting/ReceiptPosting.php';
|
||||
require_once '../../../assets/utils/classes_ac/posting/PaymentPosting.php';
|
||||
require_once '../../../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);
|
||||
@@ -54,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();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes/CompanySettingManager.php';
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_once '../../../assets/utils/classes/CompanySettingManager.php';
|
||||
require_role($user_role, ['owner', 'admin']);
|
||||
|
||||
$csm = new CompanySettingManager($pdo1, $company_id);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes/ProductManager.php';
|
||||
require '../../../assets/utils/classes_ac/ChartOfAccounts.php';
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_once '../../../assets/utils/classes/ProductManager.php';
|
||||
require_once '../../../assets/utils/classes_ac/ChartOfAccounts.php';
|
||||
|
||||
require_role($user_role, ['owner', 'admin']);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes/OperationLockManager.php';
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_once '../../../assets/utils/classes/OperationLockManager.php';
|
||||
|
||||
try {
|
||||
$lock_manager = new OperationLockManager($pdo2, $company_id, $user_id);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes_ac/ChartOfAccounts.php';
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_once '../../../assets/utils/classes_ac/ChartOfAccounts.php';
|
||||
|
||||
require_role($user_role, ['owner', 'admin']);
|
||||
|
||||
$id = (int)($data['id'] ?? 0);
|
||||
if (!$id) {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes_ac/DepartmentManager.php';
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_once '../../../assets/utils/classes_ac/DepartmentManager.php';
|
||||
|
||||
require_role($user_role, ['owner', 'admin']);
|
||||
|
||||
$id = (int)($data['id'] ?? 0);
|
||||
if (!$id) { $answer['message'] = 'Missing id'; exit(json_encode($answer)); }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes_ac/ChartOfAccounts.php';
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_once '../../../assets/utils/classes_ac/ChartOfAccounts.php';
|
||||
|
||||
$id = (int)($data['id'] ?? 0);
|
||||
if (!$id) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes_ac/DepartmentManager.php';
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_once '../../../assets/utils/classes_ac/DepartmentManager.php';
|
||||
|
||||
$id = (int)($data['id'] ?? 0);
|
||||
if (!$id) { $answer['message'] = 'Missing id'; exit(json_encode($answer)); }
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes_ac/PostingWindowGuard.php';
|
||||
require '../../../assets/utils/classes_ac/GlManager.php';
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_once '../../../assets/utils/classes/DocumentNumberManager.php';
|
||||
require_once '../../../assets/utils/classes_ac/PostingWindowGuard.php';
|
||||
require_once '../../../assets/utils/classes_ac/GlManager.php';
|
||||
require_once '../../../assets/utils/notify_node.php';
|
||||
|
||||
require_role($user_role, ['owner', 'admin']);
|
||||
|
||||
$gl_id = (int)($data['gl_id'] ?? 0);
|
||||
$event_action = $gl_id > 0 ? 'replaced' : 'posted';
|
||||
$reference = trim((string)($data['reference'] ?? ''));
|
||||
$description = trim((string)($data['description'] ?? ''));
|
||||
$journal_date = trim((string)($data['journal_date'] ?? ''));
|
||||
@@ -64,6 +69,12 @@ try {
|
||||
$gl->replaceManual($gl_id, $reference, $description, $journal_date, $period, $lines);
|
||||
$answer['message'] = 'Journal entry updated.';
|
||||
} else {
|
||||
$number_mgr = new DocumentNumberManager($pdo2, $company_id);
|
||||
if ($reference === '') {
|
||||
$reference = $number_mgr->generate('manual', trim((string)($data['doc_number_prefix'] ?? $data['document_prefix'] ?? '')));
|
||||
} else {
|
||||
$reference = $number_mgr->validateManual('manual', $reference, 'td_gl', 'reference');
|
||||
}
|
||||
$gl_id = $gl->postManual($reference, $description, $journal_date, $period, $lines);
|
||||
$answer['message'] = 'Journal entry saved.';
|
||||
}
|
||||
@@ -71,6 +82,7 @@ try {
|
||||
$pdo2->commit();
|
||||
$answer['success'] = 1;
|
||||
$answer['gl_id'] = $gl_id;
|
||||
notify_node('gl_posted', gl_posted_payload('manual', (int)$gl_id, $event_action, $lines), $company_id);
|
||||
} catch (Exception $e) {
|
||||
if ($pdo2->inTransaction()) $pdo2->rollBack();
|
||||
$answer['message'] = $e->getMessage();
|
||||
|
||||
@@ -36,10 +36,22 @@
|
||||
<i class="ti ti-search me-1"></i>Run
|
||||
</button>
|
||||
</div>
|
||||
<div class="col ms-auto d-flex align-items-end justify-content-end">
|
||||
<button class="btn btn-outline-secondary btn-sm" onclick="window.print()">
|
||||
<i class="ti ti-printer me-1"></i>Print
|
||||
</button>
|
||||
<div class="col-auto">
|
||||
<div class="dropdown">
|
||||
<a href="#" role="button" class="btn btn-outline-secondary btn-sm text-nowrap" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="ti ti-download me-1"></i>Export
|
||||
</a>
|
||||
<div class="dropdown-menu dropdown-menu-end p-0" style="min-width:160px;">
|
||||
<div class="d-flex gap-3 align-items-center border-bottom px-3 py-3">
|
||||
<i class="ti ti-download"></i><div><h4 class="mb-0 small">Export</h4></div>
|
||||
</div>
|
||||
<div class="p-3 d-flex flex-column gap-1 small lh-lg">
|
||||
<a href="javascript:;" class="ac-export-link" onclick="export_report('csv')"><i class="ti ti-file-type-csv me-2"></i>CSV</a>
|
||||
<a href="javascript:;" class="ac-export-link" onclick="export_report('xlsx')"><i class="ti ti-file-type-xls me-2"></i>Excel</a>
|
||||
<a href="javascript:;" class="ac-export-link" onclick="export_report('pdf')"><i class="ti ti-file-type-pdf me-2"></i>PDF</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -56,6 +68,7 @@
|
||||
<?php require '../include_ending.php'; ?>
|
||||
|
||||
<script>
|
||||
var export_data = [];
|
||||
var now = new Date();
|
||||
document.getElementById('f_as_of').value =
|
||||
now.getFullYear() + '-' + String(now.getMonth()+1).padStart(2,'0') + '-' + String(now.getDate()).padStart(2,'0');
|
||||
@@ -83,8 +96,9 @@
|
||||
function load_report() {
|
||||
var as_of = document.getElementById('f_as_of').value;
|
||||
var dept = document.getElementById('f_department').value || '0';
|
||||
if (!as_of) { alert('Select a date.'); return; }
|
||||
if (!as_of) { bootbox.alert('Select a date.'); return; }
|
||||
|
||||
export_data = [];
|
||||
document.getElementById('bs_body').innerHTML =
|
||||
'<div class="col-12"><div class="card p-5 text-center text-muted"><span class="spinner-border spinner-border-sm me-2"></span>Loading...</div></div>';
|
||||
|
||||
@@ -94,8 +108,7 @@
|
||||
checkRequired: 0,
|
||||
noLoading: true,
|
||||
action: 'read',
|
||||
as_of_date: as_of,
|
||||
department_id: dept,
|
||||
data: { as_of_date: as_of, department_id: dept },
|
||||
onSuccess: function(res) {
|
||||
var d = res.output;
|
||||
var rows = d.rows || [];
|
||||
@@ -103,6 +116,24 @@
|
||||
var assets = rows.filter(function(r){ return r.account_type === 'asset'; });
|
||||
var liabilities = rows.filter(function(r){ return r.account_type === 'liability'; });
|
||||
var equity = rows.filter(function(r){ return r.account_type === 'equity'; });
|
||||
export_data = [];
|
||||
assets.forEach(function(r) {
|
||||
export_data.push({ section:'Asset', code:r.account_code, account:r.account_name,
|
||||
debit:r.total_debit, credit:r.total_credit,
|
||||
net: parseFloat(r.total_debit) - parseFloat(r.total_credit) });
|
||||
});
|
||||
liabilities.forEach(function(r) {
|
||||
export_data.push({ section:'Liability', code:r.account_code, account:r.account_name,
|
||||
debit:r.total_debit, credit:r.total_credit,
|
||||
net: parseFloat(r.total_credit) - parseFloat(r.total_debit) });
|
||||
});
|
||||
equity.forEach(function(r) {
|
||||
export_data.push({ section:'Equity', code:r.account_code, account:r.account_name,
|
||||
debit:r.total_debit, credit:r.total_credit,
|
||||
net: parseFloat(r.total_credit) - parseFloat(r.total_debit) });
|
||||
});
|
||||
export_data.push({ section:'Equity', code:'—', account:'Retained Earnings (Cumulative P&L)',
|
||||
debit:'', credit:'', net: parseFloat(d.retained_earnings) || 0 });
|
||||
|
||||
function sum_net(items, type) {
|
||||
return items.reduce(function(s, r) {
|
||||
@@ -165,7 +196,34 @@
|
||||
}
|
||||
});
|
||||
}
|
||||
function export_report(format) {
|
||||
if (export_data.length === 0) { bootbox.alert('No data to export. Run the report first.'); return; }
|
||||
var as_of = document.getElementById('f_as_of').value;
|
||||
var filename = 'balance_sheet_' + as_of;
|
||||
if (format === 'csv') {
|
||||
alasql('SELECT * INTO CSV("' + filename + '.csv",{headers:true}) FROM ?', [export_data]);
|
||||
} else if (format === 'xlsx') {
|
||||
alasql('SELECT * INTO XLSX("' + filename + '.xlsx",{headers:true}) FROM ?', [export_data]);
|
||||
} else if (format === 'pdf') {
|
||||
var { jsPDF } = window.jspdf;
|
||||
var doc = new jsPDF();
|
||||
doc.setFontSize(11);
|
||||
doc.text('Balance Sheet As of ' + as_of, 14, 14);
|
||||
doc.autoTable({
|
||||
startY: 20,
|
||||
head: [['Section','Code','Account','Debit','Credit','Net']],
|
||||
body: export_data.map(function(r){ return [r.section,r.code,r.account,r.debit,r.credit,r.net]; }),
|
||||
styles: { fontSize: 8 },
|
||||
headStyles: { fillColor: [41,128,185] },
|
||||
});
|
||||
doc.save(filename + '.pdf');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -69,38 +69,7 @@
|
||||
|
||||
<?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"> </div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// (batch_process lives in custom.js)
|
||||
|
||||
var TAB_MAP = {
|
||||
'invoice': 'invoice',
|
||||
'credit_note': 'credit_note',
|
||||
@@ -110,19 +79,23 @@
|
||||
'payment': 'payment'
|
||||
};
|
||||
|
||||
// Stores the last loaded IDs per tab so Run doesn't need to re-fetch
|
||||
var _gl_loaded = {};
|
||||
// Stores full row data per tab for client-side pagination
|
||||
var _gl_rows = {};
|
||||
// Stores failed items from the last batch run per tab (for Retry Failed)
|
||||
var _gl_failed = {};
|
||||
var _gl_loaded = {}; // doc_type → [ids] (flat, used by single-formula run)
|
||||
var _gl_rows = {}; // doc_type → [rows]
|
||||
var _gl_failed = {}; // doc_type → failed items from last run
|
||||
var _gl_loaded_by_formula = {}; // doc_type → { formula_id: [ids] }
|
||||
var _formula_run_queue = {}; // doc_type → [formula_ids] remaining in "all" run
|
||||
|
||||
// ── init tab once ─────────────────────────────────────────────────────────
|
||||
var _inited = {};
|
||||
function init_gl_tab(doc_type) {
|
||||
if (_inited[doc_type]) return;
|
||||
_inited[doc_type] = true;
|
||||
load_formula_options('gl-formula-' + doc_type, doc_type, null);
|
||||
|
||||
load_formula_options('gl-formula-' + doc_type, doc_type, null, function(formulas) {
|
||||
var $sel = $('#gl-formula-' + doc_type);
|
||||
$sel.prepend('<option value="all">— All Formulas (' + formulas.length + ') —</option>');
|
||||
$sel.val('all');
|
||||
});
|
||||
|
||||
var today = new Date();
|
||||
var firstDay = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
@@ -142,15 +115,21 @@
|
||||
$('#gl-date-to-' + doc_type).val(fmt(today));
|
||||
}
|
||||
|
||||
// ── load documents into table ─────────────────────────────────────────────
|
||||
// ── load documents ────────────────────────────────────────────────────────
|
||||
function load_gl_tab(doc_type) {
|
||||
var formula_id = $('#gl-formula-' + doc_type).val();
|
||||
if (!formula_id) { alert('Please select a GL formula first.'); return; }
|
||||
if (!formula_id) { bootbox.alert('Please select a GL formula first.'); return; }
|
||||
|
||||
if (formula_id === 'all') {
|
||||
load_all_formulas(doc_type);
|
||||
return;
|
||||
}
|
||||
|
||||
var $tbody = $('#gl-tbody-' + doc_type);
|
||||
var $badge = $('#gl-badge-' + doc_type);
|
||||
|
||||
_gl_loaded[doc_type] = [];
|
||||
_gl_loaded_by_formula[doc_type] = {};
|
||||
set_btn_state('#gl-btn-run-' + doc_type, false);
|
||||
$badge.text('');
|
||||
$tbody.html('<tr><td colspan="6" class="text-center text-muted py-4">'
|
||||
@@ -172,70 +151,130 @@
|
||||
formula_id: formula_id
|
||||
},
|
||||
onSuccess: function(res) {
|
||||
var rows = res.output || [];
|
||||
var table_id = 'gl-table-' + doc_type; // HTML element id (hyphens ok)
|
||||
var ala_id = 'gl_table_' + doc_type; // alasql identifier (underscores only)
|
||||
|
||||
if (rows.length === 0) {
|
||||
$tbody.html('<tr><td colspan="6" class="text-center text-muted py-4">No documents found.</td></tr>');
|
||||
$('#' + table_id + ' tfoot').empty();
|
||||
$badge.text('0 documents');
|
||||
return;
|
||||
}
|
||||
|
||||
_gl_loaded[doc_type] = rows.map(function(r) { return r.id; });
|
||||
_gl_rows[doc_type] = rows;
|
||||
|
||||
// Register with alasql for client-side pagination
|
||||
alasql('CREATE TABLE IF NOT EXISTS ' + ala_id);
|
||||
alasql.tables[ala_id].data = rows;
|
||||
|
||||
var unposted = rows.filter(function(r) { return r.gl_status == 0; }).length;
|
||||
var posted = rows.length - unposted;
|
||||
var missing_mapping = rows.filter(function(r) {
|
||||
return Number(r.product_mapping_missing || 0) > 0;
|
||||
}).length;
|
||||
$badge.html(
|
||||
rows.length + ' document(s) · '
|
||||
+ '<span class="text-danger">' + unposted + ' unposted</span>'
|
||||
+ ' · '
|
||||
+ '<span class="text-success">' + posted + ' posted</span>'
|
||||
+ (missing_mapping > 0
|
||||
? ' · <span class="text-warning">' + missing_mapping + ' missing product account mapping</span>'
|
||||
: '')
|
||||
);
|
||||
|
||||
// Wire up dynamic change_page function keyed by HTML table id
|
||||
window['change_page_' + table_id] = function(page_num) {
|
||||
var offset = (page_num - 1) * prop_limit;
|
||||
var page_rows = alasql('SELECT * FROM ' + ala_id + ' LIMIT ' + prop_limit + ' OFFSET ' + offset);
|
||||
var html = '';
|
||||
$.each(page_rows, function(i, r) {
|
||||
var status_badge = r.gl_status == 1
|
||||
? '<span class="badge bg-success-subtle text-success">Posted</span>'
|
||||
: '<span class="badge bg-danger-subtle text-danger">Unposted</span>';
|
||||
var mapping_badge = product_mapping_badge(r);
|
||||
html +=
|
||||
'<tr>' +
|
||||
'<td>' + escape_html(r.doc_number) + '</td>' +
|
||||
'<td>' + escape_html(r.contact_name || '—') + '</td>' +
|
||||
'<td class="text-end">' + format_number(r.grand_total, 2) + '</td>' +
|
||||
'<td>' + escape_html(r.doc_date || '—') + '</td>' +
|
||||
'<td>' + mapping_badge + '</td>' +
|
||||
'<td>' + status_badge + '</td>' +
|
||||
'</tr>';
|
||||
});
|
||||
$tbody.html(html);
|
||||
};
|
||||
|
||||
$('#' + table_id + ' tfoot').html(generate_pagination(table_id, rows.length));
|
||||
window['change_page_' + table_id](1);
|
||||
|
||||
set_btn_state('#gl-btn-run-' + doc_type, missing_mapping === 0);
|
||||
_render_gl_tab(doc_type, formula_id, res.output || []);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function load_all_formulas(doc_type) {
|
||||
var $tbody = $('#gl-tbody-' + doc_type);
|
||||
var $badge = $('#gl-badge-' + doc_type);
|
||||
|
||||
// Collect all formula IDs from the dropdown (skip 'all' and empty)
|
||||
var formula_ids = [];
|
||||
$('#gl-formula-' + doc_type + ' option').each(function() {
|
||||
var v = $(this).val();
|
||||
if (v && v !== 'all') formula_ids.push(v);
|
||||
});
|
||||
if (!formula_ids.length) { bootbox.alert('No formulas available.'); return; }
|
||||
|
||||
_gl_loaded[doc_type] = [];
|
||||
_gl_loaded_by_formula[doc_type] = {};
|
||||
_gl_rows[doc_type] = [];
|
||||
set_btn_state('#gl-btn-run-' + doc_type, false);
|
||||
$badge.html('<i class="ti ti-loader ti-spin me-1"></i>Loading ' + formula_ids.length + ' formulas…');
|
||||
$tbody.html('<tr><td colspan="6" class="text-center text-muted py-4">'
|
||||
+ '<i class="ti ti-loader ti-spin me-1"></i>Loading…</td></tr>');
|
||||
|
||||
var pending = formula_ids.length;
|
||||
var all_rows = [];
|
||||
|
||||
formula_ids.forEach(function(fid) {
|
||||
var ctx = document.getElementById('session-context');
|
||||
ajax_request({
|
||||
url: '<?php echo $server_url?>accounting/api/engine/get_gl_documents.php',
|
||||
action: 'get', queueLock: false, noLoading: true,
|
||||
data: {
|
||||
otp: ctx ? ctx.dataset.otp : '',
|
||||
company_id: ctx ? ctx.dataset.companyId : '',
|
||||
action: 'get',
|
||||
doc_type: doc_type,
|
||||
date_from: $('#gl-date-from-' + doc_type).val(),
|
||||
date_to: $('#gl-date-to-' + doc_type).val(),
|
||||
formula_id: fid
|
||||
},
|
||||
onSuccess: function(res) {
|
||||
var rows = res.output || [];
|
||||
rows.forEach(function(r) { r._formula_id = fid; });
|
||||
_gl_loaded_by_formula[doc_type][fid] = rows.map(function(r) { return r.id; });
|
||||
all_rows = all_rows.concat(rows);
|
||||
if (--pending === 0) _finish_all_formulas_load(doc_type, all_rows);
|
||||
},
|
||||
onError: function() {
|
||||
if (--pending === 0) _finish_all_formulas_load(doc_type, all_rows);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _finish_all_formulas_load(doc_type, all_rows) {
|
||||
_gl_rows[doc_type] = all_rows;
|
||||
_gl_loaded[doc_type] = all_rows.map(function(r) { return r.id; });
|
||||
_render_gl_tab(doc_type, 'all', all_rows);
|
||||
}
|
||||
|
||||
function _render_gl_tab(doc_type, formula_id, rows) {
|
||||
var $tbody = $('#gl-tbody-' + doc_type);
|
||||
var $badge = $('#gl-badge-' + doc_type);
|
||||
var table_id = 'gl-table-' + doc_type;
|
||||
var ala_id = 'gl_table_' + doc_type;
|
||||
|
||||
if (rows.length === 0) {
|
||||
$tbody.html('<tr><td colspan="6" class="text-center text-muted py-4">No documents found.</td></tr>');
|
||||
$('#' + table_id + ' tfoot').empty();
|
||||
$badge.text('0 documents');
|
||||
return;
|
||||
}
|
||||
|
||||
_gl_loaded[doc_type] = rows.map(function(r) { return r.id; });
|
||||
_gl_rows[doc_type] = rows;
|
||||
|
||||
alasql('CREATE TABLE IF NOT EXISTS ' + ala_id);
|
||||
alasql.tables[ala_id].data = rows;
|
||||
|
||||
var unposted = rows.filter(function(r) { return r.gl_status == 0; }).length;
|
||||
var posted = rows.length - unposted;
|
||||
var missing_mapping = rows.filter(function(r) { return Number(r.product_mapping_missing || 0) > 0; }).length;
|
||||
|
||||
var formula_note = formula_id === 'all'
|
||||
? ' · <span class="text-info">All Formulas</span>' : '';
|
||||
$badge.html(
|
||||
rows.length + ' document(s)' + formula_note + ' · '
|
||||
+ '<span class="text-danger">' + unposted + ' unposted</span>'
|
||||
+ ' · '
|
||||
+ '<span class="text-success">' + posted + ' posted</span>'
|
||||
+ (missing_mapping > 0
|
||||
? ' · <span class="text-warning">' + missing_mapping + ' missing product account mapping</span>'
|
||||
: '')
|
||||
);
|
||||
|
||||
window['change_page_' + table_id] = function(page_num) {
|
||||
var offset = (page_num - 1) * prop_limit;
|
||||
var page_rows = alasql('SELECT * FROM ' + ala_id + ' LIMIT ' + prop_limit + ' OFFSET ' + offset);
|
||||
var html = '';
|
||||
$.each(page_rows, function(i, r) {
|
||||
var status_badge = r.gl_status == 1
|
||||
? '<span class="badge bg-success-subtle text-success">Posted</span>'
|
||||
: '<span class="badge bg-danger-subtle text-danger">Unposted</span>';
|
||||
var mapping_badge = product_mapping_badge(r);
|
||||
html +=
|
||||
'<tr>' +
|
||||
'<td>' + escape_html(r.doc_number) + '</td>' +
|
||||
'<td>' + escape_html(r.contact_name || '—') + '</td>' +
|
||||
'<td class="text-end">' + format_number(r.grand_total, 2) + '</td>' +
|
||||
'<td>' + escape_html(r.doc_date || '—') + '</td>' +
|
||||
'<td>' + mapping_badge + '</td>' +
|
||||
'<td>' + status_badge + '</td>' +
|
||||
'</tr>';
|
||||
});
|
||||
$tbody.html(html);
|
||||
};
|
||||
|
||||
$('#' + table_id + ' tfoot').html(generate_pagination(table_id, rows.length));
|
||||
window['change_page_' + table_id](1);
|
||||
set_btn_state('#gl-btn-run-' + doc_type, missing_mapping === 0);
|
||||
}
|
||||
|
||||
function product_mapping_badge(row) {
|
||||
if (String(row.product_mapping_checked || '0') !== '1') {
|
||||
return '<span class="text-muted">—</span>';
|
||||
@@ -248,39 +287,15 @@
|
||||
}
|
||||
|
||||
// ── batch overlay helpers ─────────────────────────────────────────────────
|
||||
var _batch_start_ms = 0;
|
||||
|
||||
function show_batch_overlay(label, total) {
|
||||
_batch_start_ms = Date.now();
|
||||
$('#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(' ');
|
||||
$('#batch-overlay').css('display', 'flex');
|
||||
BatchOverlay.show('Batch GL Posting — ' + label, total, { unit: 'documents' });
|
||||
}
|
||||
|
||||
function update_batch_overlay(done, total) {
|
||||
var pct = Math.round(done / total * 100);
|
||||
var elapsed = (Date.now() - _batch_start_ms) / 1000;
|
||||
var eta_txt = ' ';
|
||||
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);
|
||||
BatchOverlay.update(done, total, 'document ' + done + ' of ' + total);
|
||||
}
|
||||
|
||||
function hide_batch_overlay() {
|
||||
$('#batch-overlay').hide();
|
||||
}
|
||||
|
||||
// ── run batch over all loaded IDs ─────────────────────────────────────────
|
||||
// ── run batch ─────────────────────────────────────────────────────────────
|
||||
var DOC_LABELS = {
|
||||
invoice: 'Invoice',
|
||||
credit_note: 'Credit Note',
|
||||
@@ -291,6 +306,13 @@
|
||||
};
|
||||
|
||||
function run_gl_batch(doc_type) {
|
||||
var formula_id = $('#gl-formula-' + doc_type).val();
|
||||
|
||||
if (formula_id === 'all') {
|
||||
run_all_formula_batches(doc_type);
|
||||
return;
|
||||
}
|
||||
|
||||
var ids = _gl_loaded[doc_type] || [];
|
||||
if (!ids.length) return;
|
||||
|
||||
@@ -298,18 +320,15 @@
|
||||
return Number(r.product_mapping_missing || 0) > 0;
|
||||
});
|
||||
if (missing_mapping.length) {
|
||||
alert('Product Account Mapping is incomplete for ' + missing_mapping.length + ' loaded document(s). Fill Sales/Purchase accounts on Account Formulas > Product Accounts, then reload this batch.');
|
||||
bootbox.alert('Product Account Mapping is incomplete for ' + missing_mapping.length + ' loaded document(s). Fill Sales/Purchase accounts on Account Formulas > Product Accounts, then reload this batch.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Estimate TTL: ids × (throttle delay + avg PHP response) + 20% buffer, min 2 min
|
||||
var per_item_ms = 200 + 150; // throttle + avg response
|
||||
var estimated_ms = ids.length * per_item_ms;
|
||||
var ttl_minutes = Math.max(2, Math.ceil(estimated_ms / 60000 * 1.2));
|
||||
var per_item_ms = 200 + 150;
|
||||
var ttl_minutes = Math.max(2, Math.ceil(ids.length * per_item_ms / 60000 * 1.2));
|
||||
|
||||
// Acquire DB lock before starting — blocks other tabs/users
|
||||
acquire_op_lock('gl_post', function() {
|
||||
_start_gl_batch(doc_type, ids);
|
||||
_start_gl_batch(doc_type, ids, null);
|
||||
}, function(msg, res) {
|
||||
var wait_msg = msg || 'A GL posting batch is already running.';
|
||||
if (res && res.expires_at) {
|
||||
@@ -317,23 +336,75 @@
|
||||
var remaining_m = Math.ceil(remaining_s / 60);
|
||||
wait_msg += '\nEstimated wait: ~' + (remaining_s < 60 ? remaining_s + ' seconds' : remaining_m + ' minute(s)') + '.';
|
||||
}
|
||||
alert(wait_msg);
|
||||
bootbox.alert(wait_msg);
|
||||
}, ttl_minutes);
|
||||
}
|
||||
|
||||
function _start_gl_batch(doc_type, ids) {
|
||||
var formula_id = $('#gl-formula-' + doc_type).val();
|
||||
var $prog = $('#gl-progress-' + doc_type);
|
||||
var $bar = $prog.find('.progress-bar');
|
||||
// ── All Formulas sequential run ───────────────────────────────────────────
|
||||
function run_all_formula_batches(doc_type) {
|
||||
var by_formula = _gl_loaded_by_formula[doc_type] || {};
|
||||
var queue = Object.keys(by_formula).filter(function(fid) {
|
||||
return (by_formula[fid] || []).length > 0;
|
||||
});
|
||||
if (!queue.length) { bootbox.alert('Load documents first.'); return; }
|
||||
|
||||
_formula_run_queue[doc_type] = queue;
|
||||
set_btn_state('#gl-btn-run-' + doc_type, false);
|
||||
set_btn_state('#gl-btn-load-' + doc_type, false);
|
||||
run_next_formula(doc_type);
|
||||
}
|
||||
|
||||
function run_next_formula(doc_type) {
|
||||
var queue = _formula_run_queue[doc_type] || [];
|
||||
if (!queue.length) {
|
||||
BatchOverlay.done('All formulas posted.');
|
||||
set_btn_state('#gl-btn-load-' + doc_type, true);
|
||||
load_gl_tab(doc_type);
|
||||
return;
|
||||
}
|
||||
|
||||
var fid = queue.shift();
|
||||
var ids = (_gl_loaded_by_formula[doc_type] || {})[fid] || [];
|
||||
|
||||
var missing = (_gl_rows[doc_type] || []).filter(function(r) {
|
||||
return r._formula_id == fid && Number(r.product_mapping_missing || 0) > 0;
|
||||
});
|
||||
if (missing.length) {
|
||||
run_next_formula(doc_type); // skip this formula
|
||||
return;
|
||||
}
|
||||
|
||||
var ttl = Math.max(2, Math.ceil(ids.length * 350 / 60000 * 1.2));
|
||||
var formula_label = $('#gl-formula-' + doc_type + ' option[value="' + fid + '"]').text();
|
||||
|
||||
acquire_op_lock('gl_post', function() {
|
||||
_start_gl_batch(doc_type, ids, function() {
|
||||
run_next_formula(doc_type);
|
||||
}, fid);
|
||||
}, function(msg, res) {
|
||||
var wait_msg = msg || 'A GL posting batch is already running.';
|
||||
if (res && res.expires_at) {
|
||||
var s = Math.max(0, Math.round((new Date(res.expires_at) - new Date()) / 1000));
|
||||
wait_msg += ' (~' + (s < 60 ? s + 's' : Math.ceil(s/60) + 'm') + ')';
|
||||
}
|
||||
bootbox.alert(wait_msg);
|
||||
}, ttl);
|
||||
}
|
||||
|
||||
// ── core batch processor ──────────────────────────────────────────────────
|
||||
function _start_gl_batch(doc_type, ids, onComplete, formula_id_override) {
|
||||
var formula_id = formula_id_override || $('#gl-formula-' + doc_type).val();
|
||||
var $msg = $('#gl-msg-' + doc_type);
|
||||
|
||||
set_btn_state('#gl-btn-load-' + doc_type, false);
|
||||
set_btn_state('#gl-btn-run-' + doc_type, false);
|
||||
$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);
|
||||
var label = DOC_LABELS[doc_type] || doc_type;
|
||||
if (formula_id_override) {
|
||||
label += ' — ' + $('#gl-formula-' + doc_type + ' option[value="' + formula_id_override + '"]').text().trim();
|
||||
}
|
||||
show_batch_overlay(label, ids.length);
|
||||
|
||||
batch_process(
|
||||
'gl_post',
|
||||
@@ -351,60 +422,57 @@
|
||||
};
|
||||
},
|
||||
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);
|
||||
},
|
||||
function(failed) {
|
||||
// Always release DB lock — whether succeeded or failed
|
||||
release_op_lock('gl_post');
|
||||
|
||||
hide_batch_overlay();
|
||||
$prog.addClass('d-none');
|
||||
var succeeded = ids.length - failed.length;
|
||||
|
||||
if (failed.length === 0) {
|
||||
_gl_failed[doc_type] = [];
|
||||
$msg.removeClass('d-none alert-danger').addClass('alert alert-success')
|
||||
.text('Done. ' + succeeded + ' document(s) posted successfully.');
|
||||
if (!onComplete) {
|
||||
// Single-formula mode — show result in tab
|
||||
if (failed.length === 0) {
|
||||
_gl_failed[doc_type] = [];
|
||||
BatchOverlay.done(succeeded + ' document(s) posted successfully.');
|
||||
} else {
|
||||
_gl_failed[doc_type] = failed;
|
||||
var nums = failed.map(function(f) { return f.item; }).join(', ');
|
||||
var detail = failed.map(function(f) {
|
||||
return escape_html(f.item + (f.error ? ': ' + f.error : ''));
|
||||
}).join('<br>');
|
||||
BatchOverlay.done(failed.length + ' failed · ' + succeeded + ' succeeded.', function() {
|
||||
$msg.removeClass('d-none alert-success').addClass('alert alert-danger')
|
||||
.html('<strong>' + failed.length + ' failed</strong> after 3 retries (IDs: '
|
||||
+ escape_html(nums) + '). ' + succeeded + ' succeeded.'
|
||||
+ '<div class="small mt-2">' + detail + '</div>'
|
||||
+ ' <button class="btn btn-sm btn-warning ms-2"'
|
||||
+ ' onclick="retry_gl_batch(\'' + doc_type + '\')">'
|
||||
+ '<i class="ti ti-refresh me-1"></i>Retry Failed</button>');
|
||||
});
|
||||
}
|
||||
set_btn_state('#gl-btn-load-' + doc_type, true);
|
||||
load_gl_tab(doc_type);
|
||||
} else {
|
||||
_gl_failed[doc_type] = failed;
|
||||
var nums = failed.map(function(f) { return f.item; }).join(', ');
|
||||
var detail = failed.map(function(f) {
|
||||
return escape_html(f.item + (f.error ? ': ' + f.error : ''));
|
||||
}).join('<br>');
|
||||
$msg.removeClass('d-none alert-success').addClass('alert alert-danger')
|
||||
.html('<strong>' + failed.length + ' failed</strong> after 3 retries (IDs: '
|
||||
+ escape_html(nums) + '). ' + succeeded + ' succeeded.'
|
||||
+ '<div class="small mt-2">' + detail + '</div>'
|
||||
+ ' <button class="btn btn-sm btn-warning ms-2"'
|
||||
+ ' onclick="retry_gl_batch(\'' + doc_type + '\')">'
|
||||
+ '<i class="ti ti-refresh me-1"></i>Retry Failed</button>');
|
||||
// All-Formulas sequential mode — hand off to queue runner
|
||||
BatchOverlay.done(succeeded + ' posted' + (failed.length ? ', ' + failed.length + ' failed' : '') + '.');
|
||||
onComplete(succeeded, failed.length);
|
||||
}
|
||||
|
||||
// Log the batch action
|
||||
// Log
|
||||
var ctx = document.getElementById('session-context');
|
||||
ajax_request({
|
||||
url: '<?php echo $server_url?>accounting/api/engine/log_batch_action.php',
|
||||
queueLock: false,
|
||||
noLoading: true,
|
||||
queueLock: false, noLoading: true,
|
||||
data: {
|
||||
otp: ctx ? ctx.dataset.otp : '',
|
||||
company_id: ctx ? ctx.dataset.companyId : '',
|
||||
action: 'post',
|
||||
type: 'gl_post',
|
||||
doc_type: doc_type,
|
||||
total: ids.length,
|
||||
succeeded: succeeded,
|
||||
failed: failed.length,
|
||||
action: 'post', type: 'gl_post',
|
||||
doc_type: doc_type, total: ids.length,
|
||||
succeeded: succeeded, failed: failed.length,
|
||||
failed_ids: JSON.stringify(failed.map(function(f) { return f.item; }))
|
||||
},
|
||||
onSuccess: function() {}
|
||||
});
|
||||
|
||||
// Reload table to reflect updated GL status
|
||||
set_btn_state('#gl-btn-load-' + doc_type, true);
|
||||
load_gl_tab(doc_type);
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -420,7 +488,7 @@
|
||||
var ttl_minutes = Math.max(2, Math.ceil(ids.length * per_item_ms / 60000 * 1.2));
|
||||
|
||||
acquire_op_lock('gl_post', function() {
|
||||
_start_gl_batch(doc_type, ids);
|
||||
_start_gl_batch(doc_type, ids, null);
|
||||
}, function(msg, res) {
|
||||
var wait_msg = msg || 'A GL posting batch is already running.';
|
||||
if (res && res.expires_at) {
|
||||
@@ -428,7 +496,7 @@
|
||||
var remaining_m = Math.ceil(remaining_s / 60);
|
||||
wait_msg += '\nEstimated wait: ~' + (remaining_s < 60 ? remaining_s + ' seconds' : remaining_m + ' minute(s)') + '.';
|
||||
}
|
||||
alert(wait_msg);
|
||||
bootbox.alert(wait_msg);
|
||||
}, ttl_minutes);
|
||||
}
|
||||
|
||||
@@ -467,7 +535,6 @@ function gl_tab_html(string $doc_type, string $label): string {
|
||||
<div class="col-auto">
|
||||
<label class="form-label mb-1 small">GL Formula <span class="text-danger">*</span></label>
|
||||
<select id="gl-formula-{$doc_type}" class="form-select form-select-sm" style="width:220px;">
|
||||
<option value="">— Select formula —</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
@@ -487,17 +554,10 @@ function gl_tab_html(string $doc_type, string $label): string {
|
||||
<!-- Summary badge -->
|
||||
<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 -->
|
||||
<div id="gl-msg-{$doc_type}" class="d-none mb-3"></div>
|
||||
|
||||
<!-- Document listing (informational, no selection) -->
|
||||
<!-- Document listing -->
|
||||
<table id="gl-table-{$doc_type}" class="table table-hover mb-0 table-centered">
|
||||
<thead class="table-primary border-light">
|
||||
<tr>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<!-- Filters -->
|
||||
<div class="card p-4 mb-4">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-4">
|
||||
<div class="col-md-2">
|
||||
<label class="form-label mb-1 small">Account</label>
|
||||
<select id="f_account" class="form-select form-select-sm">
|
||||
<option value="">— Select account —</option>
|
||||
@@ -46,10 +46,22 @@
|
||||
<i class="ti ti-search me-1"></i>Run
|
||||
</button>
|
||||
</div>
|
||||
<div class="col ms-auto d-flex align-items-end justify-content-end">
|
||||
<button class="btn btn-outline-secondary btn-sm" onclick="window.print()">
|
||||
<i class="ti ti-printer me-1"></i>Print
|
||||
</button>
|
||||
<div class="col-auto">
|
||||
<div class="dropdown">
|
||||
<a href="#" role="button" class="btn btn-outline-secondary btn-sm text-nowrap" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="ti ti-download me-1"></i>Export
|
||||
</a>
|
||||
<div class="dropdown-menu dropdown-menu-end p-0" style="min-width:160px;">
|
||||
<div class="d-flex gap-3 align-items-center border-bottom px-3 py-3">
|
||||
<i class="ti ti-download"></i><div><h4 class="mb-0 small">Export</h4></div>
|
||||
</div>
|
||||
<div class="p-3 d-flex flex-column gap-1 small lh-lg">
|
||||
<a href="javascript:;" class="ac-export-link" onclick="export_report('csv')"><i class="ti ti-file-type-csv me-2"></i>CSV</a>
|
||||
<a href="javascript:;" class="ac-export-link" onclick="export_report('xlsx')"><i class="ti ti-file-type-xls me-2"></i>Excel</a>
|
||||
<a href="javascript:;" class="ac-export-link" onclick="export_report('pdf')"><i class="ti ti-file-type-pdf me-2"></i>PDF</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -92,6 +104,7 @@
|
||||
<?php require '../include_ending.php'; ?>
|
||||
|
||||
<script>
|
||||
var export_data = [];
|
||||
var now = new Date();
|
||||
var curMonth = now.getFullYear() + '-' + String(now.getMonth()+1).padStart(2,'0');
|
||||
document.getElementById('f_from').value = curMonth + '-01';
|
||||
@@ -103,6 +116,8 @@
|
||||
url: server_url + 'accounting/api/engine/account.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
noLoading: true,
|
||||
queueLock: false,
|
||||
action: 'read',
|
||||
onSuccess: function(res) {
|
||||
var opts = '<option value="">— Select account —</option>';
|
||||
@@ -139,9 +154,10 @@
|
||||
var from = document.getElementById('f_from').value;
|
||||
var to = document.getElementById('f_to').value;
|
||||
var dept = document.getElementById('f_department').value || '0';
|
||||
if (!code) { alert('Select an account.'); return; }
|
||||
if (!from || !to) { alert('Select both dates.'); return; }
|
||||
if (!code) { bootbox.alert('Select an account.'); return; }
|
||||
if (!from || !to) { bootbox.alert('Select both dates.'); return; }
|
||||
|
||||
export_data = [];
|
||||
document.getElementById('glm_tbody').innerHTML =
|
||||
'<tr><td colspan="8" class="text-center text-muted py-5"><span class="spinner-border spinner-border-sm me-2"></span>Loading...</td></tr>';
|
||||
document.getElementById('glm_tfoot').innerHTML = '';
|
||||
@@ -152,10 +168,7 @@
|
||||
checkRequired: 0,
|
||||
noLoading: true,
|
||||
action: 'read',
|
||||
account_code: code,
|
||||
from_date: from,
|
||||
to_date: to,
|
||||
department_id: dept,
|
||||
data: { account_code: code, from_date: from, to_date: to, department_id: dept },
|
||||
onSuccess: function(res) {
|
||||
var d = res.output;
|
||||
var rows = d.rows || [];
|
||||
@@ -175,12 +188,14 @@
|
||||
var running = parseFloat(d.opening_balance) || 0;
|
||||
var total_dr = 0, total_cr = 0;
|
||||
var html = '';
|
||||
export_data.push({ date:'Opening Balance', period:'', department:'', reference:'', description:'', debit:'', credit:'', balance: running });
|
||||
rows.forEach(function(r) {
|
||||
var dr = parseFloat(r.debit) || 0;
|
||||
var cr = parseFloat(r.credit) || 0;
|
||||
running += dr - cr;
|
||||
total_dr += dr;
|
||||
total_cr += cr;
|
||||
export_data.push({ date:r.entry_date||'', period:r.period||'', department:r.dept_code||'', reference:r.reference||'', description:r.line_description||r.gl_description||'', debit:dr||'', credit:cr||'', balance:running });
|
||||
var bal_color = running >= 0 ? '' : 'text-danger';
|
||||
html += '<tr>' +
|
||||
'<td class="small">' + escape_html(r.entry_date || '—') + '</td>' +
|
||||
@@ -204,7 +219,36 @@
|
||||
}
|
||||
});
|
||||
}
|
||||
function export_report(format) {
|
||||
if (export_data.length === 0) { bootbox.alert('No data to export. Run the report first.'); return; }
|
||||
var code = document.getElementById('f_account').value;
|
||||
var from = document.getElementById('f_from').value;
|
||||
var to = document.getElementById('f_to').value;
|
||||
var filename = 'gl_movement_' + code + '_' + from + '_' + to;
|
||||
if (format === 'csv') {
|
||||
alasql('SELECT * INTO CSV("' + filename + '.csv",{headers:true}) FROM ?', [export_data]);
|
||||
} else if (format === 'xlsx') {
|
||||
alasql('SELECT * INTO XLSX("' + filename + '.xlsx",{headers:true}) FROM ?', [export_data]);
|
||||
} else if (format === 'pdf') {
|
||||
var { jsPDF } = window.jspdf;
|
||||
var doc = new jsPDF({ orientation: 'landscape' });
|
||||
doc.setFontSize(11);
|
||||
doc.text('GL Movement ' + code + ' ' + from + ' to ' + to, 14, 14);
|
||||
doc.autoTable({
|
||||
startY: 20,
|
||||
head: [['Date','Period','Department','Reference','Description','Debit','Credit','Balance']],
|
||||
body: export_data.map(function(r){ return [r.date,r.period,r.department,r.reference,r.description,r.debit,r.credit,r.balance]; }),
|
||||
styles: { fontSize: 7 },
|
||||
headStyles: { fillColor: [41,128,185] },
|
||||
});
|
||||
doc.save(filename + '.pdf');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
<option value="receipt">Receipt</option>
|
||||
<option value="payment">Payment</option>
|
||||
<option value="manual">Manual</option>
|
||||
<option value="reversal">Reversal</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
@@ -186,7 +187,8 @@
|
||||
supplier_credit_note: 'Supplier Credit Note',
|
||||
receipt: 'Receipt',
|
||||
payment: 'Payment',
|
||||
manual: 'Manual'
|
||||
manual: 'Manual',
|
||||
reversal: 'Reversal'
|
||||
};
|
||||
|
||||
var SOURCE_BADGE = {
|
||||
@@ -196,7 +198,8 @@
|
||||
supplier_credit_note: 'bg-secondary-subtle text-secondary',
|
||||
receipt: 'bg-info-subtle text-info',
|
||||
payment: 'bg-danger-subtle text-danger',
|
||||
manual: 'bg-dark-subtle text-dark'
|
||||
manual: 'bg-dark-subtle text-dark',
|
||||
reversal: 'bg-danger-subtle text-danger'
|
||||
};
|
||||
|
||||
function source_badge(type) {
|
||||
|
||||
@@ -40,10 +40,22 @@
|
||||
<i class="ti ti-search me-1"></i>Run
|
||||
</button>
|
||||
</div>
|
||||
<div class="col ms-auto d-flex align-items-end justify-content-end">
|
||||
<button class="btn btn-outline-secondary btn-sm" onclick="window.print()">
|
||||
<i class="ti ti-printer me-1"></i>Print
|
||||
</button>
|
||||
<div class="col-auto">
|
||||
<div class="dropdown">
|
||||
<a href="#" role="button" class="btn btn-outline-secondary btn-sm text-nowrap" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="ti ti-download me-1"></i>Export
|
||||
</a>
|
||||
<div class="dropdown-menu dropdown-menu-end p-0" style="min-width:160px;">
|
||||
<div class="d-flex gap-3 align-items-center border-bottom px-3 py-3">
|
||||
<i class="ti ti-download"></i><div><h4 class="mb-0 small">Export</h4></div>
|
||||
</div>
|
||||
<div class="p-3 d-flex flex-column gap-1 small lh-lg">
|
||||
<a href="javascript:;" class="ac-export-link" onclick="export_report('csv')"><i class="ti ti-file-type-csv me-2"></i>CSV</a>
|
||||
<a href="javascript:;" class="ac-export-link" onclick="export_report('xlsx')"><i class="ti ti-file-type-xls me-2"></i>Excel</a>
|
||||
<a href="javascript:;" class="ac-export-link" onclick="export_report('pdf')"><i class="ti ti-file-type-pdf me-2"></i>PDF</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -59,6 +71,7 @@
|
||||
<?php require '../include_ending.php'; ?>
|
||||
|
||||
<script>
|
||||
var export_data = [];
|
||||
var now = new Date();
|
||||
var curMonth = now.getFullYear() + '-' + String(now.getMonth()+1).padStart(2,'0');
|
||||
document.getElementById('f_from').value = curMonth + '-01';
|
||||
@@ -88,8 +101,9 @@
|
||||
var from = document.getElementById('f_from').value;
|
||||
var to = document.getElementById('f_to').value;
|
||||
var dept = document.getElementById('f_department').value || '0';
|
||||
if (!from || !to) { alert('Select both dates.'); return; }
|
||||
if (!from || !to) { bootbox.alert('Select both dates.'); return; }
|
||||
|
||||
export_data = [];
|
||||
document.getElementById('pl_body').innerHTML =
|
||||
'<div class="card p-5 text-center text-muted"><span class="spinner-border spinner-border-sm me-2"></span>Loading...</div>';
|
||||
|
||||
@@ -99,14 +113,24 @@
|
||||
checkRequired: 0,
|
||||
noLoading: true,
|
||||
action: 'read',
|
||||
from_date: from,
|
||||
to_date: to,
|
||||
department_id: dept,
|
||||
data: { from_date: from, to_date: to, department_id: dept },
|
||||
onSuccess: function(res) {
|
||||
var d = res.output;
|
||||
var rows = d.rows || [];
|
||||
var revenues = rows.filter(function(r){ return r.account_type === 'revenue'; });
|
||||
var expenses = rows.filter(function(r){ return r.account_type === 'expense'; });
|
||||
export_data = [];
|
||||
revenues.forEach(function(r) {
|
||||
export_data.push({ section:'Revenue', code:r.account_code, account:r.account_name,
|
||||
debit:r.total_debit, credit:r.total_credit,
|
||||
net: parseFloat(r.total_credit) - parseFloat(r.total_debit) });
|
||||
});
|
||||
expenses.forEach(function(r) {
|
||||
export_data.push({ section:'Expense', code:r.account_code, account:r.account_name,
|
||||
debit:r.total_debit, credit:r.total_credit,
|
||||
net: parseFloat(r.total_debit) - parseFloat(r.total_credit) });
|
||||
});
|
||||
export_data.push({ section:'', code:'', account:'Net Profit / (Loss)', debit:'', credit:'', net: d.net_profit });
|
||||
|
||||
function section_rows(items, is_revenue) {
|
||||
if (items.length === 0) return '<tr><td colspan="3" class="text-muted small ps-4">No entries.</td></tr>';
|
||||
@@ -154,7 +178,35 @@
|
||||
}
|
||||
});
|
||||
}
|
||||
function export_report(format) {
|
||||
if (export_data.length === 0) { bootbox.alert('No data to export. Run the report first.'); return; }
|
||||
var from = document.getElementById('f_from').value;
|
||||
var to = document.getElementById('f_to').value;
|
||||
var filename = 'pl_statement_' + from + '_' + to;
|
||||
if (format === 'csv') {
|
||||
alasql('SELECT * INTO CSV("' + filename + '.csv",{headers:true}) FROM ?', [export_data]);
|
||||
} else if (format === 'xlsx') {
|
||||
alasql('SELECT * INTO XLSX("' + filename + '.xlsx",{headers:true}) FROM ?', [export_data]);
|
||||
} else if (format === 'pdf') {
|
||||
var { jsPDF } = window.jspdf;
|
||||
var doc = new jsPDF();
|
||||
doc.setFontSize(11);
|
||||
doc.text('Profit & Loss Statement ' + from + ' to ' + to, 14, 14);
|
||||
doc.autoTable({
|
||||
startY: 20,
|
||||
head: [['Section','Code','Account','Debit','Credit','Net']],
|
||||
body: export_data.map(function(r){ return [r.section,r.code,r.account,r.debit,r.credit,r.net]; }),
|
||||
styles: { fontSize: 8 },
|
||||
headStyles: { fillColor: [41,128,185] },
|
||||
});
|
||||
doc.save(filename + '.pdf');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -40,10 +40,22 @@
|
||||
<i class="ti ti-search me-1"></i>Run
|
||||
</button>
|
||||
</div>
|
||||
<div class="col ms-auto d-flex align-items-end justify-content-end">
|
||||
<button class="btn btn-outline-secondary btn-sm" onclick="window.print()">
|
||||
<i class="ti ti-printer me-1"></i>Print
|
||||
</button>
|
||||
<div class="col-auto">
|
||||
<div class="dropdown">
|
||||
<a href="#" role="button" class="btn btn-outline-secondary btn-sm text-nowrap" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="ti ti-download me-1"></i>Export
|
||||
</a>
|
||||
<div class="dropdown-menu dropdown-menu-end p-0" style="min-width:160px;">
|
||||
<div class="d-flex gap-3 align-items-center border-bottom px-3 py-3">
|
||||
<i class="ti ti-download"></i><div><h4 class="mb-0 small">Export</h4></div>
|
||||
</div>
|
||||
<div class="p-3 d-flex flex-column gap-1 small lh-lg">
|
||||
<a href="javascript:;" class="ac-export-link" onclick="export_report('csv')"><i class="ti ti-file-type-csv me-2"></i>CSV</a>
|
||||
<a href="javascript:;" class="ac-export-link" onclick="export_report('xlsx')"><i class="ti ti-file-type-xls me-2"></i>Excel</a>
|
||||
<a href="javascript:;" class="ac-export-link" onclick="export_report('pdf')"><i class="ti ti-file-type-pdf me-2"></i>PDF</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -80,6 +92,7 @@
|
||||
|
||||
<script>
|
||||
var type_labels = { asset:'Asset', liability:'Liability', equity:'Equity', revenue:'Revenue', expense:'Expense' };
|
||||
var export_data = [];
|
||||
|
||||
// Default: current month date range
|
||||
var now = new Date();
|
||||
@@ -111,8 +124,9 @@
|
||||
var from = document.getElementById('f_from').value;
|
||||
var to = document.getElementById('f_to').value;
|
||||
var dept = document.getElementById('f_department').value || '0';
|
||||
if (!from || !to) { alert('Select both dates.'); return; }
|
||||
if (!from || !to) { bootbox.alert('Select both dates.'); return; }
|
||||
|
||||
export_data = [];
|
||||
document.getElementById('tb_tbody').innerHTML =
|
||||
'<tr><td colspan="9" class="text-center text-muted py-5"><span class="spinner-border spinner-border-sm me-2"></span>Loading...</td></tr>';
|
||||
document.getElementById('tb_tfoot').innerHTML = '';
|
||||
@@ -123,9 +137,7 @@
|
||||
checkRequired: 0,
|
||||
noLoading: true,
|
||||
action: 'read',
|
||||
from_date: from,
|
||||
to_date: to,
|
||||
department_id: dept,
|
||||
data: { from_date: from, to_date: to, department_id: dept },
|
||||
onSuccess: function(res) {
|
||||
var d = res.output;
|
||||
var rows = d.rows || [];
|
||||
@@ -135,6 +147,14 @@
|
||||
document.getElementById('tb_tfoot').innerHTML = '';
|
||||
return;
|
||||
}
|
||||
export_data = rows.map(function(r) {
|
||||
return {
|
||||
code: r.account_code, account: r.account_name, type: type_labels[r.account_type] || r.account_type,
|
||||
bf_debit: r.opening_debit, bf_credit: r.opening_credit,
|
||||
period_debit: r.period_debit, period_credit: r.period_credit,
|
||||
cf_debit: r.closing_debit, cf_credit: r.closing_credit,
|
||||
};
|
||||
});
|
||||
var html = '';
|
||||
rows.forEach(function(r) {
|
||||
html += '<tr>' +
|
||||
@@ -163,7 +183,35 @@
|
||||
}
|
||||
});
|
||||
}
|
||||
function export_report(format) {
|
||||
if (export_data.length === 0) { bootbox.alert('No data to export. Run the report first.'); return; }
|
||||
var from = document.getElementById('f_from').value;
|
||||
var to = document.getElementById('f_to').value;
|
||||
var filename = 'trial_balance_' + from + '_' + to;
|
||||
if (format === 'csv') {
|
||||
alasql('SELECT * INTO CSV("' + filename + '.csv",{headers:true}) FROM ?', [export_data]);
|
||||
} else if (format === 'xlsx') {
|
||||
alasql('SELECT * INTO XLSX("' + filename + '.xlsx",{headers:true}) FROM ?', [export_data]);
|
||||
} else if (format === 'pdf') {
|
||||
var { jsPDF } = window.jspdf;
|
||||
var doc = new jsPDF({ orientation: 'landscape' });
|
||||
doc.setFontSize(11);
|
||||
doc.text('Trial Balance ' + from + ' to ' + to, 14, 14);
|
||||
doc.autoTable({
|
||||
startY: 20,
|
||||
head: [['Code','Account','Type','B/F Debit','B/F Credit','Period Debit','Period Credit','C/F Debit','C/F Credit']],
|
||||
body: export_data.map(function(r){ return [r.code,r.account,r.type,r.bf_debit,r.bf_credit,r.period_debit,r.period_credit,r.cf_debit,r.cf_credit]; }),
|
||||
styles: { fontSize: 7 },
|
||||
headStyles: { fillColor: [41,128,185] },
|
||||
});
|
||||
doc.save(filename + '.pdf');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -48,10 +48,22 @@
|
||||
<i class="ti ti-search me-1"></i>Run
|
||||
</button>
|
||||
</div>
|
||||
<div class="col ms-auto d-flex align-items-end justify-content-end">
|
||||
<button class="btn btn-outline-secondary btn-sm" onclick="window.print()">
|
||||
<i class="ti ti-printer me-1"></i>Print
|
||||
</button>
|
||||
<div class="col-auto">
|
||||
<div class="dropdown">
|
||||
<a href="#" role="button" class="btn btn-outline-secondary btn-sm text-nowrap" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="ti ti-download me-1"></i>Export
|
||||
</a>
|
||||
<div class="dropdown-menu dropdown-menu-end p-0" style="min-width:160px;">
|
||||
<div class="d-flex gap-3 align-items-center border-bottom px-3 py-3">
|
||||
<i class="ti ti-download"></i><div><h4 class="mb-0 small">Export</h4></div>
|
||||
</div>
|
||||
<div class="p-3 d-flex flex-column gap-1 small lh-lg">
|
||||
<a href="javascript:;" class="ac-export-link" onclick="export_report('csv')"><i class="ti ti-file-type-csv me-2"></i>CSV</a>
|
||||
<a href="javascript:;" class="ac-export-link" onclick="export_report('xlsx')"><i class="ti ti-file-type-xls me-2"></i>Excel</a>
|
||||
<a href="javascript:;" class="ac-export-link" onclick="export_report('pdf')"><i class="ti ti-file-type-pdf me-2"></i>PDF</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -121,6 +133,7 @@
|
||||
document.getElementById('f_to').value = curMonth + '-' + String(new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate()).padStart(2,'0');
|
||||
flatpickr('#f_from, #f_to', { dateFormat: 'Y-m-d', altInput: true, altFormat: 'd/m/Y', allowInput: true });
|
||||
|
||||
var export_data = [];
|
||||
var cat_labels = { sales_tax: 'Output VAT', purchase_tax: 'Input VAT' };
|
||||
var src_labels = {
|
||||
invoice: 'Invoice', credit_note: 'Credit Note',
|
||||
@@ -152,8 +165,9 @@
|
||||
var to = document.getElementById('f_to').value;
|
||||
var cat = document.getElementById('f_cat').value;
|
||||
var dept = document.getElementById('f_department').value || '0';
|
||||
if (!from || !to) { alert('Select both dates.'); return; }
|
||||
if (!from || !to) { bootbox.alert('Select both dates.'); return; }
|
||||
|
||||
export_data = [];
|
||||
document.getElementById('vat_tbody').innerHTML =
|
||||
'<tr><td colspan="10" class="text-center text-muted py-5"><span class="spinner-border spinner-border-sm me-2"></span>Loading...</td></tr>';
|
||||
document.getElementById('vat_summary').style.display = 'none';
|
||||
@@ -164,10 +178,7 @@
|
||||
checkRequired: 0,
|
||||
noLoading: true,
|
||||
action: 'read',
|
||||
from_date: from,
|
||||
to_date: to,
|
||||
categories: cat ? [cat] : [],
|
||||
department_id: dept,
|
||||
data: { from_date: from, to_date: to, categories: cat ? [cat] : [], department_id: dept },
|
||||
onSuccess: function(res) {
|
||||
var d = res.output;
|
||||
var rows = d.rows || [];
|
||||
@@ -183,6 +194,19 @@
|
||||
return;
|
||||
}
|
||||
|
||||
export_data = rows.map(function(r) {
|
||||
return {
|
||||
date: r.entry_date||'', period: r.period||'',
|
||||
type: src_labels[r.source_type]||r.source_type,
|
||||
department: r.dept_code||'',
|
||||
reference: r.reference||'',
|
||||
account: r.account_code + ' ' + r.account_name,
|
||||
category: cat_labels[r.account_category]||r.account_category,
|
||||
description: r.description||'',
|
||||
debit: parseFloat(r.debit)||'',
|
||||
credit: parseFloat(r.credit)||'',
|
||||
};
|
||||
});
|
||||
var html = '';
|
||||
rows.forEach(function(r) {
|
||||
html += '<tr>' +
|
||||
@@ -202,7 +226,35 @@
|
||||
}
|
||||
});
|
||||
}
|
||||
function export_report(format) {
|
||||
if (export_data.length === 0) { bootbox.alert('No data to export. Run the report first.'); return; }
|
||||
var from = document.getElementById('f_from').value;
|
||||
var to = document.getElementById('f_to').value;
|
||||
var filename = 'vat_report_' + from + '_' + to;
|
||||
if (format === 'csv') {
|
||||
alasql('SELECT * INTO CSV("' + filename + '.csv",{headers:true}) FROM ?', [export_data]);
|
||||
} else if (format === 'xlsx') {
|
||||
alasql('SELECT * INTO XLSX("' + filename + '.xlsx",{headers:true}) FROM ?', [export_data]);
|
||||
} else if (format === 'pdf') {
|
||||
var { jsPDF } = window.jspdf;
|
||||
var doc = new jsPDF({ orientation: 'landscape' });
|
||||
doc.setFontSize(11);
|
||||
doc.text('VAT Report ' + from + ' to ' + to, 14, 14);
|
||||
doc.autoTable({
|
||||
startY: 20,
|
||||
head: [['Date','Period','Type','Dept','Reference','Account','Category','Description','Debit','Credit']],
|
||||
body: export_data.map(function(r){ return [r.date,r.period,r.type,r.department,r.reference,r.account,r.category,r.description,r.debit,r.credit]; }),
|
||||
styles: { fontSize: 7 },
|
||||
headStyles: { fillColor: [41,128,185] },
|
||||
});
|
||||
doc.save(filename + '.pdf');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
|
||||
html, body { overflow-x: hidden; }
|
||||
|
||||
.flatpickr-day.selected {
|
||||
background: var(--bs-primary) !important;
|
||||
border-color: var(--bs-primary) !important;
|
||||
@@ -96,15 +98,14 @@
|
||||
.sidebar { padding-top: 0; }
|
||||
.sidebar .logo-area { position: sticky; top: 0; z-index: 1; }
|
||||
|
||||
.logo-area > a > img:first-child {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
transition: width 0.2s ease, height 0.2s ease;
|
||||
.logo-area img {
|
||||
height: 48px;
|
||||
width: auto;
|
||||
transition: height 0.2s ease;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .logo-area > a > img:first-child {
|
||||
width: 24px !important;
|
||||
height: 24px !important;
|
||||
.sidebar.collapsed .logo-area img {
|
||||
height: 28px !important;
|
||||
}
|
||||
|
||||
@media (max-width: 991.98px) {
|
||||
@@ -120,3 +121,6 @@
|
||||
margin-left: 0.5rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
.wms-export-link { display:block; padding:2px 4px; border-radius:4px; color:#404040; text-decoration:none; transition: background .15s, color .15s; }
|
||||
.wms-export-link:hover { background-color:#fae0d7; color:#b84a2e !important; }
|
||||
|
||||
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1008 B |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 68 KiB |
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* BatchOverlay — shared full-screen progress overlay.
|
||||
*
|
||||
* Usage:
|
||||
* BatchOverlay.show(title, total, options)
|
||||
* options: { unit: 'documents', color: '#dc3545' }
|
||||
*
|
||||
* BatchOverlay.update(done, total, currentItem)
|
||||
* Advances the bar and ETA. currentItem shown in the sub-line.
|
||||
*
|
||||
* BatchOverlay.done(message, onClose)
|
||||
* Switches to "complete" state — green checkmark + Done button.
|
||||
* onClose fires when the user dismisses.
|
||||
*
|
||||
* BatchOverlay.hide()
|
||||
* Force-hides (use for error bail-outs before done is reached).
|
||||
*/
|
||||
var BatchOverlay = (function () {
|
||||
|
||||
var _startMs = 0;
|
||||
var _onClose = null;
|
||||
var _unit = 'items';
|
||||
var _color = '#0d6efd';
|
||||
var _injected = false;
|
||||
|
||||
var _HTML = '<div id="bos-wrap" style="display:none;position:fixed;inset:0;z-index:9999;' +
|
||||
'background:rgba(0,0,0,0.55);align-items:center;justify-content:center;">' +
|
||||
'<div class="card shadow-lg p-4 text-center" style="min-width:380px;max-width:480px;width:90%;">' +
|
||||
'<div id="bos-spinner" class="spinner-border mx-auto mb-3" role="status" style="width:2.5rem;height:2.5rem;"></div>' +
|
||||
'<div id="bos-icon" class="mx-auto mb-3" style="display:none;font-size:2.5rem;color:#198754;"><i class="ti ti-circle-check"></i></div>' +
|
||||
'<div class="fw-semibold fs-6 mb-1" id="bos-title">Processing…</div>' +
|
||||
'<div class="text-muted small mb-4" id="bos-sub"></div>' +
|
||||
'<div id="bos-pct" style="font-size:3rem;font-weight:700;line-height:1;letter-spacing:-1px;">0%</div>' +
|
||||
'<div class="text-muted small mb-3" id="bos-count"></div>' +
|
||||
'<div class="progress mb-2" style="height:10px;border-radius:6px;">' +
|
||||
'<div id="bos-bar" class="progress-bar progress-bar-striped progress-bar-animated"' +
|
||||
' role="progressbar" style="width:0%;border-radius:6px;"' +
|
||||
' aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"></div>' +
|
||||
'</div>' +
|
||||
'<div class="text-muted small" id="bos-eta"> </div>' +
|
||||
'<button id="bos-close" class="btn btn-success mt-3" style="display:none;">' +
|
||||
'<i class="ti ti-check me-1"></i>Done' +
|
||||
'</button>' +
|
||||
'</div></div>';
|
||||
|
||||
function _inject() {
|
||||
if (_injected) return;
|
||||
$('body').append(_HTML);
|
||||
$('#bos-close').on('click', function () {
|
||||
_resetState();
|
||||
$('#bos-wrap').hide();
|
||||
if (_onClose) { _onClose(); _onClose = null; }
|
||||
});
|
||||
_injected = true;
|
||||
}
|
||||
|
||||
function _resetState() {
|
||||
$('#bos-spinner').show().css('color', _color);
|
||||
$('#bos-icon').hide();
|
||||
$('#bos-pct').css('color', _color);
|
||||
$('#bos-bar')
|
||||
.addClass('progress-bar-striped progress-bar-animated')
|
||||
.removeClass('bg-success')
|
||||
.css('background-color', _color)
|
||||
.css('width', '0%').attr('aria-valuenow', 0);
|
||||
$('#bos-close').hide();
|
||||
$('#bos-eta').html(' ');
|
||||
}
|
||||
|
||||
function show(title, total, options) {
|
||||
options = options || {};
|
||||
_unit = options.unit || 'items';
|
||||
_color = options.color || '#0d6efd';
|
||||
_onClose = options.onClose || null;
|
||||
_startMs = Date.now();
|
||||
|
||||
_inject();
|
||||
_resetState();
|
||||
|
||||
$('#bos-title').text(title);
|
||||
$('#bos-sub').text('Starting…');
|
||||
$('#bos-pct').text('0%');
|
||||
$('#bos-count').text('0 / ' + total + ' ' + _unit);
|
||||
$('#bos-wrap').css('display', 'flex');
|
||||
}
|
||||
|
||||
function update(done, total, currentItem) {
|
||||
var pct = Math.round(done / total * 100);
|
||||
var elapsed = (Date.now() - _startMs) / 1000;
|
||||
var eta = ' ';
|
||||
if (done > 0) {
|
||||
var remaining = Math.round((elapsed / done) * (total - done));
|
||||
eta = remaining > 0 ? ('~' + remaining + 's remaining') : 'almost done…';
|
||||
}
|
||||
$('#bos-pct').text(pct + '%');
|
||||
$('#bos-bar').css('width', pct + '%').attr('aria-valuenow', pct);
|
||||
$('#bos-count').text(done + ' / ' + total + ' ' + _unit);
|
||||
if (currentItem !== undefined) $('#bos-sub').text('Processing ' + currentItem + '…');
|
||||
$('#bos-eta').html(eta);
|
||||
}
|
||||
|
||||
function done(message, onClose) {
|
||||
_onClose = onClose || null;
|
||||
$('#bos-spinner').hide();
|
||||
$('#bos-icon').show();
|
||||
$('#bos-pct').text('100%').css('color', '#198754');
|
||||
$('#bos-bar')
|
||||
.removeClass('progress-bar-striped progress-bar-animated')
|
||||
.css('background-color', '').addClass('bg-success')
|
||||
.css('width', '100%').attr('aria-valuenow', 100);
|
||||
$('#bos-sub').text(message || 'Complete.');
|
||||
$('#bos-eta').html(' ');
|
||||
$('#bos-close').show();
|
||||
}
|
||||
|
||||
function hide() {
|
||||
if (_injected) $('#bos-wrap').hide();
|
||||
}
|
||||
|
||||
return { show: show, update: update, done: done, hide: hide };
|
||||
|
||||
})();
|
||||
@@ -543,7 +543,7 @@ function load_location_config(callback) {
|
||||
onSuccess: function(res) {
|
||||
var s = res.output;
|
||||
advanced = s.advanced_location == 1;
|
||||
label_rack = s.location_label_rack || (advanced ? 'Rack' : 'Location');
|
||||
label_bin = s.location_label_bin || (advanced ? 'Bin' : 'Location');
|
||||
label_zone = s.location_label_zone || 'Zone';
|
||||
label_aisle = s.location_label_aisle || 'Aisle';
|
||||
if (callback) callback();
|
||||
@@ -555,7 +555,7 @@ function load_location_config(callback) {
|
||||
/** =========================
|
||||
* PREPARE FORM DATA
|
||||
* ========================= */
|
||||
function load_formula_options(select_id, document_type, selected_id) {
|
||||
function load_formula_options(select_id, document_type, selected_id, onLoaded) {
|
||||
var ctx = document.getElementById('session-context');
|
||||
ajax_request({
|
||||
url: server_url + 'accounting/api/engine/account_formula.php',
|
||||
@@ -570,12 +570,13 @@ function load_formula_options(select_id, document_type, selected_id) {
|
||||
},
|
||||
onSuccess: function(res) {
|
||||
var $sel = $('#' + select_id);
|
||||
$sel.empty().append('<option value="">— None —</option>');
|
||||
$sel.empty();
|
||||
$.each(res.output || [], function(i, f) {
|
||||
var label = escape_html(f.formula_name) + (f.is_default == 1 ? ' (default)' : '');
|
||||
$sel.append('<option value="' + f.id + '">' + label + '</option>');
|
||||
});
|
||||
if (selected_id) $sel.val(selected_id);
|
||||
if (onLoaded) onLoaded(res.output || []);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -820,6 +821,21 @@ function ajax_request(options) {
|
||||
throw xhr;
|
||||
}
|
||||
|
||||
// Session displaced — another login took over this account
|
||||
if (xhr?.status === 401 && xhr?.responseJSON?.code === 'signed_elsewhere') {
|
||||
bootbox.alert('<strong>Signed in from another device.</strong><br>Your session has been ended because this account was signed in elsewhere.', function() {
|
||||
window.location.href = server_url + 'index.php';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// File / payload too large (nginx 413)
|
||||
if (xhr?.status === 413) {
|
||||
bootbox.alert('The file you selected is too large. Please use an image under 2 MB and try again.');
|
||||
options.onError?.(xhr, 'payload_too_large');
|
||||
throw xhr;
|
||||
}
|
||||
|
||||
// Usage limit reached — show upgrade notice instead of generic error
|
||||
if (xhr?.status === 402) {
|
||||
const d = xhr?.responseJSON ?? {};
|
||||
@@ -1140,9 +1156,9 @@ function show_stock_rows(source, source_id, label) {
|
||||
'0':'<span class="badge bg-warning text-dark">Draft</span>',
|
||||
'1':'<span class="badge bg-success">Approved</span>'}[String(r.status)]
|
||||
|| '—';
|
||||
var location = [r.zone, r.aisle, r.rack].filter(function(v) {
|
||||
return v && v !== r.rack;
|
||||
}).concat([r.rack]).filter(Boolean).join(' / ');
|
||||
var location = [r.zone, r.aisle, r.bin].filter(function(v) {
|
||||
return v && v !== r.bin;
|
||||
}).concat([r.bin]).filter(Boolean).join(' / ');
|
||||
html += `<tr>
|
||||
<td class="ps-4">${type_badge}</td>
|
||||
<td>${escape_html(r.product_name || r.product_sku)}<br><small class="text-muted">${escape_html(r.product_sku)}</small></td>
|
||||
|
||||
@@ -36,6 +36,19 @@ class BarcodeManager {
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
private function stockTableName(int $warehouse_id): string {
|
||||
if ($warehouse_id <= 0) {
|
||||
throw new Exception("Invalid warehouse id.");
|
||||
}
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id FROM md_warehouse
|
||||
WHERE company_id = :company_id AND id = :id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $warehouse_id]);
|
||||
if (!$sth->fetchColumn()) {
|
||||
throw new Exception("Warehouse ID {$warehouse_id} not found.");
|
||||
}
|
||||
|
||||
return 'td_stock_' . $warehouse_id;
|
||||
}
|
||||
|
||||
@@ -71,7 +84,7 @@ class BarcodeManager {
|
||||
$warehouses->execute([':company_id' => $this->company_id]);
|
||||
|
||||
foreach ($warehouses->fetchAll(PDO::FETCH_COLUMN) as $warehouse_id) {
|
||||
$table = $this->stockTableName((int)$warehouse_id);
|
||||
$table = 'td_stock_' . (int)$warehouse_id;
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT lot_number
|
||||
FROM `{$table}`
|
||||
@@ -121,17 +134,17 @@ class BarcodeManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm a rack row exists for the given warehouse + zone + aisle + rack.
|
||||
* Confirm a bin row exists for the given warehouse + zone + aisle + bin.
|
||||
* Throws if not found.
|
||||
*/
|
||||
private function validateLocation(int $warehouse_id, string $zone, string $aisle, string $rack): void {
|
||||
private function validateLocation(int $warehouse_id, string $zone, string $aisle, string $bin): void {
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT 1 FROM md_rack
|
||||
"SELECT 1 FROM md_bin
|
||||
WHERE company_id = :company_id
|
||||
AND warehouse = :warehouse
|
||||
AND COALESCE(zone, '') = :zone
|
||||
AND COALESCE(aisle,'') = :aisle
|
||||
AND rack = :rack
|
||||
AND bin = :bin
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([
|
||||
@@ -139,7 +152,7 @@ class BarcodeManager {
|
||||
':warehouse' => $warehouse_id,
|
||||
':zone' => $zone,
|
||||
':aisle' => $aisle,
|
||||
':rack' => $rack,
|
||||
':bin' => $bin,
|
||||
]);
|
||||
if (!$sth->fetchColumn()) throw new Exception('Location not found.');
|
||||
}
|
||||
@@ -156,7 +169,7 @@ class BarcodeManager {
|
||||
$warehouses->execute([':company_id' => $this->company_id]);
|
||||
|
||||
foreach ($warehouses->fetchAll(PDO::FETCH_COLUMN) as $warehouse_id) {
|
||||
$table = $this->stockTableName((int)$warehouse_id);
|
||||
$table = 'td_stock_' . (int)$warehouse_id;
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT ROUND(SUM(COALESCE(`in`, 0)) - SUM(COALESCE(`out`, 0)), 2) AS balance
|
||||
FROM `{$table}`
|
||||
@@ -343,7 +356,7 @@ class BarcodeManager {
|
||||
*/
|
||||
public function listLocationLabels(): array {
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT barcode, warehouse_id, zone, aisle, rack, status, print_count, last_printed_dt
|
||||
"SELECT barcode, warehouse_id, zone, aisle, bin, status, print_count, last_printed_dt
|
||||
FROM md_barcode
|
||||
WHERE company_id = :company_id
|
||||
AND barcode_type = 'loc'"
|
||||
@@ -355,30 +368,30 @@ class BarcodeManager {
|
||||
/**
|
||||
* Create a new location barcode label (or re-activate a previously disabled one).
|
||||
*
|
||||
* Validates that the rack exists in md_rack before inserting.
|
||||
* Validates that the bin exists in md_bin before inserting.
|
||||
*
|
||||
* @param string $barcode Full barcode string (e.g. "LOC|1|A|1|R1").
|
||||
* @param int $warehouse_id Warehouse ID.
|
||||
* @param string $zone Zone code.
|
||||
* @param string $aisle Aisle code.
|
||||
* @param string $rack Rack code.
|
||||
* @return array Keys: barcode, warehouse_id, zone, aisle, rack.
|
||||
* @throws Exception If the location is not found in md_rack.
|
||||
* @param string $bin Bin code.
|
||||
* @return array Keys: barcode, warehouse_id, zone, aisle, bin.
|
||||
* @throws Exception If the location is not found in md_bin.
|
||||
*/
|
||||
public function createLocationLabel(string $barcode, int $warehouse_id, string $zone, string $aisle, string $rack): array {
|
||||
$this->validateLocation($warehouse_id, $zone, $aisle, $rack);
|
||||
public function createLocationLabel(string $barcode, int $warehouse_id, string $zone, string $aisle, string $bin): array {
|
||||
$this->validateLocation($warehouse_id, $zone, $aisle, $bin);
|
||||
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO md_barcode
|
||||
(company_id, barcode_type, barcode, warehouse_id, zone, aisle, rack)
|
||||
(company_id, barcode_type, barcode, warehouse_id, zone, aisle, bin)
|
||||
VALUES
|
||||
(:company_id, 'loc', :barcode, :warehouse_id, :zone, :aisle, :rack)
|
||||
(:company_id, 'loc', :barcode, :warehouse_id, :zone, :aisle, :bin)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
barcode_type = VALUES(barcode_type),
|
||||
warehouse_id = VALUES(warehouse_id),
|
||||
zone = VALUES(zone),
|
||||
aisle = VALUES(aisle),
|
||||
rack = VALUES(rack),
|
||||
bin = VALUES(bin),
|
||||
status = 1"
|
||||
)->execute([
|
||||
':company_id' => $this->company_id,
|
||||
@@ -386,7 +399,7 @@ class BarcodeManager {
|
||||
':warehouse_id' => $warehouse_id,
|
||||
':zone' => $zone,
|
||||
':aisle' => $aisle,
|
||||
':rack' => $rack,
|
||||
':bin' => $bin,
|
||||
]);
|
||||
|
||||
return [
|
||||
@@ -394,7 +407,7 @@ class BarcodeManager {
|
||||
'warehouse_id' => $warehouse_id,
|
||||
'zone' => $zone,
|
||||
'aisle' => $aisle,
|
||||
'rack' => $rack,
|
||||
'bin' => $bin,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -465,7 +478,7 @@ class BarcodeManager {
|
||||
* resolve it from the caller via CompanySettingManager::get('advanced_location').
|
||||
*
|
||||
* @param string $barcode Full barcode string to resolve.
|
||||
* @param bool $advancedLocation True = zone/aisle/rack parts; false = rack only.
|
||||
* @param bool $advancedLocation True = zone/aisle/bin parts; false = bin only.
|
||||
* @return array
|
||||
* @throws Exception On parse or validation failure.
|
||||
*/
|
||||
@@ -515,13 +528,13 @@ class BarcodeManager {
|
||||
if ($advancedLocation) {
|
||||
$zone = trim($parts[2]);
|
||||
$aisle = trim($parts[3]);
|
||||
$rack = trim($parts[4]);
|
||||
$bin = trim($parts[4]);
|
||||
} else {
|
||||
$rack = trim($parts[2]);
|
||||
$zone = $rack;
|
||||
$aisle = $rack;
|
||||
$bin = trim($parts[2]);
|
||||
$zone = $bin;
|
||||
$aisle = $bin;
|
||||
}
|
||||
if (!$warehouse_id || $zone === '' || $aisle === '' || $rack === '') {
|
||||
if (!$warehouse_id || $zone === '' || $aisle === '' || $bin === '') {
|
||||
throw new Exception('Location label is incomplete.');
|
||||
}
|
||||
|
||||
@@ -531,19 +544,19 @@ class BarcodeManager {
|
||||
(int)$label['warehouse_id'] !== $warehouse_id ||
|
||||
$label['zone'] !== $zone ||
|
||||
$label['aisle'] !== $aisle ||
|
||||
$label['rack'] !== $rack
|
||||
$label['bin'] !== $bin
|
||||
) {
|
||||
throw new Exception('Location barcode label does not match its registry.');
|
||||
}
|
||||
|
||||
$this->validateLocation($warehouse_id, $zone, $aisle, $rack);
|
||||
$this->validateLocation($warehouse_id, $zone, $aisle, $bin);
|
||||
|
||||
return [
|
||||
'type' => 'loc',
|
||||
'warehouse_id' => $warehouse_id,
|
||||
'zone' => $zone,
|
||||
'aisle' => $aisle,
|
||||
'rack' => $rack,
|
||||
'bin' => $bin,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
* default_stock_status int 0 = draft, 1 = auto-approve on save
|
||||
* auto_invoice_and_credit_note int 0 = manual, 1 = auto-generate
|
||||
* auto_complete_on_ship int 0 = manual, 1 = auto-complete on shipped
|
||||
* advanced_location int 0 = simple (single location), 1 = zone/aisle/rack
|
||||
* location_label_rack string label for rack / single location field
|
||||
* advanced_location int 0 = simple (single location), 1 = zone/aisle/bin
|
||||
* location_label_bin string label for bin / single location field
|
||||
* location_label_zone string label for zone (advanced only)
|
||||
* location_label_aisle string label for aisle (advanced only)
|
||||
*
|
||||
@@ -19,7 +19,7 @@ class CompanySettingManager
|
||||
{
|
||||
private PDO $pdo;
|
||||
private ?PDO $transactionPdo;
|
||||
private int $company_id;
|
||||
private int $companyId;
|
||||
|
||||
// ── Known keys with their defaults ───────────────────────────────────────
|
||||
private const DEFAULTS = [
|
||||
@@ -27,7 +27,7 @@ class CompanySettingManager
|
||||
'auto_invoice_and_credit_note' => 0, // manual by default
|
||||
'auto_complete_on_ship' => 1, // auto-complete by default
|
||||
'advanced_location' => 0, // simple mode by default
|
||||
'location_label_rack' => 'Location', // single label default
|
||||
'location_label_bin' => 'Location', // single label default
|
||||
'location_label_zone' => 'Zone', // advanced level 1 default
|
||||
'location_label_aisle' => 'Aisle', // advanced level 2 default
|
||||
'posting_open_from' => '', // YYYY-MM-DD; empty = no lower bound
|
||||
@@ -229,7 +229,7 @@ class CompanySettingManager
|
||||
|
||||
private function hasStockTransactions(): bool
|
||||
{
|
||||
if ($this->tableHasCompanyRows('warehouse_balance')) {
|
||||
if ($this->tableHasCompanyRows('etl_stock_summary')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -316,7 +316,9 @@ class CompanySettingManager
|
||||
$allowed = array_keys(self::DEFAULTS);
|
||||
$incoming = [];
|
||||
foreach ($allowed as $key) {
|
||||
$incoming[$key] = $data[$key] ?? self::DEFAULTS[$key];
|
||||
if (array_key_exists($key, $data)) {
|
||||
$incoming[$key] = $data[$key];
|
||||
}
|
||||
}
|
||||
|
||||
$blocked = $this->blockedChanges($incoming);
|
||||
|
||||
@@ -32,10 +32,10 @@ class ContactManager {
|
||||
|
||||
/**
|
||||
* Check whether a contact is referenced by any active stock transaction
|
||||
* across all td_stock_* warehouse tables.
|
||||
* across this company's td_stock_* warehouse tables.
|
||||
*
|
||||
* Used as a pre-delete guard to prevent orphaning stock records.
|
||||
* Scans information_schema to discover all td_stock_* tables dynamically.
|
||||
* Discovers existing td_stock_* tables through md_warehouse scoped to this company.
|
||||
* Table names from information_schema are backtick-quoted for safety.
|
||||
*
|
||||
* @param int $contact_id The md_contact.id to check.
|
||||
@@ -409,7 +409,7 @@ class ContactManager {
|
||||
* Soft-delete a contact by negating its company_id.
|
||||
*
|
||||
* Blocks deletion if the contact is referenced in any active stock
|
||||
* transaction across all td_stock_* warehouse tables, preventing
|
||||
* transaction across this company's td_stock_* warehouse tables, preventing
|
||||
* broken foreign key references in transaction history.
|
||||
*
|
||||
* Must be called inside dbTransaction() by the caller.
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
<?php
|
||||
/**
|
||||
* DocumentNumberManager
|
||||
*
|
||||
* Central running-number generator for all transactional documents.
|
||||
* Config (prefix, format, digits) is read from `document_types` per company;
|
||||
* falls back to built-in defaults when no row exists so existing companies
|
||||
* work without any migration or seed data.
|
||||
*
|
||||
* Sequence counters are stored in `document_number_sequences` using an atomic
|
||||
* INSERT ... ON DUPLICATE KEY UPDATE so concurrent saves never produce the
|
||||
* same number.
|
||||
*/
|
||||
class DocumentNumberManager
|
||||
{
|
||||
private PDO $pdo;
|
||||
private int $company_id;
|
||||
|
||||
// Built-in defaults — used when no document_types row exists for a company
|
||||
private const DEFAULTS = [
|
||||
'quotation' => ['name' => 'Quotation', 'prefix' => 'QT', 'format_type' => 'YYMM', 'sequence_digits' => 5, 'allow_manual' => 1],
|
||||
'sales_order' => ['name' => 'Sales Order', 'prefix' => 'SO', 'format_type' => 'YYMM', 'sequence_digits' => 5, 'allow_manual' => 1],
|
||||
'invoice' => ['name' => 'Sales Invoice', 'prefix' => 'INV', 'format_type' => 'YYMM', 'sequence_digits' => 5, 'allow_manual' => 1],
|
||||
'credit_note' => ['name' => 'Sales Credit Note', 'prefix' => 'CN', 'format_type' => 'YYMM', 'sequence_digits' => 5, 'allow_manual' => 1],
|
||||
'purchase_request' => ['name' => 'Purchase Request', 'prefix' => 'PR', 'format_type' => 'YYMM', 'sequence_digits' => 5, 'allow_manual' => 1],
|
||||
'purchase_order' => ['name' => 'Purchase Order', 'prefix' => 'PO', 'format_type' => 'YYMM', 'sequence_digits' => 5, 'allow_manual' => 1],
|
||||
'purchase_invoice' => ['name' => 'Purchase Invoice', 'prefix' => 'PI', 'format_type' => 'YYMM', 'sequence_digits' => 5, 'allow_manual' => 1],
|
||||
'supplier_credit_note' => ['name' => 'Supplier Credit Note', 'prefix' => 'SCN', 'format_type' => 'YYMM', 'sequence_digits' => 5, 'allow_manual' => 1],
|
||||
'receipt' => ['name' => 'Receipt', 'prefix' => 'RCV', 'format_type' => 'YYMM', 'sequence_digits' => 5, 'allow_manual' => 1],
|
||||
'receipt_billing' => ['name' => 'Receipt Billing', 'prefix' => 'RCVB', 'format_type' => 'YYMM', 'sequence_digits' => 5, 'allow_manual' => 1],
|
||||
'payment' => ['name' => 'Payment', 'prefix' => 'PMT', 'format_type' => 'YYMM', 'sequence_digits' => 5, 'allow_manual' => 1],
|
||||
'payment_billing' => ['name' => 'Payment Billing', 'prefix' => 'PMTB', 'format_type' => 'YYMM', 'sequence_digits' => 5, 'allow_manual' => 1],
|
||||
'manual' => ['name' => 'Manual Journal', 'prefix' => 'JV', 'format_type' => 'YYMM', 'sequence_digits' => 5, 'allow_manual' => 1],
|
||||
'return' => ['name' => 'Customer Return', 'prefix' => 'RET', 'format_type' => 'YYMM', 'sequence_digits' => 5, 'allow_manual' => 1],
|
||||
'supplier_return' => ['name' => 'Supplier Return', 'prefix' => 'SRN', 'format_type' => 'YYMM', 'sequence_digits' => 5, 'allow_manual' => 1],
|
||||
];
|
||||
|
||||
public function __construct(PDO $pdo, int $company_id)
|
||||
{
|
||||
$this->pdo = $pdo;
|
||||
$this->company_id = $company_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the next number for a doc type using its configured (or default) prefix.
|
||||
*/
|
||||
public function generate(string $doc_type_key, string $prefix = ''): string
|
||||
{
|
||||
$cfg = $this->getConfig($doc_type_key, $prefix);
|
||||
return $this->generateWithPrefix($cfg['prefix'], $cfg['format_type'], (int)$cfg['sequence_digits']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a submitted document number. Manual numbers are validated without
|
||||
* touching the sequence counter; otherwise the next automatic number is reserved.
|
||||
*/
|
||||
public function resolveNumber(array $data, string $doc_type_key, string $table, string $column): string
|
||||
{
|
||||
$manual = trim((string)($data[$column] ?? $data['document_number'] ?? $data['manual_number'] ?? ''));
|
||||
if ($manual !== '') {
|
||||
return $this->validateManual($doc_type_key, $manual, $table, $column);
|
||||
}
|
||||
|
||||
$prefix = trim((string)($data['doc_number_prefix'] ?? $data['document_prefix'] ?? $data[$column . '_prefix'] ?? ''));
|
||||
return $this->generate($doc_type_key, $prefix);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate using an explicit prefix and format — used when the user has
|
||||
* selected a specific prefix from a multi-prefix doc type.
|
||||
*/
|
||||
public function generateWithPrefix(string $prefix, string $format_type, int $digits = 5): string
|
||||
{
|
||||
$digits = max(5, min(20, $digits));
|
||||
[$year, $month, $day, $period] = $this->periodParts($format_type);
|
||||
$seq = $this->nextSequence($prefix, $format_type, $year, $month, $day);
|
||||
return $this->format($prefix, $period, $seq, $digits);
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw if $number already exists in $table.$column for this company.
|
||||
* On duplicate, throws with a suggestion for the next available number.
|
||||
*/
|
||||
public function assertUnique(string $number, string $table, string $column, string $doc_type_key = ''): void
|
||||
{
|
||||
$this->assertIdentifier($table);
|
||||
$this->assertIdentifier($column);
|
||||
if (!$this->numberExists($table, $column, $number)) return;
|
||||
|
||||
$hint = '';
|
||||
if ($doc_type_key !== '') {
|
||||
$parsed = $this->parseManualNumber($doc_type_key, $number);
|
||||
$hint = ' Suggested: ' . $this->suggestManualNumber($table, $column, $parsed);
|
||||
}
|
||||
throw new Exception("Document number '{$number}' already exists.{$hint}");
|
||||
}
|
||||
|
||||
public function validateManual(string $doc_type_key, string $number, string $table, string $column): string
|
||||
{
|
||||
$number = strtoupper(trim($number));
|
||||
$parsed = $this->parseManualNumber($doc_type_key, $number);
|
||||
|
||||
if ((int)$parsed['config']['allow_manual'] !== 1) {
|
||||
throw new Exception("Manual numbering is disabled for {$doc_type_key}.");
|
||||
}
|
||||
|
||||
if ($this->numberExists($table, $column, $number)) {
|
||||
$hint = $this->suggestManualNumber($table, $column, $parsed);
|
||||
throw new Exception("Document number '{$number}' already exists. Suggested: {$hint}");
|
||||
}
|
||||
|
||||
return $number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the first configured prefix row for a doc type (DB row or built-in default).
|
||||
*/
|
||||
public function getConfig(string $doc_type_key, string $prefix = ''): array
|
||||
{
|
||||
$prefix = strtoupper(trim($prefix));
|
||||
if ($prefix !== '') {
|
||||
foreach ($this->getConfigsForType($doc_type_key) as $cfg) {
|
||||
if (strtoupper((string)$cfg['prefix']) === $prefix) return $cfg;
|
||||
}
|
||||
throw new Exception("Prefix '{$prefix}' is not configured for {$doc_type_key}.");
|
||||
}
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT prefix, format_type, sequence_digits, allow_manual
|
||||
FROM document_types
|
||||
WHERE company_id = :cid AND doc_type_key = :key
|
||||
ORDER BY id ASC LIMIT 1"
|
||||
);
|
||||
$sth->execute([':cid' => $this->company_id, ':key' => $doc_type_key]);
|
||||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if ($row) return $row;
|
||||
return self::DEFAULTS[$doc_type_key]
|
||||
?? ['prefix' => strtoupper($doc_type_key), 'format_type' => 'YYMM', 'sequence_digits' => 5, 'allow_manual' => 1];
|
||||
}
|
||||
|
||||
public function getConfigsForType(string $doc_type_key): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id, doc_type_key, name, prefix, format_type, sequence_digits, allow_manual
|
||||
FROM document_types
|
||||
WHERE company_id = :cid AND doc_type_key = :key
|
||||
ORDER BY id ASC"
|
||||
);
|
||||
$sth->execute([':cid' => $this->company_id, ':key' => $doc_type_key]);
|
||||
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
if ($rows) return $rows;
|
||||
|
||||
if (isset(self::DEFAULTS[$doc_type_key])) {
|
||||
return [array_merge(['id' => null, 'doc_type_key' => $doc_type_key], self::DEFAULTS[$doc_type_key])];
|
||||
}
|
||||
|
||||
return [[
|
||||
'id' => null,
|
||||
'doc_type_key' => $doc_type_key,
|
||||
'name' => $doc_type_key,
|
||||
'prefix' => strtoupper($doc_type_key),
|
||||
'format_type' => 'YYMM',
|
||||
'sequence_digits' => 5,
|
||||
'allow_manual' => 1,
|
||||
]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all configured prefix rows for every doc type — used by the settings UI.
|
||||
* Merges DB rows over DEFAULTS; doc types with no DB row appear with the default.
|
||||
*/
|
||||
public function getAllConfigs(): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id, doc_type_key, name, prefix, format_type, sequence_digits, allow_manual
|
||||
FROM document_types
|
||||
WHERE company_id = :cid
|
||||
ORDER BY doc_type_key, id"
|
||||
);
|
||||
$sth->execute([':cid' => $this->company_id]);
|
||||
$db_rows = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Index existing DB rows by doc_type_key
|
||||
$by_key = [];
|
||||
foreach ($db_rows as $r) {
|
||||
$by_key[$r['doc_type_key']][] = $r;
|
||||
}
|
||||
|
||||
// Merge defaults for any key not in DB
|
||||
$result = [];
|
||||
foreach (self::DEFAULTS as $key => $def) {
|
||||
if (isset($by_key[$key])) {
|
||||
foreach ($by_key[$key] as $row) {
|
||||
$result[] = array_merge(['doc_type_key' => $key, 'id' => $row['id']], $row);
|
||||
}
|
||||
} else {
|
||||
$result[] = [
|
||||
'id' => null,
|
||||
'doc_type_key' => $key,
|
||||
'name' => $def['name'],
|
||||
'prefix' => $def['prefix'],
|
||||
'format_type' => $def['format_type'],
|
||||
'sequence_digits' => $def['sequence_digits'],
|
||||
'allow_manual' => $def['allow_manual'],
|
||||
];
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save (insert or update) a document_types row.
|
||||
* $id = null → insert; $id > 0 → update.
|
||||
*/
|
||||
public function saveConfig(array $data, ?int $id): int
|
||||
{
|
||||
$key = trim((string)($data['doc_type_key'] ?? ''));
|
||||
$name = trim((string)($data['name'] ?? ''));
|
||||
$prefix = strtoupper(trim((string)($data['prefix'] ?? '')));
|
||||
$fmt = $data['format_type'] ?? 'YYMM';
|
||||
$digits = max(5, min(20, (int)($data['sequence_digits'] ?? 5)));
|
||||
$manual = (int)(bool)($data['allow_manual'] ?? 1);
|
||||
|
||||
if (!$key || !$prefix) throw new Exception('doc_type_key and prefix are required.');
|
||||
if (!in_array($fmt, ['YY', 'YYMM', 'YYMMDD', 'none'], true)) throw new Exception('Invalid format_type.');
|
||||
|
||||
if ($id) {
|
||||
$this->pdo->prepare(
|
||||
"UPDATE document_types SET prefix = :prefix, format_type = :fmt,
|
||||
sequence_digits = :digits, allow_manual = :manual
|
||||
WHERE id = :id AND company_id = :cid"
|
||||
)->execute([':prefix' => $prefix, ':fmt' => $fmt, ':digits' => $digits,
|
||||
':manual' => $manual, ':id' => $id, ':cid' => $this->company_id]);
|
||||
return $id;
|
||||
}
|
||||
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO document_types (company_id, doc_type_key, name, prefix, format_type, sequence_digits, allow_manual)
|
||||
VALUES (:cid, :key, :name, :prefix, :fmt, :digits, :manual)"
|
||||
)->execute([':cid' => $this->company_id, ':key' => $key, ':name' => $name,
|
||||
':prefix' => $prefix, ':fmt' => $fmt, ':digits' => $digits, ':manual' => $manual]);
|
||||
return (int)$this->pdo->lastInsertId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a document_types row. Refuses to delete the last row for a doc type.
|
||||
*/
|
||||
public function deleteConfig(int $id): void
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT doc_type_key FROM document_types WHERE id = :id AND company_id = :cid LIMIT 1"
|
||||
);
|
||||
$sth->execute([':id' => $id, ':cid' => $this->company_id]);
|
||||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row) throw new Exception('Config not found.');
|
||||
|
||||
$count_sth = $this->pdo->prepare(
|
||||
"SELECT COUNT(*) FROM document_types WHERE company_id = :cid AND doc_type_key = :key"
|
||||
);
|
||||
$count_sth->execute([':cid' => $this->company_id, ':key' => $row['doc_type_key']]);
|
||||
if ((int)$count_sth->fetchColumn() <= 1) {
|
||||
throw new Exception('Cannot delete the last prefix for a document type. Edit it instead.');
|
||||
}
|
||||
|
||||
$this->pdo->prepare("DELETE FROM document_types WHERE id = :id AND company_id = :cid")
|
||||
->execute([':id' => $id, ':cid' => $this->company_id]);
|
||||
}
|
||||
|
||||
// ── Private helpers ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Atomically reserve the next sequence number.
|
||||
* Uses INSERT...ON DUPLICATE KEY so two concurrent saves never share a number.
|
||||
*/
|
||||
private function nextSequence(string $prefix, string $format_type, int $year, int $month, int $day): int
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"INSERT INTO document_number_sequences
|
||||
(company_id, prefix, format_type, year, month, day, last_sequence)
|
||||
VALUES
|
||||
(:cid, :prefix, :fmt, :year, :month, :day, 1)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
last_sequence = LAST_INSERT_ID(last_sequence + 1)"
|
||||
);
|
||||
$sth->execute([
|
||||
':cid' => $this->company_id,
|
||||
':prefix' => $prefix,
|
||||
':fmt' => $format_type,
|
||||
':year' => $year,
|
||||
':month' => $month,
|
||||
':day' => $day,
|
||||
]);
|
||||
|
||||
// rowCount() = 1 → fresh INSERT (sequence = 1)
|
||||
// rowCount() = 2 → ON DUPLICATE UPDATE fired → LAST_INSERT_ID holds new value
|
||||
if ($sth->rowCount() === 1) {
|
||||
return 1;
|
||||
}
|
||||
return (int)$this->pdo->lastInsertId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive (year, month, day, period_string) from today given format_type.
|
||||
* Unused parts are stored as 0 to avoid NULL-comparison issues in the unique key.
|
||||
*/
|
||||
private function periodParts(string $format_type): array
|
||||
{
|
||||
$now = new DateTimeImmutable('now');
|
||||
$yy = (int)$now->format('y'); // 2-digit year
|
||||
$mm = (int)$now->format('m');
|
||||
$dd = (int)$now->format('d');
|
||||
$yy_s = $now->format('y');
|
||||
$mm_s = $now->format('m');
|
||||
$dd_s = $now->format('d');
|
||||
|
||||
switch ($format_type) {
|
||||
case 'YY': return [$yy, 0, 0, $yy_s];
|
||||
case 'YYMM': return [$yy, $mm, 0, $yy_s . $mm_s];
|
||||
case 'YYMMDD': return [$yy, $mm, $dd, $yy_s . $mm_s . $dd_s];
|
||||
default: return [0, 0, 0, '']; // 'none'
|
||||
}
|
||||
}
|
||||
|
||||
private function format(string $prefix, string $period, int $seq, int $digits): string
|
||||
{
|
||||
$padded = str_pad((string)$seq, $digits, '0', STR_PAD_LEFT);
|
||||
return $period === '' ? "{$prefix}-{$padded}" : "{$prefix}-{$period}-{$padded}";
|
||||
}
|
||||
|
||||
private function parseManualNumber(string $doc_type_key, string $number): array
|
||||
{
|
||||
$configs = $this->getConfigsForType($doc_type_key);
|
||||
usort($configs, fn($a, $b) => strlen((string)$b['prefix']) <=> strlen((string)$a['prefix']));
|
||||
|
||||
foreach ($configs as $cfg) {
|
||||
$prefix = strtoupper((string)$cfg['prefix']);
|
||||
$digits = max(5, min(20, (int)$cfg['sequence_digits']));
|
||||
$quoted = preg_quote($prefix, '/');
|
||||
$fmt = (string)$cfg['format_type'];
|
||||
|
||||
if ($fmt === 'none') {
|
||||
$regex = "/^{$quoted}-(\d{{$digits}})$/";
|
||||
} else {
|
||||
$period_len = ['YY' => 2, 'YYMM' => 4, 'YYMMDD' => 6][$fmt] ?? 4;
|
||||
$regex = "/^{$quoted}-(\d{{$period_len}})-(\d{{$digits}})$/";
|
||||
}
|
||||
|
||||
if (!preg_match($regex, $number, $m)) continue;
|
||||
|
||||
return [
|
||||
'config' => $cfg,
|
||||
'prefix' => $prefix,
|
||||
'format_type' => $fmt,
|
||||
'period' => $fmt === 'none' ? '' : $m[1],
|
||||
'sequence' => (int)($fmt === 'none' ? $m[1] : $m[2]),
|
||||
'digits' => $digits,
|
||||
];
|
||||
}
|
||||
|
||||
throw new Exception("Document number '{$number}' does not match a configured {$doc_type_key} format.");
|
||||
}
|
||||
|
||||
private function suggestManualNumber(string $table, string $column, array $parsed): string
|
||||
{
|
||||
$this->assertIdentifier($table);
|
||||
$this->assertIdentifier($column);
|
||||
|
||||
$prefix = $parsed['prefix'];
|
||||
$period = $parsed['period'];
|
||||
$digits = (int)$parsed['digits'];
|
||||
$pattern = $period === '' ? "{$prefix}-%" : "{$prefix}-{$period}-%";
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT `{$column}` FROM `{$table}`
|
||||
WHERE company_id = :cid AND `{$column}` LIKE :pattern"
|
||||
);
|
||||
$sth->execute([':cid' => $this->company_id, ':pattern' => $pattern]);
|
||||
|
||||
$max = 0;
|
||||
while (($value = $sth->fetchColumn()) !== false) {
|
||||
try {
|
||||
$candidate = $this->parseManualNumber((string)$parsed['config']['doc_type_key'], (string)$value);
|
||||
if ($candidate['prefix'] === $prefix && $candidate['period'] === $period) {
|
||||
$max = max($max, (int)$candidate['sequence']);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->format($prefix, $period, $max + 1, $digits);
|
||||
}
|
||||
|
||||
private function numberExists(string $table, string $column, string $number): bool
|
||||
{
|
||||
$this->assertIdentifier($table);
|
||||
$this->assertIdentifier($column);
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT 1 FROM `{$table}` WHERE company_id = :cid AND `{$column}` = :num LIMIT 1"
|
||||
);
|
||||
$sth->execute([':cid' => $this->company_id, ':num' => $number]);
|
||||
return $sth->fetchColumn() !== false;
|
||||
}
|
||||
|
||||
private function assertIdentifier(string $identifier): void
|
||||
{
|
||||
if (!preg_match('/^[A-Za-z0-9_]+$/', $identifier)) {
|
||||
throw new Exception('Invalid document number lookup target.');
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
<?php
|
||||
/**
|
||||
* EtlStockManager
|
||||
*
|
||||
* Manages etl_stock_summary — the materialized aggregate of td_stock_<warehouse_id>
|
||||
* by (company_id, warehouse_id, product_sku, month). Mirrors EtlManager for GL.
|
||||
*
|
||||
* etl_stock_summary is maintained two ways:
|
||||
* 1. Write-through — adjustBalance() in WarehouseManager updates it on every approve/delete.
|
||||
* 2. Batch repair — checkAndRepair() scans td_stock_* for drift and rebuilds stale months.
|
||||
*
|
||||
* Gap detection is timestamp-based (identical to GL):
|
||||
* A month is stale when any td_stock_N row (updated_at) is newer than the
|
||||
* oldest etl_stock_summary entry for that warehouse+month (source_updated_at).
|
||||
*
|
||||
* checkAndRepair() — scan all warehouses, rebuild stale months, save status
|
||||
* getGaps() — return [['warehouse_id'=>N,'period'=>'YYYY-MM'], ...]
|
||||
* rebuildPeriod(wh_id, period) — DELETE + reinsert one (warehouse, month) slice
|
||||
* rebuildFull(wh_id) — DELETE + rebuild all months for one warehouse
|
||||
* getDistinctPeriods(wh_id) — distinct approved months in td_stock_N
|
||||
* getStatus() — read etl_stock_* keys from company_setting
|
||||
*/
|
||||
class EtlStockManager
|
||||
{
|
||||
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 $gap) {
|
||||
$this->rebuildPeriod($gap['warehouse_id'], $gap['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
|
||||
{
|
||||
$stock_tables = $this->discoverStockTables();
|
||||
$gaps = [];
|
||||
|
||||
foreach ($stock_tables as $table) {
|
||||
$warehouse_id = (int)substr($table, strlen('td_stock_'));
|
||||
|
||||
$sth = $this->pdo->prepare("
|
||||
SELECT DISTINCT DATE_FORMAT(s.date, '%Y-%m') AS period
|
||||
FROM `{$table}` s
|
||||
LEFT JOIN (
|
||||
SELECT month, MAX(source_updated_at) AS etl_ts
|
||||
FROM etl_stock_summary
|
||||
WHERE company_id = :cid2 AND warehouse_id = :wh2
|
||||
GROUP BY month
|
||||
) etl ON etl.month = DATE_FORMAT(s.date, '%Y-%m')
|
||||
WHERE s.company_id = :cid
|
||||
AND s.status = 1
|
||||
AND (etl.month IS NULL OR s.updated_at > etl.etl_ts)
|
||||
ORDER BY period
|
||||
");
|
||||
$sth->execute([
|
||||
':cid' => $this->companyId,
|
||||
':cid2' => $this->companyId,
|
||||
':wh2' => $warehouse_id,
|
||||
]);
|
||||
|
||||
foreach ($sth->fetchAll(PDO::FETCH_COLUMN) as $period) {
|
||||
$gaps[] = ['warehouse_id' => $warehouse_id, 'period' => $period];
|
||||
}
|
||||
}
|
||||
|
||||
return $gaps;
|
||||
}
|
||||
|
||||
public function rebuildPeriod(int $warehouse_id, string $period): void
|
||||
{
|
||||
$table = 'td_stock_' . $warehouse_id;
|
||||
|
||||
$this->pdo->prepare(
|
||||
"DELETE FROM etl_stock_summary
|
||||
WHERE company_id = :cid AND warehouse_id = :wh AND month = :month"
|
||||
)->execute([':cid' => $this->companyId, ':wh' => $warehouse_id, ':month' => $period]);
|
||||
|
||||
$this->pdo->prepare("
|
||||
INSERT INTO etl_stock_summary
|
||||
(company_id, warehouse_id, product_sku, month, total_in, total_out, source_updated_at)
|
||||
SELECT
|
||||
company_id,
|
||||
:wh,
|
||||
product_sku,
|
||||
DATE_FORMAT(`date`, '%Y-%m') AS month,
|
||||
ROUND(SUM(`in`), 4) AS total_in,
|
||||
ROUND(SUM(`out`), 4) AS total_out,
|
||||
MAX(updated_at) AS source_updated_at
|
||||
FROM `{$table}`
|
||||
WHERE company_id = :cid
|
||||
AND status = 1
|
||||
AND DATE_FORMAT(`date`, '%Y-%m') = :month
|
||||
GROUP BY company_id, product_sku, DATE_FORMAT(`date`, '%Y-%m')
|
||||
")->execute([':cid' => $this->companyId, ':wh' => $warehouse_id, ':month' => $period]);
|
||||
}
|
||||
|
||||
public function rebuildFull(int $warehouse_id): array
|
||||
{
|
||||
$table = 'td_stock_' . $warehouse_id;
|
||||
|
||||
$this->pdo->prepare(
|
||||
"DELETE FROM etl_stock_summary WHERE company_id = :cid AND warehouse_id = :wh"
|
||||
)->execute([':cid' => $this->companyId, ':wh' => $warehouse_id]);
|
||||
|
||||
$this->pdo->prepare("
|
||||
INSERT INTO etl_stock_summary
|
||||
(company_id, warehouse_id, product_sku, month, total_in, total_out, source_updated_at)
|
||||
SELECT
|
||||
company_id,
|
||||
:wh,
|
||||
product_sku,
|
||||
DATE_FORMAT(`date`, '%Y-%m') AS month,
|
||||
ROUND(SUM(`in`), 4) AS total_in,
|
||||
ROUND(SUM(`out`), 4) AS total_out,
|
||||
MAX(updated_at) AS source_updated_at
|
||||
FROM `{$table}`
|
||||
WHERE company_id = :cid AND status = 1
|
||||
GROUP BY company_id, product_sku, DATE_FORMAT(`date`, '%Y-%m')
|
||||
")->execute([':cid' => $this->companyId, ':wh' => $warehouse_id]);
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT COUNT(DISTINCT month) FROM etl_stock_summary
|
||||
WHERE company_id = :cid AND warehouse_id = :wh"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':wh' => $warehouse_id]);
|
||||
$periods_rebuilt = (int)$sth->fetchColumn();
|
||||
|
||||
$this->saveStatus('ok', 0, $periods_rebuilt);
|
||||
|
||||
return ['periods_rebuilt' => $periods_rebuilt];
|
||||
}
|
||||
|
||||
public function getDistinctPeriods(int $warehouse_id): array
|
||||
{
|
||||
$table = 'td_stock_' . $warehouse_id;
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT DISTINCT DATE_FORMAT(`date`, '%Y-%m') AS period
|
||||
FROM `{$table}`
|
||||
WHERE company_id = :cid AND status = 1
|
||||
ORDER BY period"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId]);
|
||||
return $sth->fetchAll(PDO::FETCH_COLUMN);
|
||||
}
|
||||
|
||||
public function getStatus(): array
|
||||
{
|
||||
$keys = [
|
||||
'etl_stock_last_ran',
|
||||
'etl_stock_last_result',
|
||||
'etl_stock_gaps_found',
|
||||
'etl_stock_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_stock_last_ran'] ?? null;
|
||||
$next_run = $last_ran
|
||||
? date('Y-m-d', strtotime($last_ran . ' +1 day')) . sprintf(' %02d:30', $slot_hour)
|
||||
: date('Y-m-d') . sprintf(' %02d:30', $slot_hour);
|
||||
|
||||
return [
|
||||
'slot_hour' => $slot_hour,
|
||||
'last_ran' => $last_ran,
|
||||
'last_result' => $rows['etl_stock_last_result'] ?? null,
|
||||
'gaps_found' => (int)($rows['etl_stock_gaps_found'] ?? 0),
|
||||
'periods_rebuilt' => (int)($rows['etl_stock_periods_rebuilt'] ?? 0),
|
||||
'next_run' => $next_run,
|
||||
];
|
||||
}
|
||||
|
||||
// ── Private ──────────────────────────────────────────────────────────────
|
||||
|
||||
private function discoverStockTables(): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT t.table_name
|
||||
FROM md_warehouse w
|
||||
JOIN information_schema.tables t
|
||||
ON t.table_schema = DATABASE()
|
||||
AND t.table_name = CONCAT('td_stock_', w.id)
|
||||
WHERE w.company_id = :cid"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId]);
|
||||
return $sth->fetchAll(PDO::FETCH_COLUMN);
|
||||
}
|
||||
|
||||
private function saveStatus(string $result, int $gaps_found, int $periods_rebuilt): void
|
||||
{
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$data = [
|
||||
'etl_stock_last_ran' => $now,
|
||||
'etl_stock_last_result' => $result,
|
||||
'etl_stock_gaps_found' => (string)$gaps_found,
|
||||
'etl_stock_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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/DocumentNumberManager.php';
|
||||
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
|
||||
require_once __DIR__ . '/../classes_ac/GlManager.php';
|
||||
|
||||
@@ -70,37 +71,13 @@ class InvoiceManager {
|
||||
/**
|
||||
* Generate next sequential document number.
|
||||
*
|
||||
* @param string $doc_type 'invoice' | 'credit_note' | 'supplier_credit_note'
|
||||
* @return string e.g. "INV-20260502-0001" | "CN-20260502-0001" | "DN-20260502-0001"
|
||||
* @param string $doc_type 'invoice' | 'credit_note' | 'purchase_invoice' | 'supplier_credit_note'
|
||||
* @return string e.g. "INV-2605-00001" | "CN-2605-00001" | "PI-2605-00001" | "SCN-2605-00001"
|
||||
*/
|
||||
private function generateInvoiceNumber(string $doc_type): string
|
||||
private function generateInvoiceNumber(string $doc_type, array $data = []): string
|
||||
{
|
||||
$prefix_map = [
|
||||
'invoice' => 'INV',
|
||||
'credit_note' => 'CN',
|
||||
'supplier_credit_note' => 'DN',
|
||||
'purchase_invoice' => 'PINV',
|
||||
];
|
||||
$prefix = ($prefix_map[$doc_type] ?? 'INV') . '-' . date('Ymd') . '-';
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT invoice_number FROM td_invoice
|
||||
WHERE company_id = :company_id
|
||||
AND doc_type = :doc_type
|
||||
AND invoice_number LIKE :prefix
|
||||
ORDER BY invoice_number DESC
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':doc_type' => $doc_type,
|
||||
':prefix' => $prefix . '%',
|
||||
]);
|
||||
|
||||
$last = $sth->fetchColumn();
|
||||
$seq = $last ? ((int)substr($last, -4) + 1) : 1;
|
||||
|
||||
return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT);
|
||||
return (new DocumentNumberManager($this->pdo, $this->company_id))
|
||||
->resolveNumber($data, $doc_type, 'td_invoice', 'invoice_number');
|
||||
}
|
||||
|
||||
private function addSettlementFields(array $row): array
|
||||
@@ -1013,7 +990,7 @@ class InvoiceManager {
|
||||
)->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':uuid' => bin2hex(random_bytes(16)),
|
||||
':invoice_number' => $this->generateInvoiceNumber('credit_note'),
|
||||
':invoice_number' => $this->generateInvoiceNumber('credit_note', $data),
|
||||
':ref_invoice_id' => $ref_invoice_id,
|
||||
':order_id' => $order_id,
|
||||
':contact_id' => $contact_id,
|
||||
@@ -1132,7 +1109,7 @@ class InvoiceManager {
|
||||
)->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':uuid' => bin2hex(random_bytes(16)),
|
||||
':invoice_number' => $this->generateInvoiceNumber('supplier_credit_note'),
|
||||
':invoice_number' => $this->generateInvoiceNumber('supplier_credit_note', $data),
|
||||
':ref_invoice_id' => $ref_invoice_id,
|
||||
':contact_id' => $contact_id,
|
||||
':subtotal' => $subtotal,
|
||||
@@ -1158,6 +1135,115 @@ class InvoiceManager {
|
||||
* @param array $logging Audit entry.
|
||||
* @throws Exception
|
||||
*/
|
||||
/**
|
||||
* Soft-delete an invoice/credit_note/purchase_invoice/supplier_credit_note by negating
|
||||
* company_id. Blocked if any active (non-soft-deleted) downstream documents exist.
|
||||
* Deletes the GL entry if one was posted (subject to posting window).
|
||||
*/
|
||||
public function softDelete(int $id): void
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id, doc_type, status, issued_date FROM td_invoice
|
||||
WHERE company_id = :cid AND id = :id LIMIT 1"
|
||||
);
|
||||
$sth->execute([':cid' => $this->company_id, ':id' => $id]);
|
||||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row) throw new Exception('Invoice not found.');
|
||||
$doc_type = (string)$row['doc_type'];
|
||||
|
||||
// Drafts have no GL entry — posting window does not apply.
|
||||
if ((int)$row['status'] !== 0) {
|
||||
$this->assertPostingWindow($row['issued_date'] ?: date('Y-m-d'), ucfirst(str_replace('_', ' ', $doc_type)) . ' deletion');
|
||||
}
|
||||
|
||||
if (in_array($doc_type, ['invoice', 'credit_note'], true)) {
|
||||
$sth2 = $this->pdo->prepare(
|
||||
"SELECT COUNT(*) FROM td_receipt_item
|
||||
WHERE company_id = :cid AND invoice_id = :id"
|
||||
);
|
||||
$sth2->execute([':cid' => $this->company_id, ':id' => $id]);
|
||||
if ((int)$sth2->fetchColumn() > 0) {
|
||||
throw new Exception('Cannot delete — receipts are allocated to this document. Delete the receipts first.');
|
||||
}
|
||||
|
||||
$sth3 = $this->pdo->prepare(
|
||||
"SELECT COUNT(*) FROM td_payment_item
|
||||
WHERE company_id = :cid AND invoice_id = :id"
|
||||
);
|
||||
$sth3->execute([':cid' => $this->company_id, ':id' => $id]);
|
||||
if ((int)$sth3->fetchColumn() > 0) {
|
||||
throw new Exception('Cannot delete — payments are allocated to this document. Delete the payments first.');
|
||||
}
|
||||
|
||||
$sth4 = $this->pdo->prepare(
|
||||
"SELECT COUNT(*) FROM td_receipt_billing_item
|
||||
WHERE company_id = :cid AND invoice_id = :id"
|
||||
);
|
||||
$sth4->execute([':cid' => $this->company_id, ':id' => $id]);
|
||||
if ((int)$sth4->fetchColumn() > 0) {
|
||||
throw new Exception('Cannot delete — receipt billings reference this document. Delete the receipt billings first.');
|
||||
}
|
||||
|
||||
$sth5 = $this->pdo->prepare(
|
||||
"SELECT COUNT(*) FROM td_payment_billing_item
|
||||
WHERE company_id = :cid AND invoice_id = :id"
|
||||
);
|
||||
$sth5->execute([':cid' => $this->company_id, ':id' => $id]);
|
||||
if ((int)$sth5->fetchColumn() > 0) {
|
||||
throw new Exception('Cannot delete — payment billings reference this document. Delete the payment billings first.');
|
||||
}
|
||||
|
||||
if ($doc_type === 'invoice') {
|
||||
$sth6 = $this->pdo->prepare(
|
||||
"SELECT COUNT(*) FROM td_invoice
|
||||
WHERE company_id = :cid AND ref_invoice_id = :id AND doc_type = 'credit_note'"
|
||||
);
|
||||
$sth6->execute([':cid' => $this->company_id, ':id' => $id]);
|
||||
if ((int)$sth6->fetchColumn() > 0) {
|
||||
throw new Exception('Cannot delete — credit notes reference this invoice. Delete the credit notes first.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (in_array($doc_type, ['purchase_invoice', 'supplier_credit_note'], true)) {
|
||||
$sth7 = $this->pdo->prepare(
|
||||
"SELECT COUNT(*) FROM td_payment_item
|
||||
WHERE company_id = :cid AND invoice_id = :id"
|
||||
);
|
||||
$sth7->execute([':cid' => $this->company_id, ':id' => $id]);
|
||||
if ((int)$sth7->fetchColumn() > 0) {
|
||||
throw new Exception('Cannot delete — payments are allocated to this document. Delete the payments first.');
|
||||
}
|
||||
|
||||
$sth8 = $this->pdo->prepare(
|
||||
"SELECT COUNT(*) FROM td_payment_billing_item
|
||||
WHERE company_id = :cid AND invoice_id = :id"
|
||||
);
|
||||
$sth8->execute([':cid' => $this->company_id, ':id' => $id]);
|
||||
if ((int)$sth8->fetchColumn() > 0) {
|
||||
throw new Exception('Cannot delete — payment billings reference this document. Delete the payment billings first.');
|
||||
}
|
||||
}
|
||||
|
||||
// Delete GL entry if posted (no-op if none exists; throws if period is closed)
|
||||
global $pdo1;
|
||||
if (isset($pdo1) && $pdo1 instanceof PDO) {
|
||||
$guard = new PostingWindowGuard($pdo1, $this->company_id);
|
||||
$gl = new GlManager($this->pdo, $this->company_id, $guard);
|
||||
$gl->delete($doc_type, $id);
|
||||
}
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_invoice_item SET company_id = company_id * -1
|
||||
WHERE invoice_id = :id AND company_id = :cid"
|
||||
)->execute([':id' => $id, ':cid' => $this->company_id]);
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_invoice SET company_id = company_id * -1
|
||||
WHERE id = :id AND company_id = :cid"
|
||||
)->execute([':id' => $id, ':cid' => $this->company_id]);
|
||||
}
|
||||
|
||||
public function voidInvoice(int $id, array $logging): void
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
@@ -1190,6 +1276,35 @@ class InvoiceManager {
|
||||
if ((int)$sth->fetchColumn() > 0) {
|
||||
throw new Exception("Cannot void this document while posted receipts are allocated to it. Void the receipt first.");
|
||||
}
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT COUNT(*)
|
||||
FROM td_receipt_billing_item bi
|
||||
JOIN td_receipt_billing b
|
||||
ON b.company_id = bi.company_id
|
||||
AND b.id = bi.billing_id
|
||||
AND b.status = 1
|
||||
WHERE bi.company_id = :company_id
|
||||
AND bi.invoice_id = :invoice_id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':invoice_id' => $id]);
|
||||
if ((int)$sth->fetchColumn() > 0) {
|
||||
throw new Exception("Cannot void this document while posted receipt billing notes are allocated to it. Void the receipt billing note first.");
|
||||
}
|
||||
}
|
||||
|
||||
if ($row['doc_type'] === 'invoice') {
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT COUNT(*) FROM td_invoice
|
||||
WHERE company_id = :company_id
|
||||
AND ref_invoice_id = :invoice_id
|
||||
AND doc_type = 'credit_note'
|
||||
AND status != 4"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':invoice_id' => $id]);
|
||||
if ((int)$sth->fetchColumn() > 0) {
|
||||
throw new Exception("Cannot void this invoice while active credit notes reference it. Void the credit notes first.");
|
||||
}
|
||||
}
|
||||
|
||||
if (in_array($row['doc_type'], ['purchase_invoice', 'supplier_credit_note'], true)) {
|
||||
@@ -1207,6 +1322,21 @@ class InvoiceManager {
|
||||
if ((int)$sth->fetchColumn() > 0) {
|
||||
throw new Exception("Cannot void this document while posted payments are allocated to it. Void the payment first.");
|
||||
}
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT COUNT(*)
|
||||
FROM td_payment_billing_item bi
|
||||
JOIN td_payment_billing b
|
||||
ON b.company_id = bi.company_id
|
||||
AND b.id = bi.billing_id
|
||||
AND b.status = 1
|
||||
WHERE bi.company_id = :company_id
|
||||
AND bi.invoice_id = :invoice_id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':invoice_id' => $id]);
|
||||
if ((int)$sth->fetchColumn() > 0) {
|
||||
throw new Exception("Cannot void this document while posted payment billing notes are allocated to it. Void the payment billing note first.");
|
||||
}
|
||||
}
|
||||
|
||||
$log = json_decode($row['log'] ?? '[]', true) ?: [];
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/DocumentNumberManager.php';
|
||||
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
|
||||
|
||||
/**
|
||||
* OrderManager
|
||||
@@ -15,19 +17,19 @@
|
||||
* - Order items are stored as a JSON array in td_order.items.
|
||||
* No separate child table exists. SKU-level reporting is served by
|
||||
* td_stock_* rows (source='order') rather than querying items JSON.
|
||||
* - confirmOrder() picks racks via FIFO — oldest approved stock-in row
|
||||
* still rack-occupied, ordered by td_stock_<warehouse_id>.date ASC.
|
||||
* - confirmOrder() picks bins via FIFO — oldest approved stock-in row
|
||||
* still bin-occupied, ordered by td_stock_<warehouse_id>.date ASC.
|
||||
* - confirmOrder() creates stock-out rows with status=0 (draft).
|
||||
* Warehouse staff approve them via the existing approve_stock.php
|
||||
* engine, which handles rack release and balance adjustment.
|
||||
* engine, which handles bin release and balance adjustment.
|
||||
* - cancelOrder() sets td_order.status = -1 and soft-deletes ALL linked
|
||||
* stock-out rows (status → -1) across all td_stock_* tables. If linked
|
||||
* stock-out rows were already approved, their rack and balance effects are
|
||||
* stock-out rows were already approved, their bin and balance effects are
|
||||
* reversed before the rows are cancelled.
|
||||
* Cancellation is blocked if any active invoice (status != 4 / void) or
|
||||
* active return (status != -1 / cancelled) is linked to the order.
|
||||
* - Cancel logic stays in the order domain, but uses WarehouseManager for
|
||||
* the same rack and balance reversal rules as manual stock-out deletion.
|
||||
* the same bin and balance reversal rules as manual stock-out deletion.
|
||||
*
|
||||
* Note: Write methods do NOT manage their own DB transactions.
|
||||
* Callers must wrap multi-step operations inside dbTransaction().
|
||||
@@ -72,19 +74,28 @@ class OrderManager {
|
||||
throw new Exception("Invalid warehouse id.");
|
||||
}
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id FROM md_warehouse
|
||||
WHERE company_id = :company_id AND id = :id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $warehouse_id]);
|
||||
if (!$sth->fetchColumn()) {
|
||||
throw new Exception("Warehouse ID {$warehouse_id} not found.");
|
||||
}
|
||||
|
||||
return 'td_stock_' . $warehouse_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* FIFO rack pick — find the oldest approved stock-in row for a SKU
|
||||
* in a given warehouse that is still rack-occupied.
|
||||
* FIFO bin pick — find the oldest approved stock-in row for a SKU
|
||||
* in a given warehouse that is still bin-occupied.
|
||||
*
|
||||
* "Oldest" = smallest date value among status=1 stock-in rows
|
||||
* that have an md_rack row pointing back to them (td_stock_id IS set).
|
||||
* that have an md_bin row pointing back to them (td_stock_id IS set).
|
||||
*
|
||||
* @param int $warehouse_id
|
||||
* @param string $product_sku
|
||||
* @return array|null Full td_stock row + zone/aisle/rack from md_rack,
|
||||
* @return array|null Full td_stock row + zone/aisle/bin from md_bin,
|
||||
* or null if no available stock in this warehouse.
|
||||
*/
|
||||
private function pickFifoRack(int $warehouse_id, string $product_sku): ?array
|
||||
@@ -92,7 +103,7 @@ class OrderManager {
|
||||
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT s.*, r.zone, r.aisle, r.rack, l.expiry_date,
|
||||
"SELECT s.*, r.zone, r.aisle, r.bin, l.expiry_date,
|
||||
(s.`in` - COALESCE((
|
||||
SELECT SUM(o.`out`)
|
||||
FROM `{$table}` o
|
||||
@@ -102,7 +113,7 @@ class OrderManager {
|
||||
AND o.status != -1
|
||||
), 0)) AS available_qty
|
||||
FROM `{$table}` s
|
||||
INNER JOIN md_rack r
|
||||
INNER JOIN md_bin r
|
||||
ON r.company_id = s.company_id
|
||||
AND r.warehouse = :warehouse_id
|
||||
AND r.td_stock_id = s.id
|
||||
@@ -138,26 +149,10 @@ class OrderManager {
|
||||
*
|
||||
* @return string e.g. "ORD-20260502-0001"
|
||||
*/
|
||||
private function generateOrderNumber(): string
|
||||
private function generateOrderNumber(array $data = []): string
|
||||
{
|
||||
$prefix = 'ORD-' . date('Ymd') . '-';
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT order_number FROM td_order
|
||||
WHERE company_id = :company_id
|
||||
AND order_number LIKE :prefix
|
||||
ORDER BY order_number DESC
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':prefix' => $prefix . '%',
|
||||
]);
|
||||
|
||||
$last = $sth->fetchColumn();
|
||||
$seq = $last ? ((int)substr($last, -4) + 1) : 1;
|
||||
|
||||
return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT);
|
||||
return (new DocumentNumberManager($this->pdo, $this->company_id))
|
||||
->resolveNumber($data, 'sales_order', 'td_order', 'order_number');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -189,7 +184,7 @@ class OrderManager {
|
||||
$stock_out_id = (int)($item['stock_out_id'] ?? 0);
|
||||
if ($warehouse_id <= 0 || $stock_out_id <= 0) continue;
|
||||
|
||||
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
|
||||
$table = 'td_stock_' . $warehouse_id;
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT status FROM `{$table}`
|
||||
WHERE company_id = :company_id
|
||||
@@ -370,9 +365,9 @@ class OrderManager {
|
||||
$stock_out_id = (int)($item['stock_out_id'] ?? 0);
|
||||
|
||||
if ($stock_out_warehouse_id > 0 && $stock_out_id > 0) {
|
||||
$table = $this->stockTableNameFromWarehouseId($stock_out_warehouse_id);
|
||||
$table = 'td_stock_' . $stock_out_warehouse_id;
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id, status, zone, aisle, rack, lot_number, serial_number
|
||||
"SELECT id, status, zone, aisle, bin, lot_number, serial_number
|
||||
FROM `{$table}`
|
||||
WHERE company_id = :company_id
|
||||
AND id = :id
|
||||
@@ -388,7 +383,7 @@ class OrderManager {
|
||||
if ($stock_out) {
|
||||
$item['zone'] = $stock_out['zone'] ?? ($item['zone'] ?? '');
|
||||
$item['aisle'] = $stock_out['aisle'] ?? ($item['aisle'] ?? '');
|
||||
$item['rack'] = $stock_out['rack'] ?? ($item['rack'] ?? '');
|
||||
$item['bin'] = $stock_out['bin'] ?? ($item['bin'] ?? '');
|
||||
$item['lot_number'] = $stock_out['lot_number'] ?? ($item['lot_number'] ?? '');
|
||||
$item['serial_number'] = $stock_out['serial_number'] ?? ($item['serial_number'] ?? '');
|
||||
$item['stock_out_status'] = (int)$stock_out['status'];
|
||||
@@ -588,7 +583,7 @@ class OrderManager {
|
||||
':uuid' => bin2hex(random_bytes(16)),
|
||||
':source_id' => $source_id,
|
||||
':source' => $source,
|
||||
':order_number' => $this->generateOrderNumber(),
|
||||
':order_number' => $this->generateOrderNumber($data),
|
||||
':contact_id' => (int)($data['contact_id'] ?? 0),
|
||||
':department_id' => (int)($data['department_id'] ?? 0),
|
||||
':order_date' => $data['order_date'] ?? date('Y-m-d'),
|
||||
@@ -613,10 +608,10 @@ class OrderManager {
|
||||
|
||||
/**
|
||||
* Confirm a draft order — create stock-out rows (status=0) for each item
|
||||
* using FIFO rack selection, then advance the order to status=1.
|
||||
* using FIFO bin selection, then advance the order to status=1.
|
||||
*
|
||||
* Flow per item:
|
||||
* 1. pickFifoRack() — find oldest rack-occupied stock-in row for the SKU.
|
||||
* 1. pickFifoRack() — find oldest bin-occupied stock-in row for the SKU.
|
||||
* 2. INSERT into td_stock_<warehouse_id> with type='out', status=0,
|
||||
* source='order', source_id=order_id.
|
||||
* 3. Write back stock_out_id into the item object in td_order.items.
|
||||
@@ -625,7 +620,7 @@ class OrderManager {
|
||||
* 4. UPDATE td_order.items with stock_out_id values written back.
|
||||
* 5. UPDATE td_order.status = 1 (confirmed).
|
||||
*
|
||||
* Rack release and balance adjustment are intentionally deferred —
|
||||
* Bin release and balance adjustment are intentionally deferred —
|
||||
* they happen when warehouse staff approve the stock-out rows via
|
||||
* the existing approve_stock.php engine (StockManager::approveStock).
|
||||
*
|
||||
@@ -635,7 +630,7 @@ class OrderManager {
|
||||
* @param string $uuid UUID prefix — each stock-out row gets uuid_{$i}.
|
||||
* @param array $logging Audit entry appended to td_order.log.
|
||||
* @throws Exception If order not found, not in draft status, or any item
|
||||
* has no available FIFO rack in its assigned warehouse.
|
||||
* has no available FIFO bin in its assigned warehouse.
|
||||
*/
|
||||
public function confirmOrder(int $order_id, string $uuid, array $logging, bool $auto_approve = false): void
|
||||
{
|
||||
@@ -682,22 +677,22 @@ class OrderManager {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rack_stock = $this->pickFifoRack($warehouse_id, $product_sku);
|
||||
if (!$rack_stock) {
|
||||
$bin_stock = $this->pickFifoRack($warehouse_id, $product_sku);
|
||||
if (!$bin_stock) {
|
||||
$preflight_errors[] = "No available stock for \"{$name}\" in the selected warehouse.";
|
||||
continue;
|
||||
}
|
||||
|
||||
$available_qty = (float)($rack_stock['available_qty'] ?? $rack_stock['in'] ?? 0);
|
||||
$available_qty = (float)($bin_stock['available_qty'] ?? $bin_stock['in'] ?? 0);
|
||||
if ($quantity - $available_qty > 0.000001) {
|
||||
$preflight_errors[] =
|
||||
"Insufficient stock for \"{$name}\": " .
|
||||
"{$available_qty} available, {$quantity} requested.";
|
||||
}
|
||||
|
||||
$expiry = $rack_stock['expiry_date'] ?? null;
|
||||
$expiry = $bin_stock['expiry_date'] ?? null;
|
||||
if ($expiry && strtotime($expiry) < strtotime(date('Y-m-d'))) {
|
||||
$lot = $rack_stock['lot_number'] ?? '';
|
||||
$lot = $bin_stock['lot_number'] ?? '';
|
||||
$preflight_errors[] =
|
||||
"Lot {$lot} for \"{$name}\" expired on {$expiry} and cannot be picked.";
|
||||
}
|
||||
@@ -707,29 +702,48 @@ class OrderManager {
|
||||
throw new Exception(implode("\n", $preflight_errors));
|
||||
}
|
||||
|
||||
// Validate all warehouse IDs in one query before entering the item loop.
|
||||
$itemWarehouseIds = array_values(array_unique(array_filter(
|
||||
array_map(fn($item) => (int)($item['warehouse_id'] ?? 0), $items),
|
||||
fn($id) => $id > 0
|
||||
)));
|
||||
if (!empty($itemWarehouseIds)) {
|
||||
$placeholders = implode(',', array_fill(0, count($itemWarehouseIds), '?'));
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id FROM md_warehouse WHERE company_id = ? AND id IN ($placeholders)"
|
||||
);
|
||||
$sth->execute([$this->company_id, ...$itemWarehouseIds]);
|
||||
$validWarehouseIds = array_flip($sth->fetchAll(PDO::FETCH_COLUMN));
|
||||
} else {
|
||||
$validWarehouseIds = [];
|
||||
}
|
||||
|
||||
foreach ($items as $i => &$item) {
|
||||
|
||||
$warehouse_id = (int)($item['warehouse_id'] ?? 0);
|
||||
$product_sku = $item['product_sku'] ?? '';
|
||||
$quantity = (float)($item['quantity'] ?? 0);
|
||||
|
||||
$rack_stock = $this->pickFifoRack($warehouse_id, $product_sku);
|
||||
$available_qty = (float)($rack_stock['available_qty'] ?? $rack_stock['in'] ?? 0);
|
||||
$bin_stock = $this->pickFifoRack($warehouse_id, $product_sku);
|
||||
$available_qty = (float)($bin_stock['available_qty'] ?? $bin_stock['in'] ?? 0);
|
||||
|
||||
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
|
||||
if (!isset($validWarehouseIds[$warehouse_id])) {
|
||||
throw new Exception("Warehouse ID {$warehouse_id} not found.");
|
||||
}
|
||||
$table = 'td_stock_' . $warehouse_id;
|
||||
$item_uuid = $uuid . '_' . $i;
|
||||
$item_log = [array_merge($logging, ['action' => 'confirm_item'])];
|
||||
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO `{$table}`
|
||||
(uuid, company_id, `date`, product_sku, `out`,
|
||||
zone, aisle, rack,
|
||||
zone, aisle, bin,
|
||||
contact_id, `description`, `log`, `type`,
|
||||
ref_id, lot_number, serial_number,
|
||||
source, source_id, price, status)
|
||||
VALUES
|
||||
(:uuid, :company_id, :date, :product_sku, :quantity,
|
||||
:zone, :aisle, :rack,
|
||||
:zone, :aisle, :bin,
|
||||
:contact_id, :description, :log, 'out',
|
||||
:ref_id, :lot_number, :serial_number,
|
||||
'order', :source_id, :price, 0)"
|
||||
@@ -739,28 +753,28 @@ class OrderManager {
|
||||
':date' => date('Y-m-d H:i:s'),
|
||||
':product_sku' => $product_sku,
|
||||
':quantity' => $quantity,
|
||||
':zone' => $rack_stock['zone'],
|
||||
':aisle' => $rack_stock['aisle'],
|
||||
':rack' => $rack_stock['rack'],
|
||||
':zone' => $bin_stock['zone'],
|
||||
':aisle' => $bin_stock['aisle'],
|
||||
':bin' => $bin_stock['bin'],
|
||||
':contact_id' => (int)$order['contact_id'],
|
||||
':description' => $order['order_number'],
|
||||
':log' => json_encode($item_log),
|
||||
':ref_id' => (int)$rack_stock['id'],
|
||||
':lot_number' => $rack_stock['lot_number'] ?? '',
|
||||
':serial_number' => $rack_stock['serial_number'] ?? '',
|
||||
':ref_id' => (int)$bin_stock['id'],
|
||||
':lot_number' => $bin_stock['lot_number'] ?? '',
|
||||
':serial_number' => $bin_stock['serial_number'] ?? '',
|
||||
':source_id' => $order_id,
|
||||
':price' => (float)($item['unit_price'] ?? 0),
|
||||
]);
|
||||
|
||||
// Write rack context + stock_out_id back into the item object
|
||||
$item['zone'] = $rack_stock['zone'];
|
||||
$item['aisle'] = $rack_stock['aisle'];
|
||||
$item['rack'] = $rack_stock['rack'];
|
||||
$item['lot_number'] = $rack_stock['lot_number'] ?? '';
|
||||
$item['serial_number'] = $rack_stock['serial_number'] ?? '';
|
||||
// Write bin context + stock_out_id back into the item object
|
||||
$item['zone'] = $bin_stock['zone'];
|
||||
$item['aisle'] = $bin_stock['aisle'];
|
||||
$item['bin'] = $bin_stock['bin'];
|
||||
$item['lot_number'] = $bin_stock['lot_number'] ?? '';
|
||||
$item['serial_number'] = $bin_stock['serial_number'] ?? '';
|
||||
$item['stock_out_id'] = (int)$this->pdo->lastInsertId();
|
||||
|
||||
// Auto-approve: immediately release rack + adjust balance
|
||||
// Auto-approve: immediately release bin + adjust balance
|
||||
if ($auto_approve) {
|
||||
$stock = new StockManager($this->pdo, $this->company_id);
|
||||
$whMgmt = new WarehouseManager($this->pdo, $this->company_id);
|
||||
@@ -816,7 +830,7 @@ class OrderManager {
|
||||
* - td_return where order_id = $order_id AND status != -1
|
||||
*
|
||||
* Stock-out rows are children of the order — they follow the parent.
|
||||
* On cancel, approved rows first re-occupy the original source rack and
|
||||
* On cancel, approved rows first re-occupy the original source bin and
|
||||
* reverse the stock-out balance. Then ALL stock-out rows linked to this
|
||||
* order (any status except already -1) are soft-deleted (status → -1)
|
||||
* across all td_stock_* tables.
|
||||
@@ -879,14 +893,8 @@ class OrderManager {
|
||||
}
|
||||
|
||||
// ── Reverse approved stock-out side effects, then soft-delete rows ─
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT table_name FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name LIKE 'td_stock_%'"
|
||||
);
|
||||
$sth->execute();
|
||||
$tables = $sth->fetchAll(PDO::FETCH_COLUMN);
|
||||
$whMgmt = new WarehouseManager($this->pdo, $this->company_id);
|
||||
$tables = $whMgmt->getStockTables();
|
||||
|
||||
foreach ($tables as $table) {
|
||||
if (!preg_match('/^td_stock_(\d+)$/', (string)$table, $matches)) {
|
||||
@@ -896,7 +904,7 @@ class OrderManager {
|
||||
$warehouse_id = (int)$matches[1];
|
||||
|
||||
$row_sth = $this->pdo->prepare(
|
||||
"SELECT id, product_sku, `out`, zone, aisle, rack, ref_id, status, `date`
|
||||
"SELECT id, product_sku, `out`, zone, aisle, bin, ref_id, status, `date`
|
||||
FROM `{$table}`
|
||||
WHERE company_id = :company_id
|
||||
AND source = 'order'
|
||||
@@ -917,11 +925,11 @@ class OrderManager {
|
||||
|
||||
$whMgmt->assertStockMovementWindow($row['date'] ?? null, 'Order stock cancellation');
|
||||
|
||||
$whMgmt->occupyRack(
|
||||
$whMgmt->occupyBin(
|
||||
$warehouse_id,
|
||||
(string)$row['zone'],
|
||||
(string)$row['aisle'],
|
||||
(string)$row['rack'],
|
||||
(string)$row['bin'],
|
||||
(string)$row['product_sku'],
|
||||
(int)$row['ref_id']
|
||||
);
|
||||
@@ -992,6 +1000,106 @@ class OrderManager {
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete an order by negating company_id on the header, items, and all
|
||||
* linked stock-out rows. Approved stock-out side effects are reversed first.
|
||||
* Blocked if any invoice or return (not yet soft-deleted) exists for this order.
|
||||
*/
|
||||
public function softDelete(int $id): void
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM td_order WHERE company_id = :cid AND id = :id"
|
||||
);
|
||||
$sth->execute([':cid' => $this->company_id, ':id' => $id]);
|
||||
$order = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$order) throw new Exception('Sales order not found.');
|
||||
|
||||
global $pdo1;
|
||||
if (isset($pdo1) && $pdo1 instanceof PDO) {
|
||||
$guard = new PostingWindowGuard($pdo1, $this->company_id);
|
||||
$guard->assertOpenDate($order['order_date'] ?: date('Y-m-d'), 'Order deletion');
|
||||
}
|
||||
|
||||
$sth2 = $this->pdo->prepare(
|
||||
"SELECT COUNT(*) FROM td_invoice
|
||||
WHERE company_id = :cid AND order_id = :id"
|
||||
);
|
||||
$sth2->execute([':cid' => $this->company_id, ':id' => $id]);
|
||||
if ((int)$sth2->fetchColumn() > 0) {
|
||||
throw new Exception('Cannot delete — this order has linked invoices. Delete the invoices first.');
|
||||
}
|
||||
|
||||
$sth3 = $this->pdo->prepare(
|
||||
"SELECT COUNT(*) FROM td_return
|
||||
WHERE company_id = :cid AND order_id = :id"
|
||||
);
|
||||
$sth3->execute([':cid' => $this->company_id, ':id' => $id]);
|
||||
if ((int)$sth3->fetchColumn() > 0) {
|
||||
throw new Exception('Cannot delete — this order has linked returns. Delete the returns first.');
|
||||
}
|
||||
|
||||
// Block if any approved stock-out rows exist; user must reverse via ICS first
|
||||
$tables = (new WarehouseManager($this->pdo, $this->company_id))->getStockTables();
|
||||
|
||||
foreach ($tables as $table) {
|
||||
$chk = $this->pdo->prepare(
|
||||
"SELECT COUNT(*) FROM `{$table}`
|
||||
WHERE company_id = :cid AND source = 'order' AND source_id = :id AND type = 'out' AND status = 1"
|
||||
);
|
||||
$chk->execute([':cid' => $this->company_id, ':id' => $id]);
|
||||
if ((int)$chk->fetchColumn() > 0) {
|
||||
throw new Exception('Cannot delete — stock-out for this order has been approved. Reverse via ICS first.');
|
||||
}
|
||||
}
|
||||
|
||||
// Negate draft/pending stock-out rows (status != 1)
|
||||
foreach ($tables as $table) {
|
||||
$this->pdo->prepare(
|
||||
"UPDATE `{$table}` SET company_id = company_id * -1
|
||||
WHERE company_id = :cid AND source = 'order' AND source_id = :id AND type = 'out' AND status != 1"
|
||||
)->execute([':cid' => $this->company_id, ':id' => $id]);
|
||||
}
|
||||
|
||||
// Release converted_qty on source quotation
|
||||
$source = (string)($order['source'] ?? '');
|
||||
$source_id = (int)($order['source_id'] ?? 0);
|
||||
if ($source === 'quotation' && $source_id > 0) {
|
||||
$sth5 = $this->pdo->prepare(
|
||||
"SELECT item_id, quantity FROM td_order_item
|
||||
WHERE order_id = :id AND company_id = :cid ORDER BY item_id"
|
||||
);
|
||||
$sth5->execute([':id' => $id, ':cid' => $this->company_id]);
|
||||
$items = $sth5->fetchAll(PDO::FETCH_ASSOC);
|
||||
$dec = $this->pdo->prepare(
|
||||
"UPDATE td_quotation_item
|
||||
SET converted_qty = GREATEST(0, converted_qty - :qty)
|
||||
WHERE quotation_id = :qid AND item_id = :item_id AND company_id = :cid"
|
||||
);
|
||||
foreach ($items as $item) {
|
||||
$dec->execute([
|
||||
':qty' => (float)($item['quantity'] ?? 0),
|
||||
':qid' => $source_id,
|
||||
':item_id' => (int)($item['item_id'] ?? 0),
|
||||
':cid' => $this->company_id,
|
||||
]);
|
||||
}
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_quotation SET status = 2
|
||||
WHERE id = :id AND company_id = :cid AND status = 5"
|
||||
)->execute([':id' => $source_id, ':cid' => $this->company_id]);
|
||||
}
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_order_item SET company_id = company_id * -1
|
||||
WHERE order_id = :id AND company_id = :cid"
|
||||
)->execute([':id' => $id, ':cid' => $this->company_id]);
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_order SET company_id = company_id * -1
|
||||
WHERE id = :id AND company_id = :cid"
|
||||
)->execute([':id' => $id, ':cid' => $this->company_id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update shipping tracking number. Fulfillment itself is derived from
|
||||
* linked stock-out rows and tracking, not selected manually.
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/DocumentNumberManager.php';
|
||||
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
|
||||
|
||||
class PaymentBillingManager {
|
||||
|
||||
@@ -10,23 +12,10 @@ class PaymentBillingManager {
|
||||
$this->company_id = $company_id;
|
||||
}
|
||||
|
||||
private function generateBillingNumber(): string
|
||||
private function generateBillingNumber(array $data = []): string
|
||||
{
|
||||
$prefix = 'PB-' . date('Ymd') . '-';
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT billing_number FROM td_payment_billing
|
||||
WHERE company_id = :company_id
|
||||
AND billing_number LIKE :prefix
|
||||
ORDER BY billing_number DESC
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':prefix' => $prefix . '%',
|
||||
]);
|
||||
$last = $sth->fetchColumn();
|
||||
$seq = $last ? ((int)substr($last, -4) + 1) : 1;
|
||||
return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT);
|
||||
return (new DocumentNumberManager($this->pdo, $this->company_id))
|
||||
->resolveNumber($data, 'payment_billing', 'td_payment_billing', 'billing_number');
|
||||
}
|
||||
|
||||
private function unavailableDocumentAmount(int $invoice_id): float
|
||||
@@ -339,7 +328,7 @@ class PaymentBillingManager {
|
||||
)->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':uuid' => bin2hex(random_bytes(16)),
|
||||
':billing_number' => $this->generateBillingNumber(),
|
||||
':billing_number' => $this->generateBillingNumber($data),
|
||||
':contact_id' => $contact_id,
|
||||
':billing_date' => $billing_date,
|
||||
':amount' => $net_amount,
|
||||
@@ -391,5 +380,44 @@ class PaymentBillingManager {
|
||||
':id' => $billing_id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete a payment billing by negating company_id on the header and all items.
|
||||
* Blocked if any payment (not yet soft-deleted) is linked to this billing.
|
||||
*/
|
||||
public function softDelete(int $billing_id): void
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id, billing_date FROM td_payment_billing WHERE id = :id AND company_id = :cid LIMIT 1"
|
||||
);
|
||||
$sth->execute([':id' => $billing_id, ':cid' => $this->company_id]);
|
||||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row) throw new Exception('Payment billing not found.');
|
||||
|
||||
global $pdo1;
|
||||
if (isset($pdo1) && $pdo1 instanceof PDO) {
|
||||
$guard = new PostingWindowGuard($pdo1, $this->company_id);
|
||||
$guard->assertOpenDate($row['billing_date'] ?: date('Y-m-d'), 'Payment billing deletion');
|
||||
}
|
||||
|
||||
$sth2 = $this->pdo->prepare(
|
||||
"SELECT COUNT(*) FROM td_payment
|
||||
WHERE company_id = :cid AND payment_billing_id = :id"
|
||||
);
|
||||
$sth2->execute([':cid' => $this->company_id, ':id' => $billing_id]);
|
||||
if ((int)$sth2->fetchColumn() > 0) {
|
||||
throw new Exception('Cannot delete — payments are linked to this billing. Delete the payments first.');
|
||||
}
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_payment_billing_item SET company_id = company_id * -1
|
||||
WHERE billing_id = :id AND company_id = :cid"
|
||||
)->execute([':id' => $billing_id, ':cid' => $this->company_id]);
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_payment_billing SET company_id = company_id * -1
|
||||
WHERE id = :id AND company_id = :cid"
|
||||
)->execute([':id' => $billing_id, ':cid' => $this->company_id]);
|
||||
}
|
||||
}
|
||||
?>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/DocumentNumberManager.php';
|
||||
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
|
||||
require_once __DIR__ . '/../classes_ac/GlManager.php';
|
||||
|
||||
@@ -12,23 +13,10 @@ class PaymentManager {
|
||||
$this->company_id = $company_id;
|
||||
}
|
||||
|
||||
private function generatePaymentNumber(): string
|
||||
private function generatePaymentNumber(array $data = []): string
|
||||
{
|
||||
$prefix = 'PY-' . date('Ymd') . '-';
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT payment_number FROM td_payment
|
||||
WHERE company_id = :company_id
|
||||
AND payment_number LIKE :prefix
|
||||
ORDER BY payment_number DESC
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':prefix' => $prefix . '%',
|
||||
]);
|
||||
$last = $sth->fetchColumn();
|
||||
$seq = $last ? ((int)substr($last, -4) + 1) : 1;
|
||||
return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT);
|
||||
return (new DocumentNumberManager($this->pdo, $this->company_id))
|
||||
->resolveNumber($data, 'payment', 'td_payment', 'payment_number');
|
||||
}
|
||||
|
||||
private function assertPostingWindow(?string $date, string $context): void
|
||||
@@ -450,7 +438,7 @@ class PaymentManager {
|
||||
)->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':uuid' => bin2hex(random_bytes(16)),
|
||||
':payment_number' => $this->generatePaymentNumber(),
|
||||
':payment_number' => $this->generatePaymentNumber($data),
|
||||
':contact_id' => $contact_id,
|
||||
':department_id' => (int)($data['department_id'] ?? 0),
|
||||
':payment_billing_id' => $billing_id,
|
||||
@@ -513,5 +501,53 @@ class PaymentManager {
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete a payment by negating company_id on the header and all items.
|
||||
* Deletes the GL entry if posted. Refreshes settlement and billing status.
|
||||
*/
|
||||
public function softDelete(int $payment_id): void
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id, payment_billing_id, payment_date FROM td_payment
|
||||
WHERE company_id = :cid AND id = :id LIMIT 1"
|
||||
);
|
||||
$sth->execute([':cid' => $this->company_id, ':id' => $payment_id]);
|
||||
$payment = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$payment) throw new Exception('Payment not found.');
|
||||
|
||||
$this->assertPostingWindow($payment['payment_date'] ?: date('Y-m-d'), 'Payment deletion');
|
||||
|
||||
// Collect linked invoice ids before negating
|
||||
$sth2 = $this->pdo->prepare(
|
||||
"SELECT invoice_id FROM td_payment_item
|
||||
WHERE company_id = :cid AND payment_id = :id"
|
||||
);
|
||||
$sth2->execute([':cid' => $this->company_id, ':id' => $payment_id]);
|
||||
$invoice_ids = $sth2->fetchAll(PDO::FETCH_COLUMN);
|
||||
|
||||
// Delete GL entry if posted (no-op if none; throws if period closed)
|
||||
global $pdo1;
|
||||
if (isset($pdo1) && $pdo1 instanceof PDO) {
|
||||
$guard = new PostingWindowGuard($pdo1, $this->company_id);
|
||||
$gl = new GlManager($this->pdo, $this->company_id, $guard);
|
||||
$gl->delete('payment', $payment_id);
|
||||
}
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_payment_item SET company_id = company_id * -1
|
||||
WHERE payment_id = :id AND company_id = :cid"
|
||||
)->execute([':id' => $payment_id, ':cid' => $this->company_id]);
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_payment SET company_id = company_id * -1
|
||||
WHERE id = :id AND company_id = :cid"
|
||||
)->execute([':id' => $payment_id, ':cid' => $this->company_id]);
|
||||
|
||||
foreach ($invoice_ids as $invoice_id) {
|
||||
$this->refreshInvoiceSettlementStatus((int)$invoice_id);
|
||||
}
|
||||
$this->refreshPaymentBillingStatus((int)$payment['payment_billing_id']);
|
||||
}
|
||||
}
|
||||
?>
|
||||
|
||||
@@ -31,7 +31,7 @@ class ProductManager {
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Scan all td_stock_* warehouse tables for any active stock row
|
||||
* Scan this company's td_stock_* warehouse tables for any active stock row
|
||||
* that references the given SKU.
|
||||
*
|
||||
* Used as a pre-delete guard on products: a product cannot be removed
|
||||
@@ -274,7 +274,7 @@ class ProductManager {
|
||||
* Return all products with their current aggregate warehouse balance.
|
||||
*
|
||||
* The balance is the sum of (total_in - total_out) across all warehouses
|
||||
* from the warehouse_balance table. Products with no balance rows show 0.
|
||||
* from the etl_stock_summary table. Products with no balance rows show 0.
|
||||
*
|
||||
* Used to populate the product listing page.
|
||||
*
|
||||
@@ -285,7 +285,7 @@ class ProductManager {
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT a.*, IFNULL(SUM(b.total_in - b.total_out), 0) AS product_balance
|
||||
FROM md_product a
|
||||
LEFT JOIN warehouse_balance b
|
||||
LEFT JOIN etl_stock_summary b
|
||||
ON a.company_id = b.company_id
|
||||
AND a.sku = b.product_sku
|
||||
WHERE a.company_id = :company_id
|
||||
@@ -534,18 +534,18 @@ class ProductManager {
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Return all rack slots across all warehouses with occupancy status,
|
||||
* Return all bin slots across all warehouses with occupancy status,
|
||||
* warehouse name, and product name.
|
||||
*
|
||||
* Used by the rack occupancy dashboard to visualise which racks are
|
||||
* Used by the bin occupancy dashboard to visualise which bins are
|
||||
* empty vs. occupied, and which product is in each slot.
|
||||
* Results are ordered by warehouse → zone → aisle → rack, with
|
||||
* Results are ordered by warehouse → zone → aisle → bin, with
|
||||
* numeric-first sorting via CAST so e.g. "2" sorts before "10".
|
||||
*
|
||||
* Security fix: was previously using $this->companyId (undefined property),
|
||||
* corrected to $this->company_id.
|
||||
*
|
||||
* @return array All md_rack rows joined to warehouse and product, with 'status' field.
|
||||
* @return array All md_bin rows joined to warehouse and product, with 'status' field.
|
||||
*/
|
||||
public function getRackOccupancy(): array
|
||||
{
|
||||
@@ -554,14 +554,14 @@ class ProductManager {
|
||||
r.id,
|
||||
r.zone,
|
||||
r.aisle,
|
||||
r.rack,
|
||||
r.bin,
|
||||
r.product_sku,
|
||||
r.td_stock_id,
|
||||
mw.warehouse_name,
|
||||
mw.id AS warehouse_id,
|
||||
p.product_name,
|
||||
CASE WHEN r.product_sku IS NOT NULL THEN 'occupied' ELSE 'empty' END AS status
|
||||
FROM md_rack r
|
||||
FROM md_bin r
|
||||
INNER JOIN md_warehouse mw
|
||||
ON mw.company_id = r.company_id
|
||||
AND mw.id = r.warehouse
|
||||
@@ -571,7 +571,7 @@ class ProductManager {
|
||||
WHERE r.company_id = :company_id
|
||||
ORDER BY mw.warehouse_name, r.zone,
|
||||
CAST(r.aisle AS UNSIGNED), r.aisle,
|
||||
CAST(r.rack AS UNSIGNED), r.rack"
|
||||
CAST(r.bin AS UNSIGNED), r.bin"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id]);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/DocumentNumberManager.php';
|
||||
require_once __DIR__ . '/WarehouseManager.php';
|
||||
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
|
||||
|
||||
/**
|
||||
* PurchaseOrderManager
|
||||
@@ -23,7 +26,7 @@
|
||||
* Receipt progress (pending/partial/received) is derived via deriveReceiptStatus(),
|
||||
* same pattern as fulfillment_status on SO. Never stored in td_purchase_order.
|
||||
* - cancelPo() is blocked if any linked stock-in rows have been approved (status=1),
|
||||
* because those have already modified rack and balance.
|
||||
* because those have already modified bin and balance.
|
||||
* - Write methods do NOT manage their own DB transactions.
|
||||
* Callers must wrap multi-step operations inside dbTransaction().
|
||||
*
|
||||
@@ -58,26 +61,10 @@ class PurchaseOrderManager {
|
||||
/**
|
||||
* Generate next sequential PO number in PO-YYYYMMDD-XXXX format.
|
||||
*/
|
||||
private function generatePoNumber(): string
|
||||
private function generatePoNumber(array $data = []): string
|
||||
{
|
||||
$prefix = 'PO-' . date('Ymd') . '-';
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT po_number FROM td_purchase_order
|
||||
WHERE company_id = :company_id
|
||||
AND po_number LIKE :prefix
|
||||
ORDER BY po_number DESC
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':prefix' => $prefix . '%',
|
||||
]);
|
||||
|
||||
$last = $sth->fetchColumn();
|
||||
$seq = $last ? ((int)substr($last, -4) + 1) : 1;
|
||||
|
||||
return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT);
|
||||
return (new DocumentNumberManager($this->pdo, $this->company_id))
|
||||
->resolveNumber($data, 'purchase_order', 'td_purchase_order', 'po_number');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,13 +98,7 @@ class PurchaseOrderManager {
|
||||
}
|
||||
if (!$has_any_stock_in) return 0;
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT table_name FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name LIKE 'td_stock_%'"
|
||||
);
|
||||
$sth->execute();
|
||||
$tables = $sth->fetchAll(PDO::FETCH_COLUMN);
|
||||
$tables = (new WarehouseManager($this->pdo, $this->company_id))->getStockTables();
|
||||
|
||||
$total = 0;
|
||||
$pending = 0;
|
||||
@@ -157,6 +138,15 @@ class PurchaseOrderManager {
|
||||
throw new Exception("Invalid warehouse id.");
|
||||
}
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id FROM md_warehouse
|
||||
WHERE company_id = :company_id AND id = :id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $warehouse_id]);
|
||||
if (!$sth->fetchColumn()) {
|
||||
throw new Exception("Warehouse ID {$warehouse_id} not found.");
|
||||
}
|
||||
|
||||
return 'td_stock_' . $warehouse_id;
|
||||
}
|
||||
|
||||
@@ -288,7 +278,7 @@ class PurchaseOrderManager {
|
||||
'warehouse_id' => (int)($item['receive_wh'] ?? 0),
|
||||
'zone' => $item['receive_zone'] ?? '',
|
||||
'aisle' => $item['receive_aisle'] ?? '',
|
||||
'rack' => $item['receive_rack'] ?? '',
|
||||
'bin' => $item['receive_bin'] ?? '',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -432,7 +422,7 @@ class PurchaseOrderManager {
|
||||
':uuid' => bin2hex(random_bytes(16)),
|
||||
':source_id' => $source_id,
|
||||
':source' => $source,
|
||||
':po_number' => $this->generatePoNumber(),
|
||||
':po_number' => $this->generatePoNumber($data),
|
||||
':contact_id' => (int)($data['contact_id'] ?? 0),
|
||||
':department_id' => (int)($data['department_id'] ?? 0),
|
||||
':po_date' => $data['po_date'] ?? date('Y-m-d'),
|
||||
@@ -506,7 +496,7 @@ class PurchaseOrderManager {
|
||||
*
|
||||
* @param int $po_id td_purchase_order.id
|
||||
* @param array $receive_items Each item: { item_id, product_sku, warehouse_id,
|
||||
* quantity, zone, aisle, rack, lot_number,
|
||||
* quantity, zone, aisle, bin, lot_number,
|
||||
* expiry_date, serial_number, contact_id }
|
||||
* @param string $uuid UUID prefix for stock-in rows.
|
||||
* @param array $logging Audit entry.
|
||||
@@ -560,6 +550,22 @@ class PurchaseOrderManager {
|
||||
$stock = new StockManager($this->pdo, $this->company_id);
|
||||
$whMgmt = new WarehouseManager($this->pdo, $this->company_id);
|
||||
|
||||
// Validate all warehouse IDs in one query before entering the item loop.
|
||||
$recvWarehouseIds = array_values(array_unique(array_filter(
|
||||
array_map(fn($recv) => (int)($recv['warehouse_id'] ?? $po['warehouse_id']), $receive_items),
|
||||
fn($id) => $id > 0
|
||||
)));
|
||||
if (!empty($recvWarehouseIds)) {
|
||||
$placeholders = implode(',', array_fill(0, count($recvWarehouseIds), '?'));
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id FROM md_warehouse WHERE company_id = ? AND id IN ($placeholders)"
|
||||
);
|
||||
$sth->execute([$this->company_id, ...$recvWarehouseIds]);
|
||||
$validWarehouseIds = array_flip($sth->fetchAll(PDO::FETCH_COLUMN));
|
||||
} else {
|
||||
$validWarehouseIds = [];
|
||||
}
|
||||
|
||||
foreach ($receive_items as $j => $recv) {
|
||||
$item_id = (int)($recv['item_id'] ?? -1);
|
||||
$product_sku = $recv['product_sku'] ?? '';
|
||||
@@ -587,16 +593,16 @@ class PurchaseOrderManager {
|
||||
$item_uuid = $uuid . '_' . $j;
|
||||
$item_log = array_merge($logging, ['action' => 'receive_item']);
|
||||
|
||||
$rack = $recv['rack'] ?? '';
|
||||
$bin = $recv['bin'] ?? '';
|
||||
$zone = $recv['zone'] ?? '';
|
||||
$aisle = $recv['aisle'] ?? '';
|
||||
|
||||
// Simple location mode: zone and aisle must mirror the rack value
|
||||
// Simple location mode: zone and aisle must mirror the bin value
|
||||
// (same convention as manage_stock_in.php).
|
||||
// occupyRack() looks up md_rack WHERE zone=:zone AND aisle=:aisle AND rack=:rack,
|
||||
// so all three must match — a blank zone/aisle produces "Rack --b does not exist".
|
||||
if ($zone === '' && $rack !== '') $zone = $rack;
|
||||
if ($aisle === '' && $rack !== '') $aisle = $rack;
|
||||
// occupyBin() looks up md_bin WHERE zone=:zone AND aisle=:aisle AND bin=:bin,
|
||||
// so all three must match — a blank zone/aisle produces "Bin --b does not exist".
|
||||
if ($zone === '' && $bin !== '') $zone = $bin;
|
||||
if ($aisle === '' && $bin !== '') $aisle = $bin;
|
||||
|
||||
// Resolve unit_price: prefer the receive-time override, fall back to PO item price
|
||||
$po_item_price = 0;
|
||||
@@ -613,7 +619,7 @@ class PurchaseOrderManager {
|
||||
'price' => $unit_price,
|
||||
'zone' => $zone,
|
||||
'aisle' => $aisle,
|
||||
'rack' => $rack,
|
||||
'bin' => $bin,
|
||||
'lot_number' => $recv['lot_number'] ?? '',
|
||||
'expiry_date' => $recv['expiry_date'] ?? '',
|
||||
'serial_number' => $recv['serial_number'] ?? '',
|
||||
@@ -629,7 +635,10 @@ class PurchaseOrderManager {
|
||||
// saveStockIn() does not write source/source_id — stamp them here.
|
||||
// This links the stock-in row back to this PO for cancellation guards
|
||||
// and makes it appear under the "PO" source tab on the Stock In list.
|
||||
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
|
||||
if (!isset($validWarehouseIds[$warehouse_id])) {
|
||||
throw new Exception("Warehouse ID {$warehouse_id} not found.");
|
||||
}
|
||||
$table = 'td_stock_' . $warehouse_id;
|
||||
$this->pdo->prepare(
|
||||
"UPDATE `{$table}` SET source = 'po', source_id = :po_id
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
@@ -640,7 +649,7 @@ class PurchaseOrderManager {
|
||||
]);
|
||||
|
||||
// approveStock() handles balance updates. saveStockIn() has
|
||||
// already reserved the rack so draft receipts cannot be reused.
|
||||
// already reserved the bin so draft receipts cannot be reused.
|
||||
if ($auto_approve) {
|
||||
$stock->approveStock($new_stock_in_id, $warehouse_id, 'in', $whMgmt);
|
||||
}
|
||||
@@ -722,7 +731,7 @@ class PurchaseOrderManager {
|
||||
* Cancel a PO.
|
||||
*
|
||||
* Blocked if any linked stock-in rows have already been approved (status=1)
|
||||
* because those have modified rack occupancy and balance.
|
||||
* because those have modified bin occupancy and balance.
|
||||
*
|
||||
* For draft stock-in rows (status=0), they are soft-deleted (status=-1).
|
||||
*
|
||||
@@ -743,13 +752,8 @@ class PurchaseOrderManager {
|
||||
if ($status === -1) throw new Exception("PO is already cancelled.");
|
||||
|
||||
// Guard: block if any approved stock-in rows exist for this PO
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT table_name FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name LIKE 'td_stock_%'"
|
||||
);
|
||||
$sth->execute();
|
||||
$tables = $sth->fetchAll(PDO::FETCH_COLUMN);
|
||||
$whMgmt = new WarehouseManager($this->pdo, $this->company_id);
|
||||
$tables = $whMgmt->getStockTables();
|
||||
|
||||
foreach ($tables as $table) {
|
||||
$sth = $this->pdo->prepare(
|
||||
@@ -768,9 +772,7 @@ class PurchaseOrderManager {
|
||||
}
|
||||
}
|
||||
|
||||
$whMgmt = new WarehouseManager($this->pdo, $this->company_id);
|
||||
|
||||
// Soft-delete draft stock-in rows and release their rack reservations.
|
||||
// Soft-delete draft stock-in rows and release their bin reservations.
|
||||
foreach ($tables as $table) {
|
||||
if (!preg_match('/^td_stock_(\d+)$/', $table, $m)) {
|
||||
continue;
|
||||
@@ -778,7 +780,7 @@ class PurchaseOrderManager {
|
||||
$warehouse_id = (int)$m[1];
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id, zone, aisle, rack
|
||||
"SELECT id, zone, aisle, bin
|
||||
FROM `{$table}`
|
||||
WHERE company_id = :company_id
|
||||
AND source = 'po'
|
||||
@@ -797,11 +799,11 @@ class PurchaseOrderManager {
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
|
||||
$whMgmt->releaseRack(
|
||||
$whMgmt->releaseBin(
|
||||
$warehouse_id,
|
||||
$row['zone'],
|
||||
$row['aisle'],
|
||||
$row['rack']
|
||||
$row['bin']
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -851,4 +853,118 @@ class PurchaseOrderManager {
|
||||
)->execute([':id' => $source_id, ':cid' => $this->company_id]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete a purchase order by negating company_id on the header, items, and all
|
||||
* linked draft stock-in rows. Blocked if any purchase invoice, supplier return, or
|
||||
* approved (received) stock-in rows exist.
|
||||
*/
|
||||
public function softDelete(int $po_id): void
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM td_purchase_order WHERE company_id = :cid AND id = :id"
|
||||
);
|
||||
$sth->execute([':cid' => $this->company_id, ':id' => $po_id]);
|
||||
$po = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$po) throw new Exception('Purchase order not found.');
|
||||
|
||||
global $pdo1;
|
||||
if (isset($pdo1) && $pdo1 instanceof PDO) {
|
||||
$guard = new PostingWindowGuard($pdo1, $this->company_id);
|
||||
$guard->assertOpenDate($po['po_date'] ?: date('Y-m-d'), 'Purchase order deletion');
|
||||
}
|
||||
|
||||
$sth2 = $this->pdo->prepare(
|
||||
"SELECT COUNT(*) FROM td_invoice
|
||||
WHERE company_id = :cid AND source = 'po' AND source_id = :id AND doc_type = 'purchase_invoice'"
|
||||
);
|
||||
$sth2->execute([':cid' => $this->company_id, ':id' => $po_id]);
|
||||
if ((int)$sth2->fetchColumn() > 0) {
|
||||
throw new Exception('Cannot delete — this PO has linked purchase invoices. Delete the invoices first.');
|
||||
}
|
||||
|
||||
$sth3 = $this->pdo->prepare(
|
||||
"SELECT COUNT(*) FROM td_supplier_return
|
||||
WHERE company_id = :cid AND po_id = :id AND status != -1"
|
||||
);
|
||||
$sth3->execute([':cid' => $this->company_id, ':id' => $po_id]);
|
||||
if ((int)$sth3->fetchColumn() > 0) {
|
||||
throw new Exception('Cannot delete — this PO has linked supplier returns. Delete or cancel the returns first.');
|
||||
}
|
||||
|
||||
$whMgmt = new WarehouseManager($this->pdo, $this->company_id);
|
||||
$tables = $whMgmt->getStockTables();
|
||||
|
||||
foreach ($tables as $table) {
|
||||
$sth5 = $this->pdo->prepare(
|
||||
"SELECT COUNT(*) FROM `{$table}`
|
||||
WHERE company_id = :cid AND source = 'po' AND source_id = :id AND status = 1"
|
||||
);
|
||||
$sth5->execute([':cid' => $this->company_id, ':id' => $po_id]);
|
||||
if ((int)$sth5->fetchColumn() > 0) {
|
||||
throw new Exception('Cannot delete — received stock from this PO must be reversed via ICS first.');
|
||||
}
|
||||
}
|
||||
|
||||
// Release bins from draft stock-in rows and negate them
|
||||
foreach ($tables as $table) {
|
||||
if (!preg_match('/^td_stock_(\d+)$/', (string)$table, $matches)) continue;
|
||||
$warehouse_id = (int)$matches[1];
|
||||
|
||||
$row_sth = $this->pdo->prepare(
|
||||
"SELECT id, zone, aisle, bin FROM `{$table}`
|
||||
WHERE company_id = :cid AND source = 'po' AND source_id = :id AND status = 0"
|
||||
);
|
||||
$row_sth->execute([':cid' => $this->company_id, ':id' => $po_id]);
|
||||
$draft_rows = $row_sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
foreach ($draft_rows as $row) {
|
||||
$whMgmt->releaseBin($warehouse_id, $row['zone'], $row['aisle'], $row['bin']);
|
||||
}
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE `{$table}` SET company_id = company_id * -1
|
||||
WHERE company_id = :cid AND source = 'po' AND source_id = :id"
|
||||
)->execute([':cid' => $this->company_id, ':id' => $po_id]);
|
||||
}
|
||||
|
||||
// Release converted_qty on source purchase request
|
||||
$source = (string)($po['source'] ?? '');
|
||||
$source_id = (int)($po['source_id'] ?? 0);
|
||||
if ($source === 'purchase_request' && $source_id > 0) {
|
||||
$sth6 = $this->pdo->prepare(
|
||||
"SELECT item_id, quantity FROM td_purchase_order_item
|
||||
WHERE order_id = :id AND company_id = :cid ORDER BY item_id"
|
||||
);
|
||||
$sth6->execute([':id' => $po_id, ':cid' => $this->company_id]);
|
||||
$po_items = $sth6->fetchAll(PDO::FETCH_ASSOC);
|
||||
$dec = $this->pdo->prepare(
|
||||
"UPDATE td_purchase_request_item
|
||||
SET converted_qty = GREATEST(0, converted_qty - :qty)
|
||||
WHERE request_id = :rid AND item_id = :item_id AND company_id = :cid"
|
||||
);
|
||||
foreach ($po_items as $item) {
|
||||
$dec->execute([
|
||||
':qty' => (float)($item['quantity'] ?? 0),
|
||||
':rid' => $source_id,
|
||||
':item_id' => (int)($item['item_id'] ?? 0),
|
||||
':cid' => $this->company_id,
|
||||
]);
|
||||
}
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_purchase_request SET status = 2
|
||||
WHERE id = :id AND company_id = :cid AND status = 5"
|
||||
)->execute([':id' => $source_id, ':cid' => $this->company_id]);
|
||||
}
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_purchase_order_item SET company_id = company_id * -1
|
||||
WHERE order_id = :id AND company_id = :cid"
|
||||
)->execute([':id' => $po_id, ':cid' => $this->company_id]);
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_purchase_order SET company_id = company_id * -1
|
||||
WHERE id = :id AND company_id = :cid"
|
||||
)->execute([':id' => $po_id, ':cid' => $this->company_id]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/DocumentNumberManager.php';
|
||||
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
|
||||
|
||||
/**
|
||||
* PurchaseRequestManager
|
||||
@@ -30,18 +32,10 @@ class PurchaseRequestManager
|
||||
// Private helpers
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
private function generateNumber(): string
|
||||
private function generateNumber(array $data = []): string
|
||||
{
|
||||
$prefix = 'PR-' . date('Ym') . '-';
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT request_number FROM td_purchase_request
|
||||
WHERE company_id = :cid AND request_number LIKE :prefix
|
||||
ORDER BY request_number DESC LIMIT 1"
|
||||
);
|
||||
$sth->execute([':cid' => $this->company_id, ':prefix' => $prefix . '%']);
|
||||
$last = $sth->fetchColumn();
|
||||
$seq = $last ? ((int)substr($last, -4) + 1) : 1;
|
||||
return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT);
|
||||
return (new DocumentNumberManager($this->pdo, $this->company_id))
|
||||
->resolveNumber($data, 'purchase_request', 'td_purchase_request', 'request_number');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -175,7 +169,7 @@ class PurchaseRequestManager
|
||||
[$subtotal, $tax, $grand] = $this->computeTotals($items, $discount, $shipping_fee);
|
||||
|
||||
if ($id === 0) {
|
||||
$number = $this->generateNumber();
|
||||
$number = $this->generateNumber($data);
|
||||
$log = [array_merge($logging, ['action' => 'create_purchase_request'])];
|
||||
|
||||
$this->pdo->prepare(
|
||||
@@ -347,4 +341,43 @@ class PurchaseRequestManager
|
||||
)->execute([':id' => $request_id, ':cid' => $this->company_id]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete a purchase request by negating company_id on the header and all items.
|
||||
* Blocked if any purchase order (not yet soft-deleted) was converted from this request.
|
||||
*/
|
||||
public function softDelete(int $id): void
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id, request_date FROM td_purchase_request WHERE id = :id AND company_id = :cid LIMIT 1"
|
||||
);
|
||||
$sth->execute([':id' => $id, ':cid' => $this->company_id]);
|
||||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row) throw new Exception('Purchase request not found.');
|
||||
|
||||
global $pdo1;
|
||||
if (isset($pdo1) && $pdo1 instanceof PDO) {
|
||||
$guard = new PostingWindowGuard($pdo1, $this->company_id);
|
||||
$guard->assertOpenDate($row['request_date'] ?: date('Y-m-d'), 'Purchase request deletion');
|
||||
}
|
||||
|
||||
$sth2 = $this->pdo->prepare(
|
||||
"SELECT COUNT(*) FROM td_purchase_order
|
||||
WHERE company_id = :cid AND source = 'purchase_request' AND source_id = :id"
|
||||
);
|
||||
$sth2->execute([':cid' => $this->company_id, ':id' => $id]);
|
||||
if ((int)$sth2->fetchColumn() > 0) {
|
||||
throw new Exception('Cannot delete — this request has linked purchase orders. Delete the POs first.');
|
||||
}
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_purchase_request_item SET company_id = company_id * -1
|
||||
WHERE request_id = :id AND company_id = :cid"
|
||||
)->execute([':id' => $id, ':cid' => $this->company_id]);
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_purchase_request SET company_id = company_id * -1
|
||||
WHERE id = :id AND company_id = :cid"
|
||||
)->execute([':id' => $id, ':cid' => $this->company_id]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/DocumentNumberManager.php';
|
||||
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
|
||||
|
||||
/**
|
||||
* QuotationManager
|
||||
@@ -30,18 +32,10 @@ class QuotationManager
|
||||
// Private helpers
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
private function generateNumber(): string
|
||||
private function generateNumber(array $data = []): string
|
||||
{
|
||||
$prefix = 'QT-' . date('Ym') . '-';
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT quotation_number FROM td_quotation
|
||||
WHERE company_id = :cid AND quotation_number LIKE :prefix
|
||||
ORDER BY id DESC LIMIT 1"
|
||||
);
|
||||
$sth->execute([':cid' => $this->company_id, ':prefix' => $prefix . '%']);
|
||||
$last = $sth->fetchColumn();
|
||||
$seq = $last ? ((int)substr($last, -4) + 1) : 1;
|
||||
return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT);
|
||||
return (new DocumentNumberManager($this->pdo, $this->company_id))
|
||||
->resolveNumber($data, 'quotation', 'td_quotation', 'quotation_number');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -182,7 +176,7 @@ class QuotationManager
|
||||
[$subtotal, $tax, $grand] = $this->computeTotals($items, $discount);
|
||||
|
||||
if ($id === 0) {
|
||||
$number = $this->generateNumber();
|
||||
$number = $this->generateNumber($data);
|
||||
$log = [array_merge($logging, ['action' => 'create_quotation'])];
|
||||
|
||||
$this->pdo->prepare(
|
||||
@@ -304,6 +298,45 @@ class QuotationManager
|
||||
)->execute([':status' => $t['to'], ':id' => $id, ':cid' => $this->company_id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete a quotation by negating company_id on the header and all items.
|
||||
* Blocked if any sales order (not yet soft-deleted) was converted from this quotation.
|
||||
*/
|
||||
public function softDelete(int $id): void
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id, quotation_date FROM td_quotation WHERE id = :id AND company_id = :cid LIMIT 1"
|
||||
);
|
||||
$sth->execute([':id' => $id, ':cid' => $this->company_id]);
|
||||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row) throw new Exception('Quotation not found.');
|
||||
|
||||
global $pdo1;
|
||||
if (isset($pdo1) && $pdo1 instanceof PDO) {
|
||||
$guard = new PostingWindowGuard($pdo1, $this->company_id);
|
||||
$guard->assertOpenDate($row['quotation_date'] ?: date('Y-m-d'), 'Quotation deletion');
|
||||
}
|
||||
|
||||
$sth2 = $this->pdo->prepare(
|
||||
"SELECT COUNT(*) FROM td_order
|
||||
WHERE company_id = :cid AND source = 'quotation' AND source_id = :id"
|
||||
);
|
||||
$sth2->execute([':cid' => $this->company_id, ':id' => $id]);
|
||||
if ((int)$sth2->fetchColumn() > 0) {
|
||||
throw new Exception('Cannot delete — this quotation has linked sales orders. Delete the orders first.');
|
||||
}
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_quotation_item SET company_id = company_id * -1
|
||||
WHERE quotation_id = :id AND company_id = :cid"
|
||||
)->execute([':id' => $id, ':cid' => $this->company_id]);
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_quotation SET company_id = company_id * -1
|
||||
WHERE id = :id AND company_id = :cid"
|
||||
)->execute([':id' => $id, ':cid' => $this->company_id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment converted_qty per item after a successful order conversion.
|
||||
* Automatically sets quotation status = 5 when all items are fully converted.
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/DocumentNumberManager.php';
|
||||
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
|
||||
|
||||
class ReceiptBillingManager {
|
||||
|
||||
@@ -10,23 +12,10 @@ class ReceiptBillingManager {
|
||||
$this->company_id = $company_id;
|
||||
}
|
||||
|
||||
private function generateBillingNumber(): string
|
||||
private function generateBillingNumber(array $data = []): string
|
||||
{
|
||||
$prefix = 'RB-' . date('Ymd') . '-';
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT billing_number FROM td_receipt_billing
|
||||
WHERE company_id = :company_id
|
||||
AND billing_number LIKE :prefix
|
||||
ORDER BY billing_number DESC
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':prefix' => $prefix . '%',
|
||||
]);
|
||||
$last = $sth->fetchColumn();
|
||||
$seq = $last ? ((int)substr($last, -4) + 1) : 1;
|
||||
return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT);
|
||||
return (new DocumentNumberManager($this->pdo, $this->company_id))
|
||||
->resolveNumber($data, 'receipt_billing', 'td_receipt_billing', 'billing_number');
|
||||
}
|
||||
|
||||
private function unavailableDocumentAmount(int $invoice_id): float
|
||||
@@ -337,7 +326,7 @@ class ReceiptBillingManager {
|
||||
)->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':uuid' => bin2hex(random_bytes(16)),
|
||||
':billing_number' => $this->generateBillingNumber(),
|
||||
':billing_number' => $this->generateBillingNumber($data),
|
||||
':contact_id' => $contact_id,
|
||||
':billing_date' => $billing_date,
|
||||
':amount' => $net_amount,
|
||||
@@ -389,5 +378,44 @@ class ReceiptBillingManager {
|
||||
':id' => $billing_id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete a receipt billing by negating company_id on the header and all items.
|
||||
* Blocked if any receipt (not yet soft-deleted) is linked to this billing.
|
||||
*/
|
||||
public function softDelete(int $billing_id): void
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id, billing_date FROM td_receipt_billing WHERE id = :id AND company_id = :cid LIMIT 1"
|
||||
);
|
||||
$sth->execute([':id' => $billing_id, ':cid' => $this->company_id]);
|
||||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row) throw new Exception('Receipt billing not found.');
|
||||
|
||||
global $pdo1;
|
||||
if (isset($pdo1) && $pdo1 instanceof PDO) {
|
||||
$guard = new PostingWindowGuard($pdo1, $this->company_id);
|
||||
$guard->assertOpenDate($row['billing_date'] ?: date('Y-m-d'), 'Receipt billing deletion');
|
||||
}
|
||||
|
||||
$sth2 = $this->pdo->prepare(
|
||||
"SELECT COUNT(*) FROM td_receipt
|
||||
WHERE company_id = :cid AND receipt_billing_id = :id"
|
||||
);
|
||||
$sth2->execute([':cid' => $this->company_id, ':id' => $billing_id]);
|
||||
if ((int)$sth2->fetchColumn() > 0) {
|
||||
throw new Exception('Cannot delete — receipts are linked to this billing. Delete the receipts first.');
|
||||
}
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_receipt_billing_item SET company_id = company_id * -1
|
||||
WHERE billing_id = :id AND company_id = :cid"
|
||||
)->execute([':id' => $billing_id, ':cid' => $this->company_id]);
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_receipt_billing SET company_id = company_id * -1
|
||||
WHERE id = :id AND company_id = :cid"
|
||||
)->execute([':id' => $billing_id, ':cid' => $this->company_id]);
|
||||
}
|
||||
}
|
||||
?>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/DocumentNumberManager.php';
|
||||
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
|
||||
require_once __DIR__ . '/../classes_ac/GlManager.php';
|
||||
|
||||
@@ -12,23 +13,10 @@ class ReceiptManager {
|
||||
$this->company_id = $company_id;
|
||||
}
|
||||
|
||||
private function generateReceiptNumber(): string
|
||||
private function generateReceiptNumber(array $data = []): string
|
||||
{
|
||||
$prefix = 'RC-' . date('Ymd') . '-';
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT receipt_number FROM td_receipt
|
||||
WHERE company_id = :company_id
|
||||
AND receipt_number LIKE :prefix
|
||||
ORDER BY receipt_number DESC
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':prefix' => $prefix . '%',
|
||||
]);
|
||||
$last = $sth->fetchColumn();
|
||||
$seq = $last ? ((int)substr($last, -4) + 1) : 1;
|
||||
return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT);
|
||||
return (new DocumentNumberManager($this->pdo, $this->company_id))
|
||||
->resolveNumber($data, 'receipt', 'td_receipt', 'receipt_number');
|
||||
}
|
||||
|
||||
private function assertPostingWindow(?string $date, string $context): void
|
||||
@@ -448,7 +436,7 @@ class ReceiptManager {
|
||||
)->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':uuid' => bin2hex(random_bytes(16)),
|
||||
':receipt_number' => $this->generateReceiptNumber(),
|
||||
':receipt_number' => $this->generateReceiptNumber($data),
|
||||
':contact_id' => $contact_id,
|
||||
':department_id' => (int)($data['department_id'] ?? 0),
|
||||
':receipt_billing_id' => $billing_id,
|
||||
@@ -511,5 +499,53 @@ class ReceiptManager {
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete a receipt by negating company_id on the header and all items.
|
||||
* Deletes the GL entry if posted. Refreshes settlement and billing status.
|
||||
*/
|
||||
public function softDelete(int $receipt_id): void
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id, receipt_billing_id, receipt_date FROM td_receipt
|
||||
WHERE company_id = :cid AND id = :id LIMIT 1"
|
||||
);
|
||||
$sth->execute([':cid' => $this->company_id, ':id' => $receipt_id]);
|
||||
$receipt = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$receipt) throw new Exception('Receipt not found.');
|
||||
|
||||
$this->assertPostingWindow($receipt['receipt_date'] ?: date('Y-m-d'), 'Receipt deletion');
|
||||
|
||||
// Collect linked invoice ids before negating
|
||||
$sth2 = $this->pdo->prepare(
|
||||
"SELECT invoice_id FROM td_receipt_item
|
||||
WHERE company_id = :cid AND receipt_id = :id"
|
||||
);
|
||||
$sth2->execute([':cid' => $this->company_id, ':id' => $receipt_id]);
|
||||
$invoice_ids = $sth2->fetchAll(PDO::FETCH_COLUMN);
|
||||
|
||||
// Delete GL entry if posted (no-op if none; throws if period closed)
|
||||
global $pdo1;
|
||||
if (isset($pdo1) && $pdo1 instanceof PDO) {
|
||||
$guard = new PostingWindowGuard($pdo1, $this->company_id);
|
||||
$gl = new GlManager($this->pdo, $this->company_id, $guard);
|
||||
$gl->delete('receipt', $receipt_id);
|
||||
}
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_receipt_item SET company_id = company_id * -1
|
||||
WHERE receipt_id = :id AND company_id = :cid"
|
||||
)->execute([':id' => $receipt_id, ':cid' => $this->company_id]);
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_receipt SET company_id = company_id * -1
|
||||
WHERE id = :id AND company_id = :cid"
|
||||
)->execute([':id' => $receipt_id, ':cid' => $this->company_id]);
|
||||
|
||||
foreach ($invoice_ids as $invoice_id) {
|
||||
$this->refreshInvoiceSettlementStatus((int)$invoice_id);
|
||||
}
|
||||
$this->refreshReceiptBillingStatus((int)$receipt['receipt_billing_id']);
|
||||
}
|
||||
}
|
||||
?>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* Dashboard reports → stats, movement charts, most moved, recent activity
|
||||
* Warehouse detail → capacity, space used, balance, movement, trend, activity
|
||||
* Expiry reports → expired / near-expiry stock
|
||||
* Lot / Rack reports → lot stock log, product lots, rack log, rack occupancy
|
||||
* Lot / Bin reports → lot stock log, product lots, bin log, bin occupancy
|
||||
* Balance summary → getWarehouseBalanceSummary
|
||||
*
|
||||
* Security: All SQL uses PDO prepared statements with bound parameters.
|
||||
@@ -27,7 +27,7 @@ class ReportManager
|
||||
public function __construct(PDO $pdo, int $company_id, string $mainDb = '')
|
||||
{
|
||||
$this->pdo = $pdo;
|
||||
$this->companyId = $company_id;
|
||||
$this->company_id = $company_id;
|
||||
$this->mainDb = $mainDb;
|
||||
}
|
||||
|
||||
@@ -44,13 +44,14 @@ class ReportManager
|
||||
return 'td_stock_' . $warehouse_id;
|
||||
}
|
||||
|
||||
|
||||
private function resolveWarehouseTable(int $warehouse_id): ?string
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id FROM md_warehouse
|
||||
WHERE company_id = :company_id AND id = :id AND status = 1"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->companyId, ':id' => $warehouse_id]);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $warehouse_id]);
|
||||
$id = $sth->fetchColumn();
|
||||
if (!$id) return null;
|
||||
|
||||
@@ -69,7 +70,7 @@ class ReportManager
|
||||
private function fetchScalar(string $sql)
|
||||
{
|
||||
$sth = $this->pdo->prepare($sql);
|
||||
$sth->execute([":company_id" => $this->companyId]);
|
||||
$sth->execute([":company_id" => $this->company_id]);
|
||||
return $sth->fetchColumn();
|
||||
}
|
||||
|
||||
@@ -85,7 +86,7 @@ class ReportManager
|
||||
private function fetchAll(string $sql): array
|
||||
{
|
||||
$sth = $this->pdo->prepare($sql);
|
||||
$sth->execute([":company_id" => $this->companyId]);
|
||||
$sth->execute([":company_id" => $this->company_id]);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
@@ -158,25 +159,25 @@ class ReportManager
|
||||
}
|
||||
|
||||
/**
|
||||
* Total rack capacity (all racks across all warehouses) for this company.
|
||||
* Total bin capacity (all bins across all warehouses) for this company.
|
||||
* Used by the reports_stats dashboard tile.
|
||||
*/
|
||||
public function getTotalCapacity(): int
|
||||
{
|
||||
$sql = "SELECT COUNT(*)
|
||||
FROM md_rack
|
||||
FROM md_bin
|
||||
WHERE company_id = :company_id";
|
||||
return (int) $this->fetchScalar($sql);
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of occupied racks (product_sku IS NOT NULL) for this company.
|
||||
* Number of occupied bins (product_sku IS NOT NULL) for this company.
|
||||
* Used by the reports_stats dashboard tile.
|
||||
*/
|
||||
public function getSpaceUsed(): int
|
||||
{
|
||||
$sql = "SELECT COUNT(*)
|
||||
FROM md_rack
|
||||
FROM md_bin
|
||||
WHERE company_id = :company_id
|
||||
AND product_sku IS NOT NULL";
|
||||
return (int) $this->fetchScalar($sql);
|
||||
@@ -196,13 +197,13 @@ class ReportManager
|
||||
|
||||
/**
|
||||
* Number of distinct SKUs with a positive running balance across all warehouses.
|
||||
* "In stock" means (total_in - total_out) > 0 in warehouse_balance.
|
||||
* "In stock" means (total_in - total_out) > 0 in etl_stock_summary.
|
||||
* Used by the reports_stats dashboard tile.
|
||||
*/
|
||||
public function getTotalProductInStock(): int
|
||||
{
|
||||
$sql = "SELECT COUNT(DISTINCT product_sku)
|
||||
FROM warehouse_balance
|
||||
FROM etl_stock_summary
|
||||
WHERE company_id = :company_id
|
||||
AND (total_in - total_out) > 0";
|
||||
return (int) $this->fetchScalar($sql);
|
||||
@@ -264,7 +265,7 @@ class ReportManager
|
||||
|
||||
/**
|
||||
* Return aggregated total_in, total_out, and min_stock for every SKU
|
||||
* across all warehouses from warehouse_balance.
|
||||
* across all warehouses from etl_stock_summary.
|
||||
*
|
||||
* Used internally by getLowStockCount and as a general balance query.
|
||||
*
|
||||
@@ -278,7 +279,7 @@ class ReportManager
|
||||
SUM(a.total_out) AS total_out,
|
||||
b.min_stock,
|
||||
b.cost_price
|
||||
FROM warehouse_balance a
|
||||
FROM etl_stock_summary a
|
||||
LEFT JOIN md_product b
|
||||
ON a.company_id = b.company_id
|
||||
AND a.product_sku = b.sku
|
||||
@@ -313,10 +314,10 @@ class ReportManager
|
||||
"SELECT
|
||||
ROUND(COALESCE(SUM(total_in), 0), 2) AS total_in,
|
||||
ROUND(COALESCE(SUM(total_out), 0), 2) AS total_out
|
||||
FROM warehouse_balance
|
||||
FROM etl_stock_summary
|
||||
WHERE company_id = :company_id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->companyId]);
|
||||
$sth->execute([':company_id' => $this->company_id]);
|
||||
return $sth->fetch(PDO::FETCH_ASSOC) ?: ['total_in' => 0, 'total_out' => 0];
|
||||
}
|
||||
|
||||
@@ -328,7 +329,7 @@ class ReportManager
|
||||
WHERE company_id = :company_id
|
||||
AND status != -1"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->companyId]);
|
||||
$sth->execute([':company_id' => $this->company_id]);
|
||||
[$total_orders, $total_revenue] = $sth->fetch(PDO::FETCH_NUM) ?: [0, 0];
|
||||
|
||||
return [
|
||||
@@ -345,7 +346,7 @@ class ReportManager
|
||||
AND doc_type = 'invoice'
|
||||
AND status IN (0, 1, 3)"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->companyId]);
|
||||
$sth->execute([':company_id' => $this->company_id]);
|
||||
$unpaid_invoices = (int)$sth->fetchColumn();
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
@@ -353,7 +354,7 @@ class ReportManager
|
||||
WHERE company_id = :company_id
|
||||
AND status = 0"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->companyId]);
|
||||
$sth->execute([':company_id' => $this->company_id]);
|
||||
$pending_returns = (int)$sth->fetchColumn();
|
||||
|
||||
return [
|
||||
@@ -411,7 +412,7 @@ class ReportManager
|
||||
mp.product_image,
|
||||
mp.cost_price,
|
||||
ROUND(SUM(wb.total_in) - SUM(wb.total_out), 2) AS balance
|
||||
FROM warehouse_balance wb
|
||||
FROM etl_stock_summary wb
|
||||
INNER JOIN md_product mp
|
||||
ON wb.company_id = mp.company_id
|
||||
AND wb.product_sku = mp.sku
|
||||
@@ -492,11 +493,11 @@ class ReportManager
|
||||
ROUND(COALESCE(SUM(total_in), 0), 2) AS total_in,
|
||||
ROUND(COALESCE(SUM(total_out), 0), 2) AS total_out,
|
||||
COUNT(DISTINCT product_sku) AS active_products
|
||||
FROM warehouse_balance
|
||||
FROM etl_stock_summary
|
||||
WHERE company_id = :company_id
|
||||
AND month = :month"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->companyId, ':month' => $month]);
|
||||
$sth->execute([':company_id' => $this->company_id, ':month' => $month]);
|
||||
return $sth->fetch(PDO::FETCH_ASSOC) ?: [
|
||||
'total_in' => 0, 'total_out' => 0, 'active_products' => 0
|
||||
];
|
||||
@@ -516,14 +517,14 @@ class ReportManager
|
||||
"SELECT wb.product_sku,
|
||||
ROUND(SUM(wb.total_in) - SUM(wb.total_out), 2) AS balance,
|
||||
mp.min_stock
|
||||
FROM warehouse_balance wb
|
||||
FROM etl_stock_summary wb
|
||||
INNER JOIN md_product mp
|
||||
ON mp.company_id = wb.company_id
|
||||
AND mp.sku = wb.product_sku
|
||||
WHERE wb.company_id = :company_id
|
||||
GROUP BY wb.product_sku, mp.min_stock"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->companyId]);
|
||||
$sth->execute([':company_id' => $this->company_id]);
|
||||
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
$count = 0;
|
||||
foreach ($rows as $row) {
|
||||
@@ -536,7 +537,7 @@ class ReportManager
|
||||
* Return monthly stock_in and stock_out totals for a rolling N-month window.
|
||||
*
|
||||
* Produces chart-ready arrays with labels (e.g. "Apr 2025"), stock_in, and stock_out.
|
||||
* Months with no data return 0. Data is sourced from warehouse_balance (all warehouses).
|
||||
* Months with no data return 0. Data is sourced from etl_stock_summary (all warehouses).
|
||||
*
|
||||
* @param int $months Number of months to include (default 12).
|
||||
* @return array Keys: labels (array), stock_in (array), stock_out (array).
|
||||
@@ -552,14 +553,14 @@ class ReportManager
|
||||
month,
|
||||
ROUND(SUM(total_in), 2) AS stock_in,
|
||||
ROUND(SUM(total_out), 2) AS stock_out
|
||||
FROM warehouse_balance
|
||||
FROM etl_stock_summary
|
||||
WHERE company_id = :company_id
|
||||
AND month BETWEEN :start AND :end
|
||||
GROUP BY month
|
||||
ORDER BY month ASC"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->companyId,
|
||||
':company_id' => $this->company_id,
|
||||
':start' => $start,
|
||||
':end' => $end,
|
||||
]);
|
||||
@@ -601,7 +602,7 @@ class ReportManager
|
||||
ROUND(SUM(wb.total_out), 2) AS total_out,
|
||||
p.product_name,
|
||||
pc.category AS category_name
|
||||
FROM warehouse_balance wb
|
||||
FROM etl_stock_summary wb
|
||||
INNER JOIN md_product p
|
||||
ON p.company_id = wb.company_id
|
||||
AND p.sku = wb.product_sku
|
||||
@@ -615,14 +616,14 @@ class ReportManager
|
||||
LIMIT {$limit}"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->companyId,
|
||||
':company_id' => $this->company_id,
|
||||
':month' => $month,
|
||||
]);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all months for which warehouse_balance data exists, newest first.
|
||||
* Return all months for which etl_stock_summary data exists, newest first.
|
||||
*
|
||||
* Used to populate the month selector on the dashboard and reports pages.
|
||||
*
|
||||
@@ -632,11 +633,11 @@ class ReportManager
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT DISTINCT month
|
||||
FROM warehouse_balance
|
||||
FROM etl_stock_summary
|
||||
WHERE company_id = :company_id
|
||||
ORDER BY month DESC"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->companyId]);
|
||||
$sth->execute([':company_id' => $this->company_id]);
|
||||
return $sth->fetchAll(PDO::FETCH_COLUMN);
|
||||
}
|
||||
|
||||
@@ -656,7 +657,7 @@ class ReportManager
|
||||
"SELECT id, warehouse_name FROM md_warehouse
|
||||
WHERE company_id = :company_id AND status = 1"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->companyId]);
|
||||
$sth->execute([':company_id' => $this->company_id]);
|
||||
$warehouses = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if (empty($warehouses)) return [];
|
||||
@@ -664,7 +665,7 @@ class ReportManager
|
||||
$unions = implode("\nUNION ALL\n", array_map(
|
||||
function ($wh) {
|
||||
$table = $this->stockTableNameFromWarehouseId((int)$wh['id']);
|
||||
$cid = (int) $this->companyId;
|
||||
$cid = (int) $this->company_id;
|
||||
$warehouse_name = $this->pdo->quote($wh['warehouse_name']);
|
||||
return "SELECT s.date, s.product_sku, s.type,
|
||||
ROUND(COALESCE(s.`in`, 0), 2) AS stock_in,
|
||||
@@ -709,40 +710,40 @@ class ReportManager
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Total rack capacity for a specific warehouse (all racks regardless of occupancy).
|
||||
* Total bin capacity for a specific warehouse (all bins regardless of occupancy).
|
||||
*
|
||||
* @param int $warehouse_id The warehouse to query.
|
||||
* @return int Total rack count.
|
||||
* @return int Total bin count.
|
||||
*/
|
||||
public function getWarehouseCapacity(int $warehouse_id): int
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT COUNT(*) FROM md_rack
|
||||
"SELECT COUNT(*) FROM md_bin
|
||||
WHERE company_id = :company_id AND warehouse = :warehouse_id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->companyId, ':warehouse_id' => $warehouse_id]);
|
||||
$sth->execute([':company_id' => $this->company_id, ':warehouse_id' => $warehouse_id]);
|
||||
return (int) $sth->fetchColumn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of occupied racks (product_sku IS NOT NULL) for a specific warehouse.
|
||||
* Number of occupied bins (product_sku IS NOT NULL) for a specific warehouse.
|
||||
*
|
||||
* @param int $warehouse_id The warehouse to query.
|
||||
* @return int Occupied rack count.
|
||||
* @return int Occupied bin count.
|
||||
*/
|
||||
public function getWarehouseSpaceUsed(int $warehouse_id): int
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT COUNT(*) FROM md_rack
|
||||
"SELECT COUNT(*) FROM md_bin
|
||||
WHERE company_id = :company_id AND warehouse = :warehouse_id
|
||||
AND product_sku IS NOT NULL"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->companyId, ':warehouse_id' => $warehouse_id]);
|
||||
$sth->execute([':company_id' => $this->company_id, ':warehouse_id' => $warehouse_id]);
|
||||
return (int) $sth->fetchColumn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Total in and out balance for a specific warehouse from warehouse_balance.
|
||||
* Total in and out balance for a specific warehouse from etl_stock_summary.
|
||||
*
|
||||
* @param int $warehouse_id The warehouse to query.
|
||||
* @return array Keys: total_in, total_out.
|
||||
@@ -753,17 +754,17 @@ class ReportManager
|
||||
"SELECT
|
||||
ROUND(COALESCE(SUM(total_in), 0), 2) AS total_in,
|
||||
ROUND(COALESCE(SUM(total_out), 0), 2) AS total_out
|
||||
FROM warehouse_balance
|
||||
FROM etl_stock_summary
|
||||
WHERE company_id = :company_id AND warehouse_id = :warehouse_id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->companyId, ':warehouse_id' => $warehouse_id]);
|
||||
$sth->execute([':company_id' => $this->company_id, ':warehouse_id' => $warehouse_id]);
|
||||
return $sth->fetch(PDO::FETCH_ASSOC) ?: ['total_in' => 0, 'total_out' => 0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Count SKUs below min_stock threshold for a specific warehouse.
|
||||
*
|
||||
* Scoped to warehouse_balance rows for this warehouse only.
|
||||
* Scoped to etl_stock_summary rows for this warehouse only.
|
||||
*
|
||||
* @param int $warehouse_id The warehouse to query.
|
||||
* @return int Number of SKUs below min_stock.
|
||||
@@ -774,7 +775,7 @@ class ReportManager
|
||||
"SELECT wb.product_sku,
|
||||
ROUND(SUM(wb.total_in) - SUM(wb.total_out), 2) AS balance,
|
||||
mp.min_stock
|
||||
FROM warehouse_balance wb
|
||||
FROM etl_stock_summary wb
|
||||
INNER JOIN md_product mp
|
||||
ON mp.company_id = wb.company_id
|
||||
AND mp.sku = wb.product_sku
|
||||
@@ -782,7 +783,7 @@ class ReportManager
|
||||
AND wb.warehouse_id = :warehouse_id
|
||||
GROUP BY wb.product_sku, mp.min_stock"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->companyId, ':warehouse_id' => $warehouse_id]);
|
||||
$sth->execute([':company_id' => $this->company_id, ':warehouse_id' => $warehouse_id]);
|
||||
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
$count = 0;
|
||||
foreach ($rows as $row) {
|
||||
@@ -794,7 +795,7 @@ class ReportManager
|
||||
/**
|
||||
* Return monthly stock_in and stock_out totals for a warehouse over the last 12 months.
|
||||
*
|
||||
* Queries the per-warehouse td_stock_<warehouse_id> table directly (not warehouse_balance)
|
||||
* Queries the per-warehouse td_stock_<warehouse_id> table directly (not etl_stock_summary)
|
||||
* for per-warehouse granularity. Produces chart-ready arrays with month labels.
|
||||
*
|
||||
* @param int $warehouse_id The warehouse to query.
|
||||
@@ -822,7 +823,7 @@ class ReportManager
|
||||
ORDER BY sort_key ASC"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->companyId,
|
||||
':company_id' => $this->company_id,
|
||||
':start' => $start,
|
||||
':end' => $end,
|
||||
]);
|
||||
@@ -869,7 +870,7 @@ class ReportManager
|
||||
FROM `{$table}`
|
||||
WHERE company_id = :company_id AND status = 1 AND date < :start"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->companyId, ':start' => $start]);
|
||||
$sth->execute([':company_id' => $this->company_id, ':start' => $start]);
|
||||
$running = (float)$sth->fetchColumn();
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
@@ -884,7 +885,7 @@ class ReportManager
|
||||
ORDER BY sort_key ASC"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->companyId,
|
||||
':company_id' => $this->company_id,
|
||||
':start' => $start,
|
||||
':end' => $end,
|
||||
]);
|
||||
@@ -934,7 +935,7 @@ class ReportManager
|
||||
ORDER BY a.date DESC, a.id DESC
|
||||
LIMIT 10"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->companyId]);
|
||||
$sth->execute([':company_id' => $this->company_id]);
|
||||
|
||||
$activities = [];
|
||||
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
@@ -985,19 +986,19 @@ class ReportManager
|
||||
"SELECT id, warehouse_name FROM md_warehouse
|
||||
WHERE company_id = :company_id AND status = 1 AND id = :warehouse_id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->companyId, ':warehouse_id' => $warehouse_id]);
|
||||
$sth->execute([':company_id' => $this->company_id, ':warehouse_id' => $warehouse_id]);
|
||||
} else {
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id, warehouse_name FROM md_warehouse
|
||||
WHERE company_id = :company_id AND status = 1"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->companyId]);
|
||||
$sth->execute([':company_id' => $this->company_id]);
|
||||
}
|
||||
$warehouses = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if (empty($warehouses)) return [];
|
||||
|
||||
$cid = (int) $this->companyId; // cast before interpolation
|
||||
$cid = (int) $this->company_id; // cast before interpolation
|
||||
|
||||
$unions = implode("\nUNION ALL\n", array_map(
|
||||
function ($wh) use ($cid) {
|
||||
@@ -1007,14 +1008,14 @@ class ReportManager
|
||||
s.lot_number,
|
||||
s.zone,
|
||||
s.aisle,
|
||||
s.rack,
|
||||
s.bin,
|
||||
ROUND(SUM(s.`in`) - SUM(s.`out`), 2) AS quantity,
|
||||
{$wh['id']} AS warehouse_id
|
||||
FROM `{$table}` s
|
||||
WHERE s.company_id = {$cid}
|
||||
AND s.lot_number IS NOT NULL
|
||||
AND s.status = 1
|
||||
GROUP BY s.product_sku, s.lot_number, s.zone, s.aisle, s.rack
|
||||
GROUP BY s.product_sku, s.lot_number, s.zone, s.aisle, s.bin
|
||||
HAVING quantity > 0";
|
||||
},
|
||||
$warehouses
|
||||
@@ -1025,7 +1026,7 @@ class ReportManager
|
||||
stock.lot_number,
|
||||
stock.zone,
|
||||
stock.aisle,
|
||||
stock.rack,
|
||||
stock.bin,
|
||||
stock.quantity,
|
||||
l.expiry_date,
|
||||
DATEDIFF(l.expiry_date, CURDATE()) AS days_remaining,
|
||||
@@ -1072,7 +1073,7 @@ class ReportManager
|
||||
'lot_number' => $row['lot_number'],
|
||||
'zone' => $row['zone'],
|
||||
'aisle' => $row['aisle'],
|
||||
'rack' => $row['rack'],
|
||||
'bin' => $row['bin'],
|
||||
'quantity' => (float) $row['quantity'],
|
||||
'expiry_date' => $row['expiry_date'],
|
||||
'days_remaining' => $days,
|
||||
@@ -1084,7 +1085,7 @@ class ReportManager
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// REPORT BASIS — Lot / Rack reports
|
||||
// REPORT BASIS — Lot / Bin reports
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -1096,7 +1097,7 @@ class ReportManager
|
||||
*
|
||||
* @param string $product_sku SKU to filter by.
|
||||
* @param string $lot_number Lot number to filter by.
|
||||
* @return array Stock rows with date, type, zone/aisle/rack, in/out amounts, warehouse_name.
|
||||
* @return array Stock rows with date, type, zone/aisle/bin, in/out amounts, warehouse_name.
|
||||
*/
|
||||
public function getLotStockLog(string $product_sku, string $lot_number): array
|
||||
{
|
||||
@@ -1106,7 +1107,7 @@ class ReportManager
|
||||
"SELECT id, warehouse_name FROM md_warehouse
|
||||
WHERE company_id = :company_id AND status = 1"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->companyId]);
|
||||
$sth->execute([':company_id' => $this->company_id]);
|
||||
$warehouses = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$rows = [];
|
||||
@@ -1122,7 +1123,7 @@ class ReportManager
|
||||
s.type,
|
||||
s.zone,
|
||||
s.aisle,
|
||||
s.rack,
|
||||
s.bin,
|
||||
ROUND(COALESCE(s.`in`, 0), 2) AS stock_in,
|
||||
ROUND(COALESCE(s.`out`, 0), 2) AS stock_out,
|
||||
s.serial_number,
|
||||
@@ -1136,7 +1137,7 @@ class ReportManager
|
||||
ORDER BY s.date DESC"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->companyId,
|
||||
':company_id' => $this->company_id,
|
||||
':product_sku' => $product_sku,
|
||||
':lot_number' => $lot_number,
|
||||
]);
|
||||
@@ -1168,13 +1169,13 @@ class ReportManager
|
||||
"SELECT id, warehouse_name FROM md_warehouse
|
||||
WHERE company_id = :company_id AND status = 1"
|
||||
);
|
||||
$warehouses->execute([':company_id' => $this->companyId]);
|
||||
$warehouses->execute([':company_id' => $this->company_id]);
|
||||
$wh_list = $warehouses->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Build per-lot balance by summing approved td_stock rows across all warehouses.
|
||||
// warehouse_balance is per-product, not per-lot, so we query td_stock directly.
|
||||
// etl_stock_summary is per-product, not per-lot, so we query td_stock directly.
|
||||
$lot_balance = [];
|
||||
$cid = (int) $this->companyId;
|
||||
$cid = (int) $this->company_id;
|
||||
|
||||
foreach ($wh_list as $wh) {
|
||||
$table = $this->stockTableNameFromWarehouseId((int)$wh['id']);
|
||||
@@ -1210,7 +1211,7 @@ class ReportManager
|
||||
WHERE l.company_id = :company_id
|
||||
ORDER BY l.product_sku, l.expiry_date ASC"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->companyId]);
|
||||
$sth->execute([':company_id' => $this->company_id]);
|
||||
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$active = $expired = $near = 0;
|
||||
@@ -1246,21 +1247,21 @@ class ReportManager
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the action log for a specific rack, with user name.
|
||||
* Return the action log for a specific bin, with user name.
|
||||
*
|
||||
* Used by the rack log dashboard report page.
|
||||
* Used by the bin log dashboard report page.
|
||||
* Joins the wms.user table for the acting user's name.
|
||||
*
|
||||
* Security fix: the database name is now fetched once via a bound query
|
||||
* and stored as $db_name (string), then used as a backtick-quoted identifier
|
||||
* rather than being embedded in the JOIN without escaping.
|
||||
*
|
||||
* @param int $rack_id The md_rack.id to fetch logs for (0 returns empty array).
|
||||
* @param int $bin_id The md_bin.id to fetch logs for (0 returns empty array).
|
||||
* @return array Log rows with dt, action, product_sku, td_stock_id, login, user_name.
|
||||
*/
|
||||
public function getRackLog(int $rack_id): array
|
||||
public function getBinLog(int $bin_id): array
|
||||
{
|
||||
if (!$rack_id) return [];
|
||||
if (!$bin_id) return [];
|
||||
|
||||
$main_db = $this->mainDb;
|
||||
if ($main_db === '' || !ctype_alnum(str_replace('_', '', $main_db))) {
|
||||
@@ -1276,30 +1277,30 @@ class ReportManager
|
||||
rl.td_stock_id,
|
||||
rl.login,
|
||||
u.name AS user_name
|
||||
FROM td_rack_log rl
|
||||
FROM td_bin_log rl
|
||||
LEFT JOIN `{$main_db}`.user u
|
||||
ON u.user_id = rl.user_id
|
||||
WHERE rl.company_id = :company_id
|
||||
AND rl.md_rack_id = :rack_id
|
||||
AND rl.md_bin_id = :bin_id
|
||||
ORDER BY rl.dt DESC"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->companyId,
|
||||
':rack_id' => $rack_id,
|
||||
':company_id' => $this->company_id,
|
||||
':bin_id' => $bin_id,
|
||||
]);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all rack slots across all warehouses with occupancy status,
|
||||
* Return all bin slots across all warehouses with occupancy status,
|
||||
* warehouse name, and product name.
|
||||
*
|
||||
* Used by the rack occupancy dashboard report page to visualise which
|
||||
* racks are empty vs occupied and which product is in each slot.
|
||||
* Results are ordered by warehouse → zone → aisle → rack with
|
||||
* Used by the bin occupancy dashboard report page to visualise which
|
||||
* bins are empty vs occupied and which product is in each slot.
|
||||
* Results are ordered by warehouse → zone → aisle → bin with
|
||||
* numeric-first sorting via CAST.
|
||||
*
|
||||
* @return array All md_rack rows joined to warehouse and product with 'status' field.
|
||||
* @return array All md_bin rows joined to warehouse and product with 'status' field.
|
||||
*/
|
||||
public function getRackOccupancy(): array
|
||||
{
|
||||
@@ -1308,14 +1309,14 @@ class ReportManager
|
||||
r.id,
|
||||
r.zone,
|
||||
r.aisle,
|
||||
r.rack,
|
||||
r.bin,
|
||||
r.product_sku,
|
||||
r.td_stock_id,
|
||||
mw.warehouse_name,
|
||||
mw.id AS warehouse_id,
|
||||
p.product_name,
|
||||
CASE WHEN r.product_sku IS NOT NULL THEN 'occupied' ELSE 'empty' END AS status
|
||||
FROM md_rack r
|
||||
FROM md_bin r
|
||||
INNER JOIN md_warehouse mw
|
||||
ON mw.company_id = r.company_id
|
||||
AND mw.id = r.warehouse
|
||||
@@ -1325,9 +1326,9 @@ class ReportManager
|
||||
WHERE r.company_id = :company_id
|
||||
ORDER BY mw.warehouse_name, r.zone,
|
||||
CAST(r.aisle AS UNSIGNED), r.aisle,
|
||||
CAST(r.rack AS UNSIGNED), r.rack"
|
||||
CAST(r.bin AS UNSIGNED), r.bin"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->companyId]);
|
||||
$sth->execute([':company_id' => $this->company_id]);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
@@ -1349,10 +1350,10 @@ class ReportManager
|
||||
SUM(total_in) AS total_in,
|
||||
SUM(total_out) AS total_out,
|
||||
COUNT(DISTINCT warehouse_id) AS total_warehouse
|
||||
FROM warehouse_balance
|
||||
FROM etl_stock_summary
|
||||
WHERE company_id = :company_id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->companyId]);
|
||||
$sth->execute([':company_id' => $this->company_id]);
|
||||
return $sth->fetch(PDO::FETCH_ASSOC) ?: [];
|
||||
}
|
||||
|
||||
@@ -1363,7 +1364,7 @@ class ReportManager
|
||||
* Each row is one individual transaction — no grouping. Full datetime is
|
||||
* returned as stored in the td_stock_* table.
|
||||
*
|
||||
* Brought-forward balance is sourced from warehouse_balance for all months
|
||||
* Brought-forward balance is sourced from etl_stock_summary for all months
|
||||
* strictly before from_month, giving the cumulative net before the range.
|
||||
*
|
||||
* Used by reports/stock_movement.php.
|
||||
@@ -1408,7 +1409,7 @@ class ReportManager
|
||||
AND date BETWEEN :date_from AND :date_to
|
||||
ORDER BY product_sku ASC"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->companyId, ':date_from' => $date_from, ':date_to' => $date_to]);
|
||||
$sth->execute([':company_id' => $this->company_id, ':date_from' => $date_from, ':date_to' => $date_to]);
|
||||
$skus = $sth->fetchAll(PDO::FETCH_COLUMN);
|
||||
|
||||
// Summary stats across full range
|
||||
@@ -1422,7 +1423,7 @@ class ReportManager
|
||||
AND status = 1
|
||||
AND date BETWEEN :date_from AND :date_to"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->companyId, ':date_from' => $date_from, ':date_to' => $date_to]);
|
||||
$sth->execute([':company_id' => $this->company_id, ':date_from' => $date_from, ':date_to' => $date_to]);
|
||||
$stats = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
return [
|
||||
@@ -1476,7 +1477,7 @@ class ReportManager
|
||||
AND status = 1
|
||||
AND date < :date_from"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->companyId, ':sku' => $product_sku, ':date_from' => $date_from]);
|
||||
$sth->execute([':company_id' => $this->company_id, ':sku' => $product_sku, ':date_from' => $date_from]);
|
||||
$bf = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
$bf_in = (float)($bf['bf_in'] ?? 0);
|
||||
$bf_out = (float)($bf['bf_out'] ?? 0);
|
||||
@@ -1487,7 +1488,7 @@ class ReportManager
|
||||
"SELECT product_name, uom FROM md_product
|
||||
WHERE company_id = :company_id AND sku = :sku LIMIT 1"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->companyId, ':sku' => $product_sku]);
|
||||
$sth->execute([':company_id' => $this->company_id, ':sku' => $product_sku]);
|
||||
$product_row = $sth->fetch(PDO::FETCH_ASSOC) ?: [];
|
||||
$product_name = $product_row['product_name'] ?: $product_sku;
|
||||
$product_uom = $product_row['uom'] ?? '';
|
||||
@@ -1509,7 +1510,7 @@ class ReportManager
|
||||
s.serial_number,
|
||||
s.zone,
|
||||
s.aisle,
|
||||
s.rack,
|
||||
s.bin,
|
||||
ROUND(COALESCE(s.`in`, 0), 2) AS stock_in,
|
||||
ROUND(COALESCE(s.`out`, 0), 2) AS stock_out,
|
||||
CASE
|
||||
@@ -1537,7 +1538,7 @@ class ReportManager
|
||||
AND s.date BETWEEN :date_from AND :date_to
|
||||
ORDER BY s.date ASC, s.id ASC"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->companyId, ':sku' => $product_sku, ':date_from' => $date_from, ':date_to' => $date_to]);
|
||||
$sth->execute([':company_id' => $this->company_id, ':sku' => $product_sku, ':date_from' => $date_from, ':date_to' => $date_to]);
|
||||
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Append running_total and margin to each row.
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/DocumentNumberManager.php';
|
||||
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
|
||||
require_once __DIR__ . '/WarehouseManager.php';
|
||||
|
||||
/**
|
||||
* ReturnManager
|
||||
@@ -15,13 +18,13 @@
|
||||
* 1. INSERT td_stock_<warehouse_id> in rows (source='return', source_id=return_id,
|
||||
* status=1 ALWAYS — confirming a return is a final business decision,
|
||||
* no separate warehouse approval step needed).
|
||||
* 2. occupyRack() + adjustBalance() via WarehouseManager.
|
||||
* 2. occupyBin() + adjustBalance() via WarehouseManager.
|
||||
* 3. If auto_invoice_and_credit_note=1 → InvoiceManager::createCreditNote()
|
||||
* auto-generates a CN linked to the original invoice (status=1 issued).
|
||||
* If =0 → user creates CN manually from the return detail page.
|
||||
* - Return items stored as JSON in td_return.items (same pattern as td_order).
|
||||
* - Each item references stock_out_id from the original order so the correct
|
||||
* warehouse/rack/lot context is known for restock.
|
||||
* warehouse/bin/lot context is known for restock.
|
||||
*
|
||||
* Note: Write methods do NOT manage their own DB transactions.
|
||||
* Callers must wrap multi-step operations inside dbTransaction().
|
||||
@@ -54,22 +57,10 @@ class ReturnManager {
|
||||
];
|
||||
}
|
||||
|
||||
private function generateReturnNumber(): string
|
||||
private function generateReturnNumber(array $data = []): string
|
||||
{
|
||||
$prefix = 'RET-' . date('Ymd') . '-';
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT return_number FROM td_return
|
||||
WHERE company_id = :company_id
|
||||
AND return_number LIKE :prefix
|
||||
ORDER BY return_number DESC
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':prefix' => $prefix . '%']);
|
||||
|
||||
$last = $sth->fetchColumn();
|
||||
$seq = $last ? ((int)substr($last, -4) + 1) : 1;
|
||||
return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT);
|
||||
return (new DocumentNumberManager($this->pdo, $this->company_id))
|
||||
->resolveNumber($data, 'return', 'td_return', 'return_number');
|
||||
}
|
||||
|
||||
private function stockTableNameFromWarehouseId(int $warehouse_id): string
|
||||
@@ -78,6 +69,15 @@ class ReturnManager {
|
||||
throw new Exception("Invalid warehouse id.");
|
||||
}
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id FROM md_warehouse
|
||||
WHERE company_id = :company_id AND id = :id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $warehouse_id]);
|
||||
if (!$sth->fetchColumn()) {
|
||||
throw new Exception("Warehouse ID {$warehouse_id} not found.");
|
||||
}
|
||||
|
||||
return 'td_stock_' . $warehouse_id;
|
||||
}
|
||||
|
||||
@@ -252,7 +252,7 @@ class ReturnManager {
|
||||
*
|
||||
* items JSON array — each element mirrors td_order.items with stock_out_id
|
||||
* referencing the original stock-out row so confirmReturn() knows which
|
||||
* rack/warehouse/lot to restock.
|
||||
* bin/warehouse/lot to restock.
|
||||
*
|
||||
* Must be called inside dbTransaction() by the caller.
|
||||
*
|
||||
@@ -372,7 +372,7 @@ class ReturnManager {
|
||||
)->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':uuid' => bin2hex(random_bytes(16)),
|
||||
':return_number' => $this->generateReturnNumber(),
|
||||
':return_number' => $this->generateReturnNumber($data),
|
||||
':order_id' => (int)($data['order_id'] ?? 0),
|
||||
':invoice_id' => (int)($data['invoice_id'] ?? 0),
|
||||
':contact_id' => (int)($data['contact_id'] ?? 0),
|
||||
@@ -399,8 +399,8 @@ class ReturnManager {
|
||||
* Flow per item:
|
||||
* 1. INSERT td_stock_<warehouse_id> in row with type='in', status=1 (force confirmed),
|
||||
* source='return', source_id=return_id.
|
||||
* 2. occupyRack() — place returned stock into the user-selected empty rack.
|
||||
* 3. adjustBalance() — update warehouse_balance.
|
||||
* 2. occupyBin() — place returned stock into the user-selected empty bin.
|
||||
* 3. adjustBalance() — update etl_stock_summary.
|
||||
*
|
||||
* After all items:
|
||||
* 4. UPDATE td_return.status = 1 (confirmed).
|
||||
@@ -410,7 +410,7 @@ class ReturnManager {
|
||||
*
|
||||
* Stock-in rows respect $auto_approve (mirrors default_stock_status config).
|
||||
* When false, rows are created as draft (status=0) and warehouse staff approve
|
||||
* them via approve_stock.php; rack occupation and balance are deferred.
|
||||
* them via approve_stock.php; bin occupation and balance are deferred.
|
||||
*
|
||||
* Must be called inside dbTransaction() by the caller.
|
||||
*
|
||||
@@ -505,13 +505,13 @@ class ReturnManager {
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO `{$table}`
|
||||
(uuid, company_id, `date`, product_sku, `in`,
|
||||
zone, aisle, rack,
|
||||
zone, aisle, bin,
|
||||
contact_id, `description`, `log`, `type`,
|
||||
lot_number, serial_number,
|
||||
source, source_id, ref_id, price, status)
|
||||
VALUES
|
||||
(:uuid, :company_id, :date, :product_sku, :quantity,
|
||||
:zone, :aisle, :rack,
|
||||
:zone, :aisle, :bin,
|
||||
:contact_id, :description, :log, 'in',
|
||||
:lot_number, :serial_number,
|
||||
'return', :source_id, :ref_id, :price, :status)"
|
||||
@@ -523,7 +523,7 @@ class ReturnManager {
|
||||
':quantity' => $quantity,
|
||||
':zone' => $item['zone'] ?? '',
|
||||
':aisle' => $item['aisle'] ?? '',
|
||||
':rack' => $item['rack'] ?? '',
|
||||
':bin' => $item['bin'] ?? '',
|
||||
':contact_id' => (int)$return['contact_id'],
|
||||
':description' => $return['return_number'],
|
||||
':log' => json_encode($item_log),
|
||||
@@ -538,11 +538,11 @@ class ReturnManager {
|
||||
$stock_in_id = (int)$this->pdo->lastInsertId();
|
||||
|
||||
if ($auto_approve) {
|
||||
$whMgmt->occupyRack(
|
||||
$whMgmt->occupyBin(
|
||||
$warehouse_id,
|
||||
$item['zone'] ?? '',
|
||||
$item['aisle'] ?? '',
|
||||
$item['rack'] ?? '',
|
||||
$item['bin'] ?? '',
|
||||
$product_sku,
|
||||
$stock_in_id,
|
||||
true
|
||||
@@ -593,9 +593,9 @@ class ReturnManager {
|
||||
* and doc_type.
|
||||
*
|
||||
* If the return was confirmed, stock-in rows (source='return', source_id=$return_id)
|
||||
* were created with status=1 and rack/balance were already adjusted.
|
||||
* were created with status=1 and bin/balance were already adjusted.
|
||||
* On cancel we soft-delete those rows (status → -1) and reverse the
|
||||
* rack occupation and balance via WarehouseManager.
|
||||
* bin occupation and balance via WarehouseManager.
|
||||
*
|
||||
* If the return was still draft, no stock rows exist — simple status flip.
|
||||
*
|
||||
@@ -646,7 +646,7 @@ class ReturnManager {
|
||||
}
|
||||
}
|
||||
|
||||
// ── If confirmed: reverse stock-in rows + rack/balance ────────────
|
||||
// ── If confirmed: reverse stock-in rows + bin/balance ────────────
|
||||
if ($status === 1) {
|
||||
$sth2 = $this->pdo->prepare(
|
||||
"SELECT * FROM td_return_item
|
||||
@@ -667,7 +667,7 @@ class ReturnManager {
|
||||
|
||||
// Fetch ALL non-cancelled stock-in rows (status=1 approved or status=0 draft)
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id, status, zone, aisle, rack, `date` FROM `{$table}`
|
||||
"SELECT id, status, zone, aisle, bin, `date` FROM `{$table}`
|
||||
WHERE company_id = :company_id
|
||||
AND source = 'return'
|
||||
AND source_id = :return_id
|
||||
@@ -689,11 +689,11 @@ class ReturnManager {
|
||||
|
||||
if ((int)$row['status'] === 1) {
|
||||
$whMgmt->assertStockMovementWindow($row['date'] ?? null, 'Return stock cancellation');
|
||||
// Approved rows: reverse rack occupation + balance
|
||||
$whMgmt->releaseRack($warehouse_id, $row['zone'], $row['aisle'], $row['rack']);
|
||||
// Approved rows: reverse bin occupation + balance
|
||||
$whMgmt->releaseBin($warehouse_id, $row['zone'], $row['aisle'], $row['bin']);
|
||||
$whMgmt->adjustBalance('out', $warehouse_id, $product_sku, 0, $quantity);
|
||||
}
|
||||
// Draft (status=0) rows: just soft-delete — rack/balance were never applied
|
||||
// Draft (status=0) rows: just soft-delete — bin/balance were never applied
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -713,4 +713,69 @@ class ReturnManager {
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete a return by negating company_id on the header, items, and all linked
|
||||
* stock-in rows. Approved stock-in side effects are reversed first.
|
||||
* Blocked if any credit note (not yet soft-deleted) exists for the linked order.
|
||||
*/
|
||||
public function softDelete(int $return_id): void
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM td_return WHERE company_id = :cid AND id = :id"
|
||||
);
|
||||
$sth->execute([':cid' => $this->company_id, ':id' => $return_id]);
|
||||
$return = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$return) throw new Exception('Return not found.');
|
||||
|
||||
global $pdo1;
|
||||
if (isset($pdo1) && $pdo1 instanceof PDO) {
|
||||
$guard = new PostingWindowGuard($pdo1, $this->company_id);
|
||||
$guard->assertOpenDate($return['return_date'] ?: date('Y-m-d'), 'Return deletion');
|
||||
}
|
||||
|
||||
$order_id = (int)$return['order_id'];
|
||||
if ($order_id > 0) {
|
||||
$sth2 = $this->pdo->prepare(
|
||||
"SELECT COUNT(*) FROM td_invoice
|
||||
WHERE company_id = :cid AND order_id = :order_id AND doc_type = 'credit_note'"
|
||||
);
|
||||
$sth2->execute([':cid' => $this->company_id, ':order_id' => $order_id]);
|
||||
if ((int)$sth2->fetchColumn() > 0) {
|
||||
throw new Exception('Cannot delete — a credit note exists for this return\'s order. Delete the credit note first.');
|
||||
}
|
||||
}
|
||||
|
||||
// Block if any approved stock-in rows exist; user must reverse via ICS first
|
||||
$tables = (new WarehouseManager($this->pdo, $this->company_id))->getStockTables();
|
||||
|
||||
foreach ($tables as $table) {
|
||||
$chk = $this->pdo->prepare(
|
||||
"SELECT COUNT(*) FROM `{$table}`
|
||||
WHERE company_id = :cid AND source = 'return' AND source_id = :id AND status = 1"
|
||||
);
|
||||
$chk->execute([':cid' => $this->company_id, ':id' => $return_id]);
|
||||
if ((int)$chk->fetchColumn() > 0) {
|
||||
throw new Exception('Cannot delete — stock-in for this return has been approved. Reverse via ICS first.');
|
||||
}
|
||||
}
|
||||
|
||||
// Negate draft/pending stock-in rows (status != 1)
|
||||
foreach ($tables as $table) {
|
||||
$this->pdo->prepare(
|
||||
"UPDATE `{$table}` SET company_id = company_id * -1
|
||||
WHERE company_id = :cid AND source = 'return' AND source_id = :id AND status != 1"
|
||||
)->execute([':cid' => $this->company_id, ':id' => $return_id]);
|
||||
}
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_return_item SET company_id = company_id * -1
|
||||
WHERE return_id = :id AND company_id = :cid"
|
||||
)->execute([':id' => $return_id, ':cid' => $this->company_id]);
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_return SET company_id = company_id * -1
|
||||
WHERE id = :id AND company_id = :cid"
|
||||
)->execute([':id' => $return_id, ':cid' => $this->company_id]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../db_helpers.php';
|
||||
require_once __DIR__ . '/../notify_node.php';
|
||||
|
||||
/**
|
||||
* StockManager
|
||||
*
|
||||
@@ -12,7 +15,7 @@
|
||||
* getTransferById, saveStockIn, saveStockOut, saveStockTransfer
|
||||
* Report basis → (none — reporting is handled by ReportManager)
|
||||
*
|
||||
* Write operations delegate rack and balance side-effects to WarehouseManager.
|
||||
* Write operations delegate bin and balance side-effects to WarehouseManager.
|
||||
* Delete operations are handled directly in WarehouseManager (deleteStockIn, etc.).
|
||||
*
|
||||
* Note: Write methods do NOT manage their own DB transactions.
|
||||
@@ -41,9 +44,23 @@ class StockManager {
|
||||
throw new Exception("Invalid warehouse id.");
|
||||
}
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id FROM md_warehouse
|
||||
WHERE company_id = :company_id AND id = :id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $warehouse_id]);
|
||||
if (!$sth->fetchColumn()) {
|
||||
throw new Exception("Warehouse ID {$warehouse_id} not found.");
|
||||
}
|
||||
|
||||
return 'td_stock_' . $warehouse_id;
|
||||
}
|
||||
|
||||
private function stockReferenceSql(): string
|
||||
{
|
||||
return "CONCAT(DATE_FORMAT(COALESCE(a.`date`, a.updated_at), '%Y%m%d%H%i%s'), '-', LPAD(a.id, 11, '0'))";
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// TRANSACTION BASIS — Read
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
@@ -63,12 +80,13 @@ class StockManager {
|
||||
{
|
||||
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
|
||||
$column = $type === 'out' ? 'ROUND(a.out, 2)' : 'ROUND(a.in, 2)';
|
||||
$stock_ref = $this->stockReferenceSql();
|
||||
|
||||
// Transfer list: show only the outbound side (out > 0) to avoid duplicate display
|
||||
$extra_cond = ($type === 'transfer') ? 'AND a.out > 0' : '';
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT a.*, {$column} AS quantity, b.product_name, b.uom
|
||||
"SELECT a.*, {$stock_ref} AS stock_reference, {$column} AS quantity, b.product_name, b.uom
|
||||
FROM `{$table}` a
|
||||
LEFT JOIN md_product b
|
||||
ON a.company_id = b.company_id
|
||||
@@ -99,9 +117,10 @@ class StockManager {
|
||||
public function getStockInById(int $warehouse_id, int $id): array|false
|
||||
{
|
||||
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
|
||||
$stock_ref = $this->stockReferenceSql();
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT a.*, a.in AS quantity,
|
||||
"SELECT a.*, {$stock_ref} AS stock_reference, a.in AS quantity,
|
||||
b.contact_name,
|
||||
c.product_name, c.uom,
|
||||
d.expiry_date
|
||||
@@ -134,9 +153,10 @@ class StockManager {
|
||||
public function getStockOutById(int $warehouse_id, int $id): array|false
|
||||
{
|
||||
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
|
||||
$stock_ref = $this->stockReferenceSql();
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT a.*, a.out AS quantity,
|
||||
"SELECT a.*, {$stock_ref} AS stock_reference, a.out AS quantity,
|
||||
b.contact_name,
|
||||
c.product_name, c.uom
|
||||
FROM `{$table}` a
|
||||
@@ -167,10 +187,11 @@ class StockManager {
|
||||
public function getTransferById(int $warehouse_id, int $id): array|false
|
||||
{
|
||||
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
|
||||
$stock_ref = $this->stockReferenceSql();
|
||||
|
||||
// Fetch the outbound (from) row
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT a.*, a.out AS quantity,
|
||||
"SELECT a.*, {$stock_ref} AS stock_reference, a.out AS quantity,
|
||||
b.contact_name,
|
||||
c.product_name, c.uom
|
||||
FROM `{$table}` a
|
||||
@@ -190,7 +211,7 @@ class StockManager {
|
||||
$to_table = $this->stockTableNameFromWarehouseId($to_warehouse_id);
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT a.*, a.in AS quantity,
|
||||
"SELECT a.*, {$stock_ref} AS stock_reference, a.in AS quantity,
|
||||
b.contact_name,
|
||||
c.product_name, c.uom
|
||||
FROM `{$to_table}` a
|
||||
@@ -216,16 +237,16 @@ class StockManager {
|
||||
* Insert flow (id = 0):
|
||||
* 1. Upserts md_lot if lot_number + expiry_date are provided.
|
||||
* 2. Inserts the td_stock_<warehouse_id> row.
|
||||
* 3. Calls WarehouseManager::occupyRack() to reserve the rack.
|
||||
* warehouse_balance is updated later by approveStock().
|
||||
* 3. Calls WarehouseManager::occupyBin() to reserve the bin.
|
||||
* etl_stock_summary is updated later by approveStock().
|
||||
*
|
||||
* Update flow (id > 0):
|
||||
* - Updates contact_id, description, and log only.
|
||||
* - Quantity, rack, lot, and serial are immutable after creation.
|
||||
* - Quantity, bin, lot, and serial are immutable after creation.
|
||||
*
|
||||
* Must be called inside dbTransaction() by the caller.
|
||||
*
|
||||
* @param array $data Keys: id, warehouse, product_sku, quantity, zone, aisle, rack,
|
||||
* @param array $data Keys: id, warehouse, product_sku, quantity, zone, aisle, bin,
|
||||
* contact_id, description, lot_number, expiry_date, serial_number.
|
||||
* @param array $logging Audit entry to append to the log column.
|
||||
* @param string $uuid UUID for this transaction (shared across transfer pairs).
|
||||
@@ -233,7 +254,12 @@ class StockManager {
|
||||
public function saveStockIn(array $data, array $logging, string $uuid): int
|
||||
{
|
||||
$id = (int)($data['id'] ?? 0);
|
||||
$warehouse_id = (int)($data['warehouse'] ?? 0);
|
||||
$warehouse_id = (int)($data["warehouse"] ?? 0);
|
||||
$quantity = (float)($data['quantity'] ?? 0);
|
||||
|
||||
if ($id === 0 && $quantity <= 0) {
|
||||
throw new Exception("Quantity must be greater than zero.");
|
||||
}
|
||||
$whMgmt = new WarehouseManager($this->pdo, $this->company_id);
|
||||
|
||||
$ctx = $whMgmt->getStockContext($warehouse_id, $id);
|
||||
@@ -267,8 +293,8 @@ class StockManager {
|
||||
|
||||
} else {
|
||||
|
||||
$lot_number = $data['lot_number'] ?: null;
|
||||
$expiry_date = $data['expiry_date'] ?: null;
|
||||
$lot_number = trim((string)($data["lot_number"] ?? ""));
|
||||
$expiry_date = trim((string)($data["expiry_date"] ?? ""));
|
||||
|
||||
// Upsert md_lot: preserve existing expiry_date if already recorded
|
||||
if ($lot_number && $expiry_date) {
|
||||
@@ -286,38 +312,38 @@ class StockManager {
|
||||
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO `$table`
|
||||
(uuid, company_id, `date`, product_sku, `in`, price, zone, aisle, rack,
|
||||
contact_id, `description`, `log`, `type`, lot_number, serial_number, status)
|
||||
(uuid, company_id, `date`, product_sku, `in`, price, zone, aisle, bin,
|
||||
contact_id, `description`, `log`, `type`, lot_number, serial_number, status, updated_at)
|
||||
VALUES
|
||||
(:uuid, :company_id, :date, :product_sku, :quantity, :price, :zone, :aisle, :rack,
|
||||
:contact_id, :description, :log, 'in', :lot_number, :serial_number, 0)"
|
||||
(:uuid, :company_id, :date, :product_sku, :quantity, :price, :zone, :aisle, :bin,
|
||||
:contact_id, :description, :log, 'in', :lot_number, :serial_number, 0, NOW())"
|
||||
)->execute([
|
||||
':uuid' => $uuid,
|
||||
':company_id' => $this->company_id,
|
||||
':date' => date('Y-m-d H:i:s'),
|
||||
':product_sku' => $data['product_sku'],
|
||||
':quantity' => $data['quantity'],
|
||||
':quantity' => $quantity,
|
||||
':price' => (float)($data['price'] ?? 0),
|
||||
':zone' => $data['zone'],
|
||||
':aisle' => $data['aisle'],
|
||||
':rack' => $data['rack'],
|
||||
':bin' => $data['bin'],
|
||||
':contact_id' => (int)($data['contact_id'] ?? 0),
|
||||
':description' => $data['description'] ?? '',
|
||||
':log' => json_encode($table_log),
|
||||
':lot_number' => $lot_number,
|
||||
':serial_number' => ($data['serial_number'] ?? null) ?: null,
|
||||
':serial_number' => trim((string)($data["serial_number"] ?? "")),
|
||||
]);
|
||||
|
||||
$td_stock_id = (int)$this->pdo->lastInsertId();
|
||||
|
||||
// Reserve the location immediately so draft stock-in rows cannot
|
||||
// leave the same rack available for another receipt. Balance still
|
||||
// leave the same bin available for another receipt. Balance still
|
||||
// changes only when approveStock() runs.
|
||||
$whMgmt->occupyRack(
|
||||
$whMgmt->occupyBin(
|
||||
$warehouse_id,
|
||||
$data['zone'],
|
||||
$data['aisle'],
|
||||
$data['rack'],
|
||||
$data['bin'],
|
||||
$data['product_sku'],
|
||||
$td_stock_id
|
||||
);
|
||||
@@ -330,21 +356,21 @@ class StockManager {
|
||||
* Insert a new stock_out record or update metadata on an existing one.
|
||||
*
|
||||
* Insert flow (id = 0):
|
||||
* 1. Validates the rack is occupied with the correct SKU / lot / serial.
|
||||
* 2. Inserts the td_stock_<warehouse_id> row, copying quantity and lot info from the rack.
|
||||
* 3. Calls WarehouseManager::releaseRack() to free the rack slot.
|
||||
* 4. Calls WarehouseManager::adjustBalance() to update warehouse_balance.
|
||||
* 1. Validates the bin is occupied with the correct SKU / lot / serial.
|
||||
* 2. Inserts the td_stock_<warehouse_id> row, copying quantity and lot info from the bin.
|
||||
* 3. Calls WarehouseManager::releaseBin() to free the bin slot.
|
||||
* 4. Calls WarehouseManager::adjustBalance() to update etl_stock_summary.
|
||||
*
|
||||
* Update flow (id > 0):
|
||||
* - Updates contact_id, description, and log only.
|
||||
*
|
||||
* Must be called inside dbTransaction() by the caller.
|
||||
*
|
||||
* @param array $data Keys: id, warehouse, product_sku, zone, aisle, rack,
|
||||
* @param array $data Keys: id, warehouse, product_sku, zone, aisle, bin,
|
||||
* contact_id, description, lot_number, serial_number.
|
||||
* @param array $logging Audit entry to append to the log column.
|
||||
* @param string $uuid UUID for this transaction.
|
||||
* @throws Exception If the rack is empty, holds a different SKU/lot/serial.
|
||||
* @throws Exception If the bin is empty, holds a different SKU/lot/serial.
|
||||
*/
|
||||
public function saveStockOut(array $data, array $logging, string $uuid): int
|
||||
{
|
||||
@@ -383,33 +409,33 @@ class StockManager {
|
||||
|
||||
} else {
|
||||
|
||||
// Validate rack holds the expected product / lot / serial
|
||||
$source_stock = $whMgmt->getRackStock(
|
||||
// Validate bin holds the expected product / lot / serial
|
||||
$source_stock = $whMgmt->getBinStock(
|
||||
$warehouse_id,
|
||||
$data['zone'], $data['aisle'], $data['rack']
|
||||
$data['zone'], $data['aisle'], $data['bin']
|
||||
);
|
||||
|
||||
if (!$source_stock) {
|
||||
throw new Exception(
|
||||
"Rack {$data['zone']}-{$data['aisle']}-{$data['rack']} is empty — nothing to take out."
|
||||
"Bin {$data['zone']}-{$data['aisle']}-{$data['bin']} is empty — nothing to take out."
|
||||
);
|
||||
}
|
||||
|
||||
if ($source_stock['product_sku'] !== $data['product_sku']) {
|
||||
throw new Exception(
|
||||
"Rack holds {$source_stock['product_sku']}, not {$data['product_sku']}."
|
||||
"Bin holds {$source_stock['product_sku']}, not {$data['product_sku']}."
|
||||
);
|
||||
}
|
||||
|
||||
if (!empty($data['lot_number']) && $source_stock['lot_number'] !== $data['lot_number']) {
|
||||
throw new Exception(
|
||||
"Rack holds lot '{$source_stock['lot_number']}', not '{$data['lot_number']}'."
|
||||
"Bin holds lot '{$source_stock['lot_number']}', not '{$data['lot_number']}'."
|
||||
);
|
||||
}
|
||||
|
||||
if (!empty($data['serial_number']) && $source_stock['serial_number'] !== $data['serial_number']) {
|
||||
throw new Exception(
|
||||
"Rack holds serial '{$source_stock['serial_number']}', not '{$data['serial_number']}'."
|
||||
"Bin holds serial '{$source_stock['serial_number']}', not '{$data['serial_number']}'."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -421,11 +447,11 @@ class StockManager {
|
||||
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO `$table`
|
||||
(uuid, company_id, `date`, product_sku, `out`, zone, aisle, rack,
|
||||
contact_id, `description`, `log`, `type`, ref_id, lot_number, serial_number, status)
|
||||
(uuid, company_id, `date`, product_sku, `out`, zone, aisle, bin,
|
||||
contact_id, `description`, `log`, `type`, ref_id, lot_number, serial_number, status, updated_at)
|
||||
VALUES
|
||||
(:uuid, :company_id, :date, :product_sku, :quantity, :zone, :aisle, :rack,
|
||||
:contact_id, :description, :log, 'out', :ref_id, :lot_number, :serial_number, 0)"
|
||||
(:uuid, :company_id, :date, :product_sku, :quantity, :zone, :aisle, :bin,
|
||||
:contact_id, :description, :log, 'out', :ref_id, :lot_number, :serial_number, 0, NOW())"
|
||||
)->execute([
|
||||
':uuid' => $uuid,
|
||||
':company_id' => $this->company_id,
|
||||
@@ -434,7 +460,7 @@ class StockManager {
|
||||
':quantity' => $quantity,
|
||||
':zone' => $data['zone'],
|
||||
':aisle' => $data['aisle'],
|
||||
':rack' => $data['rack'],
|
||||
':bin' => $data['bin'],
|
||||
':contact_id' => (int)($data['contact_id'] ?? 0),
|
||||
':description' => $data['description'] ?? '',
|
||||
':log' => json_encode($table_log),
|
||||
@@ -445,7 +471,7 @@ class StockManager {
|
||||
|
||||
$out_stock_id = (int)$this->pdo->lastInsertId();
|
||||
|
||||
// releaseRack and adjustBalance deferred — called from approveStock() only.
|
||||
// releaseBin and adjustBalance deferred — called from approveStock() only.
|
||||
return $out_stock_id;
|
||||
}
|
||||
}
|
||||
@@ -458,11 +484,11 @@ class StockManager {
|
||||
* sharing the same UUID and cross-referencing each other via ref_id.
|
||||
*
|
||||
* Insert flow (id = 0):
|
||||
* 1. Validates the source rack holds the correct SKU / lot / serial.
|
||||
* 1. Validates the source bin holds the correct SKU / lot / serial.
|
||||
* 2. Inserts the outbound row in td_stock_<from>.
|
||||
* 3. Inserts the inbound row in td_stock_<to> with ref_id pointing to from.
|
||||
* 4. Back-fills ref_id on the from row so both point at each other.
|
||||
* 5. Releases the source rack, occupies the destination rack.
|
||||
* 5. Releases the source bin, occupies the destination bin.
|
||||
* 6. Adjusts balance on both warehouses (out from source, in to dest).
|
||||
*
|
||||
* Update flow (id > 0):
|
||||
@@ -471,11 +497,11 @@ class StockManager {
|
||||
* Must be called inside dbTransaction() by the caller.
|
||||
*
|
||||
* @param array $data Keys: id, warehouse_from, warehouse_to, product_sku,
|
||||
* zone_from, aisle_from, rack_from, zone_to, aisle_to, rack_to,
|
||||
* zone_from, aisle_from, bin_from, zone_to, aisle_to, bin_to,
|
||||
* contact_id, description, lot_number, serial_number.
|
||||
* @param array $logging Audit entry to append to the log column on both rows.
|
||||
* @param string $uuid UUID shared by both the from and to rows.
|
||||
* @throws Exception If source rack validation fails or paired record is missing on update.
|
||||
* @throws Exception If source bin validation fails or paired record is missing on update.
|
||||
*/
|
||||
public function saveStockTransfer(array $data, array $logging, string $uuid): int
|
||||
{
|
||||
@@ -549,39 +575,39 @@ class StockManager {
|
||||
return 0; // update — no new row
|
||||
}
|
||||
|
||||
// Insert: validate source rack, then create paired rows
|
||||
// Insert: validate source bin, then create paired rows
|
||||
$from_warehouse = (int)$data['warehouse_from'];
|
||||
$from_zone = $data['zone_from'];
|
||||
$from_aisle = $data['aisle_from'];
|
||||
$from_rack = $data['rack_from'];
|
||||
$from_bin = $data['bin_from'];
|
||||
$to_warehouse = (int)$data['warehouse_to'];
|
||||
$to_zone = $data['zone_to'];
|
||||
$to_aisle = $data['aisle_to'];
|
||||
$to_rack = $data['rack_to'];
|
||||
$to_bin = $data['bin_to'];
|
||||
|
||||
$source_stock = $whMgmt->getRackStock(
|
||||
$from_warehouse, $from_zone, $from_aisle, $from_rack
|
||||
$source_stock = $whMgmt->getBinStock(
|
||||
$from_warehouse, $from_zone, $from_aisle, $from_bin
|
||||
);
|
||||
|
||||
if (!$source_stock) {
|
||||
throw new Exception("Source rack {$from_zone}-{$from_aisle}-{$from_rack} is empty.");
|
||||
throw new Exception("Source bin {$from_zone}-{$from_aisle}-{$from_bin} is empty.");
|
||||
}
|
||||
|
||||
if ($source_stock['product_sku'] !== $data['product_sku']) {
|
||||
throw new Exception(
|
||||
"Source rack holds {$source_stock['product_sku']}, not {$data['product_sku']}."
|
||||
"Source bin holds {$source_stock['product_sku']}, not {$data['product_sku']}."
|
||||
);
|
||||
}
|
||||
|
||||
if (!empty($data['lot_number']) && $source_stock['lot_number'] !== $data['lot_number']) {
|
||||
throw new Exception(
|
||||
"Source rack holds lot '{$source_stock['lot_number']}', not '{$data['lot_number']}'."
|
||||
"Source bin holds lot '{$source_stock['lot_number']}', not '{$data['lot_number']}'."
|
||||
);
|
||||
}
|
||||
|
||||
if (!empty($data['serial_number']) && $source_stock['serial_number'] !== $data['serial_number']) {
|
||||
throw new Exception(
|
||||
"Source rack holds serial '{$source_stock['serial_number']}', not '{$data['serial_number']}'."
|
||||
"Source bin holds serial '{$source_stock['serial_number']}', not '{$data['serial_number']}'."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -598,12 +624,12 @@ class StockManager {
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO `$from_table`
|
||||
(uuid, company_id, `date`, product_sku, `out`,
|
||||
ref_warehouse, zone, aisle, rack,
|
||||
contact_id, `description`, `log`, `type`, lot_number, serial_number, status)
|
||||
ref_warehouse, zone, aisle, bin,
|
||||
contact_id, `description`, `log`, `type`, lot_number, serial_number, status, updated_at)
|
||||
VALUES
|
||||
(:uuid, :company_id, :date, :product_sku, :quantity,
|
||||
:ref_warehouse, :zone, :aisle, :rack,
|
||||
:contact_id, :description, :log, 'transfer', :lot_number, :serial_number, 0)"
|
||||
:ref_warehouse, :zone, :aisle, :bin,
|
||||
:contact_id, :description, :log, 'transfer', :lot_number, :serial_number, 0, NOW())"
|
||||
)->execute([
|
||||
':uuid' => $uuid,
|
||||
':company_id' => $this->company_id,
|
||||
@@ -613,7 +639,7 @@ class StockManager {
|
||||
':ref_warehouse' => $to_warehouse,
|
||||
':zone' => $from_zone,
|
||||
':aisle' => $from_aisle,
|
||||
':rack' => $from_rack,
|
||||
':bin' => $from_bin,
|
||||
':contact_id' => $contact_id,
|
||||
':description' => $description,
|
||||
':log' => json_encode($table_log),
|
||||
@@ -626,12 +652,12 @@ class StockManager {
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO `$to_table`
|
||||
(uuid, company_id, `date`, product_sku, `in`,
|
||||
ref_warehouse, ref_id, zone, aisle, rack,
|
||||
contact_id, `description`, `log`, `type`, lot_number, serial_number, status)
|
||||
ref_warehouse, ref_id, zone, aisle, bin,
|
||||
contact_id, `description`, `log`, `type`, lot_number, serial_number, status, updated_at)
|
||||
VALUES
|
||||
(:uuid, :company_id, :date, :product_sku, :quantity,
|
||||
:ref_warehouse, :ref_id, :zone, :aisle, :rack,
|
||||
:contact_id, :description, :log, 'transfer', :lot_number, :serial_number, 0)"
|
||||
:ref_warehouse, :ref_id, :zone, :aisle, :bin,
|
||||
:contact_id, :description, :log, 'transfer', :lot_number, :serial_number, 0, NOW())"
|
||||
)->execute([
|
||||
':uuid' => $uuid,
|
||||
':company_id' => $this->company_id,
|
||||
@@ -642,7 +668,7 @@ class StockManager {
|
||||
':ref_id' => $from_stock_id,
|
||||
':zone' => $to_zone,
|
||||
':aisle' => $to_aisle,
|
||||
':rack' => $to_rack,
|
||||
':bin' => $to_bin,
|
||||
':contact_id' => $contact_id,
|
||||
':description' => $description,
|
||||
':log' => json_encode($table_log),
|
||||
@@ -661,7 +687,7 @@ class StockManager {
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
|
||||
// releaseRack, occupyRack and adjustBalance deferred — called from approveStock() only.
|
||||
// releaseBin, occupyBin and adjustBalance deferred — called from approveStock() only.
|
||||
return $from_stock_id;
|
||||
}
|
||||
|
||||
@@ -670,7 +696,7 @@ class StockManager {
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Approve a draft td_stock row (status 0 → 1) and update warehouse_balance.
|
||||
* Approve a draft td_stock row (status 0 → 1) and update etl_stock_summary.
|
||||
*
|
||||
* This is the single entry point for balance updates — both auto-approve
|
||||
* (called immediately after save) and manual approve go through here.
|
||||
@@ -710,21 +736,21 @@ class StockManager {
|
||||
|
||||
// ── Approve this row ──────────────────────────────────────────────
|
||||
$this->pdo->prepare(
|
||||
"UPDATE `{$table}` SET status = 1 WHERE id = :id AND company_id = :company_id"
|
||||
"UPDATE `{$table}` SET status = 1, updated_at = NOW() WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([':id' => $id, ':company_id' => $this->company_id]);
|
||||
|
||||
// ── Rack state + balance ──────────────────────────────────────────
|
||||
// ── Bin state + balance ──────────────────────────────────────────
|
||||
if ($type === 'in') {
|
||||
|
||||
// Occupy rack now that stock is approved
|
||||
$whMgmt->occupyRack(
|
||||
// Occupy bin now that stock is approved
|
||||
$whMgmt->occupyBin(
|
||||
$warehouse_id,
|
||||
$row['zone'], $row['aisle'], $row['rack'],
|
||||
$row['zone'], $row['aisle'], $row['bin'],
|
||||
$row['product_sku'],
|
||||
$id
|
||||
);
|
||||
$whMgmt->adjustBalance('in', $warehouse_id, $row['product_sku'], 0, (float)$row['in'],
|
||||
$id, $row['source'] ?? '', (int)($row['source_id'] ?? 0));
|
||||
$id, $row['source'] ?? '', (int)($row['source_id'] ?? 0), $row['date'] ?? '');
|
||||
|
||||
} elseif ($type === 'out') {
|
||||
|
||||
@@ -749,12 +775,12 @@ class StockManager {
|
||||
$remaining_qty = (float)$remaining_sth->fetchColumn();
|
||||
}
|
||||
|
||||
// Release the rack only when the source batch is fully consumed.
|
||||
// Release the bin only when the source batch is fully consumed.
|
||||
if ($remaining_qty <= 0.000001) {
|
||||
$whMgmt->releaseRack($warehouse_id, $row['zone'], $row['aisle'], $row['rack']);
|
||||
$whMgmt->releaseBin($warehouse_id, $row['zone'], $row['aisle'], $row['bin']);
|
||||
}
|
||||
$whMgmt->adjustBalance('out', $warehouse_id, $row['product_sku'], 0, (float)$row['out'],
|
||||
$id, $row['source'] ?? '', (int)($row['source_id'] ?? 0));
|
||||
$id, $row['source'] ?? '', (int)($row['source_id'] ?? 0), $row['date'] ?? '');
|
||||
|
||||
} elseif ($type === 'transfer') {
|
||||
|
||||
@@ -788,6 +814,7 @@ class StockManager {
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM `{$paired_table}`
|
||||
WHERE uuid = :uuid AND company_id = :company_id
|
||||
AND `in` > 0
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':uuid' => $row['uuid'], ':company_id' => $this->company_id]);
|
||||
@@ -798,6 +825,7 @@ class StockManager {
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM `{$from_table}`
|
||||
WHERE uuid = :uuid AND company_id = :company_id
|
||||
AND `out` > 0
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':uuid' => $row['uuid'], ':company_id' => $this->company_id]);
|
||||
@@ -816,7 +844,7 @@ class StockManager {
|
||||
// Approve outbound row
|
||||
if ($from_row && (int)$from_row['status'] === 0) {
|
||||
$this->pdo->prepare(
|
||||
"UPDATE `{$from_table}` SET status = 1
|
||||
"UPDATE `{$from_table}` SET status = 1, updated_at = NOW()
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([':id' => $from_row['id'], ':company_id' => $this->company_id]);
|
||||
}
|
||||
@@ -824,31 +852,36 @@ class StockManager {
|
||||
// Approve inbound row
|
||||
if ($inbound_row && (int)$inbound_row['status'] === 0) {
|
||||
$this->pdo->prepare(
|
||||
"UPDATE `{$paired_table}` SET status = 1
|
||||
"UPDATE `{$paired_table}` SET status = 1, updated_at = NOW()
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([':id' => $inbound_row['id'], ':company_id' => $this->company_id]);
|
||||
}
|
||||
|
||||
// Rack state: release source, occupy destination
|
||||
// Bin state: release source, occupy destination
|
||||
if ($from_row && $from_wh_id) {
|
||||
$whMgmt->releaseRack(
|
||||
$whMgmt->releaseBin(
|
||||
$from_wh_id,
|
||||
$from_row['zone'], $from_row['aisle'], $from_row['rack']
|
||||
$from_row['zone'], $from_row['aisle'], $from_row['bin']
|
||||
);
|
||||
$whMgmt->adjustBalance('out', $from_wh_id, $from_row['product_sku'], 0, (float)$from_row['out'],
|
||||
(int)$from_row['id'], $from_row['source'] ?? '', (int)($from_row['source_id'] ?? 0));
|
||||
(int)$from_row['id'], $from_row['source'] ?? '', (int)($from_row['source_id'] ?? 0), $from_row['date'] ?? '');
|
||||
}
|
||||
if ($inbound_row && $paired_wh_id) {
|
||||
$whMgmt->occupyRack(
|
||||
$whMgmt->occupyBin(
|
||||
$paired_wh_id,
|
||||
$inbound_row['zone'], $inbound_row['aisle'], $inbound_row['rack'],
|
||||
$inbound_row['zone'], $inbound_row['aisle'], $inbound_row['bin'],
|
||||
$inbound_row['product_sku'],
|
||||
$inbound_row['id']
|
||||
);
|
||||
$whMgmt->adjustBalance('in', $paired_wh_id, $inbound_row['product_sku'], 0, (float)$inbound_row['in'],
|
||||
(int)$inbound_row['id'], $inbound_row['source'] ?? '', (int)($inbound_row['source_id'] ?? 0));
|
||||
(int)$inbound_row['id'], $inbound_row['source'] ?? '', (int)($inbound_row['source_id'] ?? 0), $inbound_row['date'] ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
$notify_company_id = $this->company_id;
|
||||
db_after_commit(function() use ($warehouse_id, $type, $notify_company_id) {
|
||||
notify_node("stock_updated", ["warehouse_id" => $warehouse_id, "type" => $type], $notify_company_id);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ class StockSourceManager
|
||||
$warehouse_id = (int)$match[1];
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT s.id, s.type, s.product_sku, s.`in`, s.`out`,
|
||||
s.zone, s.aisle, s.rack, s.lot_number, s.serial_number,
|
||||
s.zone, s.aisle, s.bin, s.lot_number, s.serial_number,
|
||||
s.status, s.date, s.price,
|
||||
p.product_name
|
||||
FROM `{$table}` s
|
||||
@@ -74,12 +74,14 @@ class StockSourceManager
|
||||
private function getStockTables(): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name LIKE 'td_stock_%'"
|
||||
"SELECT t.table_name
|
||||
FROM md_warehouse w
|
||||
JOIN information_schema.tables t
|
||||
ON t.table_schema = DATABASE()
|
||||
AND t.table_name = CONCAT('td_stock_', w.id)
|
||||
WHERE w.company_id = :company_id"
|
||||
);
|
||||
$sth->execute();
|
||||
$sth->execute([':company_id' => $this->companyId]);
|
||||
return $sth->fetchAll(PDO::FETCH_COLUMN);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Provides getStockTables() — discovers td_stock_* tables scoped to this
|
||||
* company's active and historical warehouses via an information_schema JOIN.
|
||||
*
|
||||
* Requires the using class to expose:
|
||||
* $this->pdo PDO
|
||||
* $this->company_id int
|
||||
*/
|
||||
trait StockTablesTrait
|
||||
{
|
||||
private function getStockTables(): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT t.table_name
|
||||
FROM md_warehouse w
|
||||
JOIN information_schema.tables t
|
||||
ON t.table_schema = DATABASE()
|
||||
AND t.table_name = CONCAT('td_stock_', w.id)
|
||||
WHERE w.company_id = :company_id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id]);
|
||||
return $sth->fetchAll(PDO::FETCH_COLUMN);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/DocumentNumberManager.php';
|
||||
require_once __DIR__ . '/StockTablesTrait.php';
|
||||
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
|
||||
require_once __DIR__ . '/WarehouseManager.php';
|
||||
|
||||
/**
|
||||
* SupplierReturnManager
|
||||
@@ -25,6 +29,8 @@
|
||||
*/
|
||||
class SupplierReturnManager {
|
||||
|
||||
use StockTablesTrait;
|
||||
|
||||
private PDO $pdo;
|
||||
private int $company_id;
|
||||
|
||||
@@ -37,27 +43,24 @@ class SupplierReturnManager {
|
||||
// Private helpers
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
private function generateReturnNumber(): string
|
||||
private function generateReturnNumber(array $data = []): string
|
||||
{
|
||||
$prefix = 'SRN-' . date('Ymd') . '-';
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT return_number FROM td_supplier_return
|
||||
WHERE company_id = :company_id
|
||||
AND return_number LIKE :prefix
|
||||
ORDER BY return_number DESC
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':prefix' => $prefix . '%']);
|
||||
|
||||
$last = $sth->fetchColumn();
|
||||
$seq = $last ? ((int)substr($last, -4) + 1) : 1;
|
||||
return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT);
|
||||
return (new DocumentNumberManager($this->pdo, $this->company_id))
|
||||
->resolveNumber($data, 'supplier_return', 'td_supplier_return', 'return_number');
|
||||
}
|
||||
|
||||
private function stockTableName(int $warehouse_id): string
|
||||
{
|
||||
if ($warehouse_id <= 0) throw new Exception("Invalid warehouse id.");
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id FROM md_warehouse
|
||||
WHERE company_id = :company_id AND id = :id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $warehouse_id]);
|
||||
if (!$sth->fetchColumn()) {
|
||||
throw new Exception("Warehouse ID {$warehouse_id} not found.");
|
||||
}
|
||||
|
||||
return 'td_stock_' . $warehouse_id;
|
||||
}
|
||||
|
||||
@@ -70,13 +73,7 @@ class SupplierReturnManager {
|
||||
*/
|
||||
private function deriveFulfillmentStatus(array $ret): int
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT table_name FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name LIKE 'td_stock_%'"
|
||||
);
|
||||
$sth->execute();
|
||||
$tables = $sth->fetchAll(PDO::FETCH_COLUMN);
|
||||
$tables = $this->getStockTables();
|
||||
|
||||
$has_stock_out = false;
|
||||
$has_draft = false;
|
||||
@@ -114,10 +111,10 @@ class SupplierReturnManager {
|
||||
$sth = $this->pdo->prepare(
|
||||
"INSERT INTO td_supplier_return_item
|
||||
(company_id, return_id, item_id, product_sku, product_name,
|
||||
quantity, unit_price, total_price, tax_amount, tax_rate, warehouse_id, stock_in_id, zone, aisle, rack)
|
||||
quantity, unit_price, total_price, tax_amount, tax_rate, warehouse_id, stock_in_id, zone, aisle, bin)
|
||||
VALUES
|
||||
(:company_id, :return_id, :item_id, :product_sku, :product_name,
|
||||
:quantity, :unit_price, :total_price, :tax_amount, :tax_rate, :warehouse_id, :stock_in_id, :zone, :aisle, :rack)"
|
||||
:quantity, :unit_price, :total_price, :tax_amount, :tax_rate, :warehouse_id, :stock_in_id, :zone, :aisle, :bin)"
|
||||
);
|
||||
foreach ($items as $pos => $item) {
|
||||
$sth->execute([
|
||||
@@ -135,7 +132,7 @@ class SupplierReturnManager {
|
||||
':stock_in_id' => (int)($item['stock_in_id'] ?? 0),
|
||||
':zone' => $item['zone'] ?? '',
|
||||
':aisle' => $item['aisle'] ?? '',
|
||||
':rack' => $item['rack'] ?? '',
|
||||
':bin' => $item['bin'] ?? '',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -332,7 +329,7 @@ class SupplierReturnManager {
|
||||
)->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':uuid' => bin2hex(random_bytes(16)),
|
||||
':return_number' => $this->generateReturnNumber(),
|
||||
':return_number' => $this->generateReturnNumber($data),
|
||||
':po_id' => (int)($data['po_id'] ?? 0),
|
||||
':invoice_id' => (int)($data['invoice_id'] ?? 0),
|
||||
':contact_id' => (int)($data['contact_id'] ?? 0),
|
||||
@@ -358,7 +355,7 @@ class SupplierReturnManager {
|
||||
* Flow per item:
|
||||
* 1. Validate stock-in row (from PO receive) is approved.
|
||||
* 2. INSERT td_stock_<warehouse_id> out row, status respects $auto_approve.
|
||||
* 3. If $auto_approve: releaseRack() + adjustBalance('out').
|
||||
* 3. If $auto_approve: releaseBin() + adjustBalance('out').
|
||||
* After all items:
|
||||
* 4. UPDATE td_supplier_return.status = 1.
|
||||
*
|
||||
@@ -443,9 +440,9 @@ class SupplierReturnManager {
|
||||
|
||||
$zone = $item['zone'] ?? '';
|
||||
$aisle = $item['aisle'] ?? '';
|
||||
$rack = $item['rack'] ?? '';
|
||||
if ($zone === '' && $rack !== '') $zone = $rack;
|
||||
if ($aisle === '' && $rack !== '') $aisle = $rack;
|
||||
$bin = $item['bin'] ?? '';
|
||||
if ($zone === '' && $bin !== '') $zone = $bin;
|
||||
if ($aisle === '' && $bin !== '') $aisle = $bin;
|
||||
|
||||
$item_uuid = $uuid . '_srn_' . $i;
|
||||
$item_log = [array_merge($logging, ['action' => 'confirm_return_item'])];
|
||||
@@ -453,13 +450,13 @@ class SupplierReturnManager {
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO `{$table}`
|
||||
(uuid, company_id, `date`, product_sku, `out`,
|
||||
zone, aisle, rack,
|
||||
zone, aisle, bin,
|
||||
contact_id, `description`, `log`, `type`,
|
||||
lot_number, serial_number,
|
||||
source, source_id, ref_id, price, status)
|
||||
VALUES
|
||||
(:uuid, :company_id, :date, :product_sku, :quantity,
|
||||
:zone, :aisle, :rack,
|
||||
:zone, :aisle, :bin,
|
||||
:contact_id, :description, :log, 'out',
|
||||
:lot_number, :serial_number,
|
||||
'supplier_return', :source_id, :ref_id, :price, :status)"
|
||||
@@ -471,7 +468,7 @@ class SupplierReturnManager {
|
||||
':quantity' => $quantity,
|
||||
':zone' => $zone,
|
||||
':aisle' => $aisle,
|
||||
':rack' => $rack,
|
||||
':bin' => $bin,
|
||||
':contact_id' => (int)$return['contact_id'],
|
||||
':description' => $return['return_number'],
|
||||
':log' => json_encode($item_log),
|
||||
@@ -485,7 +482,7 @@ class SupplierReturnManager {
|
||||
|
||||
if ($auto_approve) {
|
||||
$stock_out_id = (int)$this->pdo->lastInsertId();
|
||||
$whMgmt->releaseRack($warehouse_id, $zone, $aisle, $rack);
|
||||
$whMgmt->releaseBin($warehouse_id, $zone, $aisle, $bin);
|
||||
$whMgmt->adjustBalance('out', $warehouse_id, $product_sku, 0, $quantity);
|
||||
}
|
||||
}
|
||||
@@ -570,7 +567,7 @@ class SupplierReturnManager {
|
||||
|
||||
// Fetch ALL non-cancelled stock-out rows (status=1 approved or status=0 draft)
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id, status, zone, aisle, rack, ref_id, `date` FROM `{$table}`
|
||||
"SELECT id, status, zone, aisle, bin, ref_id, `date` FROM `{$table}`
|
||||
WHERE company_id = :company_id
|
||||
AND source = 'supplier_return'
|
||||
AND source_id = :return_id
|
||||
@@ -592,14 +589,14 @@ class SupplierReturnManager {
|
||||
|
||||
if ((int)$row['status'] === 1) {
|
||||
$whMgmt->assertStockMovementWindow($row['date'] ?? null, 'Supplier return stock cancellation');
|
||||
// Approved rows: re-occupy rack (reverse the release) + reverse balance
|
||||
// Approved rows: re-occupy bin (reverse the release) + reverse balance
|
||||
$ref_id = (int)$row['ref_id'];
|
||||
if ($ref_id > 0) {
|
||||
$whMgmt->occupyRack($warehouse_id, $row['zone'], $row['aisle'], $row['rack'], $product_sku, $ref_id, false);
|
||||
$whMgmt->occupyBin($warehouse_id, $row['zone'], $row['aisle'], $row['bin'], $product_sku, $ref_id, false);
|
||||
}
|
||||
$whMgmt->adjustBalance('in', $warehouse_id, $product_sku, 0, $quantity);
|
||||
}
|
||||
// Draft (status=0) rows: just soft-delete — rack/balance were never applied
|
||||
// Draft (status=0) rows: just soft-delete — bin/balance were never applied
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -616,4 +613,67 @@ class SupplierReturnManager {
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete a supplier return by negating company_id on the header, items, and all
|
||||
* linked stock-out rows. Approved stock-out side effects are reversed first.
|
||||
* Blocked if any supplier credit note (not yet soft-deleted) exists for this return.
|
||||
*/
|
||||
public function softDelete(int $return_id): void
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM td_supplier_return WHERE company_id = :cid AND id = :id"
|
||||
);
|
||||
$sth->execute([':cid' => $this->company_id, ':id' => $return_id]);
|
||||
$return = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$return) throw new Exception('Supplier return not found.');
|
||||
|
||||
global $pdo1;
|
||||
if (isset($pdo1) && $pdo1 instanceof PDO) {
|
||||
$guard = new PostingWindowGuard($pdo1, $this->company_id);
|
||||
$guard->assertOpenDate($return['return_date'] ?: date('Y-m-d'), 'Supplier return deletion');
|
||||
}
|
||||
|
||||
$sth2 = $this->pdo->prepare(
|
||||
"SELECT COUNT(*) FROM td_invoice
|
||||
WHERE company_id = :cid AND source = 'supplier_return' AND source_id = :id
|
||||
AND doc_type = 'supplier_credit_note'"
|
||||
);
|
||||
$sth2->execute([':cid' => $this->company_id, ':id' => $return_id]);
|
||||
if ((int)$sth2->fetchColumn() > 0) {
|
||||
throw new Exception('Cannot delete — a supplier credit note exists for this return. Delete the credit note first.');
|
||||
}
|
||||
|
||||
// Block if any approved stock-out rows exist; user must reverse via ICS first
|
||||
$tables = $this->getStockTables();
|
||||
|
||||
foreach ($tables as $table) {
|
||||
$chk = $this->pdo->prepare(
|
||||
"SELECT COUNT(*) FROM `{$table}`
|
||||
WHERE company_id = :cid AND source = 'supplier_return' AND source_id = :id AND status = 1"
|
||||
);
|
||||
$chk->execute([':cid' => $this->company_id, ':id' => $return_id]);
|
||||
if ((int)$chk->fetchColumn() > 0) {
|
||||
throw new Exception('Cannot delete — stock-out for this supplier return has been approved. Reverse via ICS first.');
|
||||
}
|
||||
}
|
||||
|
||||
// Negate draft/pending stock-out rows (status != 1)
|
||||
foreach ($tables as $table) {
|
||||
$this->pdo->prepare(
|
||||
"UPDATE `{$table}` SET company_id = company_id * -1
|
||||
WHERE company_id = :cid AND source = 'supplier_return' AND source_id = :id AND status != 1"
|
||||
)->execute([':cid' => $this->company_id, ':id' => $return_id]);
|
||||
}
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_supplier_return_item SET company_id = company_id * -1
|
||||
WHERE return_id = :id AND company_id = :cid"
|
||||
)->execute([':id' => $return_id, ':cid' => $this->company_id]);
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_supplier_return SET company_id = company_id * -1
|
||||
WHERE id = :id AND company_id = :cid"
|
||||
)->execute([':id' => $return_id, ':cid' => $this->company_id]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,6 +156,7 @@ class UserManager {
|
||||
u.email,
|
||||
u.profile_picture,
|
||||
u.status,
|
||||
u.license,
|
||||
(m.invite_token IS NOT NULL) AS is_pending_invite
|
||||
FROM company_map_user m
|
||||
JOIN user u ON u.user_id = m.user_id
|
||||
@@ -179,19 +180,21 @@ class UserManager {
|
||||
public function searchUsers(string $keyword): array {
|
||||
if ($keyword === '') return [];
|
||||
|
||||
// Exact email match only — LIKE across the global user table leaks
|
||||
// names and usernames of users belonging to other companies.
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT u.user_id, u.username, u.name, u.surname, u.email
|
||||
FROM user u
|
||||
WHERE u.email LIKE :kw
|
||||
WHERE u.email = :email
|
||||
AND u.status = 'active'
|
||||
AND u.user_id NOT IN (
|
||||
SELECT user_id FROM company_map_user
|
||||
WHERE company_id = :company_id
|
||||
)
|
||||
ORDER BY u.email ASC
|
||||
LIMIT 10"
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([
|
||||
':kw' => '%' . $keyword . '%',
|
||||
':email' => $keyword,
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
@@ -233,26 +236,38 @@ class UserManager {
|
||||
}
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT map_id FROM company_map_user
|
||||
"SELECT map_id, invite_token FROM company_map_user
|
||||
WHERE company_id = :company_id AND user_id = :user_id
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':user_id' => $target_user_id]);
|
||||
if ($sth->fetch()) {
|
||||
$existing = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if ($existing) {
|
||||
if ($existing['invite_token']) {
|
||||
throw new Exception('An invitation is already pending for this user. Use Resend Invite to refresh it.');
|
||||
}
|
||||
throw new Exception('This user is already a member of your company.');
|
||||
}
|
||||
|
||||
// Existing user must explicitly accept — generate token and send email
|
||||
$invite_token = bin2hex(random_bytes(32));
|
||||
$expires_at = date('Y-m-d H:i:s', strtotime('+7 days'));
|
||||
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO company_map_user (company_id, user_id, role, app_access, created_at)
|
||||
VALUES (:company_id, :user_id, :role, :app_access, NOW())"
|
||||
"INSERT INTO company_map_user
|
||||
(company_id, user_id, role, app_access, invite_token, invite_expires_at, created_at)
|
||||
VALUES
|
||||
(:company_id, :user_id, :role, :app_access, :token, :expires, NOW())"
|
||||
)->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':user_id' => $target_user_id,
|
||||
':role' => $role,
|
||||
':app_access' => $app_access,
|
||||
':token' => $invite_token,
|
||||
':expires' => $expires_at,
|
||||
]);
|
||||
|
||||
return ['new_user' => false, 'email' => $target['email'], 'token' => null];
|
||||
return ['new_user' => false, 'email' => $target['email'], 'token' => $invite_token];
|
||||
}
|
||||
|
||||
// Email not in system — create a pending invited account
|
||||
@@ -260,32 +275,43 @@ class UserManager {
|
||||
$expires_at = date('Y-m-d H:i:s', strtotime('+7 days'));
|
||||
$temp_username = 'invited_' . bin2hex(random_bytes(8));
|
||||
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO user
|
||||
(username, name, surname, email, password, status, license, default_company,
|
||||
profile_picture, verify_token, verify_expires_at)
|
||||
VALUES
|
||||
(:username, '', '', :email, '', 'pending', 'user', :default_company,
|
||||
'', :token, :expires)"
|
||||
)->execute([
|
||||
':username' => $temp_username,
|
||||
':email' => $email,
|
||||
':default_company' => $this->company_id,
|
||||
':token' => $invite_token,
|
||||
':expires' => $expires_at,
|
||||
]);
|
||||
$new_user_id = (int)$this->pdo->lastInsertId();
|
||||
$this->pdo->beginTransaction();
|
||||
try {
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO user
|
||||
(username, name, surname, email, password, status, license, default_company,
|
||||
profile_picture, verify_token, verify_expires_at)
|
||||
VALUES
|
||||
(:username, '', '', :email, '', 'pending', 'user', :default_company,
|
||||
'', :token, :expires)"
|
||||
)->execute([
|
||||
':username' => $temp_username,
|
||||
':email' => $email,
|
||||
':default_company' => $this->company_id,
|
||||
':token' => $invite_token,
|
||||
':expires' => $expires_at,
|
||||
]);
|
||||
$new_user_id = (int)$this->pdo->lastInsertId();
|
||||
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO company_map_user (company_id, user_id, role, app_access, invite_token, created_at)
|
||||
VALUES (:company_id, :user_id, :role, :app_access, :token, NOW())"
|
||||
)->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':user_id' => $new_user_id,
|
||||
':role' => $role,
|
||||
':app_access' => $app_access,
|
||||
':token' => $invite_token,
|
||||
]);
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO company_map_user
|
||||
(company_id, user_id, role, app_access, invite_token, invite_expires_at, created_at)
|
||||
VALUES
|
||||
(:company_id, :user_id, :role, :app_access, :token, :expires, NOW())"
|
||||
)->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':user_id' => $new_user_id,
|
||||
':role' => $role,
|
||||
':app_access' => $app_access,
|
||||
':token' => $invite_token,
|
||||
':expires' => $expires_at,
|
||||
]);
|
||||
|
||||
$this->pdo->commit();
|
||||
} catch (Exception $e) {
|
||||
$this->pdo->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return ['new_user' => true, 'email' => $email, 'token' => $invite_token];
|
||||
}
|
||||
@@ -305,7 +331,7 @@ class UserManager {
|
||||
if (!$map_id) throw new Exception('Invalid request.');
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT u.user_id, u.email, u.status, u.license, m.invite_token
|
||||
"SELECT u.user_id, u.email, u.status, u.license, m.invite_token, m.invite_resent_at
|
||||
FROM company_map_user m
|
||||
JOIN user u ON u.user_id = m.user_id
|
||||
WHERE m.map_id = :map_id AND m.company_id = :company_id
|
||||
@@ -314,25 +340,44 @@ class UserManager {
|
||||
$sth->execute([':map_id' => $map_id, ':company_id' => $this->company_id]);
|
||||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$row) throw new Exception('User not found.');
|
||||
if ($row['license'] !== 'user') throw new Exception('Cannot resend invite to an owner account.');
|
||||
if ($row['status'] !== 'pending') throw new Exception('User has already accepted the invitation.');
|
||||
if (!$row['invite_token']) throw new Exception('No pending invitation found for this user.');
|
||||
if (!$row) throw new Exception('User not found.');
|
||||
if (!$row['invite_token']) throw new Exception('No pending invitation found for this user.');
|
||||
|
||||
$new_token = bin2hex(random_bytes(32));
|
||||
$expires_at = date('Y-m-d H:i:s', strtotime('+7 days'));
|
||||
// Rate limit — one resend per 60 seconds
|
||||
if ($row['invite_resent_at'] &&
|
||||
strtotime($row['invite_resent_at']) > time() - 60) {
|
||||
throw new Exception('Please wait before resending the invitation.');
|
||||
}
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE user SET verify_token = :token, verify_expires_at = :expires
|
||||
WHERE user_id = :uid"
|
||||
)->execute([':token' => $new_token, ':expires' => $expires_at, ':uid' => (int)$row['user_id']]);
|
||||
$is_new_user = ($row['license'] === 'user' && $row['status'] === 'pending');
|
||||
$new_token = bin2hex(random_bytes(32));
|
||||
$expires_at = date('Y-m-d H:i:s', strtotime('+7 days'));
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE company_map_user SET invite_token = :token
|
||||
WHERE map_id = :map_id AND company_id = :company_id"
|
||||
)->execute([':token' => $new_token, ':map_id' => $map_id, ':company_id' => $this->company_id]);
|
||||
$this->pdo->beginTransaction();
|
||||
try {
|
||||
// Brand-new pending accounts also need user.verify_token updated (used by invited_onboarding.php)
|
||||
if ($is_new_user) {
|
||||
$this->pdo->prepare(
|
||||
"UPDATE user SET verify_token = :token, verify_expires_at = :expires
|
||||
WHERE user_id = :uid"
|
||||
)->execute([':token' => $new_token, ':expires' => $expires_at, ':uid' => (int)$row['user_id']]);
|
||||
}
|
||||
|
||||
return ['email' => $row['email'], 'token' => $new_token];
|
||||
$this->pdo->prepare(
|
||||
"UPDATE company_map_user
|
||||
SET invite_token = :token,
|
||||
invite_expires_at = :expires,
|
||||
invite_resent_at = NOW()
|
||||
WHERE map_id = :map_id AND company_id = :company_id"
|
||||
)->execute([':token' => $new_token, ':expires' => $expires_at, ':map_id' => $map_id, ':company_id' => $this->company_id]);
|
||||
|
||||
$this->pdo->commit();
|
||||
} catch (Exception $e) {
|
||||
$this->pdo->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return ['email' => $row['email'], 'token' => $new_token, 'is_new_user' => $is_new_user];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -436,21 +481,43 @@ class UserManager {
|
||||
if ($row['role'] === 'owner') throw new Exception('The owner cannot be removed.');
|
||||
if ((int)$row['user_id'] === $this->user_id) throw new Exception('You cannot remove yourself.');
|
||||
|
||||
$this->pdo->prepare(
|
||||
"DELETE FROM company_map_user
|
||||
WHERE map_id = :map_id AND company_id = :company_id"
|
||||
)->execute([':map_id' => $map_id, ':company_id' => $this->company_id]);
|
||||
$target_uid = (int)$row['user_id'];
|
||||
|
||||
// If this was a pending invited account that was never activated, delete
|
||||
// the placeholder user row so the email is free for future invitations.
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT license, status FROM user WHERE user_id = :uid LIMIT 1"
|
||||
);
|
||||
$sth->execute([':uid' => (int)$row['user_id']]);
|
||||
$u = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if ($u && $u['license'] === 'user' && $u['status'] === 'pending') {
|
||||
$this->pdo->prepare("DELETE FROM user WHERE user_id = :uid")
|
||||
->execute([':uid' => (int)$row['user_id']]);
|
||||
$this->pdo->beginTransaction();
|
||||
try {
|
||||
// Lock the user row first — if invited_onboarding.php is activating this
|
||||
// account at the same moment, one will wait rather than both proceeding
|
||||
// with stale status data.
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT license, status, default_company FROM user
|
||||
WHERE user_id = :uid LIMIT 1 FOR UPDATE"
|
||||
);
|
||||
$sth->execute([':uid' => $target_uid]);
|
||||
$u = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
$this->pdo->prepare(
|
||||
"DELETE FROM company_map_user
|
||||
WHERE map_id = :map_id AND company_id = :company_id"
|
||||
)->execute([':map_id' => $map_id, ':company_id' => $this->company_id]);
|
||||
|
||||
if ($u) {
|
||||
if ($u['license'] === 'user' && $u['status'] === 'pending') {
|
||||
// Never activated — delete the placeholder row so the email is free
|
||||
$this->pdo->prepare("DELETE FROM user WHERE user_id = :uid")
|
||||
->execute([':uid' => $target_uid]);
|
||||
} elseif ((int)$u['default_company'] === $this->company_id) {
|
||||
// Active user removed from their default company — clear it so they
|
||||
// are not left pointing at a company they no longer belong to
|
||||
$this->pdo->prepare(
|
||||
"UPDATE user SET default_company = 0 WHERE user_id = :uid"
|
||||
)->execute([':uid' => $target_uid]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->pdo->commit();
|
||||
} catch (Exception $e) {
|
||||
$this->pdo->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,10 +106,45 @@ class ChartOfAccounts
|
||||
public function delete(int $id): void
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"UPDATE md_account SET status = 0
|
||||
WHERE company_id = :cid AND id = :id"
|
||||
"SELECT account_code FROM md_account
|
||||
WHERE company_id = :cid AND id = :id LIMIT 1"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
|
||||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row) throw new Exception('Account not found.');
|
||||
$code = $row['account_code'];
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT COUNT(*) FROM td_gl_item WHERE account_code = :code LIMIT 1"
|
||||
);
|
||||
$sth->execute([':code' => $code]);
|
||||
if ((int)$sth->fetchColumn() > 0) {
|
||||
throw new Exception("Cannot deactivate — account {$code} has GL journal entries.");
|
||||
}
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT COUNT(*) FROM md_account_formula_item
|
||||
WHERE company_id = :cid AND account_code = :code LIMIT 1"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':code' => $code]);
|
||||
if ((int)$sth->fetchColumn() > 0) {
|
||||
throw new Exception("Cannot deactivate — account {$code} is used in one or more account formulas.");
|
||||
}
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT COUNT(*) FROM md_product
|
||||
WHERE company_id = :cid
|
||||
AND (sales_account_code = :code OR purchase_account_code = :code) LIMIT 1"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':code' => $code]);
|
||||
if ((int)$sth->fetchColumn() > 0) {
|
||||
throw new Exception("Cannot deactivate — account {$code} is mapped to one or more products.");
|
||||
}
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE md_account SET status = 0
|
||||
WHERE company_id = :cid AND id = :id"
|
||||
)->execute([':cid' => $this->companyId, ':id' => $id]);
|
||||
}
|
||||
|
||||
public function isPostingAccount(string $account_code): bool
|
||||
|
||||
@@ -71,10 +71,17 @@ class DepartmentManager
|
||||
public function delete(int $id): void
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT COUNT(*) FROM td_gl_item WHERE department_id = :id LIMIT 1"
|
||||
);
|
||||
$sth->execute([':id' => $id]);
|
||||
if ((int)$sth->fetchColumn() > 0) {
|
||||
throw new Exception('Cannot deactivate — this department has GL journal entries.');
|
||||
}
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE md_department SET status = 0
|
||||
WHERE company_id = :cid AND id = :id"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
|
||||
)->execute([':cid' => $this->companyId, ':id' => $id]);
|
||||
}
|
||||
|
||||
public function getStats(): array
|
||||
|
||||
@@ -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, MAX(source_updated_at) AS etl_ts
|
||||
FROM etl_gl_summary
|
||||
WHERE company_id = :cid2
|
||||
GROUP BY period
|
||||
) etl ON etl.period = g.period
|
||||
WHERE g.company_id = :cid
|
||||
AND g.source_type NOT IN ('voided', 'reversal')
|
||||
AND (
|
||||
etl.period IS NULL
|
||||
OR g.updated_at > etl.etl_ts
|
||||
)
|
||||
ORDER BY g.period
|
||||
");
|
||||
$sth->execute([':cid' => $this->companyId, ':cid2' => $this->companyId]);
|
||||
return $sth->fetchAll(PDO::FETCH_COLUMN);
|
||||
}
|
||||
|
||||
public function rebuildFull(): array
|
||||
{
|
||||
$this->pdo->prepare(
|
||||
"DELETE FROM etl_gl_summary WHERE company_id = :cid"
|
||||
)->execute([':cid' => $this->companyId]);
|
||||
|
||||
$this->pdo->prepare("
|
||||
INSERT INTO etl_gl_summary
|
||||
(company_id, acc_code, period, debit, credit, count, source_updated_at, updated_at)
|
||||
SELECT
|
||||
i.company_id,
|
||||
i.account_code,
|
||||
g.period,
|
||||
SUM(i.debit),
|
||||
SUM(i.credit),
|
||||
COUNT(*),
|
||||
MAX(g.updated_at),
|
||||
NOW()
|
||||
FROM td_gl_item i
|
||||
JOIN td_gl g ON g.id = i.gl_id AND g.company_id = i.company_id
|
||||
WHERE i.company_id = :cid
|
||||
AND g.source_type != 'voided'
|
||||
GROUP BY i.company_id, i.account_code, g.period
|
||||
")->execute([':cid' => $this->companyId]);
|
||||
|
||||
$periods_rebuilt = (int)$this->pdo->query(
|
||||
"SELECT COUNT(DISTINCT period) FROM etl_gl_summary WHERE company_id = {$this->companyId}"
|
||||
)->fetchColumn();
|
||||
|
||||
$this->saveStatus('ok', 0, $periods_rebuilt);
|
||||
|
||||
return ['periods_rebuilt' => $periods_rebuilt];
|
||||
}
|
||||
|
||||
public function rebuildPeriod(string $period): void
|
||||
{
|
||||
$this->pdo->prepare(
|
||||
"DELETE FROM etl_gl_summary WHERE company_id = :cid AND period = :period"
|
||||
)->execute([':cid' => $this->companyId, ':period' => $period]);
|
||||
|
||||
$this->pdo->prepare("
|
||||
INSERT INTO etl_gl_summary
|
||||
(company_id, acc_code, period, debit, credit, count, source_updated_at, updated_at)
|
||||
SELECT
|
||||
i.company_id,
|
||||
i.account_code,
|
||||
g.period,
|
||||
SUM(i.debit),
|
||||
SUM(i.credit),
|
||||
COUNT(*),
|
||||
MAX(g.updated_at),
|
||||
NOW()
|
||||
FROM td_gl_item i
|
||||
JOIN td_gl g ON g.id = i.gl_id AND g.company_id = i.company_id
|
||||
WHERE i.company_id = :cid
|
||||
AND g.period = :period
|
||||
AND g.source_type != 'voided'
|
||||
GROUP BY i.company_id, i.account_code, g.period
|
||||
")->execute([':cid' => $this->companyId, ':period' => $period]);
|
||||
}
|
||||
|
||||
public function getStatus(): array
|
||||
{
|
||||
$keys = ['etl_last_ran', 'etl_last_result', 'etl_gaps_found', 'etl_periods_rebuilt'];
|
||||
$sth = $this->pdo1->prepare(
|
||||
"SELECT setting_key, value FROM company_setting
|
||||
WHERE company_id = :cid AND setting_key IN ('" . implode("','", $keys) . "')"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId]);
|
||||
$rows = $sth->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||
|
||||
$slot_hour = $this->companyId % 24;
|
||||
$last_ran = $rows['etl_last_ran'] ?? null;
|
||||
$next_run = $last_ran
|
||||
? date('Y-m-d', strtotime($last_ran . ' +1 day')) . sprintf(' %02d:00', $slot_hour)
|
||||
: date('Y-m-d') . sprintf(' %02d:00', $slot_hour);
|
||||
|
||||
return [
|
||||
'slot_hour' => $slot_hour,
|
||||
'last_ran' => $last_ran,
|
||||
'last_result' => $rows['etl_last_result'] ?? null,
|
||||
'gaps_found' => (int)($rows['etl_gaps_found'] ?? 0),
|
||||
'periods_rebuilt' => (int)($rows['etl_periods_rebuilt'] ?? 0),
|
||||
'next_run' => $next_run,
|
||||
];
|
||||
}
|
||||
|
||||
// ── Private ──────────────────────────────────────────────────────────────
|
||||
|
||||
private function saveStatus(string $result, int $gaps_found, int $periods_rebuilt): void
|
||||
{
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$data = [
|
||||
'etl_last_ran' => $now,
|
||||
'etl_last_result' => $result,
|
||||
'etl_gaps_found' => (string)$gaps_found,
|
||||
'etl_periods_rebuilt' => (string)$periods_rebuilt,
|
||||
];
|
||||
$sth = $this->pdo1->prepare(
|
||||
"INSERT INTO company_setting (company_id, setting_key, value, updated_at)
|
||||
VALUES (:cid, :key, :val, :ts)
|
||||
ON DUPLICATE KEY UPDATE value = VALUES(value), updated_at = VALUES(updated_at)"
|
||||
);
|
||||
foreach ($data as $key => $val) {
|
||||
$sth->execute([':cid' => $this->companyId, ':key' => $key, ':val' => $val, ':ts' => $now]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,15 @@
|
||||
/**
|
||||
* FinancialReports
|
||||
*
|
||||
* Generates all financial statements from td_gl + td_gl_item joined with md_account.
|
||||
* Read-only — no writes. All results are reusable across API endpoints and future reports.
|
||||
* Generates all financial statements using a mixed ETL + raw strategy:
|
||||
* - etl_gl_summary for interior whole months (fast aggregation)
|
||||
* - td_gl_item for boundary months (precise date filtering)
|
||||
*
|
||||
* ETL is bypassed when dept_id != 0 (ETL has no department dimension)
|
||||
* or when from_month == to_month (single month = only one boundary, no interior).
|
||||
*
|
||||
* Rule: never query ETL for the start or end month of a range — only months
|
||||
* that are fully contained within the range are safe to read from the aggregate table.
|
||||
*/
|
||||
class FinancialReports
|
||||
{
|
||||
@@ -16,6 +23,215 @@ class FinancialReports
|
||||
$this->companyId = $company_id;
|
||||
}
|
||||
|
||||
// ── ETL-aware aggregate helpers ───────────────────────────────────────────
|
||||
// All three return: [acc_code => ['debit' => float, 'credit' => float]]
|
||||
|
||||
/**
|
||||
* Sum debits/credits per account for journal_date in [from_date, to_date].
|
||||
* Uses ETL for interior whole months; raw for boundary months.
|
||||
*/
|
||||
private function aggRange(string $from_date, string $to_date, int $dept_id = 0): array
|
||||
{
|
||||
$from_month = substr($from_date, 0, 7);
|
||||
$to_month = substr($to_date, 0, 7);
|
||||
$same_month = ($from_month === $to_month);
|
||||
$dept_filter = $dept_id ? 'AND i.department_id = :dept_id' : '';
|
||||
|
||||
$out = [];
|
||||
$merge = function (array $rows) use (&$out) {
|
||||
foreach ($rows as $r) {
|
||||
$c = (int)$r['acc_code'];
|
||||
$out[$c]['debit'] = ($out[$c]['debit'] ?? 0.0) + (float)$r['d'];
|
||||
$out[$c]['credit'] = ($out[$c]['credit'] ?? 0.0) + (float)$r['c'];
|
||||
}
|
||||
};
|
||||
|
||||
if ($same_month || $dept_id !== 0) {
|
||||
$sth = $this->pdo->prepare("
|
||||
SELECT i.account_code AS acc_code, SUM(i.debit) AS d, SUM(i.credit) AS c
|
||||
FROM td_gl_item i
|
||||
JOIN td_gl g ON g.id = i.gl_id AND g.company_id = i.company_id
|
||||
WHERE i.company_id = :cid
|
||||
AND g.journal_date BETWEEN :fd AND :td
|
||||
AND g.source_type != 'voided'
|
||||
$dept_filter
|
||||
GROUP BY i.account_code
|
||||
");
|
||||
$p = [':cid' => $this->companyId, ':fd' => $from_date, ':td' => $to_date];
|
||||
if ($dept_id) $p[':dept_id'] = $dept_id;
|
||||
$sth->execute($p);
|
||||
$merge($sth->fetchAll(PDO::FETCH_ASSOC));
|
||||
return $out;
|
||||
}
|
||||
|
||||
// ETL: interior months only (strictly between boundaries)
|
||||
$sth = $this->pdo->prepare("
|
||||
SELECT acc_code, SUM(debit) AS d, SUM(credit) AS c
|
||||
FROM etl_gl_summary
|
||||
WHERE company_id = :cid AND period > :fm AND period < :tm
|
||||
GROUP BY acc_code
|
||||
");
|
||||
$sth->execute([':cid' => $this->companyId, ':fm' => $from_month, ':tm' => $to_month]);
|
||||
$merge($sth->fetchAll(PDO::FETCH_ASSOC));
|
||||
|
||||
// Raw: start boundary month (from_date → last day of from_month)
|
||||
$from_month_end = date('Y-m-t', strtotime($from_date));
|
||||
$sth = $this->pdo->prepare("
|
||||
SELECT i.account_code AS acc_code, SUM(i.debit) AS d, SUM(i.credit) AS c
|
||||
FROM td_gl_item i
|
||||
JOIN td_gl g ON g.id = i.gl_id AND g.company_id = i.company_id
|
||||
WHERE i.company_id = :cid
|
||||
AND g.journal_date BETWEEN :fd AND :fe
|
||||
AND g.source_type != 'voided'
|
||||
GROUP BY i.account_code
|
||||
");
|
||||
$sth->execute([':cid' => $this->companyId, ':fd' => $from_date, ':fe' => $from_month_end]);
|
||||
$merge($sth->fetchAll(PDO::FETCH_ASSOC));
|
||||
|
||||
// Raw: end boundary month (1st of to_month → to_date)
|
||||
$sth = $this->pdo->prepare("
|
||||
SELECT i.account_code AS acc_code, SUM(i.debit) AS d, SUM(i.credit) AS c
|
||||
FROM td_gl_item i
|
||||
JOIN td_gl g ON g.id = i.gl_id AND g.company_id = i.company_id
|
||||
WHERE i.company_id = :cid
|
||||
AND g.journal_date BETWEEN :ts AND :td
|
||||
AND g.source_type != 'voided'
|
||||
GROUP BY i.account_code
|
||||
");
|
||||
$sth->execute([':cid' => $this->companyId, ':ts' => $to_month . '-01', ':td' => $to_date]);
|
||||
$merge($sth->fetchAll(PDO::FETCH_ASSOC));
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum debits/credits per account for journal_date strictly BEFORE before_date.
|
||||
* Uses ETL for months prior to the month containing before_date; raw within that month.
|
||||
*/
|
||||
private function aggBefore(string $before_date, int $dept_id = 0): array
|
||||
{
|
||||
$month = substr($before_date, 0, 7);
|
||||
$month_start = $month . '-01';
|
||||
$dept_filter = $dept_id ? 'AND i.department_id = :dept_id' : '';
|
||||
|
||||
$out = [];
|
||||
$merge = function (array $rows) use (&$out) {
|
||||
foreach ($rows as $r) {
|
||||
$c = (int)$r['acc_code'];
|
||||
$out[$c]['debit'] = ($out[$c]['debit'] ?? 0.0) + (float)$r['d'];
|
||||
$out[$c]['credit'] = ($out[$c]['credit'] ?? 0.0) + (float)$r['c'];
|
||||
}
|
||||
};
|
||||
|
||||
if ($dept_id !== 0) {
|
||||
$sth = $this->pdo->prepare("
|
||||
SELECT i.account_code AS acc_code, SUM(i.debit) AS d, SUM(i.credit) AS c
|
||||
FROM td_gl_item i
|
||||
JOIN td_gl g ON g.id = i.gl_id AND g.company_id = i.company_id
|
||||
WHERE i.company_id = :cid
|
||||
AND g.journal_date < :bd
|
||||
AND g.source_type != 'voided'
|
||||
$dept_filter
|
||||
GROUP BY i.account_code
|
||||
");
|
||||
$p = [':cid' => $this->companyId, ':bd' => $before_date];
|
||||
if ($dept_id) $p[':dept_id'] = $dept_id;
|
||||
$sth->execute($p);
|
||||
$merge($sth->fetchAll(PDO::FETCH_ASSOC));
|
||||
return $out;
|
||||
}
|
||||
|
||||
// ETL: all periods strictly before the month containing before_date
|
||||
$sth = $this->pdo->prepare("
|
||||
SELECT acc_code, SUM(debit) AS d, SUM(credit) AS c
|
||||
FROM etl_gl_summary
|
||||
WHERE company_id = :cid AND period < :month
|
||||
GROUP BY acc_code
|
||||
");
|
||||
$sth->execute([':cid' => $this->companyId, ':month' => $month]);
|
||||
$merge($sth->fetchAll(PDO::FETCH_ASSOC));
|
||||
|
||||
// Raw: within before_date's month, up to but not including before_date
|
||||
if ($before_date > $month_start) {
|
||||
$sth = $this->pdo->prepare("
|
||||
SELECT i.account_code AS acc_code, SUM(i.debit) AS d, SUM(i.credit) AS c
|
||||
FROM td_gl_item i
|
||||
JOIN td_gl g ON g.id = i.gl_id AND g.company_id = i.company_id
|
||||
WHERE i.company_id = :cid
|
||||
AND g.journal_date >= :ms AND g.journal_date < :bd
|
||||
AND g.source_type != 'voided'
|
||||
GROUP BY i.account_code
|
||||
");
|
||||
$sth->execute([':cid' => $this->companyId, ':ms' => $month_start, ':bd' => $before_date]);
|
||||
$merge($sth->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum debits/credits per account for all journal_date up to and including as_of_date.
|
||||
* Uses ETL for months before as_of_month; raw within as_of_month.
|
||||
*/
|
||||
private function aggUpTo(string $as_of_date, int $dept_id = 0): array
|
||||
{
|
||||
$month = substr($as_of_date, 0, 7);
|
||||
$month_start = $month . '-01';
|
||||
$dept_filter = $dept_id ? 'AND i.department_id = :dept_id' : '';
|
||||
|
||||
$out = [];
|
||||
$merge = function (array $rows) use (&$out) {
|
||||
foreach ($rows as $r) {
|
||||
$c = (int)$r['acc_code'];
|
||||
$out[$c]['debit'] = ($out[$c]['debit'] ?? 0.0) + (float)$r['d'];
|
||||
$out[$c]['credit'] = ($out[$c]['credit'] ?? 0.0) + (float)$r['c'];
|
||||
}
|
||||
};
|
||||
|
||||
if ($dept_id !== 0) {
|
||||
$sth = $this->pdo->prepare("
|
||||
SELECT i.account_code AS acc_code, SUM(i.debit) AS d, SUM(i.credit) AS c
|
||||
FROM td_gl_item i
|
||||
JOIN td_gl g ON g.id = i.gl_id AND g.company_id = i.company_id
|
||||
WHERE i.company_id = :cid
|
||||
AND g.journal_date <= :as_of
|
||||
AND g.source_type != 'voided'
|
||||
$dept_filter
|
||||
GROUP BY i.account_code
|
||||
");
|
||||
$p = [':cid' => $this->companyId, ':as_of' => $as_of_date];
|
||||
if ($dept_id) $p[':dept_id'] = $dept_id;
|
||||
$sth->execute($p);
|
||||
$merge($sth->fetchAll(PDO::FETCH_ASSOC));
|
||||
return $out;
|
||||
}
|
||||
|
||||
// ETL: all periods strictly before as_of_month
|
||||
$sth = $this->pdo->prepare("
|
||||
SELECT acc_code, SUM(debit) AS d, SUM(credit) AS c
|
||||
FROM etl_gl_summary
|
||||
WHERE company_id = :cid AND period < :month
|
||||
GROUP BY acc_code
|
||||
");
|
||||
$sth->execute([':cid' => $this->companyId, ':month' => $month]);
|
||||
$merge($sth->fetchAll(PDO::FETCH_ASSOC));
|
||||
|
||||
// Raw: within as_of_month up to as_of_date
|
||||
$sth = $this->pdo->prepare("
|
||||
SELECT i.account_code AS acc_code, SUM(i.debit) AS d, SUM(i.credit) AS c
|
||||
FROM td_gl_item i
|
||||
JOIN td_gl g ON g.id = i.gl_id AND g.company_id = i.company_id
|
||||
WHERE i.company_id = :cid
|
||||
AND g.journal_date BETWEEN :ms AND :as_of
|
||||
AND g.source_type != 'voided'
|
||||
GROUP BY i.account_code
|
||||
");
|
||||
$sth->execute([':cid' => $this->companyId, ':ms' => $month_start, ':as_of' => $as_of_date]);
|
||||
$merge($sth->fetchAll(PDO::FETCH_ASSOC));
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
// ── Trial Balance ─────────────────────────────────────────────────────────
|
||||
// Returns every posting account with brought-forward balance, period movement,
|
||||
// and carry-forward balance for the selected date range.
|
||||
@@ -37,68 +253,47 @@ class FinancialReports
|
||||
$from_date = $this->normalizeDate($from_date);
|
||||
$to_date = $this->normalizeDate($to_date, true);
|
||||
|
||||
$opening_agg = $this->aggBefore($from_date, $dept_id);
|
||||
$period_agg = $this->aggRange($from_date, $to_date, $dept_id);
|
||||
|
||||
$sth = $this->pdo->prepare("
|
||||
SELECT
|
||||
a.account_code,
|
||||
a.account_name,
|
||||
a.account_type,
|
||||
COALESCE(SUM(CASE WHEN COALESCE(g.journal_date, DATE(g.created_at)) < :from_date THEN i.debit ELSE 0 END), 0) AS opening_debit,
|
||||
COALESCE(SUM(CASE WHEN COALESCE(g.journal_date, DATE(g.created_at)) < :from_date THEN i.credit ELSE 0 END), 0) AS opening_credit,
|
||||
COALESCE(SUM(CASE WHEN COALESCE(g.journal_date, DATE(g.created_at)) BETWEEN :from_date AND :to_date THEN i.debit ELSE 0 END), 0) AS period_debit,
|
||||
COALESCE(SUM(CASE WHEN COALESCE(g.journal_date, DATE(g.created_at)) BETWEEN :from_date AND :to_date THEN i.credit ELSE 0 END), 0) AS period_credit
|
||||
FROM md_account a
|
||||
LEFT JOIN td_gl_item i ON i.company_id = a.company_id
|
||||
AND i.account_code = a.account_code
|
||||
AND (:dept_id = 0 OR i.department_id = :dept_id)
|
||||
LEFT JOIN td_gl g ON g.id = i.gl_id
|
||||
AND g.company_id = a.company_id
|
||||
AND COALESCE(g.journal_date, DATE(g.created_at)) <= :to_date
|
||||
WHERE a.company_id = :cid AND a.is_posting = 1 AND a.status = 1
|
||||
GROUP BY a.account_code, a.account_name, a.account_type
|
||||
HAVING opening_debit <> 0
|
||||
OR opening_credit <> 0
|
||||
OR period_debit <> 0
|
||||
OR period_credit <> 0
|
||||
ORDER BY a.account_code
|
||||
SELECT account_code, account_name, account_type
|
||||
FROM md_account
|
||||
WHERE company_id = :cid AND is_posting = 1 AND status = 1
|
||||
ORDER BY account_code
|
||||
");
|
||||
$sth->execute([
|
||||
':cid' => $this->companyId,
|
||||
':from_date' => $from_date,
|
||||
':to_date' => $to_date,
|
||||
':dept_id' => $dept_id,
|
||||
]);
|
||||
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
$sth->execute([':cid' => $this->companyId]);
|
||||
|
||||
$total_opening_debit = 0.0;
|
||||
$total_opening_credit = 0.0;
|
||||
$total_period_debit = 0.0;
|
||||
$total_period_credit = 0.0;
|
||||
$total_closing_debit = 0.0;
|
||||
$total_closing_credit = 0.0;
|
||||
$rows = [];
|
||||
$total_opening_debit = 0.0; $total_opening_credit = 0.0;
|
||||
$total_period_debit = 0.0; $total_period_credit = 0.0;
|
||||
$total_closing_debit = 0.0; $total_closing_credit = 0.0;
|
||||
|
||||
foreach ($rows as &$row) {
|
||||
$opening_balance = (float)$row['opening_debit'] - (float)$row['opening_credit'];
|
||||
$period_debit = (float)$row['period_debit'];
|
||||
$period_credit = (float)$row['period_credit'];
|
||||
$closing_balance = $opening_balance + $period_debit - $period_credit;
|
||||
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $a) {
|
||||
$code = (int)$a['account_code'];
|
||||
$od = $opening_agg[$code]['debit'] ?? 0.0;
|
||||
$oc = $opening_agg[$code]['credit'] ?? 0.0;
|
||||
$pd = $period_agg[$code]['debit'] ?? 0.0;
|
||||
$pc = $period_agg[$code]['credit'] ?? 0.0;
|
||||
|
||||
$row['opening_balance'] = $opening_balance;
|
||||
$row['opening_debit'] = $opening_balance > 0 ? $opening_balance : 0.0;
|
||||
$row['opening_credit'] = $opening_balance < 0 ? abs($opening_balance) : 0.0;
|
||||
$row['period_debit'] = $period_debit;
|
||||
$row['period_credit'] = $period_credit;
|
||||
$row['closing_balance'] = $closing_balance;
|
||||
$row['closing_debit'] = $closing_balance > 0 ? $closing_balance : 0.0;
|
||||
$row['closing_credit'] = $closing_balance < 0 ? abs($closing_balance) : 0.0;
|
||||
$opening_balance = $od - $oc;
|
||||
$closing_balance = $opening_balance + $pd - $pc;
|
||||
|
||||
$total_opening_debit += $row['opening_debit'];
|
||||
$total_opening_credit += $row['opening_credit'];
|
||||
$total_period_debit += $row['period_debit'];
|
||||
$total_period_credit += $row['period_credit'];
|
||||
$total_closing_debit += $row['closing_debit'];
|
||||
$total_closing_credit += $row['closing_credit'];
|
||||
$a['opening_debit'] = $opening_balance > 0 ? $opening_balance : 0.0;
|
||||
$a['opening_credit'] = $opening_balance < 0 ? abs($opening_balance) : 0.0;
|
||||
$a['period_debit'] = $pd;
|
||||
$a['period_credit'] = $pc;
|
||||
$a['closing_debit'] = $closing_balance > 0 ? $closing_balance : 0.0;
|
||||
$a['closing_credit'] = $closing_balance < 0 ? abs($closing_balance) : 0.0;
|
||||
$rows[] = $a;
|
||||
|
||||
$total_opening_debit += $a['opening_debit'];
|
||||
$total_opening_credit += $a['opening_credit'];
|
||||
$total_period_debit += $pd;
|
||||
$total_period_credit += $pc;
|
||||
$total_closing_debit += $a['closing_debit'];
|
||||
$total_closing_credit += $a['closing_credit'];
|
||||
}
|
||||
unset($row);
|
||||
|
||||
return [
|
||||
'rows' => $rows,
|
||||
@@ -126,39 +321,33 @@ class FinancialReports
|
||||
$from_date = $this->normalizeDate($from_date);
|
||||
$to_date = $this->normalizeDate($to_date, true);
|
||||
|
||||
$sth = $this->pdo->prepare("
|
||||
SELECT
|
||||
a.account_code,
|
||||
a.account_name,
|
||||
a.account_type,
|
||||
COALESCE(SUM(i.debit), 0) AS total_debit,
|
||||
COALESCE(SUM(i.credit), 0) AS total_credit
|
||||
FROM md_account a
|
||||
JOIN td_gl_item i ON i.company_id = a.company_id
|
||||
AND i.account_code = a.account_code
|
||||
AND (:dept_id = 0 OR i.department_id = :dept_id)
|
||||
JOIN td_gl g ON g.id = i.gl_id
|
||||
AND g.company_id = a.company_id
|
||||
AND COALESCE(g.journal_date, DATE(g.created_at)) BETWEEN :from_date AND :to_date
|
||||
WHERE a.company_id = :cid
|
||||
AND a.account_type IN ('revenue','expense')
|
||||
AND a.is_posting = 1
|
||||
GROUP BY a.account_code, a.account_name, a.account_type
|
||||
ORDER BY a.account_type DESC, a.account_code
|
||||
");
|
||||
$sth->execute([
|
||||
':cid' => $this->companyId,
|
||||
':from_date' => $from_date,
|
||||
':to_date' => $to_date,
|
||||
':dept_id' => $dept_id,
|
||||
]);
|
||||
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
$agg = $this->aggRange($from_date, $to_date, $dept_id);
|
||||
|
||||
$sth = $this->pdo->prepare("
|
||||
SELECT account_code, account_name, account_type
|
||||
FROM md_account
|
||||
WHERE company_id = :cid
|
||||
AND account_type IN ('revenue','expense')
|
||||
AND is_posting = 1
|
||||
ORDER BY account_type DESC, account_code
|
||||
");
|
||||
$sth->execute([':cid' => $this->companyId]);
|
||||
|
||||
$rows = [];
|
||||
$total_revenue = 0.0;
|
||||
$total_expense = 0.0;
|
||||
foreach ($rows as $r) {
|
||||
if ($r['account_type'] === 'revenue') $total_revenue += $r['total_credit'] - $r['total_debit'];
|
||||
if ($r['account_type'] === 'expense') $total_expense += $r['total_debit'] - $r['total_credit'];
|
||||
|
||||
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $a) {
|
||||
$code = (int)$a['account_code'];
|
||||
$d = $agg[$code]['debit'] ?? 0.0;
|
||||
$c = $agg[$code]['credit'] ?? 0.0;
|
||||
|
||||
$a['total_debit'] = $d;
|
||||
$a['total_credit'] = $c;
|
||||
$rows[] = $a;
|
||||
|
||||
if ($a['account_type'] === 'revenue') $total_revenue += $c - $d;
|
||||
if ($a['account_type'] === 'expense') $total_expense += $d - $c;
|
||||
}
|
||||
|
||||
return [
|
||||
@@ -181,52 +370,40 @@ class FinancialReports
|
||||
{
|
||||
$as_of_date = $this->normalizeDate($as_of_date, true);
|
||||
|
||||
$sth = $this->pdo->prepare("
|
||||
SELECT
|
||||
a.account_code,
|
||||
a.account_name,
|
||||
a.account_type,
|
||||
COALESCE(SUM(i.debit), 0) AS total_debit,
|
||||
COALESCE(SUM(i.credit), 0) AS total_credit
|
||||
FROM md_account a
|
||||
JOIN td_gl_item i ON i.company_id = a.company_id
|
||||
AND i.account_code = a.account_code
|
||||
AND (:dept_id = 0 OR i.department_id = :dept_id)
|
||||
JOIN td_gl g ON g.id = i.gl_id
|
||||
AND g.company_id = a.company_id
|
||||
AND COALESCE(g.journal_date, DATE(g.created_at)) <= :as_of
|
||||
WHERE a.company_id = :cid
|
||||
AND a.account_type IN ('asset','liability','equity')
|
||||
AND a.is_posting = 1 AND a.status = 1
|
||||
GROUP BY a.account_code, a.account_name, a.account_type
|
||||
ORDER BY a.account_type, a.account_code
|
||||
");
|
||||
$sth->execute([':cid' => $this->companyId, ':as_of' => $as_of_date, ':dept_id' => $dept_id]);
|
||||
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
$agg = $this->aggUpTo($as_of_date, $dept_id);
|
||||
|
||||
$sth = $this->pdo->prepare("
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN a.account_type = 'revenue'
|
||||
THEN i.credit - i.debit ELSE 0 END), 0) AS net_revenue,
|
||||
COALESCE(SUM(CASE WHEN a.account_type = 'expense'
|
||||
THEN i.debit - i.credit ELSE 0 END), 0) AS net_expense
|
||||
FROM md_account a
|
||||
JOIN td_gl_item i ON i.company_id = a.company_id
|
||||
AND i.account_code = a.account_code
|
||||
AND (:dept_id = 0 OR i.department_id = :dept_id)
|
||||
JOIN td_gl g ON g.id = i.gl_id
|
||||
AND g.company_id = a.company_id
|
||||
AND COALESCE(g.journal_date, DATE(g.created_at)) <= :as_of
|
||||
WHERE a.company_id = :cid
|
||||
AND a.account_type IN ('revenue','expense')
|
||||
AND a.is_posting = 1
|
||||
SELECT account_code, account_name, account_type
|
||||
FROM md_account
|
||||
WHERE company_id = :cid
|
||||
AND account_type IN ('asset','liability','equity','revenue','expense')
|
||||
AND is_posting = 1 AND status = 1
|
||||
ORDER BY account_type, account_code
|
||||
");
|
||||
$sth->execute([':cid' => $this->companyId, ':as_of' => $as_of_date, ':dept_id' => $dept_id]);
|
||||
$pl = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
$sth->execute([':cid' => $this->companyId]);
|
||||
|
||||
$rows = [];
|
||||
$net_revenue = 0.0;
|
||||
$net_expense = 0.0;
|
||||
|
||||
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $a) {
|
||||
$code = (int)$a['account_code'];
|
||||
$d = $agg[$code]['debit'] ?? 0.0;
|
||||
$c = $agg[$code]['credit'] ?? 0.0;
|
||||
|
||||
if (in_array($a['account_type'], ['asset', 'liability', 'equity'])) {
|
||||
$a['total_debit'] = $d;
|
||||
$a['total_credit'] = $c;
|
||||
$rows[] = $a;
|
||||
} else {
|
||||
if ($a['account_type'] === 'revenue') $net_revenue += $c - $d;
|
||||
if ($a['account_type'] === 'expense') $net_expense += $d - $c;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'rows' => $rows,
|
||||
'retained_earnings' => (float)($pl['net_revenue'] ?? 0) - (float)($pl['net_expense'] ?? 0),
|
||||
'retained_earnings' => $net_revenue - $net_expense,
|
||||
'as_of_date' => $as_of_date,
|
||||
'as_of_period' => substr($as_of_date, 0, 7),
|
||||
];
|
||||
@@ -234,25 +411,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 +462,47 @@ 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,
|
||||
];
|
||||
}
|
||||
|
||||
public function getDashboardJournalCount(): int
|
||||
@@ -319,7 +539,7 @@ class FinancialReports
|
||||
SUM(i.credit) AS total_credit
|
||||
FROM td_gl g
|
||||
JOIN td_gl_item i ON i.gl_id = g.id AND i.company_id = g.company_id
|
||||
WHERE g.company_id = :cid
|
||||
WHERE g.company_id = :cid AND g.source_type != 'voided'
|
||||
GROUP BY g.id, g.source_type, g.reference, g.description, g.journal_date, g.period, g.created_at
|
||||
ORDER BY g.created_at DESC
|
||||
LIMIT {$limit}
|
||||
@@ -404,6 +624,38 @@ class FinancialReports
|
||||
];
|
||||
}
|
||||
|
||||
// Returns periods where td_gl is newer than etl_gl_summary (drift detected)
|
||||
// or where ETL is missing entirely. Used to signal which periods need a rebuild.
|
||||
private function getEtlGaps(string $from_period = ''): array
|
||||
{
|
||||
$period_filter = $from_period ? "AND g.period >= :from_period" : "";
|
||||
|
||||
$sql = "
|
||||
SELECT DISTINCT g.period
|
||||
FROM td_gl g
|
||||
LEFT JOIN (
|
||||
SELECT 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')
|
||||
$period_filter
|
||||
AND (
|
||||
etl.period IS NULL
|
||||
OR g.updated_at > etl.etl_ts
|
||||
)
|
||||
ORDER BY g.period
|
||||
";
|
||||
$params = [':cid' => $this->companyId, ':cid2' => $this->companyId];
|
||||
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.
|
||||
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
* Lifecycle:
|
||||
* post() — first-time GL creation; throws if a record already exists.
|
||||
* replace() — snapshot current lines into td_gl.history, then delete + re-insert.
|
||||
* delete() — create a reversal journal entry (audit trail), then hard-delete the original.
|
||||
* delete() — create a reversal journal entry (debit/credit swapped), then mark the original
|
||||
* source_type = 'voided' so it stays in the table for audit trail but is invisible
|
||||
* to getBySource() lookups.
|
||||
*/
|
||||
class GlManager
|
||||
{
|
||||
@@ -69,6 +71,7 @@ class GlManager
|
||||
':updated_at' => $now,
|
||||
]);
|
||||
$this->insertLines((int)$this->pdo->lastInsertId(), $lines);
|
||||
$this->upsertEtl($lines, $period, 1, $now);
|
||||
}
|
||||
|
||||
public function replaceManual(int $gl_id, string $reference, string $description, string $journal_date, string $period, array $lines): void
|
||||
@@ -97,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]);
|
||||
@@ -120,6 +126,8 @@ class GlManager
|
||||
]);
|
||||
|
||||
$this->insertLines($gl_id, $lines);
|
||||
$this->upsertEtl($old_lines, $old_period, -1, $now);
|
||||
$this->upsertEtl($lines, $period, 1, $now);
|
||||
}
|
||||
|
||||
public function postManual(string $reference, string $description, string $journal_date, string $period, array $lines): int
|
||||
@@ -146,6 +154,7 @@ class GlManager
|
||||
]);
|
||||
$gl_id = (int)$this->pdo->lastInsertId();
|
||||
$this->insertLines($gl_id, $lines);
|
||||
$this->upsertEtl($lines, $period, 1, $now);
|
||||
return $gl_id;
|
||||
}
|
||||
|
||||
@@ -154,7 +163,7 @@ class GlManager
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id, current_version, formula_id, history, journal_date
|
||||
"SELECT id, current_version, formula_id, history, journal_date, period
|
||||
FROM td_gl
|
||||
WHERE company_id = :cid
|
||||
AND source_type = :source_type
|
||||
@@ -177,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(
|
||||
@@ -208,6 +220,8 @@ class GlManager
|
||||
]);
|
||||
|
||||
$this->insertLines($gl_id, $lines);
|
||||
$this->upsertEtl($old_lines, $old_period, -1, $now);
|
||||
$this->upsertEtl($lines, $period, 1, $now);
|
||||
}
|
||||
|
||||
public function delete(string $source_type, int $source_id): void
|
||||
@@ -262,15 +276,15 @@ 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'], 1, $now);
|
||||
}
|
||||
|
||||
// Mark original as voided — keeps audit trail; getBySource() won't match 'voided' source_type
|
||||
$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]);
|
||||
|
||||
$this->pdo->prepare(
|
||||
"DELETE FROM td_gl WHERE id = :id AND company_id = :cid"
|
||||
"UPDATE td_gl SET source_type = 'voided', updated_at = NOW()
|
||||
WHERE id = :id AND company_id = :cid"
|
||||
)->execute([':id' => $gl_id, ':cid' => $this->companyId]);
|
||||
}
|
||||
|
||||
@@ -326,4 +340,35 @@ 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, string $now = ''): void
|
||||
{
|
||||
if (empty($lines) || empty($period)) return;
|
||||
if (!$now) $now = date('Y-m-d H:i:s');
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"INSERT INTO etl_gl_summary
|
||||
(company_id, acc_code, period, debit, credit, count, source_updated_at, updated_at)
|
||||
VALUES
|
||||
(:cid, :acc, :period, :debit, :credit, :cnt, :src_ts, :src_ts)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
debit = debit + VALUES(debit),
|
||||
credit = credit + VALUES(credit),
|
||||
count = count + VALUES(count),
|
||||
source_updated_at = GREATEST(COALESCE(source_updated_at, VALUES(source_updated_at)), VALUES(source_updated_at)),
|
||||
updated_at = VALUES(updated_at)"
|
||||
);
|
||||
foreach ($lines as $line) {
|
||||
$sth->execute([
|
||||
':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,
|
||||
':src_ts' => $now,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ class GlQueryManager
|
||||
private array $invoiceTypes = ['invoice', 'credit_note', 'purchase_invoice', 'supplier_credit_note'];
|
||||
private array $mappingTypes = ['invoice', 'credit_note', 'purchase_invoice', 'supplier_credit_note', 'purchase_order'];
|
||||
private array $postingTypes = ['invoice', 'credit_note', 'purchase_invoice', 'supplier_credit_note', 'receipt', 'payment', 'purchase_order'];
|
||||
private array $journalTypes = ['invoice', 'credit_note', 'purchase_invoice', 'supplier_credit_note', 'receipt', 'payment', 'manual', 'purchase_order'];
|
||||
private array $journalTypes = ['invoice', 'credit_note', 'purchase_invoice', 'supplier_credit_note', 'receipt', 'payment', 'manual', 'purchase_order', 'reversal'];
|
||||
|
||||
public function __construct(PDO $pdo, int $company_id)
|
||||
{
|
||||
@@ -50,7 +50,7 @@ class GlQueryManager
|
||||
$date_from = $this->parseDate($date_from);
|
||||
$date_to = $this->parseDate($date_to);
|
||||
|
||||
$where = ['g.company_id = :cid'];
|
||||
$where = ['g.company_id = :cid', "g.source_type != 'voided'"];
|
||||
$params = [':cid' => $this->companyId];
|
||||
|
||||
if ($source_type && in_array($source_type, $this->journalTypes, true)) {
|
||||
@@ -201,7 +201,7 @@ class GlQueryManager
|
||||
) AS product_mapping_missing"
|
||||
: ", 0 AS product_mapping_missing";
|
||||
|
||||
$where = ['i.company_id = :cid', 'i.doc_type = :doc_type', 'i.status != 4'];
|
||||
$where = ['i.company_id = :cid', 'i.doc_type = :doc_type', 'i.status NOT IN (0, 4)'];
|
||||
$params = [':cid' => $this->companyId, ':doc_type' => $doc_type, ':source_type' => $doc_type];
|
||||
if ($date_from) { $where[] = 'i.issued_date >= :date_from'; $params[':date_from'] = $date_from; }
|
||||
if ($date_to) { $where[] = 'i.issued_date <= :date_to'; $params[':date_to'] = $date_to; }
|
||||
|
||||
@@ -64,7 +64,11 @@ class PostingWindowGuard
|
||||
$date = "{$m[3]}-{$m[2]}-{$m[1]}";
|
||||
}
|
||||
|
||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
||||
if (preg_match("#^([0-9]{4}-[0-9]{2}-[0-9]{2})[ T]#", $date, $m)) {
|
||||
$date = $m[1];
|
||||
}
|
||||
|
||||
if (!preg_match("#^[0-9]{4}-[0-9]{2}-[0-9]{2}$#", $date)) {
|
||||
throw new Exception("Invalid posting date {$date}. Use YYYY-MM-DD.");
|
||||
}
|
||||
|
||||
|
||||
@@ -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];
|
||||
@@ -77,6 +77,27 @@ if(!empty($_SESSION["login_company_id"])){
|
||||
$user_role = $map[0]['role'] ?? 'viewer';
|
||||
$_SESSION['login_role'] = $user_role;
|
||||
|
||||
// Single-session enforcement: if a session_token was issued at login, verify
|
||||
// it still matches the DB. A mismatch means a newer login has taken over.
|
||||
if (!empty($_SESSION['session_token'])) {
|
||||
$sth = $pdo1->prepare("SELECT session_token FROM user WHERE user_id = :uid LIMIT 1");
|
||||
$sth->execute([':uid' => $_SESSION['login_user_id']]);
|
||||
$db_token = $sth->fetchColumn();
|
||||
if ($db_token !== $_SESSION['session_token']) {
|
||||
session_destroy();
|
||||
http_response_code(401);
|
||||
exit(json_encode(['success' => 0, 'message' => 'You have been signed in from another device.', 'code' => 'signed_elsewhere']));
|
||||
}
|
||||
|
||||
// Keep session_last_seen fresh (throttled: at most one DB write per 60 s).
|
||||
// login_confirm.php reads this to detect when PHP GC has expired the session.
|
||||
if (!isset($_SESSION['_last_seen_updated']) || (time() - $_SESSION['_last_seen_updated']) > 60) {
|
||||
$pdo1->prepare("UPDATE user SET session_last_seen = NOW() WHERE user_id = :uid AND session_token = :tok")
|
||||
->execute([':uid' => $_SESSION['login_user_id'], ':tok' => $_SESSION['session_token']]);
|
||||
$_SESSION['_last_seen_updated'] = time();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Fail closed — reject any request that arrives without an authenticated session
|
||||
|
||||
@@ -69,17 +69,43 @@ function db_check($sth, &$answer, $options = []) {
|
||||
|
||||
|
||||
|
||||
// Run a callback after the surrounding dbTransaction() commits. If no managed
|
||||
// transaction is active, run immediately. Used for non-authoritative side effects
|
||||
// such as live dashboard notifications.
|
||||
function db_after_commit(callable $callback): void {
|
||||
if (!isset($GLOBALS['_db_after_commit_stack']) || empty($GLOBALS['_db_after_commit_stack'])) {
|
||||
$callback();
|
||||
return;
|
||||
}
|
||||
|
||||
$idx = count($GLOBALS['_db_after_commit_stack']) - 1;
|
||||
$GLOBALS['_db_after_commit_stack'][$idx][] = $callback;
|
||||
}
|
||||
|
||||
// transaction wrapper with retry logic for deadlocks
|
||||
function dbTransaction($pdo, $callback, $maxRetries = 3) {
|
||||
for ($attempt = 0; $attempt < $maxRetries; $attempt++) {
|
||||
$GLOBALS['_db_after_commit_stack'][] = [];
|
||||
|
||||
try {
|
||||
$pdo->beginTransaction();
|
||||
$result = $callback($pdo);
|
||||
$pdo->commit();
|
||||
|
||||
$callbacks = array_pop($GLOBALS['_db_after_commit_stack']);
|
||||
foreach ($callbacks as $after_commit) {
|
||||
try {
|
||||
$after_commit();
|
||||
} catch (Throwable $e) {
|
||||
error_log("[AFTER COMMIT ERROR] " . $e->getMessage() . " | " . $e->getFile() . ":" . $e->getLine());
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
|
||||
} catch (PDOException $e) {
|
||||
$pdo->rollBack();
|
||||
if ($pdo->inTransaction()) $pdo->rollBack();
|
||||
array_pop($GLOBALS['_db_after_commit_stack']);
|
||||
|
||||
if ($e->getCode() == '40001' && $attempt < $maxRetries - 1) {
|
||||
usleep(600000 * ($attempt + 1));
|
||||
@@ -90,7 +116,8 @@ function dbTransaction($pdo, $callback, $maxRetries = 3) {
|
||||
throw $e;
|
||||
|
||||
} catch (Exception $e) {
|
||||
$pdo->rollBack();
|
||||
if ($pdo->inTransaction()) $pdo->rollBack();
|
||||
array_pop($GLOBALS['_db_after_commit_stack']);
|
||||
error_log("[ERROR] " . $e->getMessage() . " | " . $e->getFile() . ":" . $e->getLine());
|
||||
throw $e;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<?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, string $target = '', int $user_id = 0): void
|
||||
{
|
||||
if ($company_id <= 0) return;
|
||||
|
||||
$payload = ['event' => $event, 'data' => $data, 'company_id' => $company_id];
|
||||
if ($target) $payload['target'] = $target;
|
||||
if ($user_id) $payload['user_id'] = $user_id;
|
||||
|
||||
$body = json_encode($payload);
|
||||
|
||||
$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);
|
||||
}
|
||||
|
||||
/**
|
||||
* emit_notification — push a real-time notification to all browser tabs in a company.
|
||||
*
|
||||
* The browser shows a toast immediately and adds the item to the bell dropdown.
|
||||
*
|
||||
* @param int $company_id Target company
|
||||
* @param string $title Short heading, e.g. 'PO Approved'
|
||||
* @param string $message Detail line, e.g. 'PO-2605-00042 has been approved.'
|
||||
* @param string $type 'success' | 'info' | 'warning' | 'danger' (default 'info')
|
||||
*/
|
||||
// Company-wide notification — all users in the company see the toast.
|
||||
function emit_notification(int $company_id, string $title, string $message, string $type = 'info'): void
|
||||
{
|
||||
notify_node('notification', [
|
||||
'title' => $title,
|
||||
'message' => $message,
|
||||
'type' => $type,
|
||||
], $company_id);
|
||||
}
|
||||
|
||||
// Document notification — the acting user + all admins/owners see the toast. Other staff/viewers do not.
|
||||
function emit_notification_user(int $company_id, int $user_id, string $title, string $message, string $type = 'info'): void
|
||||
{
|
||||
notify_node('notification', [
|
||||
'title' => $title,
|
||||
'message' => $message,
|
||||
'type' => $type,
|
||||
], $company_id, 'user', $user_id);
|
||||
}
|
||||
|
||||
function gl_posted_payload(string $doc_type, int $id, string $action, array $lines): array
|
||||
{
|
||||
$has_revenue = false;
|
||||
$has_expense = false;
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$code = (int)($line['account_code'] ?? 0);
|
||||
if ($code >= 4000 && $code < 5000) $has_revenue = true;
|
||||
if ($code >= 5000) $has_expense = true;
|
||||
}
|
||||
|
||||
return [
|
||||
'doc_type' => $doc_type,
|
||||
'id' => $id,
|
||||
'action' => $action,
|
||||
'has_revenue' => $has_revenue,
|
||||
'has_expense' => $has_expense,
|
||||
];
|
||||
}
|
||||
@@ -22,6 +22,33 @@ if(true){
|
||||
$time_zone = "Asia/Bangkok";
|
||||
}
|
||||
|
||||
// ── Real-time Node.js server ─────────────────────────────────────────────────
|
||||
// Base URL the browser uses to reach nodejs/server.js (Socket.IO). Must match
|
||||
// PORT in nodejs/.env.
|
||||
if (!defined('NODE_PUBLIC_URL')) {
|
||||
define('NODE_PUBLIC_URL', 'http://localhost:3000');
|
||||
}
|
||||
// Server-to-server calls (PHP → Node /emit, and Node scheduler → PHP cron
|
||||
// endpoints) authenticate with this shared secret — must match EMIT_SECRET
|
||||
// in nodejs/.env exactly.
|
||||
if (!defined('NODE_EMIT_URL')) {
|
||||
define('NODE_EMIT_URL', 'http://127.0.0.1:3000/emit');
|
||||
}
|
||||
if (!defined('NODE_EMIT_SECRET')) {
|
||||
define('NODE_EMIT_SECRET', 'YOUR_NODE_EMIT_SECRET'); // must match nodejs/.env EMIT_SECRET
|
||||
}
|
||||
|
||||
// ── Usage packages ───────────────────────────────────────────────────────────
|
||||
// Keyed by company_list.package (defaults to 'starter'). Read by UsageGuard to
|
||||
// enforce daily/weekly action limits and which features lock once exceeded.
|
||||
$packages = [
|
||||
'starter' => [
|
||||
'daily_limit' => 30,
|
||||
'weekly_limit' => 100,
|
||||
'lock_on_limit' => ['dashboard'],
|
||||
],
|
||||
];
|
||||
|
||||
|
||||
|
||||
// unique key — used for SMTP password encryption, keep consistent across deploys
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes/ContactManager.php';
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_once '../../../assets/utils/classes/ContactManager.php';
|
||||
|
||||
$contact = new ContactManager($pdo2, $company_id);
|
||||
$answer['output'] = $contact->getContactList();
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<?php
|
||||
session_start();
|
||||
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes/ReportManager.php';
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_once '../../../assets/utils/classes/UsageGuard.php';
|
||||
require_once '../../../assets/utils/classes/ReportManager.php';
|
||||
|
||||
(new UsageGuard($pdo1, $company_id, $packages))->assertFeatureAccessible('dashboard');
|
||||
|
||||
$report = new ReportManager($pdo2, $company_id);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes/ContactManager.php';
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_once '../../../assets/utils/classes/ContactManager.php';
|
||||
|
||||
$contact = new ContactManager($pdo2, $company_id);
|
||||
$answer['output'] = $contact->getContactTypeList();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes/FileUploader.php';
|
||||
require '../../../assets/utils/classes/ContactManager.php';
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_once '../../../assets/utils/classes/FileUploader.php';
|
||||
require_once '../../../assets/utils/classes/ContactManager.php';
|
||||
|
||||
require_role($user_role, ['owner', 'admin', 'staff']);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes/ContactManager.php';
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_once '../../../assets/utils/classes/ContactManager.php';
|
||||
|
||||
require_role($user_role, ['owner', 'admin']);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes/ContactManager.php';
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_once '../../../assets/utils/classes/ContactManager.php';
|
||||
|
||||
require_role($user_role, ['owner', 'admin']);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes/ContactManager.php';
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_once '../../../assets/utils/classes/ContactManager.php';
|
||||
|
||||
require_role($user_role, ['owner', 'admin']);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes/ContactManager.php';
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_once '../../../assets/utils/classes/ContactManager.php';
|
||||
|
||||
$contact = new ContactManager($pdo2, $company_id);
|
||||
$answer['contact_dir'] = $include_url . 'uploads/contact/';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes/ContactManager.php';
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_once '../../../assets/utils/classes/ContactManager.php';
|
||||
|
||||
$contact = new ContactManager($pdo2, $company_id);
|
||||
$answer['output'] = $contact->getContactTypeById((int)$data['id']);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
/**
|
||||
* alert_low_stock.php — Called by Node.js scheduler daily at 8am.
|
||||
* Returns companies that have one or more SKUs below their min_stock threshold.
|
||||
* Response: { success: 1, alerts: [{ company_id, count }] }
|
||||
*/
|
||||
require __DIR__ . '/../assets/utils/cron_auth.php';
|
||||
|
||||
$sth = $pdo2->query(
|
||||
"SELECT e.company_id, COUNT(*) AS count
|
||||
FROM (
|
||||
SELECT company_id, product_sku, SUM(total_in - total_out) AS balance
|
||||
FROM etl_stock_summary
|
||||
WHERE company_id > 0
|
||||
GROUP BY company_id, product_sku
|
||||
) e
|
||||
JOIN md_product p
|
||||
ON p.sku = e.product_sku
|
||||
AND p.company_id = e.company_id
|
||||
AND p.status = 1
|
||||
WHERE p.min_stock > 0
|
||||
AND e.balance < p.min_stock
|
||||
GROUP BY e.company_id
|
||||
HAVING count > 0"
|
||||
|
||||
);
|
||||
|
||||
$answer['success'] = 1;
|
||||
$answer['alerts'] = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
exit(json_encode($answer));
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
/**
|
||||
* alert_overdue_invoices.php — Called by Node.js scheduler daily at 9am.
|
||||
* Returns companies that have one or more issued sales invoices past their due date.
|
||||
* status = 1 means issued but not fully settled. status = 2 means fully settled.
|
||||
* Response: { success: 1, alerts: [{ company_id, count }] }
|
||||
*/
|
||||
require __DIR__ . '/../assets/utils/cron_auth.php';
|
||||
|
||||
$sth = $pdo2->query(
|
||||
"SELECT company_id, COUNT(*) AS count
|
||||
FROM td_invoice
|
||||
WHERE company_id > 0
|
||||
AND doc_type = 'invoice'
|
||||
AND status = 1
|
||||
AND due_date IS NOT NULL
|
||||
AND due_date < CURDATE()
|
||||
GROUP BY company_id
|
||||
HAVING count > 0"
|
||||
);
|
||||
|
||||
$answer['success'] = 1;
|
||||
$answer['alerts'] = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
exit(json_encode($answer));
|
||||
@@ -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 __DIR__ . '/../assets/utils/cron_auth.php';
|
||||
require_once __DIR__ . '/../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));
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
/**
|
||||
* etl_stock_maintenance.php — Called by Node.js scheduler every hour.
|
||||
* Checks and repairs etl_stock_summary for companies in the current slot.
|
||||
*
|
||||
* Body: { slot: 14 } (0–23, = HOUR(NOW()) from Node.js)
|
||||
*/
|
||||
require __DIR__ . '/../assets/utils/cron_auth.php';
|
||||
require_once __DIR__ . '/../assets/utils/classes/EtlStockManager.php';
|
||||
|
||||
$slot = (int)($data['slot'] ?? -1);
|
||||
if ($slot < 0 || $slot > 23) {
|
||||
http_response_code(400);
|
||||
exit(json_encode(['success' => 0, 'message' => 'Invalid slot.']));
|
||||
}
|
||||
|
||||
$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 EtlStockManager($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));
|
||||