Compare commits

...
48 Commits
Author SHA1 Message Date
Thanakorn 6c39700d74 Add interactive script to generate root .env for docker-compose
Prompts for DB password, public host, EMIT_SECRET, and SMTP creds,
auto-generating secrets where left blank, instead of hand-editing
.env.example.
2026-08-17 13:50:03 +07:00
Thanakorn 136084f259 Add Docker Compose production stack (php-apache, mariadb, node/pm2)
Single-command deploy: docker compose up -d --build brings up the LEMP
stack plus the Node realtime/scheduler service. app/config.php and DB
secrets are generated from .env at container start, never baked into
the image or committed.
2026-08-17 13:44:14 +07:00
Thanakorn 63cea23dc4 Seed Demo Data - Rebranding 2026-08-17 13:12:07 +07:00
Thanakorn dd48a8b96d Demo Data Population 2026-08-14 14:10:31 +07:00
nok b2c437426b fix login and configurations 2026-08-03 11:17:41 +07:00
Thanakorn S a0677d6d8d Classe methods: remove reducdancy 2026-05-29 08:56:58 +07:00
Thanakorn SandClaude Sonnet 4.6 ed3dd2f215 Fix horizontal scrollbar caused by sidebar margin overflowing viewport
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 16:58:15 +07:00
Thanakorn SandClaude Sonnet 4.6 fda211b1a8 Block concurrent login: reject new session if account already active
If session_token is set and session_last_seen is within the last hour,
the incoming login is rejected with a clear message. Stale sessions
(idle > 1 h) and explicit logouts (token = NULL via back.php) still allow
re-login normally.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 16:21:55 +07:00
Thanakorn S 9a50238347 Scope stock table access by company warehouses 2026-05-28 15:36:49 +07:00
Thanakorn SandClaude Sonnet 4.6 5df67367fa Fix incomplete Rack→Bin rename: missing file renames, stale UI labels, ReportManager property bug
- Rename rack_log.php → bin_log.php and rack_occupancy.php → bin_occupancy.php
  (occupy_rack.php was already calling bin_*.php, causing 404 on every load)
- Fix occupy_rack.php: page title, stat card label (add id="stat_label_bins"),
  section headings, and <th> column headers still read "Rack/Racks"
- Fix ReportManager: constructor wrote to $this->companyId (dynamic property)
  instead of the declared $this->company_id, causing all queries to filter on
  company_id = 0 under strict PHP 8.2+ property semantics

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 09:53:19 +07:00
Thanakorn S 8f57ab5570 Change 'Rack' to 'Bin' 2026-05-27 17:14:53 +07:00
Thanakorn SandClaude Sonnet 4.6 b8798bc02d Wire targeted toast notifications to document status transitions
Adds emit_notification_user() to 7 endpoints so the acting user and
all admins/owners receive a real-time toast on key document actions:
confirm/cancel order, issue/void invoice, confirm return, confirm PO,
receive PO goods. Other staff and viewers are not notified.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:44:55 +07:00
Thanakorn S bbe89b0380 notify node: userIDguard 2026-05-27 13:12:18 +07:00
Thanakorn S 714b70d552 NODEJS cron fix 2026-05-27 12:05:29 +07:00
Thanakorn S f3c0e3c876 fix NodeJS cron 2026-05-27 11:56:40 +07:00
Thanakorn S a6651c41b9 cron low stock - overdue invoice 2026-05-27 11:45:48 +07:00
Thanakorn S 458a883810 CORS whitelist for NodeJS 2026-05-27 11:26:40 +07:00
Thanakorn S 5221b3a1a1 nodejs status check 2026-05-27 11:18:09 +07:00
Thanakorn S 418dbf9ba6 autostart node process 2026-05-27 11:07:30 +07:00
Thanakorn S 99ae35dc98 all .md reviewed - fix remaining gaps 2026-05-27 10:42:44 +07:00
Thanakorn S 1f371c6f94 add missing roleGuards 2026-05-27 09:19:52 +07:00
Thanakorn SandClaude Sonnet 4.6 d7f104ff25 Stop tracking docs/ — already in .gitignore
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 08:15:29 +07:00
Thanakorn SandClaude Sonnet 4.6 dfeb57533a Master data review: spec updates and C1/C2/M3-M6 fixes
Spec (docs/reviewing/master-data.md):
- M1: Remove margin from stored product fields (it's a derived frontend value)
- M2: Clarify posting window only restricts transaction dates, not master data
- M5: Document AccountFormulaManager::delete() as archive, not physical delete

Code:
- C1: Fix CompanySettingManager property typo (company_id → companyId) —
  prevented PHP 8.4 dynamic property fatal on all posting window operations
- C2: Fix WarehouseManager::deleteWarehouse() guard — was comparing
  warehouse_name (string) against warehouse id column (no-op); now correctly
  blocks on storage rows and active stock rows
- M3: Remove hard-delete of td_rack_log in deleteStorage() — retain rack
  history consistent with soft-delete philosophy elsewhere
- M4: Add reference guards to ChartOfAccounts::delete() (blocks on GL items,
  formula items, product account mappings) and DepartmentManager::delete()
  (blocks on GL items)
- M6: Fix CompanySettingManager::handle() partial update — only upsert keys
  present in the request, not all allowed keys defaulted

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 17:33:58 +07:00
Thanakorn SandClaude Sonnet 4.6 cb36d3b8fd Document lifecycle review: spec updates and C2/M9 code fixes
Spec (docs/reviewed/document-lifecycle.md):
- C1: Correct GlManager::delete() description — reversal entry, not hard delete
- C3: Clarify PO status 2/3 are derived (receipt_status), never stored
- C4: Add invoice status=2 (Paid/Settled) to status table
- C5: Add full Receipt/Payment Billing Note lifecycle section
- C6: Add Quotation status table and transition rules
- C7: Document soft-delete tombstone mechanism (company_id negation)

Code (InvoiceManager.php):
- C2: voidInvoice() now blocks on active credit notes, posted receipt
  billing notes, and posted payment billing notes in addition to the
  existing receipt/payment checks
- M9: softDelete() skips assertPostingWindow for draft invoices (status=0)
  since drafts have no GL entry and no accounting impact

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 16:23:46 +07:00
Thanakorn S 5687b562fb system notification 2026-05-26 13:26:57 +07:00
Thanakorn S d93abb0b81 Seal transaction limit coverage gaps 2026-05-26 13:10:54 +07:00
Thanakorn SandClaude Sonnet 4.6 b4b1f5cbec Security hardening: invited user onboarding flow (C1–N7)
- C1: verify.php now filters license='owner' — invite tokens no longer accepted
- C1: onboarding API rejects non-owner sessions
- C2: Existing-user invite requires explicit acceptance via accept_invite.php
- C2: New accept_invite.php page and API engine added
- C2: inviteUser() generates token + expiry for existing users; resendInvite() handles active users
- C3: session_regenerate_id(true) before writing invite session keys on both invite pages
- C4: invited_onboarding API wraps activation in transaction with SELECT FOR UPDATE; rowCount check added; SQLSTATE 23000 caught cleanly
- C5: inviteUser() and resendInvite() two-table writes wrapped in transactions
- M2: removeUser() wrapped in transaction with FOR UPDATE; clears default_company on active user removal
- M4: Logged-in user guard added to invited_onboarding.php and accept_invite.php
- M5: manage_users.php uses $server_url instead of HTTP_HOST for invite URLs
- M6: Username regex enforces 3-32 chars; reserved name blocklist added
- N5: searchUsers() changed from LIKE fuzzy search to exact email match only
- N7: resendInvite() rate-limited to once per 60s via invite_resent_at column
- Schema: company_map_user gains invite_expires_at and invite_resent_at columns

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 10:18:40 +07:00
Thanakorn S 1904fea84c document number sequence 2026-05-26 08:19:40 +07:00
Thanakorn S 4f884177a5 Seal live dashboard event gaps 2026-05-25 17:02:52 +07:00
Thanakorn S 4733c78ac6 Close login gap 2026-05-25 15:34:12 +07:00
Thanakorn S 9b41c0a73a PHP event trigger by NodeJS [stock dashboard] 2026-05-25 14:33:36 +07:00
Thanakorn S ba96de50a1 stock aggregate table 2026-05-25 13:30:10 +07:00
Thanakorn S 293097363b login/ block concurrent login, allow single factor authen for staff and viewer 2026-05-25 09:43:30 +07:00
Thanakorn SandClaude Sonnet 4.6 b07882e3f4 code audit fixes: require_once, issue flow, role guards
- Upgraded all plain `require` to `require_once` across 172 api/engine
  and api/engine_report files to prevent class-redeclaration errors
- Added issue button, issue_invoice() with GL toastr, and delete_invoice()
  to expense/manage_purchase_invoice.php, bringing it in line with
  po/manage_purchase_invoice.php
- Added can_delete role guard (admin/owner only) to trash icons on
  revenue/invoice.php and expense/purchase_invoice.php, matching the
  existing pattern in finance/receipt.php and finance/payment.php

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 17:06:08 +07:00
Thanakorn S eddb10aa22 automate and view gl entries 2026-05-23 16:46:35 +07:00
Thanakorn S 363da054e0 batch journal entries 2026-05-23 15:55:22 +07:00
Thanakorn S 65e9c1668d accounting Reports 2026-05-23 15:07:11 +07:00
Thanakorn S f4ef776e9c fix file path 2026-05-23 13:11:22 +07:00
Thanakorn S 59037b5158 fix file path 2026-05-23 13:09:18 +07:00
Thanakorn S f5ea74e134 gl aggregate table (ETL), cronjob by NODEJS 2026-05-23 10:52:27 +07:00
Thanakorn S 9c275eb358 NODE JS introduction: socket polling 2026-05-22 17:01:59 +07:00
Thanakorn S 2410f7bade softDelete features 2026-05-22 14:07:22 +07:00
Thanakorn S f04554793e users app access badge 2026-05-22 13:21:46 +07:00
Thanakorn S ba8e275426 user badge 2026-05-22 10:42:01 +07:00
Thanakorn S e36d304521 use roles guards 2026-05-22 08:45:35 +07:00
Thanakorn S b76dc679af fix onboarding bugs 2026-05-21 16:51:19 +07:00
Thanakorn SandClaude Sonnet 4.6 fdf6292466 add setup.php — one-shot production database setup script
Creates wms + wms2 databases and all 54 tables from scratch using
CREATE TABLE IF NOT EXISTS. Reads credentials from app/config.php.
Safe to re-run (idempotent). CLI-only guard prevents web access.
Dynamic td_stock_<warehouse_id> tables are excluded — the app creates
them automatically on first warehouse use.

Run: php setup.php

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 15:21:36 +07:00
Thanakorn SandClaude Sonnet 4.6 94032dd65b fix StockManager lot/serial coercion + add document flow test suite
- StockManager: coerce lot_number/serial_number to trimmed string (fixes
  Array-to-string warnings); validate quantity > 0 on insert; tighten
  transfer pair lookup to match in/out side by column value
- PostingWindowGuard: strip time component from datetime strings before
  date-format validation
- docs/tests/doc_flow_test.php: 32-assertion document flow suite covering
  Stock In create + approve, Sales Order draft/confirm, Invoice from Order
  (with duplicate-block check), PO create/confirm, Quotation create, and
  usage increment wiring check; all 32/32 PASS against wms_codex_test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 14:20:41 +07:00
366 changed files with 16002 additions and 4206 deletions
+17
View File
@@ -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
+8 -7
View File
@@ -2,18 +2,19 @@
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
sdlc/
+3 -3
View File
@@ -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);
+3 -3
View File
@@ -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);
+3 -3
View File
@@ -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') ?: '';
+3 -3
View File
@@ -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);
+3 -3
View File
@@ -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);
+105 -28
View File
@@ -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>
+2 -2
View File
@@ -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);
+5 -2
View File
@@ -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);
+2 -2
View File
@@ -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)));
+3 -3
View File
@@ -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);
+4 -2
View File
@@ -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';
+30 -11
View File
@@ -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();
+2 -2
View File
@@ -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);
+4 -2
View File
@@ -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();
+65 -7
View File
@@ -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>
+247 -187
View File
@@ -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">&nbsp;</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) &nbsp;·&nbsp; '
+ '<span class="text-danger">' + unposted + ' unposted</span>'
+ ' &nbsp;·&nbsp; '
+ '<span class="text-success">' + posted + ' posted</span>'
+ (missing_mapping > 0
? ' &nbsp;·&nbsp; <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'
? ' &nbsp;·&nbsp; <span class="text-info">All Formulas</span>' : '';
$badge.html(
rows.length + ' document(s)' + formula_note + ' &nbsp;·&nbsp; '
+ '<span class="text-danger">' + unposted + ' unposted</span>'
+ ' &nbsp;·&nbsp; '
+ '<span class="text-success">' + posted + ' posted</span>'
+ (missing_mapping > 0
? ' &nbsp;·&nbsp; <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('&nbsp;');
$('#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 = '&nbsp;';
if (done > 0) {
var remaining = Math.round((elapsed / done) * (total - done));
eta_txt = remaining > 0 ? ('~' + remaining + 's remaining') : 'almost done…';
}
$('#batch-overlay-pct').text(pct + '%');
$('#batch-overlay-bar').css('width', pct + '%').attr('aria-valuenow', pct);
$('#batch-overlay-count').text(done + ' / ' + total + ' documents');
$('#batch-overlay-sub').text('Processing document ' + done + ' of ' + total + '…');
$('#batch-overlay-eta').html(eta_txt);
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>
+55 -11
View File
@@ -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>
+5 -2
View File
@@ -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) {
+60 -8
View File
@@ -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>
+56 -8
View File
@@ -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>
+61 -9
View File
@@ -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>
+11 -7
View File
@@ -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; }
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1008 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 33 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 68 KiB

+122
View File
@@ -0,0 +1,122 @@
/**
* BatchOverlay — shared full-screen progress overlay.
*
* Usage:
* BatchOverlay.show(title, total, options)
* options: { unit: 'documents', color: '#dc3545' }
*
* BatchOverlay.update(done, total, currentItem)
* Advances the bar and ETA. currentItem shown in the sub-line.
*
* BatchOverlay.done(message, onClose)
* Switches to "complete" state — green checkmark + Done button.
* onClose fires when the user dismisses.
*
* BatchOverlay.hide()
* Force-hides (use for error bail-outs before done is reached).
*/
var BatchOverlay = (function () {
var _startMs = 0;
var _onClose = null;
var _unit = 'items';
var _color = '#0d6efd';
var _injected = false;
var _HTML = '<div id="bos-wrap" style="display:none;position:fixed;inset:0;z-index:9999;' +
'background:rgba(0,0,0,0.55);align-items:center;justify-content:center;">' +
'<div class="card shadow-lg p-4 text-center" style="min-width:380px;max-width:480px;width:90%;">' +
'<div id="bos-spinner" class="spinner-border mx-auto mb-3" role="status" style="width:2.5rem;height:2.5rem;"></div>' +
'<div id="bos-icon" class="mx-auto mb-3" style="display:none;font-size:2.5rem;color:#198754;"><i class="ti ti-circle-check"></i></div>' +
'<div class="fw-semibold fs-6 mb-1" id="bos-title">Processing…</div>' +
'<div class="text-muted small mb-4" id="bos-sub"></div>' +
'<div id="bos-pct" style="font-size:3rem;font-weight:700;line-height:1;letter-spacing:-1px;">0%</div>' +
'<div class="text-muted small mb-3" id="bos-count"></div>' +
'<div class="progress mb-2" style="height:10px;border-radius:6px;">' +
'<div id="bos-bar" class="progress-bar progress-bar-striped progress-bar-animated"' +
' role="progressbar" style="width:0%;border-radius:6px;"' +
' aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"></div>' +
'</div>' +
'<div class="text-muted small" id="bos-eta">&nbsp;</div>' +
'<button id="bos-close" class="btn btn-success mt-3" style="display:none;">' +
'<i class="ti ti-check me-1"></i>Done' +
'</button>' +
'</div></div>';
function _inject() {
if (_injected) return;
$('body').append(_HTML);
$('#bos-close').on('click', function () {
_resetState();
$('#bos-wrap').hide();
if (_onClose) { _onClose(); _onClose = null; }
});
_injected = true;
}
function _resetState() {
$('#bos-spinner').show().css('color', _color);
$('#bos-icon').hide();
$('#bos-pct').css('color', _color);
$('#bos-bar')
.addClass('progress-bar-striped progress-bar-animated')
.removeClass('bg-success')
.css('background-color', _color)
.css('width', '0%').attr('aria-valuenow', 0);
$('#bos-close').hide();
$('#bos-eta').html('&nbsp;');
}
function show(title, total, options) {
options = options || {};
_unit = options.unit || 'items';
_color = options.color || '#0d6efd';
_onClose = options.onClose || null;
_startMs = Date.now();
_inject();
_resetState();
$('#bos-title').text(title);
$('#bos-sub').text('Starting…');
$('#bos-pct').text('0%');
$('#bos-count').text('0 / ' + total + ' ' + _unit);
$('#bos-wrap').css('display', 'flex');
}
function update(done, total, currentItem) {
var pct = Math.round(done / total * 100);
var elapsed = (Date.now() - _startMs) / 1000;
var eta = '&nbsp;';
if (done > 0) {
var remaining = Math.round((elapsed / done) * (total - done));
eta = remaining > 0 ? ('~' + remaining + 's remaining') : 'almost done…';
}
$('#bos-pct').text(pct + '%');
$('#bos-bar').css('width', pct + '%').attr('aria-valuenow', pct);
$('#bos-count').text(done + ' / ' + total + ' ' + _unit);
if (currentItem !== undefined) $('#bos-sub').text('Processing ' + currentItem + '…');
$('#bos-eta').html(eta);
}
function done(message, onClose) {
_onClose = onClose || null;
$('#bos-spinner').hide();
$('#bos-icon').show();
$('#bos-pct').text('100%').css('color', '#198754');
$('#bos-bar')
.removeClass('progress-bar-striped progress-bar-animated')
.css('background-color', '').addClass('bg-success')
.css('width', '100%').attr('aria-valuenow', 100);
$('#bos-sub').text(message || 'Complete.');
$('#bos-eta').html('&nbsp;');
$('#bos-close').show();
}
function hide() {
if (_injected) $('#bos-wrap').hide();
}
return { show: show, update: update, done: done, hide: hide };
})();
+22 -6
View File
@@ -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>
+41 -28
View File
@@ -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);
+3 -3
View File
@@ -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]);
}
}
}
+161 -31
View File
@@ -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) ?: [];
+181 -73
View File
@@ -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]);
}
}
?>
+53 -17
View File
@@ -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']);
}
}
?>
+10 -10
View File
@@ -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);
+169 -53
View File
@@ -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]);
}
}
+45 -12
View File
@@ -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]);
}
}
?>
+53 -17
View File
@@ -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']);
}
}
?>
+96 -95
View File
@@ -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.
+99 -34
View File
@@ -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]);
}
}
+118 -85
View File
@@ -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]);
}
}
+131 -64
View File
@@ -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;
}
}
}
File diff suppressed because it is too large Load Diff
@@ -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
+172
View File
@@ -0,0 +1,172 @@
<?php
/**
* EtlManager
*
* Manages etl_gl_summary — the materialized aggregation of td_gl_item by period.
* Used for fast dashboard queries instead of scanning raw GL rows.
*
* rebuildFull() — wipe and rebuild all periods for this company (manual trigger)
* rebuildPeriod() — rebuild a single period (nightly scheduler, gap repair)
* getStatus() — return last run info from company_setting
*/
class EtlManager
{
private PDO $pdo;
private PDO $pdo1;
private int $companyId;
public function __construct(PDO $pdo, PDO $pdo1, int $company_id)
{
$this->pdo = $pdo;
$this->pdo1 = $pdo1;
$this->companyId = $company_id;
}
// ── Public ───────────────────────────────────────────────────────────────
public function checkAndRepair(): array
{
$gaps = $this->getGaps();
foreach ($gaps as $period) {
$this->rebuildPeriod($period);
}
$gaps_found = count($gaps);
$periods_rebuilt = $gaps_found;
$this->saveStatus('ok', $gaps_found, $periods_rebuilt);
return ['gaps_found' => $gaps_found, 'periods_rebuilt' => $periods_rebuilt];
}
public function getGaps(): array
{
$sth = $this->pdo->prepare("
SELECT DISTINCT g.period
FROM td_gl g
LEFT JOIN (
SELECT period, 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]);
}
}
}
+409 -157
View File
@@ -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.
+54 -9
View File
@@ -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.");
}
+21
View File
@@ -0,0 +1,21 @@
<?php
/**
* cron_auth.php — Secret-based auth for cron endpoints called by Node.js scheduler.
* No session required. Validates x-cron-secret header against NODE_EMIT_SECRET.
* Sets up $pdo1, $pdo2, and $answer identical to db_auth.php.
*/
ini_set('display_errors', 0);
ini_set('log_errors', 1);
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../../config.php';
require_once __DIR__ . '/../../dbconn.php';
$secret = $_SERVER['HTTP_X_CRON_SECRET'] ?? '';
if (!defined('NODE_EMIT_SECRET') || $secret !== NODE_EMIT_SECRET) {
http_response_code(403);
exit(json_encode(['success' => 0, 'message' => 'Forbidden']));
}
$data = json_decode(file_get_contents('php://input'), true) ?? [];
$answer = ['success' => 0];
+21
View File
@@ -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
+29 -2
View File
@@ -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;
}
+86
View File
@@ -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,
];
}
+27
View File
@@ -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
+2 -2
View File
@@ -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();
+5 -2
View File
@@ -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);
+2 -2
View File
@@ -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();
+3 -3
View File
@@ -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']);
+2 -2
View File
@@ -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']);
+2 -2
View File
@@ -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']);
+31
View File
@@ -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));
+25
View File
@@ -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));
+43
View File
@@ -0,0 +1,43 @@
<?php
/**
* etl_gl_maintenance.php — Called by Node.js scheduler every hour.
* Checks and repairs etl_gl_summary (GL summary) for companies in the current slot.
* For stock ETL maintenance use etl_stock_maintenance.php (not yet implemented).
*
* Body: { slot: 14 } (0–23, = HOUR(NOW()) from Node.js)
*/
require __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));
+41
View File
@@ -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));

Some files were not shown because too many files have changed in this diff Show More