Compare commits

...
96 Commits
Author SHA1 Message Date
Thanakorn c6e5ab2841 Version theme CSS/JS URLs so browsers drop the cached CDN main.css 2026-09-24 15:38:10 +07:00
Thanakorn 9bb92bc20a Merge branch 'fix/security-baseline' 2026-09-24 14:56:20 +07:00
Thanakorn f11af6e949 Self-host front-end libraries, minimal sign-in header and CSP
- sign-in pages load only what they use (include_login_header.php, ajax_core.js)
- every library, font and data file served from assets/vendor with pinned
  versions (VERSIONS.json); no CDN at runtime
- Content-Security-Policy on app, sign-in and landing pages
- remove httpbin Dropzone target and source-map references
2026-09-24 14:53:41 +07:00
Thanakorn 8705be0d1b Enforce roles on admin endpoints and return real status codes
- users, SMTP and batch-lock endpoints are owner/admin only
- engines answer 400/403/404/409/500 instead of 200 with an error body;
  database errors no longer leak to the client
2026-09-24 14:53:40 +07:00
Thanakorn 73c680e844 Harden sign-in and password reset
- OTP attempt limits, constant-time compare, random reference codes
- DB-backed rate limits (429) on sign-in, OTP, reset, register, onboarding
- one generic sign-in failure message; reset request no longer reveals accounts
- no password kept in the session; real status codes on failures
2026-09-24 14:53:40 +07:00
Thanakorn ae98dcdcdd Harden web root, secrets and realtime auth
- .htaccess: refuse .git, .env, docker, nodejs, sdlc*, scripts, archives and
  app internals; security headers, HSTS over TLS, optional HTTPS redirect
- uploads served through app/file.php to signed-in users only
- Apache/PHP hardening config for the container (ServerTokens, expose_php)
- least-privilege DB account and APP_SECRET_KEY via docker/php/provision.php;
  SMTP passwords re-encrypted with a random IV (secret_box.php)
- Socket.IO rooms from a PHP-signed token; /emit and cron refuse empty secrets
- escape notification text; CLI guards on build scripts; no fixed demo password
2026-09-24 14:53:40 +07:00
Thanakorn e579dd596c Start every session through session.php; idle timeout, app access and auth status codes 2026-09-24 14:30:35 +07:00
Thanakorn 5cc43cff92 Merge branch 'fix/qa-review' 2026-09-19 11:00:59 +07:00
Thanakorn c89b28da4c Fix QA review findings: server-side validation, notes encoding, dashboard totals
Validate document lines on the server and recompute their totals, store notes with quotes/markup/emoji (utf8mb4, idempotent escaping, decode in form fields), exclude transfers from company-wide stock in/out, count revenue from confirmed orders only, one low-stock rule everywhere, list unapproved lots, natural bin sort, stable order/PO sort, status tiles that add up.
2026-09-19 10:58:42 +07:00
Thanakorn de760d02da Merge fix/scan2 2026-09-19 07:45:06 +07:00
Thanakorn 3668f22e55 Fix customer return confirm, auto credit note VAT and quotation link
Store the put-away location on return lines (new td_return_item columns, needs setup.php), split auto credit notes into net and VAT with the parent's department, drop the write to a td_quotation column that does not exist, and have the demo seed confirm its customer return.
2026-09-19 07:32:07 +07:00
Thanakorn 114cf73748 Merge fix/manual-journal 2026-09-19 06:56:27 +07:00
Thanakorn 9a8998796c Redirect finance detail pages to their list when opened without an id 2026-09-19 06:33:45 +07:00
Thanakorn 80c7eab0d2 Merge fix/manual-journal 2026-09-19 06:28:11 +07:00
Thanakorn 033846dece Fix stock-out boot, transfer 500, Clear buttons and uncaught engine errors
Return JSON from any uncaught engine exception, stop the empty stock-out warehouse list aborting page boot, reject non-transfer rows in the transfer lookup, define the missing reset_input helper, and remove a stale unreferenced copy of confirm_order.php.
2026-09-18 20:21:34 +07:00
Thanakorn c14822add6 Merge fix/manual-journal 2026-09-18 16:33:35 +07:00
Thanakorn c60b705d98 Fix GL journal save, edit, detail view and list filters
Send journal lines, gl_id and list filters inside the ajax data payload where ajax_request reads them, select period when replacing a manual journal, and show ledger amounts to two decimals.
2026-09-18 16:19:27 +07:00
Thanakorn d8363f6ca7 Merge fix/feedback-16-09 2026-09-17 09:00:37 +07:00
Thanakorn f70f226bd1 Fix timestamps, delete requests, invoice dates and GR quantities
Apply the configured timezone to PHP and both DB connections, wrap
unwrapped ajax payloads so delete buttons reach their engines, normalise
and validate invoice due dates, reject stock quantities below the stored
4dp scale, and list stock movements across all warehouses.
2026-09-17 09:00:15 +07:00
Thanakorn f14c850c70 Merge fix/accounting-feedback 2026-09-15 16:13:55 +07:00
Thanakorn c915379e2e Fix item counts, PR/QT conversions, validation and department loss 2026-09-15 16:11:47 +07:00
Thanakorn 78d69d81df Merge fix/stock-transfer-seed 2026-09-15 13:13:18 +07:00
Thanakorn 693c72f9ea Fix transfer quantity, unify date format, align demo seeds
Stock Transfer list showed 0.00 (read in instead of out); stock-out/transfer forms show the location quantity; dates display as YYYY-MM-DD HH:mm:ss. Demo seeds map product accounts and use product names and supplier batches.
2026-09-15 13:09:19 +07:00
Thanakorn 9512d440dd Merge fix/switch-branch 2026-09-14 17:19:02 +07:00
Thanakorn 45331948c1 Use absolute URLs in invitation emails 2026-09-14 17:18:42 +07:00
Thanakorn ba73456185 Send the chosen company when switching branch 2026-09-14 17:17:40 +07:00
Thanakorn 501c70050f Merge fix/untrack-node-logs 2026-09-14 17:06:16 +07:00
Thanakorn f6909b08eb Stop tracking PM2 log files 2026-09-14 17:05:57 +07:00
Thanakorn 5846c8b282 Merge fix/app-registry-default 2026-09-14 16:58:00 +07:00
Thanakorn 6a271c1f7d Default the app registry when config.php does not define it 2026-09-14 16:57:36 +07:00
Thanakorn 2ef2f32107 Merge fix/retrieve-bin-endpoint 2026-09-14 16:29:56 +07:00
Thanakorn 1f72633cb6 Install libpng, libjpeg and freetype for the gd extension 2026-09-14 16:29:38 +07:00
Thanakorn 4efab9f7c9 Rename retrieve_rack.php to retrieve_bin.php 2026-09-14 16:27:53 +07:00
Thanakorn 44c66c49a5 Merge feature/otp-off-by-default 2026-09-14 15:38:53 +07:00
Thanakorn 6b3a590aa9 Make email OTP login off by default 2026-09-14 15:38:36 +07:00
Thanakorn 21148bf50c Merge feature/onboarding-optional-smtp 2026-09-14 15:27:46 +07:00
Thanakorn cf8106771b Make onboarding SMTP optional when OTP is off 2026-09-14 15:27:01 +07:00
Thanakorn d7203583b7 Merge feature/otp-login-toggle 2026-09-14 15:11:45 +07:00
Thanakorn 9afcf072b0 Add OTP_REQUIRED switch for email OTP login 2026-09-14 15:03:16 +07:00
Thanakorn 2f290ddb26 Merge fix/api-json-notices 2026-09-14 13:33:59 +07:00
Thanakorn 5d021d7683 Accept uppercase channel names in onboarding and company settings 2026-09-14 13:31:26 +07:00
Thanakorn 5ee0c8d41b Keep PHP notices out of login API JSON responses 2026-09-14 12:46:13 +07:00
Thanakorn c3113bc70d Fix login redirect and hide PHP errors on pages 2026-09-14 12:46:13 +07:00
Thanakorn 6765054950 SDLC delivery package 2026-08-18 13:22:26 +07:00
Thanakorn 075d80ad40 SDLC docs package delivery script 2026-08-18 12:38:15 +07:00
Thanakorn a859cfda3e SDLC docs alignment 2026-08-18 12:37:28 +07:00
Thanakorn 37d2f8e725 Hand off 2026-08-17 17:12:34 +07:00
Thanakorn 39201df36e SDLC docs 2026-08-17 17:09:27 +07:00
Thanakorn 3045c4a8ef Add interactive script to generate root .env for docker-compose 2026-08-17 13:50:03 +07:00
Thanakorn 4c4522169c Add Docker Compose production stack (php-apache, mariadb, node/pm2) 2026-08-17 13:44:14 +07:00
Thanakorn 234814a23c Seed Demo Data - Rebranding 2026-08-17 13:12:07 +07:00
Thanakorn 0eab2a3b96 Demo Data Population 2026-08-14 14:10:31 +07:00
nok 618045540a fix login and configurations 2026-08-03 11:17:41 +07:00
Thanakorn S 7b294f70da Classe methods: remove reducdancy 2026-05-29 08:56:58 +07:00
Thanakorn S 356d308907 Fix horizontal scrollbar caused by sidebar margin overflowing viewport 2026-05-28 16:58:15 +07:00
Thanakorn S 9c7a4a139d Block concurrent login: reject new session if account already active 2026-05-28 16:21:55 +07:00
Thanakorn S b634372b22 Scope stock table access by company warehouses 2026-05-28 15:36:49 +07:00
Thanakorn S 2a6441c6a9 Complete Rack→Bin rename and fix ReportManager property bug 2026-05-28 09:53:19 +07:00
Thanakorn S 987cf7ded9 Change 'Rack' to 'Bin' 2026-05-27 17:14:53 +07:00
Thanakorn S ea94b6a6ae Wire targeted toast notifications to document status transitions 2026-05-27 13:44:55 +07:00
Thanakorn S dbbc89f8f2 notify node: userIDguard 2026-05-27 13:12:18 +07:00
Thanakorn S 82f66b0c41 NODEJS cron fix 2026-05-27 12:05:29 +07:00
Thanakorn S 8905b5bf35 fix NodeJS cron 2026-05-27 11:56:40 +07:00
Thanakorn S 8916d9180d cron low stock - overdue invoice 2026-05-27 11:45:48 +07:00
Thanakorn S ebccca4989 CORS whitelist for NodeJS 2026-05-27 11:26:40 +07:00
Thanakorn S aee797b998 nodejs status check 2026-05-27 11:18:09 +07:00
Thanakorn S 5ff1a60c7d autostart node process 2026-05-27 11:07:30 +07:00
Thanakorn S 5bfaf6f8c3 all .md reviewed - fix remaining gaps 2026-05-27 10:42:44 +07:00
Thanakorn S 17a62e50e4 add missing roleGuards 2026-05-27 09:19:52 +07:00
Thanakorn S 1b34482216 Stop tracking docs/ — already in .gitignore 2026-05-27 08:15:29 +07:00
Thanakorn S 1c5236d8e0 Master data review: spec updates and C1/C2/M3-M6 fixes 2026-05-26 17:33:58 +07:00
Thanakorn S 6ca4c91865 Document lifecycle review: spec updates and C2/M9 code fixes 2026-05-26 16:23:46 +07:00
Thanakorn S 632c039790 system notification 2026-05-26 13:26:57 +07:00
Thanakorn S 1a952b42dd Seal transaction limit coverage gaps 2026-05-26 13:10:54 +07:00
Thanakorn S 9e200d31fe Security hardening: invited user onboarding flow (C1–N7) 2026-05-26 10:18:40 +07:00
Thanakorn S 0815ae3292 document number sequence 2026-05-26 08:19:40 +07:00
Thanakorn S 5c0166ae73 Seal live dashboard event gaps 2026-05-25 17:02:52 +07:00
Thanakorn S 3c9475f3fa Close login gap 2026-05-25 15:34:12 +07:00
Thanakorn S 8027ab569e PHP event trigger by NodeJS [stock dashboard] 2026-05-25 14:33:36 +07:00
Thanakorn S 1237888ab9 stock aggregate table 2026-05-25 13:30:10 +07:00
Thanakorn S 45a78a3fed login: block concurrent login, single-factor auth for staff/viewer 2026-05-25 09:43:30 +07:00
Thanakorn S 5ca6b49fd0 code audit fixes: require_once, issue flow, role guards 2026-05-23 17:06:08 +07:00
Thanakorn S 54f3f11fd2 automate and view gl entries 2026-05-23 16:46:35 +07:00
Thanakorn S 5affae1fc5 batch journal entries 2026-05-23 15:55:22 +07:00
Thanakorn S 4bb378a905 accounting Reports 2026-05-23 15:07:11 +07:00
Thanakorn S 15618f4955 fix file path 2026-05-23 13:11:22 +07:00
Thanakorn S c0de84a575 fix file path 2026-05-23 13:09:18 +07:00
Thanakorn S 5780183b82 gl aggregate table (ETL), cronjob by NODEJS 2026-05-23 10:52:27 +07:00
Thanakorn S 5a3bfa3435 NODE JS introduction: socket polling 2026-05-22 17:01:59 +07:00
Thanakorn S 648efee991 softDelete features 2026-05-22 14:07:22 +07:00
Thanakorn S 738f600fe8 users app access badge 2026-05-22 13:21:46 +07:00
Thanakorn S 7379ac9e4a user badge 2026-05-22 10:42:01 +07:00
Thanakorn S cf25732da0 use roles guards 2026-05-22 08:45:35 +07:00
Thanakorn S f2b87cfd0f fix onboarding bugs 2026-05-21 16:51:19 +07:00
Thanakorn S 86b1aa9d62 add setup.php — one-shot production database setup script 2026-05-21 15:21:36 +07:00
Thanakorn S e1135d2bce fix StockManager lot/serial coercion + add document flow test suite 2026-05-21 14:20:41 +07:00
588 changed files with 27563 additions and 5784 deletions
+23
View File
@@ -0,0 +1,23 @@
# 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=
# Email OTP on sign-in. Off by default; only the exact value "true" turns it on,
# and that needs working SMTP. While off, sign-in is password only (logged as
# OTP_BYPASSED, shown on the login page and top bar).
# Applied to app/config.php by the php container on every start.
OTP_REQUIRED=false
# Port to expose the web app on (default 80)
HTTP_PORT=80
+10 -8
View File
@@ -2,18 +2,20 @@
app/config.php
app/uploads
# Logs
logs
*.log
# PHP dependencies
vendor/
# Node dependencies
node_modules/
nodejs/.env
# PM2 runtime logs (written by the node container; nodejs/logs/.gitkeep keeps the folder)
nodejs/logs/*.log
# Docker deploy secrets
/.env
# zxcvbn dev dependencies (not needed at runtime)
lib/zxcvbn-php-master/vendor/sebastian/
# custom files
notes/
docs/
.claude/
SESSION.php
+53
View File
@@ -0,0 +1,53 @@
# wms-app — web server rules for the repository root.
#
# The whole repository sits under the web root (/wms-app/), so everything that is
# not part of the running app must be refused here: git history, .env files,
# deployment and build folders, SDLC documents, the Node server source, CLI-only
# PHP scripts and library internals. Needs AllowOverride All (docker/php/apache-wms.conf
# enables it for the container) plus mod_rewrite and mod_headers.
Options -Indexes
<IfModule mod_rewrite.c>
RewriteEngine On
# HTTP → HTTPS when the deployment says TLS is available (FORCE_HTTPS=true in the
# environment). Honours X-Forwarded-Proto so it also works behind a TLS proxy.
RewriteCond %{ENV:FORCE_HTTPS} ^true$
RewriteCond %{HTTPS} !=on
RewriteCond %{HTTP:X-Forwarded-Proto} !=https
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
# Dotfiles and dot-folders anywhere: .git, .env, .claude, .htaccess, .mcp.json …
RewriteRule (^|/)\. - [R=404,L]
# Folders that are never served.
RewriteRule ^(nodejs|docker|sdlc|sdlc-delivery|scripts|lib|notes|docs|vendor|node_modules)(/|$) - [R=404,L]
# Repository files at the root: build/deploy config, CLI scripts, archives, docs.
RewriteRule ^(composer\.(json|lock)|docker-compose\.ya?ml|setup\.php|demo_seed[^/]*\.php)$ - [R=404,L]
RewriteRule \.(zip|tar|gz|tgz|sql|sh|md|log|bak|old|orig|swp|dist|example|ini|yml|yaml|lock|env|pem|key|crt|map)$ - [R=404,L]
# App internals included by the entry points, never requested directly: config,
# DB connection, shared utilities, manager classes, bundled libraries (PHPMailer
# ships get_oauth_token.php), and the page fragments.
RewriteRule ^app/(config[^/]*\.php|dbconn\.php|preset\.php)$ - [R=404,L]
RewriteRule ^app/assets/utils/ - [R=404,L]
RewriteRule ^app/include_[^/]+\.php$ - [R=404,L]
# Uploaded files are served through a PHP gate that requires a signed-in session.
RewriteRule ^app/uploads/(.+)$ app/file.php?path=$1 [L,QSA,B]
</IfModule>
<IfModule mod_headers.c>
# Sent on every response (pages, API JSON, static files). Pages add a
# Content-Security-Policy of their own from include_header.php.
Header always set X-Content-Type-Options "nosniff"
Header always set X-Frame-Options "SAMEORIGIN"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Permissions-Policy "geolocation=(), microphone=(), payment=(), usb=()"
Header always unset X-Powered-By
Header unset X-Powered-By
# HSTS only means anything over HTTPS; browsers ignore it on plain HTTP.
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains" "expr=%{HTTPS} == 'on' || %{HTTP:X-Forwarded-Proto} == 'https'"
</IfModule>
+4 -4
View File
@@ -1,10 +1,10 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes/UsageGuard.php';
require_once __DIR__ . '/../../../session.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);
+4 -4
View File
@@ -1,10 +1,10 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes/UsageGuard.php';
require_once __DIR__ . '/../../../session.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);
+4 -4
View File
@@ -1,10 +1,10 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes/UsageGuard.php';
require_once __DIR__ . '/../../../session.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 __DIR__ . '/../../../session.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') ?: '';
+4 -4
View File
@@ -1,10 +1,10 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes/UsageGuard.php';
require_once __DIR__ . '/../../../session.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);
+4 -4
View File
@@ -1,10 +1,10 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes/UsageGuard.php';
require_once __DIR__ . '/../../../session.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);
+107 -30
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../session.php';
require '../config.php';
require '../include_header.php';
?>
@@ -179,7 +179,16 @@
<?php require '../include_ending.php'; ?>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
<script src="<?php echo $server_url?>assets/vendor/chart.js/4.5.1/chart.umd.min.js"></script>
<style>
/* Brief yellow flash when a card updates silently via WebSocket */
@keyframes card-flash {
0% { box-shadow: 0 0 0 3px rgba(250,204,21,0.8); }
100% { box-shadow: none; }
}
.card-flash { animation: card-flash 1s ease-out; }
</style>
<script>
var trend_chart_instance = null;
@@ -207,26 +216,33 @@
return Number(n).toLocaleString(undefined, {minimumFractionDigits:2, maximumFractionDigits:2});
}
function show_overlays() {
$('#overlay_revenue, #overlay_expense, #overlay_net, #overlay_journals, #overlay_lock, #overlay_trend, #overlay_source, #overlay_recent').show();
// Flash a card briefly to signal a silent update happened
function flash_card(el) {
if (!el) return;
el.classList.remove('card-flash');
void el.offsetWidth; // force reflow so animation restarts
el.classList.add('card-flash');
setTimeout(function() { el.classList.remove('card-flash'); }, 1000);
}
function set_dashboard_loading() {
show_overlays();
['stat_revenue', 'stat_expense', 'stat_net', 'stat_journals'].forEach(function(id) {
document.getElementById(id).textContent = '—';
});
document.getElementById('posting_window_banner').textContent = 'Loading...';
document.getElementById('by_source_list').innerHTML =
'<div class="text-center text-muted small py-4">Loading...</div>';
document.getElementById('recent_tbody').innerHTML =
'<tr><td colspan="7" class="text-center text-muted py-4 small">Loading...</td></tr>';
}
// ── Individual load functions ─────────────────────────────────────────────
// Each accepts a `silent` flag:
// false (default) — show overlay spinner, blank value while loading
// true — keep existing value visible, flash card on update
function load_dashboard() {
set_dashboard_loading();
// load_pl(silent, flash_revenue, flash_expense)
// One DB query — but only flash cards that actually changed.
// flash_revenue / flash_expense default to true when not passed.
function load_pl(silent, flash_revenue, flash_expense) {
if (flash_revenue === undefined) flash_revenue = true;
if (flash_expense === undefined) flash_expense = true;
// Revenue, Expenses, Net Profit
if (!silent) {
$('#overlay_revenue, #overlay_expense, #overlay_net').show();
['stat_revenue', 'stat_expense', 'stat_net'].forEach(function(id) {
document.getElementById(id).textContent = '—';
});
}
ajax_request({
url: server_url + 'ac_dashboard/api/engine/pl.php',
autoPrepare: true, checkRequired: 0, noLoading: true, queueLock: false, action: 'read',
@@ -252,48 +268,89 @@
if (sub) sub.className = net >= 0 ? 'text-success mb-0 small' : 'text-danger mb-0 small';
}
$('#overlay_net').hide();
// Flash only cards that actually changed
if (silent) {
if (flash_revenue) flash_card(document.getElementById('stat_revenue').closest('.card'));
if (flash_expense) flash_card(document.getElementById('stat_expense').closest('.card'));
flash_card(net_el.closest('.card')); // net profit always updates if either changed
}
}
});
}
// Journal Entries count
function load_journals(silent) {
if (!silent) {
$('#overlay_journals').show();
document.getElementById('stat_journals').textContent = '—';
}
ajax_request({
url: server_url + 'ac_dashboard/api/engine/journals.php',
autoPrepare: true, checkRequired: 0, noLoading: true, queueLock: false, action: 'read',
onSuccess: function(res) {
document.getElementById('stat_journals').textContent = res.output.journal_count;
$('#overlay_journals').hide();
if (silent) flash_card(document.getElementById('stat_journals').closest('.card'));
}
});
}
// Posting window banner
function load_posting_window() {
$('#overlay_lock').show();
ajax_request({
url: server_url + 'ac_dashboard/api/engine/posting_window.php',
autoPrepare: true, checkRequired: 0, noLoading: true, queueLock: false, action: 'read',
onSuccess: function(res) { render_posting_window(res.output); }
});
}
// Revenue vs Expenses trend chart
function load_trend(silent) {
if (!silent) $('#overlay_trend').show();
ajax_request({
url: server_url + 'ac_dashboard/api/engine/trend.php',
autoPrepare: true, checkRequired: 0, noLoading: true, queueLock: false, action: 'read',
onSuccess: function(res) { render_trend(res.output.trend); }
onSuccess: function(res) { render_trend(res.output.trend, silent); }
});
}
// Entries by type breakdown
function load_by_source(silent) {
if (!silent) {
$('#overlay_source').show();
document.getElementById('by_source_list').innerHTML =
'<div class="text-center text-muted small py-4">Loading...</div>';
}
ajax_request({
url: server_url + 'ac_dashboard/api/engine/by_source.php',
autoPrepare: true, checkRequired: 0, noLoading: true, queueLock: false, action: 'read',
onSuccess: function(res) { render_by_source(res.output.by_source); }
onSuccess: function(res) { render_by_source(res.output.by_source, silent); }
});
}
// Recent journal entries table
function load_recent(silent) {
if (!silent) {
$('#overlay_recent').show();
document.getElementById('recent_tbody').innerHTML =
'<tr><td colspan="7" class="text-center text-muted py-4 small">Loading...</td></tr>';
}
ajax_request({
url: server_url + 'ac_dashboard/api/engine/recent.php',
autoPrepare: true, checkRequired: 0, noLoading: true, queueLock: false, action: 'read',
onSuccess: function(res) { render_recent(res.output.recent); }
onSuccess: function(res) { render_recent(res.output.recent, silent); }
});
}
// ── Full dashboard load (first load) ─────────────────────────────────────
function load_dashboard() {
load_pl(false);
load_journals(false);
load_posting_window();
load_trend(false);
load_by_source(false);
load_recent(false);
}
// ── Render functions ──────────────────────────────────────────────────────
function render_posting_window(window_data) {
var el = document.getElementById('posting_window_banner');
var from = window_data.open_from || '';
@@ -308,8 +365,7 @@
$('#overlay_lock').hide();
}
function render_trend(trend) {
// Build exactly 6 month labels (last 6 months ending today), fill zeros for missing
function render_trend(trend, silent) {
var labels = [], revenues = [], expenses = [];
var idx = {};
trend.forEach(function(r){ idx[r.period] = r; });
@@ -345,9 +401,10 @@
}
});
$('#overlay_trend').hide();
if (silent) flash_card(document.getElementById('trend_chart').closest('.card'));
}
function render_by_source(rows) {
function render_by_source(rows, silent) {
var el = document.getElementById('by_source_list');
if (!rows || rows.length === 0) {
el.innerHTML = '<div class="text-center text-muted small py-4">No entries found.</div>';
@@ -371,9 +428,10 @@
});
el.innerHTML = html;
$('#overlay_source').hide();
if (silent) flash_card(el.closest('.card'));
}
function render_recent(rows) {
function render_recent(rows, silent) {
var tbody = document.getElementById('recent_tbody');
if (!rows || rows.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-4 small">No journal entries found.</td></tr>';
@@ -397,9 +455,28 @@
});
tbody.innerHTML = html;
$('#overlay_recent').hide();
if (silent) flash_card(tbody.closest('.card'));
}
// ── Initial load ──────────────────────────────────────────────────────────
load_dashboard();
// ── Real-time: targeted silent updates per event payload ──────────────────
if (window._socket) {
window._socket.on('gl_posted', function(data) {
console.log('[dashboard] gl_posted', data);
if (data.has_revenue || data.has_expense) {
load_pl(true, data.has_revenue, data.has_expense); // only affected side flashes
load_trend(true); // trend = revenue vs expense chart, only relevant when those change
}
// Always update regardless of which accounts were hit
load_journals(true);
load_by_source(true);
load_recent(true);
});
}
</script>
</body>
+1 -1
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../session.php';
require '../config.php';
require '../include_header.php';
?>
+3 -3
View File
@@ -1,7 +1,7 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes_ac/ChartOfAccounts.php';
require_once __DIR__ . '/../../../session.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 __DIR__ . '/../../../session.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);
+6 -3
View File
@@ -1,7 +1,10 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes_ac/ChartOfAccounts.php';
require_once __DIR__ . '/../../../session.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();
+14 -3
View File
@@ -1,7 +1,10 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes/OperationLockManager.php';
require_once __DIR__ . '/../../../session.php';
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes/OperationLockManager.php';
// Batch GL posting takes this lock; posting itself is owner/admin only.
require_role($user_role, ['owner', 'admin']);
try {
$lock_manager = new OperationLockManager($pdo2, $company_id, $user_id);
@@ -10,7 +13,15 @@ try {
(int)($data['ttl_minutes'] ?? 120)
);
$answer = array_merge($answer, $result);
if (empty($result['success'])) {
http_response_code(409); // another tab or user holds the lock
}
} catch (PDOException $e) {
error_log('[acquire_op_lock] ' . $e->getMessage());
http_response_code(500);
$answer['message'] = 'Database error, please try again.';
} catch (Exception $e) {
http_response_code(400);
$answer['message'] = $e->getMessage();
}
+3 -3
View File
@@ -1,7 +1,7 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes_ac/DepartmentManager.php';
require_once __DIR__ . '/../../../session.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 __DIR__ . '/../../../session.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 __DIR__ . '/../../../session.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,40 @@
<?php
require_once __DIR__ . '/../../../session.php';
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) {
http_response_code(400);
$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 (PDOException $e) {
error_log('[' . basename(__FILE__) . '] ' . $e->getMessage());
$answer['message'] = 'Database error, please try again.';
http_response_code(500);
} catch (Exception $e) {
$answer['message'] = $e->getMessage();
http_response_code(400);
}
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 __DIR__ . '/../../../session.php';
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes_ac/GlQueryManager.php';
try {
$gl_query = new GlQueryManager($pdo2, $company_id);
@@ -12,8 +12,13 @@ try {
(int)($data['formula_id'] ?? 0)
);
$answer['success'] = 1;
} catch (PDOException $e) {
error_log('[' . basename(__FILE__) . '] ' . $e->getMessage());
$answer['message'] = 'Database error, please try again.';
http_response_code(500);
} catch (Exception $e) {
$answer['message'] = $e->getMessage();
http_response_code(400);
}
exit(json_encode($answer));
@@ -1,10 +1,10 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes/UsageGuard.php';
require_once __DIR__ . '/../../../session.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'))));
@@ -12,6 +12,7 @@ $to_date = trim((string)($data['to_date'] ?? ($data['to_period'] ??
$dept_id = (int)($data['department_id'] ?? 0);
if ($account_code === '') {
http_response_code(400);
$answer['message'] = 'account_code is required.';
exit(json_encode($answer));
}
@@ -1,14 +1,19 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes_ac/GlQueryManager.php';
require_once __DIR__ . '/../../../session.php';
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes_ac/GlQueryManager.php';
try {
$gl_query = new GlQueryManager($pdo2, $company_id);
$answer['output'] = $gl_query->getJournalDetail((int)($data['gl_id'] ?? 0));
$answer['success'] = 1;
} catch (PDOException $e) {
error_log('[' . basename(__FILE__) . '] ' . $e->getMessage());
$answer['message'] = 'Database error, please try again.';
http_response_code(500);
} catch (Exception $e) {
$answer['message'] = $e->getMessage();
http_response_code(400);
}
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 __DIR__ . '/../../../session.php';
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes_ac/GlQueryManager.php';
try {
$gl_query = new GlQueryManager($pdo2, $company_id);
@@ -11,8 +11,13 @@ try {
trim((string)($data['date_to'] ?? ''))
);
$answer['success'] = 1;
} catch (PDOException $e) {
error_log('[' . basename(__FILE__) . '] ' . $e->getMessage());
$answer['message'] = 'Database error, please try again.';
http_response_code(500);
} catch (Exception $e) {
$answer['message'] = $e->getMessage();
http_response_code(400);
}
exit(json_encode($answer));
@@ -1,10 +1,10 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes/UsageGuard.php';
require_once __DIR__ . '/../../../session.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 __DIR__ . '/../../../session.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)));
+4 -4
View File
@@ -1,10 +1,10 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes/UsageGuard.php';
require_once __DIR__ . '/../../../session.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)));
+11 -3
View File
@@ -1,14 +1,22 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes/BatchActionManager.php';
require_once __DIR__ . '/../../../session.php';
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes/BatchActionManager.php';
// Logged at the end of a batch GL posting run, which is owner/admin only.
require_role($user_role, ['owner', 'admin']);
try {
$batch_action = new BatchActionManager($pdo2, $company_id, $user_id);
$batch_action->log($data);
$answer['success'] = 1;
$answer['message'] = 'Batch action logged.';
} catch (PDOException $e) {
error_log('[log_batch_action] ' . $e->getMessage());
http_response_code(500);
$answer['message'] = 'Database error, please try again.';
} catch (Exception $e) {
http_response_code(400);
$answer['message'] = $e->getMessage();
}
+6 -3
View File
@@ -1,9 +1,12 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes_ac/ChartOfAccounts.php';
require_once __DIR__ . '/../../../session.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'])) {
http_response_code(400);
$answer['message'] = 'Account code, name, and type are required';
exit(json_encode($answer));
}
@@ -1,9 +1,12 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes_ac/DepartmentManager.php';
require_once __DIR__ . '/../../../session.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'])) {
http_response_code(400);
$answer['message'] = 'Department code and name are required';
exit(json_encode($answer));
}
+38 -12
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 __DIR__ . '/../../../session.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);
@@ -27,6 +30,7 @@ $posting_map = [
];
if (!isset($posting_map[$doc_type]) || $id <= 0) {
http_response_code(400);
$answer['message'] = 'Invalid doc_type or id.';
exit(json_encode($answer));
}
@@ -54,9 +58,31 @@ 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 (PDOException $e) {
if ($pdo2->inTransaction()) $pdo2->rollBack();
error_log('[' . basename(__FILE__) . '] ' . $e->getMessage());
$answer['message'] = 'Database error, please try again.';
http_response_code(500);
} catch (Exception $e) {
if ($pdo2->inTransaction()) $pdo2->rollBack();
$answer['message'] = $e->getMessage();
http_response_code(400);
}
exit(json_encode($answer));
+7 -3
View File
@@ -1,7 +1,7 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes/CompanySettingManager.php';
require_once __DIR__ . '/../../../session.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);
@@ -30,14 +30,17 @@ if ($data['action'] === 'save') {
$to = trim((string)($data['open_to'] ?? ''));
if ($from !== '' && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $from)) {
http_response_code(400);
$answer['message'] = 'Invalid open_from date. Use YYYY-MM-DD.';
exit(json_encode($answer));
}
if ($to !== '' && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $to)) {
http_response_code(400);
$answer['message'] = 'Invalid open_to date. Use YYYY-MM-DD.';
exit(json_encode($answer));
}
if ($from && $to && $from > $to) {
http_response_code(400);
$answer['message'] = 'Open From must be on or before Open To.';
exit(json_encode($answer));
}
@@ -50,5 +53,6 @@ if ($data['action'] === 'save') {
exit(json_encode($answer));
}
http_response_code(400);
$answer['message'] = 'Invalid action.';
exit(json_encode($answer));
@@ -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 __DIR__ . '/../../../session.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']);
+11 -3
View File
@@ -1,14 +1,22 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes/OperationLockManager.php';
require_once __DIR__ . '/../../../session.php';
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes/OperationLockManager.php';
// Batch GL posting takes this lock; posting itself is owner/admin only.
require_role($user_role, ['owner', 'admin']);
try {
$lock_manager = new OperationLockManager($pdo2, $company_id, $user_id);
$lock_manager->release(trim((string)($data['operation_type'] ?? '')));
$answer['success'] = 1;
$answer['message'] = 'Lock released.';
} catch (PDOException $e) {
error_log('[release_op_lock] ' . $e->getMessage());
http_response_code(500);
$answer['message'] = 'Database error, please try again.';
} catch (Exception $e) {
http_response_code(400);
$answer['message'] = $e->getMessage();
}
+6 -3
View File
@@ -1,10 +1,13 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes_ac/ChartOfAccounts.php';
require_once __DIR__ . '/../../../session.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) {
http_response_code(400);
$answer['message'] = 'Missing id';
exit(json_encode($answer));
}
@@ -1,10 +1,12 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes_ac/DepartmentManager.php';
require_once __DIR__ . '/../../../session.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)); }
if (!$id) { http_response_code(400); $answer['message'] = 'Missing id'; exit(json_encode($answer)); }
$dept = new DepartmentManager($pdo2, $company_id);
$dept->delete($id);
@@ -1,10 +1,11 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes_ac/ChartOfAccounts.php';
require_once __DIR__ . '/../../../session.php';
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes_ac/ChartOfAccounts.php';
$id = (int)($data['id'] ?? 0);
if (!$id) {
http_response_code(400);
$answer['message'] = 'Missing id';
exit(json_encode($answer));
}
@@ -12,6 +13,7 @@ if (!$id) {
$coa = new ChartOfAccounts($pdo2, $company_id);
$row = $coa->getById($id);
if (!$row) {
http_response_code(404);
$answer['message'] = 'Account not found';
exit(json_encode($answer));
}
@@ -1,14 +1,14 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes_ac/DepartmentManager.php';
require_once __DIR__ . '/../../../session.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)); }
if (!$id) { http_response_code(400); $answer['message'] = 'Missing id'; exit(json_encode($answer)); }
$dept = new DepartmentManager($pdo2, $company_id);
$row = $dept->getById($id);
if (!$row) { $answer['message'] = 'Department not found'; exit(json_encode($answer)); }
if (!$row) { http_response_code(404); $answer['message'] = 'Department not found'; exit(json_encode($answer)); }
$answer['output'] = $row;
$answer['success'] = 1;
@@ -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 __DIR__ . '/../../../session.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'] ?? ''));
@@ -20,6 +25,7 @@ if (preg_match('#^(\d{2})/(\d{2})/(\d{4})$#', $journal_date, $m)) {
}
if (!$journal_date || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $journal_date)) {
http_response_code(400);
$answer['message'] = 'Valid journal date is required.';
exit(json_encode($answer));
}
@@ -46,11 +52,13 @@ foreach ($lines_raw as $l) {
}
if (count($lines) < 2) {
http_response_code(400);
$answer['message'] = 'At least two journal lines are required.';
exit(json_encode($answer));
}
if (abs($total_debit - $total_credit) > 0.005) {
http_response_code(400);
$answer['message'] = 'Journal is not balanced. Debit ' . number_format($total_debit, 2) . ' ≠ Credit ' . number_format($total_credit, 2) . '.';
exit(json_encode($answer));
}
@@ -64,6 +72,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,9 +85,16 @@ 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 (PDOException $e) {
if ($pdo2->inTransaction()) $pdo2->rollBack();
error_log('[' . basename(__FILE__) . '] ' . $e->getMessage());
$answer['message'] = 'Database error, please try again.';
http_response_code(500);
} catch (Exception $e) {
if ($pdo2->inTransaction()) $pdo2->rollBack();
$answer['message'] = $e->getMessage();
http_response_code(400);
}
exit(json_encode($answer));
+66 -8
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../session.php';
require '../config.php';
require '../include_header.php';
?>
@@ -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="<?php echo $server_url?>assets/vendor/xlsx/0.18.5/xlsx.full.min.js"></script>
<script src="<?php echo $server_url?>assets/vendor/jspdf/2.5.1/jspdf.umd.min.js"></script>
<script src="<?php echo $server_url?>assets/vendor/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js"></script>
</body>
</html>
+1 -1
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../session.php';
require '../config.php';
require '../include_header.php';
?>
+1 -1
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../session.php';
require '../config.php';
require '../include_header.php';
?>
+248 -188
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../session.php';
require '../config.php';
require '../include_header.php';
?>
@@ -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(format_date(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>
+57 -13
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../session.php';
require '../config.php';
require '../include_header.php';
?>
@@ -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,15 +188,17 @@
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>' +
'<td class="small">' + escape_html(format_date(r.entry_date)) + '</td>' +
'<td class="small">' + escape_html(r.period) + '</td>' +
'<td class="small">' + (r.dept_code ? escape_html(r.dept_code) : '<span class="text-muted">—</span>') + '</td>' +
'<td class="small">' + escape_html(r.reference || '—') + '</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="<?php echo $server_url?>assets/vendor/xlsx/0.18.5/xlsx.full.min.js"></script>
<script src="<?php echo $server_url?>assets/vendor/jspdf/2.5.1/jspdf.umd.min.js"></script>
<script src="<?php echo $server_url?>assets/vendor/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js"></script>
</body>
</html>
+8 -5
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../session.php';
require '../config.php';
require '../include_header.php';
?>
@@ -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) {
@@ -278,7 +281,7 @@
'<td class="text-muted small">' + escape_html(r.formula_name || '—') + '</td>' +
'<td class="text-end">' + format_number(r.total_debit, 2) + '</td>' +
'<td class="text-end">' + format_number(r.total_credit, 2) + '</td>' +
'<td class="text-muted small">' + escape_html(r.posted_at) + '</td>' +
'<td class="text-muted small">' + escape_html(format_date(r.posted_at)) + '</td>' +
'<td>' +
'<a href="javascript:;" onclick="show_journal_detail(' + r.id + ',\'' + escape_html(r.doc_number || '') + '\')" title="View lines">' +
'<i class="ti ti-eye fs-5"></i></a>' +
@@ -506,7 +509,7 @@
var extra_fields = h.source_type === 'manual'
? '<div class="col-sm-4"><div class="text-muted small">Reference</div><div class="fw-semibold">' + escape_html(h.reference || ('MJE-' + h.id)) + '</div></div>' +
'<div class="col-sm-4"><div class="text-muted small">Journal Date</div><div>' + escape_html(h.journal_date_fmt || '—') + '</div></div>' +
'<div class="col-sm-4"><div class="text-muted small">Journal Date</div><div>' + escape_html(format_date(h.journal_date)) + '</div></div>' +
'<div class="col-sm-4"><div class="text-muted small">Description</div><div>' + escape_html(h.description || '—') + '</div></div>'
: '<div class="col-sm-4"><div class="text-muted small">Formula</div><div>' + escape_html(h.formula_name) + '</div></div>' +
'<div class="col-sm-4"><div class="text-muted small">Version</div><div>v' + h.current_version + (h.current_version > 1 ? ' <span class="text-muted small">(replaced)</span>' : '') + '</div></div>' +
+2 -2
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../session.php';
require '../config.php';
require '../include_header.php';
?>
@@ -222,7 +222,7 @@
autoPrepare: true,
checkRequired: 0,
action: 'read',
data: { id: <?php echo (int)$_GET['id']; ?> },
data: { id: <?php echo (int)($_GET['id'] ?? 0); ?> },
onSuccess: function(res) {
var item = res.output;
$('#id').val(item.id);
+2 -2
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../session.php';
require '../config.php';
require '../include_header.php';
?>
@@ -89,7 +89,7 @@
autoPrepare: true,
checkRequired: 0,
action: 'read',
data: { id: <?php echo (int)$_GET['id']; ?> },
data: { id: <?php echo (int)($_GET['id'] ?? 0); ?> },
onSuccess: function(res) {
var item = res.output;
$('#id').val(item.id);
+61 -9
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../session.php';
require '../config.php';
require '../include_header.php';
?>
@@ -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="<?php echo $server_url?>assets/vendor/xlsx/0.18.5/xlsx.full.min.js"></script>
<script src="<?php echo $server_url?>assets/vendor/jspdf/2.5.1/jspdf.umd.min.js"></script>
<script src="<?php echo $server_url?>assets/vendor/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js"></script>
</body>
</html>
+6 -5
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../session.php';
require '../config.php';
require '../include_header.php';
?>
@@ -157,8 +157,10 @@
autoPrepare: true,
checkRequired: 0,
action: 'save',
open_from: from,
open_to: to,
// Inside `data`: as top-level options these were ignored, and the save
// only worked because autoPrepare happens to sweep the two inputs, whose
// ids match the field names.
data: { open_from: from, open_to: to },
onSuccess: function() {
render_display(from, to);
}
@@ -173,8 +175,7 @@
autoPrepare: true,
checkRequired: 0,
action: 'save',
open_from: '',
open_to: '',
data: { open_from: '', open_to: '' },
onSuccess: function() {
render_display('', '');
}
+57 -9
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../session.php';
require '../config.php';
require '../include_header.php';
?>
@@ -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="<?php echo $server_url?>assets/vendor/xlsx/0.18.5/xlsx.full.min.js"></script>
<script src="<?php echo $server_url?>assets/vendor/jspdf/2.5.1/jspdf.umd.min.js"></script>
<script src="<?php echo $server_url?>assets/vendor/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js"></script>
</body>
</html>
+63 -11
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../session.php';
require '../config.php';
require '../include_header.php';
?>
@@ -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,10 +194,23 @@
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>' +
'<td class="small">' + escape_html(r.entry_date || '—') + '</td>' +
'<td class="small">' + escape_html(format_date(r.entry_date)) + '</td>' +
'<td class="small">' + escape_html(r.period) + '</td>' +
'<td class="small"><span class="badge bg-secondary bg-opacity-10 text-secondary">' + escape_html(src_labels[r.source_type] || r.source_type) + '</span></td>' +
'<td class="small">' + (r.dept_code ? escape_html(r.dept_code) : '<span class="text-muted">—</span>') + '</td>' +
@@ -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="<?php echo $server_url?>assets/vendor/xlsx/0.18.5/xlsx.full.min.js"></script>
<script src="<?php echo $server_url?>assets/vendor/jspdf/2.5.1/jspdf.umd.min.js"></script>
<script src="<?php echo $server_url?>assets/vendor/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; }
+1 -1
View File
@@ -1,4 +1,4 @@
@charset "UTF-8";@import"https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap";@import"https://cdnjs.cloudflare.com/ajax/libs/tabler-icons/3.35.0/tabler-icons.min.css";/*!
@charset "UTF-8";@import"../vendor/fonts/poppins/poppins.css";@import"../vendor/tabler-icons/3.35.0/tabler-icons.min.css";/*!
* Bootstrap v5.3.8 (https://getbootstrap.com/)
* Copyright 2011-2025 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
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

+370
View File
@@ -0,0 +1,370 @@
/**
* ajax_core.js — helpers every page needs, including the sign-in pages:
* HTML escaping, form-data collection, the ajax_request() wrapper, the
* page-wide form-submit guard and live required-field validation.
*
* Loaded by include_header.php (before custom.js) and by the minimal
* login/include_login_header.php, so the sign-in pages no longer download
* custom.js with every feature's API URLs.
*/
function escape_html(value) {
return String(value ?? '').replace(/[&<>"']/g, function(c) {
return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];
});
}
// Text is stored HTML-escaped (see db_statement in dbconn.php), which is right
// for anything written into markup but wrong inside a form field: a note saved
// as 5" pipe <spare> came back as 5&quot; pipe &lt;spare&gt;. Field values
// are never parsed as HTML, so decoding them here is safe.
function decode_html(value) {
if (typeof value !== 'string' || value.indexOf('&') === -1) return value;
return value.replace(/&(quot|#0*39|#x0*27|apos|lt|gt|amp);/gi, function (m, name) {
name = name.toLowerCase();
if (name === 'quot') return '"';
if (name === 'lt') return '<';
if (name === 'gt') return '>';
if (name === 'amp') return '&';
return "'";
});
}
(function ($) {
if (!$ || !$.fn || $.fn.val.__decodes_html) return;
var original_val = $.fn.val;
$.fn.val = function (value) {
if (arguments.length && typeof value === 'string') {
// Only free-text fields; a <select> value must keep matching its option.
var text_fields = this.filter('input, textarea');
if (text_fields.length === this.length) return original_val.call(this, decode_html(value));
}
return original_val.apply(this, arguments);
};
$.fn.val.__decodes_html = true;
})(window.jQuery);
/** =========================
* FORMS
* ========================= */
// Prevent all forms from refreshing the page
$(function () {
$("form").on("submit", function (e) {
e.preventDefault();
});
});
function prepare_form_data(check_required, raw_data) {
var q = {};
// Get session context
const session_element = document.getElementById('session-context');
if (session_element) {
q['company_id'] = session_element.dataset.companyId;
q['otp'] = session_element.dataset.otp;
}
// Include GET parameters
const url_params = new URLSearchParams(window.location.search);
url_params.forEach((value, key) => {
q[key] = value;
});
// Collect form inputs (exclude search inputs — UI-only filters, not API data)
$(".form-control:not([type=search]), .form-select").each(function () {
if (!$(this).attr("id")) return true;
var el = $(this).get(0);
if (!el || !el.nodeName) return true;
q[$(this).attr("id")] = $(this).val();
});
// Validate required fields
if (check_required === 1) {
const required_inputs = document.querySelectorAll('[required]');
let is_valid = true;
required_inputs.forEach(input => {
if (!input.value.trim()) {
input.classList.add('is-invalid');
is_valid = false;
} else {
input.classList.remove('is-invalid');
input.classList.add('is-valid');
}
});
if (!is_valid) {
alert("Please fill in all mandatory fields.");
isAjaxProcessing = false;
return false;
}
}
return (raw_data) ? q : JSON.stringify(q);
}
// server_url is set by include_topbar.php (app pages) and login/include_login_header.php.
function app_base_url() {
if (typeof server_url !== 'undefined' && server_url) return server_url;
return (document.body && document.body.dataset.serverUrl) || '/';
}
/** =========================
* AJAX WRAPPER
* ========================= */
// Prevent double firing
let isAjaxProcessing = false;
function ajax_request(options) {
if (isAjaxProcessing && options.queueLock !== false) {
// Instead of rejecting, we just return a "never-ending" promise
// or a resolved promise that does nothing.
console.warn("Request is busy... ignoring click.");
return new Promise(() => { }); // This stays pending and won't trigger .then or .catch
}
if (options.queueLock !== false) {
isAjaxProcessing = true;
}
// Auto prepare form data
if (options.autoPrepare === true) {
let payloadJson = prepare_form_data(options.checkRequired ?? 0, true);
if (payloadJson === false) {
isAjaxProcessing = false;
return Promise.reject("validation_failed");
}
if (options.data) {
Object.entries(options.data).forEach(([key, value]) => {
payloadJson[key] = value;
});
}
if (options.action) {
// modify action
if (options.action === 'manage') {
options.action = (payloadJson['id']) ? 'update' : 'create';
}
// add action to JSON
payloadJson['action'] = options.action;
} else {
isAjaxProcessing = false;
return Promise.reject("please_define_action");
}
options.data = { json: JSON.stringify(payloadJson) };
if (options.debugMode) {
isAjaxProcessing = false;
// Show FormData contents if applicable
if (options.formData instanceof FormData) {
// Log original formData before merging
for (let [key, value] of options.formData.entries()) {
console.log("FORMDATA: " + key, value);
}
}
// Show stringified JSON payload
console.log("REQUEST DATA:", options.data);
}
// IF formData exist, we pass as $_POST [not json]
if (options.formData instanceof FormData) {
// THE BYPASS: If formData exists, move all text data into it
Object.entries(payloadJson).forEach(([key, value]) => {
options.formData.append(key, value);
});
// Override options.data with the full FormData object
options.data = options.formData;
}
} else if (
options.data && !(options.data instanceof FormData) &&
typeof options.data === 'object' && !('json' in options.data)
) {
// autoPrepare: false with a plain field map — e.g. the delete buttons'
// `data: { id: id }`. db_auth.php only accepts a `json` string or a
// FormData post carrying `otp`, so an unwrapped map was rejected outright
// with "Request denied: No valid JSON payload or Form Data detected.", and
// `options.action` was dropped because only the autoPrepare branch applied
// it. Wrap it the same way here, without touching callers that already
// pass a ready-made `{ json: ... }`.
const session_element = document.getElementById('session-context');
const payload = {};
if (session_element) {
payload['company_id'] = session_element.dataset.companyId;
payload['otp'] = session_element.dataset.otp;
}
Object.entries(options.data).forEach(([key, value]) => {
payload[key] = value;
});
if (options.action) {
payload['action'] = (options.action === 'manage')
? (payload['id'] ? 'update' : 'create')
: options.action;
}
options.data = { json: JSON.stringify(payload) };
if (options.debugMode) {
console.log("REQUEST DATA:", options.data);
}
}
// --- START MODIFIED $.AJAX BLOCK ---
let isSendingFiles = (options.data instanceof FormData);
// Show loading overlay
if (options.noLoading !== true) {
$.LoadingOverlay("show", {
imageColor: "#525252",
imageAnimation: "2s rotate_right",
background: "rgba(255,255,255,0.8)"
});
}
return $.ajax({
async: true,
type: options.type || "POST",
url: options.url,
data: options.data,
dataType: "json",
// These two settings are only triggered when sending files
processData: isSendingFiles ? false : true,
contentType: isSendingFiles ? false : "application/x-www-form-urlencoded; charset=UTF-8",
// for CSRF validation
headers: {
'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')
}
})
.then(function (res) {
isAjaxProcessing = false;
if (options.noLoading !== true) $.LoadingOverlay("hide");
if (options.debugMode) {
console.log("RESPONSE:", res);
return res;
}
if (!res || res.success != 1) {
if (options.noAlert !== true) bootbox.alert(res?.message || "Unexpected error");
options.onError?.(null, res?.message || 'api_failed');
throw new Error(res?.message || "api_failed");
}
options.onSuccess?.(res);
return res;
})
.catch(function (xhr) {
isAjaxProcessing = false;
$.LoadingOverlay("hide");
// Errors re-thrown from .then() — pass through
if (xhr instanceof Error) {
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 = app_base_url() + 'index.php';
});
return;
}
// Session ended on the server (idle timeout, not signed in, password changed):
// drop per-tab data and go back to the sign-in form.
const endedCodes = ['session_expired', 'auth_required', 'password_changed'];
if (xhr?.status === 401 && endedCodes.includes(xhr?.responseJSON?.code)) {
try { sessionStorage.clear(); } catch (e) {}
bootbox.alert(escape_html(xhr.responseJSON.message || 'Please sign in again.'), function() {
window.location.href = app_base_url() + 'login/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 ?? {};
const daily = d.daily_limit > 0 ? `Daily: <strong>${d.daily_count} / ${d.daily_limit}</strong>` : null;
const weekly = d.weekly_limit > 0 ? `Weekly: <strong>${d.weekly_count} / ${d.weekly_limit}</strong>` : null;
const detail = [daily, weekly].filter(Boolean).join('&nbsp;&nbsp;|&nbsp;&nbsp;');
bootbox.alert(
`<strong>Usage limit reached.</strong><br>${detail}<br><br>` +
`Reports are locked until the quota resets. Upgrade your package for higher limits.`
);
options.onError?.(xhr, 'limit_reached');
throw xhr;
}
// Extract server's error message from JSON response
let serverMessage = xhr?.responseJSON?.message;
// Fallback: parse responseText if responseJSON isn't set
if (!serverMessage && xhr?.responseText) {
try {
serverMessage = JSON.parse(xhr.responseText)?.message;
} catch (e) {
// Response wasn't JSON — real server crash or HTML error page
}
}
if (serverMessage) {
if (options.noAlert !== true) bootbox.alert(serverMessage);
options.onError?.(xhr, serverMessage);
} else {
console.error("AJAX Error:", xhr?.status, xhr?.responseText);
if (options.noAlert !== true) bootbox.alert("Server error occurred.");
options.onError?.(xhr, null);
}
throw xhr;
});
}
/** =========================
* REAL-TIME REQUIRED VALIDATION
* ========================= */
document.addEventListener('DOMContentLoaded', () => {
const required_inputs = document.querySelectorAll('[required]');
required_inputs.forEach(input => {
input.addEventListener('input', function () {
if (this.value.trim() !== "") {
this.classList.remove('is-invalid');
this.classList.add('is-valid');
} else {
this.classList.remove('is-valid');
this.classList.add('is-invalid');
}
});
});
});
+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 };
})();
+79 -260
View File
@@ -1,8 +1,3 @@
function escape_html(value) {
return String(value ?? '').replace(/[&<>"']/g, function(c) {
return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];
});
}
/** =========================
* SIDEBAR ACTIVE STATE
@@ -63,10 +58,6 @@ function debounce(fn, wait) {
* ========================= */
$(function () {
// Prevent all forms from refreshing the page
$("form").on("submit", function (e) {
e.preventDefault();
});
// Initialize autocomplete for product search inputs
init_product_search_inputs();
@@ -543,7 +534,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 +546,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,19 +561,30 @@ 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 || []);
}
});
}
// Line tax rate; derived from tax_amount / total_price when only the amount was stored
function line_tax_rate(item) {
var rate = parseFloat(item.tax_rate) || 0;
var amount = parseFloat(item.tax_amount) || 0;
var total = parseFloat(item.total_price) || 0;
if (rate === 0 && amount > 0 && total > 0) rate = round_dp(amount / total * 100, 2);
return rate;
}
// Returns the request promise so callers can await the options before selecting a value
function load_departments(select_id, selected_id) {
var ctx = document.getElementById('session-context');
ajax_request({
return ajax_request({
url: server_url + 'accounting/api/engine/department.php',
action: 'get',
queueLock: false,
@@ -637,227 +639,6 @@ function populate_dept_filter(select_id) {
});
}
function prepare_form_data(check_required, raw_data) {
var q = {};
// Get session context
const session_element = document.getElementById('session-context');
if (session_element) {
q['company_id'] = session_element.dataset.companyId;
q['otp'] = session_element.dataset.otp;
}
// Include GET parameters
const url_params = new URLSearchParams(window.location.search);
url_params.forEach((value, key) => {
q[key] = value;
});
// Collect form inputs (exclude search inputs — UI-only filters, not API data)
$(".form-control:not([type=search]), .form-select").each(function () {
if (!$(this).attr("id")) return true;
var el = $(this).get(0);
if (!el || !el.nodeName) return true;
q[$(this).attr("id")] = $(this).val();
});
// Validate required fields
if (check_required === 1) {
const required_inputs = document.querySelectorAll('[required]');
let is_valid = true;
required_inputs.forEach(input => {
if (!input.value.trim()) {
input.classList.add('is-invalid');
is_valid = false;
} else {
input.classList.remove('is-invalid');
input.classList.add('is-valid');
}
});
if (!is_valid) {
alert("Please fill in all mandatory fields.");
isAjaxProcessing = false;
return false;
}
}
return (raw_data) ? q : JSON.stringify(q);
}
/** =========================
* AJAX WRAPPER
* ========================= */
// Prevent double firing
let isAjaxProcessing = false;
function ajax_request(options) {
if (isAjaxProcessing && options.queueLock !== false) {
// Instead of rejecting, we just return a "never-ending" promise
// or a resolved promise that does nothing.
console.warn("Request is busy... ignoring click.");
return new Promise(() => { }); // This stays pending and won't trigger .then or .catch
}
if (options.queueLock !== false) {
isAjaxProcessing = true;
}
// Auto prepare form data
if (options.autoPrepare === true) {
let payloadJson = prepare_form_data(options.checkRequired ?? 0, true);
if (payloadJson === false) {
isAjaxProcessing = false;
return Promise.reject("validation_failed");
}
if (options.data) {
Object.entries(options.data).forEach(([key, value]) => {
payloadJson[key] = value;
});
}
if (options.action) {
// modify action
if (options.action === 'manage') {
options.action = (payloadJson['id']) ? 'update' : 'create';
}
// add action to JSON
payloadJson['action'] = options.action;
} else {
isAjaxProcessing = false;
return Promise.reject("please_define_action");
}
options.data = { json: JSON.stringify(payloadJson) };
if (options.debugMode) {
isAjaxProcessing = false;
// Show FormData contents if applicable
if (options.formData instanceof FormData) {
// Log original formData before merging
for (let [key, value] of options.formData.entries()) {
console.log("FORMDATA: " + key, value);
}
}
// Show stringified JSON payload
console.log("REQUEST DATA:", options.data);
}
// IF formData exist, we pass as $_POST [not json]
if (options.formData instanceof FormData) {
// THE BYPASS: If formData exists, move all text data into it
Object.entries(payloadJson).forEach(([key, value]) => {
options.formData.append(key, value);
});
// Override options.data with the full FormData object
options.data = options.formData;
}
}
// --- START MODIFIED $.AJAX BLOCK ---
let isSendingFiles = (options.data instanceof FormData);
// Show loading overlay
if (options.noLoading !== true) {
$.LoadingOverlay("show", {
imageColor: "#525252",
imageAnimation: "2s rotate_right",
background: "rgba(255,255,255,0.8)"
});
}
return $.ajax({
async: true,
type: options.type || "POST",
url: options.url,
data: options.data,
dataType: "json",
// These two settings are only triggered when sending files
processData: isSendingFiles ? false : true,
contentType: isSendingFiles ? false : "application/x-www-form-urlencoded; charset=UTF-8",
// for CSRF validation
headers: {
'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')
}
})
.then(function (res) {
isAjaxProcessing = false;
if (options.noLoading !== true) $.LoadingOverlay("hide");
if (options.debugMode) {
console.log("RESPONSE:", res);
return res;
}
if (!res || res.success != 1) {
if (options.noAlert !== true) bootbox.alert(res?.message || "Unexpected error");
options.onError?.(null, res?.message || 'api_failed');
throw new Error(res?.message || "api_failed");
}
options.onSuccess?.(res);
return res;
})
.catch(function (xhr) {
isAjaxProcessing = false;
$.LoadingOverlay("hide");
// Errors re-thrown from .then() — pass through
if (xhr instanceof Error) {
throw xhr;
}
// Usage limit reached — show upgrade notice instead of generic error
if (xhr?.status === 402) {
const d = xhr?.responseJSON ?? {};
const daily = d.daily_limit > 0 ? `Daily: <strong>${d.daily_count} / ${d.daily_limit}</strong>` : null;
const weekly = d.weekly_limit > 0 ? `Weekly: <strong>${d.weekly_count} / ${d.weekly_limit}</strong>` : null;
const detail = [daily, weekly].filter(Boolean).join('&nbsp;&nbsp;|&nbsp;&nbsp;');
bootbox.alert(
`<strong>Usage limit reached.</strong><br>${detail}<br><br>` +
`Reports are locked until the quota resets. Upgrade your package for higher limits.`
);
options.onError?.(xhr, 'limit_reached');
throw xhr;
}
// Extract server's error message from JSON response
let serverMessage = xhr?.responseJSON?.message;
// Fallback: parse responseText if responseJSON isn't set
if (!serverMessage && xhr?.responseText) {
try {
serverMessage = JSON.parse(xhr.responseText)?.message;
} catch (e) {
// Response wasn't JSON — real server crash or HTML error page
}
}
if (serverMessage) {
if (options.noAlert !== true) bootbox.alert(serverMessage);
options.onError?.(xhr, serverMessage);
} else {
console.error("AJAX Error:", xhr?.status, xhr?.responseText);
if (options.noAlert !== true) bootbox.alert("Server error occurred.");
options.onError?.(xhr, null);
}
throw xhr;
});
}
/** =========================
@@ -954,40 +735,29 @@ flatpickr(".flatpickr", {
});
/** =========================
* REAL-TIME REQUIRED VALIDATION
* ========================= */
document.addEventListener('DOMContentLoaded', () => {
const required_inputs = document.querySelectorAll('[required]');
required_inputs.forEach(input => {
input.addEventListener('input', function () {
if (this.value.trim() !== "") {
this.classList.remove('is-invalid');
this.classList.add('is-valid');
} else {
this.classList.remove('is-valid');
this.classList.add('is-invalid');
}
});
});
});
/**
* Helper function to format date strings (assuming input is in ISO format)
*/
// Display format used across the app: YYYY-MM-DD, or YYYY-MM-DD HH:mm:ss when the value has a time.
function format_date(iso_string) {
if (!iso_string) return '—';
var split = iso_string.split(" ");
var split = String(iso_string).split(" ");
var datePart = split[0].split("-");
if (datePart.length !== 3) return iso_string;
var formatted = `${datePart[2]}/${datePart[1]}/${datePart[0]}`;
var formatted = `${datePart[0]}-${datePart[1]}-${datePart[2]}`;
return split.length === 2 ? `${formatted} ${split[1]}` : formatted;
}
// DD/MM/YYYY for filling date inputs (flatpickr dateFormat 'd/m/Y'); to_iso_date() reverses it.
function format_date_input(iso_string) {
if (!iso_string) return '';
var datePart = String(iso_string).split(" ")[0].split("-");
if (datePart.length !== 3) return iso_string;
return `${datePart[2]}/${datePart[1]}/${datePart[0]}`;
}
function to_iso_date(dateStr) {
if (!dateStr) return null;
@@ -1003,6 +773,36 @@ function to_iso_date(dateStr) {
}
/**
* Clear every field inside a form or form-like container — the "Clear" buttons
* on the master-data pages. It was called by five pages but never defined, so
* each click threw a ReferenceError, and where the container is a <div> rather
* than a <form> (Chart of Accounts, Departments) the button did nothing at all.
* Disabled and hidden inputs are left alone: those carry the record id and
* locked values, not user input.
*/
function reset_input(selector) {
var $scope = $(selector);
if (!$scope.length) return;
$scope.find('input, textarea, select').each(function () {
if (this.disabled || this.type === 'hidden' || this.type === 'button' || this.type === 'submit') return;
if (this._flatpickr) {
this._flatpickr.clear();
} else if (this.type === 'checkbox' || this.type === 'radio') {
this.checked = this.defaultChecked;
} else if (this.tagName === 'SELECT') {
this.selectedIndex = 0;
} else {
this.value = '';
}
$(this).removeAttr('secondary').removeClass('is-invalid is-valid');
});
}
function round_dp(value, places) {
var factor = Math.pow(10, places);
return Math.round((Number(value) + Number.EPSILON) * factor) / factor;
@@ -1038,6 +838,25 @@ function expand_exponential_number(value) {
return sign + digits.slice(0, point) + '.' + digits.slice(point);
}
/**
* Format a stock quantity without hiding real data.
*
* Quantity columns are decimal(18,4), so a genuine 0.0001 exists. Formatting
* every quantity at 2 dp printed such a value as "0.00", which reads as "no
* data" — the stock popups and list pages all showed an empty-looking QTY for
* a receipt that had in fact been made. Show 2 dp normally, and the stored
* 4 dp whenever rounding to 2 would lose something.
*/
function format_quantity(value) {
var n = Number(value);
if (isNaN(n)) return '--';
return (round_dp(n, 2) !== round_dp(n, 4))
? format_number(n, 4)
: format_number(n, 2);
}
function format_number(value, decimal) {
var n = Number(value);
if (isNaN(n)) return '--';
@@ -1140,16 +959,16 @@ 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>
<td>${escape_html(r.warehouse_name)}</td>
<td><small>${escape_html(location)}</small></td>
<td><small>${escape_html(r.lot_number || '—')}</small></td>
<td class="text-end fw-semibold">${format_number(r.quantity, 2)}</td>
<td class="text-end fw-semibold">${format_quantity(r.quantity)}</td>
<td>${status_badge}</td>
<td><small>${format_date(r.date)}</small></td>
</tr>`;
File diff suppressed because one or more lines are too long
+37
View File
@@ -0,0 +1,37 @@
<?php
/**
* app_access.php — which app (WMS / Accounting) a script belongs to, and whether
* the signed-in user's app_access allows it.
*
* app_access used to only choose which menus the topbar drew; a WMS-only user
* could still open the accounting pages and call their APIs directly. db_auth.php
* (API engines) and include_topbar.php (pages) now both enforce it through here.
*/
// Accounting endpoints the WMS screens also call (master-data lookups, the
// batch operation lock, and the GL panel on purchase invoices).
const APP_ACCESS_SHARED_ACCOUNTING = [
'accounting/api/engine/account.php',
'accounting/api/engine/account_formula.php',
'accounting/api/engine/department.php',
'accounting/api/engine/acquire_op_lock.php',
'accounting/api/engine/release_op_lock.php',
'accounting/api/engine/get_gl_by_source.php',
];
/** The app a script under app/ belongs to: 'accounting', or null for WMS/shared. */
function app_access_app_for(string $script_name): ?string {
$path = str_replace('\\', '/', $script_name);
$pos = strpos($path, '/app/');
if ($pos === false) return null;
$rel = substr($path, $pos + 5);
if (in_array($rel, APP_ACCESS_SHARED_ACCOUNTING, true)) return null;
if (preg_match('#^(accounting|ac_dashboard|revenue|expense|finance|journal)/#', $rel)) return 'accounting';
return null;
}
/** Whether an app_access value ('wms', 'accounting', 'all') includes $app. */
function app_access_allows(string $access, string $app): bool {
return $access === 'all' || $access === $app;
}
+22
View File
@@ -0,0 +1,22 @@
<?php
// app/assets/utils/app_registry.php
//
// The apps a user can be given access to (user.app_access and
// company_map_user.app_access), with the label, icon and badge colour the
// Users Access page shows for each.
//
// config.php may define its own $app_registry; this file only fills it in when
// it is missing or empty — which is every Docker-generated config.php written
// before the setting was documented. Without it the Add User dialog breaks
// (Object.entries(null) in setting/users.php) and inviting a user fails
// (array_keys(null) in setting/api/engine/manage_users.php).
//
// Keys must stay within the user.app_access enum: 'wms' and 'accounting'
// ('all' is implied and never listed here).
if (!isset($app_registry) || !is_array($app_registry) || !$app_registry) {
$app_registry = [
'wms' => ['label' => 'WMS', 'icon' => 'ti-box', 'color' => 'bg-label-primary'],
'accounting' => ['label' => 'Accounting', 'icon' => 'ti-calculator', 'color' => 'bg-label-success'],
];
}
+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,
];
}
@@ -99,7 +99,7 @@ class CompanyProfileManager
public function saveProfile(array $data, string $company_logo, string $company_seal): void
{
$channel = strtolower(preg_replace('/[^a-z0-9\-_]/', '', $data['channel_name'] ?? ''));
$channel = preg_replace('/[^a-z0-9\-_]/', '', strtolower(trim($data['channel_name'] ?? '')));
$sth = $this->pdo->prepare(
"UPDATE company_list SET
@@ -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,131 @@
<?php
/**
* Server-side rules shared by the documents that carry priced lines: sales
* orders, purchase orders, quotations and purchase requests.
*
* The pages enforce the same limits, but only in JavaScript, so a request sent
* straight to the engine could store a 150% tax rate, a negative price or a
* line total that does not match quantity × price. Everything here throws a
* plain Exception, which the engines already report back as the alert text.
*/
class DocumentValidator
{
const MAX_TAX_RATE = 100;
// Far above any real unit price, low enough to stop a slipped keystroke
// (or a crafted request) from booking billions.
const MAX_UNIT_PRICE = 999999999.99;
const MAX_QUANTITY = 999999999.9999;
const MIN_QUANTITY = 0.0001;
/**
* Validate the lines and return them with total_price and tax_amount
* recomputed, using the same formula as the pages:
* total = quantity × unit_price, tax = total × tax_rate / 100 (4 dp).
*/
public static function normaliseLines(array $items, string $doc_label = 'Document'): array
{
$out = [];
foreach (array_values($items) as $i => $item) {
if (!is_array($item)) {
throw new Exception("{$doc_label} line #" . ($i + 1) . " is not valid.");
}
$name = trim((string)($item['product_name'] ?? '')) ?: trim((string)($item['product_sku'] ?? ''));
$label = 'Line #' . ($i + 1) . ($name !== '' ? " ({$name})" : '');
$qty = self::number($item['quantity'] ?? 0, "{$label}: quantity");
$price = self::number($item['unit_price'] ?? $item['price'] ?? 0, "{$label}: unit price");
$rate = self::number($item['tax_rate'] ?? 0, "{$label}: tax rate");
$qty = round($qty, 4);
if ($qty < self::MIN_QUANTITY) {
throw new Exception("{$label}: quantity must be greater than zero.");
}
if ($qty > self::MAX_QUANTITY) {
throw new Exception("{$label}: quantity is too large.");
}
if ($price < 0) {
throw new Exception("{$label}: unit price cannot be negative.");
}
if ($price > self::MAX_UNIT_PRICE) {
throw new Exception("{$label}: unit price cannot exceed " . number_format(self::MAX_UNIT_PRICE, 2) . ".");
}
if ($rate < 0 || $rate > self::MAX_TAX_RATE) {
throw new Exception("{$label}: tax rate must be between 0 and " . self::MAX_TAX_RATE . "%.");
}
$total = round($qty * $price, 4);
$item['quantity'] = $qty;
$item['unit_price'] = round($price, 4);
$item['tax_rate'] = round($rate, 2);
$item['total_price'] = $total;
$item['tax_amount'] = round($total * $item['tax_rate'] / 100, 4);
$out[] = $item;
}
return $out;
}
/** Header amounts (discount, shipping fee): numeric and never negative. */
public static function amount($value, string $label): float
{
$n = self::number($value, $label);
if ($n < 0) {
throw new Exception("{$label} cannot be negative.");
}
if ($n > self::MAX_UNIT_PRICE * 1000) {
throw new Exception("{$label} is too large.");
}
return $n;
}
/** A discount larger than the goods would turn the document negative. */
public static function discount($value, float $subtotal): float
{
$discount = self::amount($value, 'Discount');
if ($discount > $subtotal + 0.00005) {
throw new Exception('Discount cannot exceed the subtotal.');
}
return $discount;
}
public static function requireId($value, string $message): int
{
$id = (int)$value;
if ($id <= 0) {
throw new Exception($message);
}
return $id;
}
/**
* A department is mandatory once the company uses departments. A company
* that has never defined one keeps saving with "No Department".
*/
public static function requireDepartment(PDO $pdo, int $company_id, $value): int
{
$id = (int)$value;
if ($id > 0) {
$sth = $pdo->prepare("SELECT COUNT(*) FROM md_department WHERE company_id = :cid AND id = :id");
$sth->execute([':cid' => $company_id, ':id' => $id]);
if ((int)$sth->fetchColumn() === 0) {
throw new Exception('The selected department does not exist.');
}
return $id;
}
$sth = $pdo->prepare("SELECT COUNT(*) FROM md_department WHERE company_id = :cid AND status = 1");
$sth->execute([':cid' => $company_id]);
if ((int)$sth->fetchColumn() > 0) {
throw new Exception('Department is required.');
}
return 0;
}
private static function number($value, string $label): float
{
if ($value === '' || $value === null) return 0.0;
if (!is_numeric($value) || !is_finite((float)$value)) {
throw new Exception("{$label} must be a number.");
}
return (float)$value;
}
}
@@ -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]);
}
}
}
+249 -38
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
@@ -426,12 +403,47 @@ class InvoiceManager {
* @param array $logging Audit entry.
* @throws Exception If invoice not found or not in draft status.
*/
/**
* Normalise a client-supplied date to ISO YYYY-MM-DD and reject anything
* that is not a real calendar date.
*
* The date pickers display d/m/Y, and a page that forgets to convert before
* posting sends that text straight through to a MySQL DATE column, where it
* fails as a PDOException and surfaces to the user as the opaque
* "Database error, please try again." Accepting both spellings here keeps
* the failure mode a named, actionable message instead.
*
* @param string $value ISO or d/m/Y date; '' is treated as "not set".
* @param string $label Field name used in the error message.
* @return string|null ISO date, or null when nothing was supplied.
* @throws Exception When the value is not a valid date.
*/
private function normaliseDate(string $value, string $label): ?string
{
$value = trim($value);
if ($value === '') return null;
// Strip a time part, if the caller passed a datetime.
$value = explode(' ', $value)[0];
foreach (['Y-m-d', 'd/m/Y'] as $format) {
$parsed = DateTime::createFromFormat('!' . $format, $value);
// createFromFormat() accepts overflowing values such as 32/01/2026
// and rolls them over, so compare the round-trip to reject those.
if ($parsed && $parsed->format($format) === $value) {
return $parsed->format('Y-m-d');
}
}
throw new Exception("{$label} is not a valid date.");
}
public function saveInvoice(array $data, array $logging): void
{
$id = (int)($data['id'] ?? 0);
$sth = $this->pdo->prepare(
"SELECT status, doc_type, issued_date, `log` FROM td_invoice
"SELECT status, doc_type, issued_date, due_date, `log` FROM td_invoice
WHERE company_id = :company_id AND id = :id"
);
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
@@ -451,8 +463,21 @@ class InvoiceManager {
$formula_id = isset($data['formula_id']) && (int)$data['formula_id'] > 0
? (int)$data['formula_id'] : null;
// Absent key means "not being edited" — keep what is stored rather than
// clearing it, so a caller that posts only tax_adjustment cannot wipe
// the agreed payment term.
$due_date = array_key_exists('due_date', $data)
? $this->normaliseDate((string)$data['due_date'], 'Due date')
: ($row['due_date'] ?: null);
$issued_date = $row['issued_date'] ?: null;
if ($due_date !== null && $issued_date !== null && $due_date < $issued_date) {
throw new Exception("The due date cannot be earlier than the issue date.");
}
$params = [
':due_date' => $data['due_date'] ?: null,
':due_date' => $due_date,
':notes' => $data['notes'] ?? '',
':formula_id' => $formula_id,
':log' => json_encode($log),
@@ -548,6 +573,11 @@ class InvoiceManager {
if (in_array($row['doc_type'], ['invoice', 'purchase_invoice']) && !$due_date) {
throw new Exception("Due date is required before issuing this document.");
}
$due_date = $this->normaliseDate((string)($due_date ?? ''), 'Due date');
if ($due_date !== null && $due_date < $issued_date) {
throw new Exception("The due date cannot be earlier than the issue date.");
}
$this->assertPostingWindow($issued_date, ucfirst(str_replace('_', ' ', $row['doc_type'])));
$log = json_decode($row['log'] ?? '[]', true) ?: [];
@@ -875,18 +905,46 @@ class InvoiceManager {
$log = [array_merge($logging, ['action' => 'create_credit_note'])];
// Split the credited amount into net and VAT from the lines being
// credited. This used to store the whole VAT-inclusive amount as the
// subtotal with tax = 0, so the header disagreed with its own lines: the
// VAT report missed the output-tax reversal, and a GL formula posting
// from the header reversed revenue by the gross figure.
//
// The VAT is taken as "amount minus net" so the grand total still
// equals the caller's amount exactly, including any rounding adjustment
// the return carried; that adjustment is recorded as tax_adjustment.
// With no priced lines to split by, the amount is kept whole as before.
$net = round(array_reduce($items, fn($c, $i) => $c + (float)($i['total_price'] ?? 0), 0.0), 4);
$line_tax = round(array_reduce($items, fn($c, $i) => $c + (float)($i['tax_amount'] ?? 0), 0.0), 2);
if ($net > 0 && $net <= abs($amount) + 0.005) {
$subtotal = $net;
$tax = round(abs($amount) - $net, 4);
$tax_adj = round($tax - $line_tax, 2);
} else {
$subtotal = abs($amount);
$tax = 0.0;
$tax_adj = 0.0;
}
$this->pdo->prepare(
"INSERT INTO td_invoice
(company_id, uuid, source_id, `source`, doc_type, invoice_number, ref_invoice_id,
order_id, contact_id, issued_date, due_date,
subtotal, discount, tax, shipping_fee, grand_total,
order_id, contact_id, department_id, issued_date, due_date,
subtotal, discount, tax, tax_adjustment, shipping_fee, grand_total,
status, notes, `log`)
VALUES
(:company_id, :uuid, :source_id, :source, 'credit_note', :invoice_number, :ref_invoice_id,
:order_id, :contact_id, :issued_date, NULL,
:amount, 0, 0, 0, :grand_total,
:order_id, :contact_id, :department_id, :issued_date, NULL,
:amount, 0, :tax, :tax_adjustment, 0, :grand_total,
1, '', :log)"
)->execute([
// The credit note belongs to the same department as the invoice it
// corrects; it was left at 0 before.
':department_id' => (int)($parent['department_id'] ?? 0),
':tax' => $tax,
':tax_adjustment' => $tax_adj,
':company_id' => $this->company_id,
':uuid' => bin2hex(random_bytes(16)),
':source_id' => $source_id,
@@ -896,7 +954,7 @@ class InvoiceManager {
':order_id' => (int)$parent['order_id'],
':contact_id' => (int)$parent['contact_id'],
':issued_date' => $issued_date,
':amount' => $amount,
':amount' => $subtotal,
':grand_total' => -abs($amount), // negative for net-balance queries
':log' => json_encode($log),
]);
@@ -1013,7 +1071,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 +1190,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 +1216,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 +1357,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 +1403,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) ?: [];
+215 -82
View File
@@ -1,4 +1,7 @@
<?php
require_once __DIR__ . '/DocumentNumberManager.php';
require_once __DIR__ . '/DocumentValidator.php';
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
/**
* OrderManager
@@ -15,19 +18,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 +75,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 +104,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 +114,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 +150,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 +185,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
@@ -265,13 +261,16 @@ class OrderManager {
{
$sth = $this->pdo->prepare(
"SELECT o.*,
COALESCE(c.contact_name, '') AS contact_name
COALESCE(c.contact_name, '') AS contact_name,
(SELECT COUNT(*) FROM td_order_item i
WHERE i.company_id = o.company_id
AND i.order_id = o.id) AS item_count
FROM td_order o
LEFT JOIN md_contact c
ON c.company_id = o.company_id
AND c.id = o.contact_id
WHERE o.company_id = :company_id
ORDER BY o.created_at DESC"
ORDER BY o.order_date DESC, o.id DESC"
);
$sth->execute([':company_id' => $this->company_id]);
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
@@ -370,9 +369,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 +387,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'];
@@ -408,12 +407,23 @@ class OrderManager {
return $returnable;
}
/**
* Mark an accepted quotation as converted once an order has been created
* from it.
*
* The link itself lives on the order (td_order.source = 'quotation',
* source_id = quotation id), which saveOrder() has already written and
* QuotationManager::getById() joins on. This used to also write
* td_quotation.order_id — a column that has never existed — so saving an
* order with source=quotation failed with "Unknown column" and rolled the
* new order back with it.
*/
public function linkQuotationToOrder(int $quotation_id, int $order_id): void
{
$this->pdo->prepare(
"UPDATE td_quotation SET order_id = :order_id, status = 5
"UPDATE td_quotation SET status = 5
WHERE id = :id AND company_id = :cid AND status = 2"
)->execute([':order_id' => $order_id, ':id' => $quotation_id, ':cid' => $this->company_id]);
)->execute([':id' => $quotation_id, ':cid' => $this->company_id]);
}
public function assertRevenueOrderEditable(int $order_id): void
@@ -476,13 +486,18 @@ class OrderManager {
public function saveOrder(array $data, array $logging): int
{
$id = (int)($data['id'] ?? 0);
$items = $data['items'] ?? [];
$items = DocumentValidator::normaliseLines($data['items'] ?? [], 'Order');
$data['items'] = $items;
DocumentValidator::requireId($data['contact_id'] ?? 0, 'Contact is required.');
if ($id === 0 || array_key_exists('department_id', $data)) {
$data['department_id'] = DocumentValidator::requireDepartment($this->pdo, $this->company_id, $data['department_id'] ?? 0);
}
// Calculate totals from items
$subtotal = array_reduce($items, fn($carry, $item) =>
$carry + (float)($item['total_price'] ?? 0), 0.0
);
$discount = (float)($data['discount'] ?? 0);
$discount = DocumentValidator::discount($data['discount'] ?? 0, $subtotal);
$tax_adjustment = (float)($data['tax_adjustment'] ?? 0);
if (abs($tax_adjustment) > 0.30) {
throw new Exception("Tax adjustment cannot exceed ±0.30.");
@@ -490,7 +505,7 @@ class OrderManager {
$tax = round(array_reduce($items, fn($carry, $item) =>
$carry + (float)($item['tax_amount'] ?? 0), 0.0
), 2) + $tax_adjustment;
$shipping_fee = (float)($data['shipping_fee'] ?? 0);
$shipping_fee = DocumentValidator::amount($data['shipping_fee'] ?? 0, 'Shipping fee');
$tracking_no = trim((string)($data['shipping_tracking_number'] ?? ''));
$grand_total = $subtotal - $discount + $tax + $shipping_fee;
@@ -498,7 +513,7 @@ class OrderManager {
// Fetch existing row to check status and load log
$sth = $this->pdo->prepare(
"SELECT status, `log` FROM td_order
"SELECT status, department_id, `log` FROM td_order
WHERE company_id = :company_id AND id = :id"
);
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
@@ -507,6 +522,11 @@ class OrderManager {
if (!$row) {
throw new Exception("Order not found.");
}
// Pages without a department field (Revenue SO) must not wipe the stored one
$department_id = array_key_exists('department_id', $data)
? (int)$data['department_id']
: (int)$row['department_id'];
$cur_status = (int)$row['status'];
if ($cur_status !== 0 && $cur_status !== -2) {
throw new Exception("Only draft or pending orders can be edited.");
@@ -546,7 +566,7 @@ class OrderManager {
WHERE id = :id AND company_id = :company_id"
)->execute([
':contact_id' => (int)($data['contact_id'] ?? 0),
':department_id' => (int)($data['department_id'] ?? 0),
':department_id' => $department_id,
':order_date' => $data['order_date'] ?? date('Y-m-d'),
':subtotal' => $subtotal,
':discount' => $discount,
@@ -588,7 +608,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 +633,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 +645,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 +655,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 +702,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 +727,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 +778,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 +855,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 +918,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 +929,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 +950,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 +1025,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.
@@ -14,20 +14,25 @@
*
* The OTP is a 6-digit TOTP derived from the user's current password hash via HMAC-SHA1,
* scoped to a 3-minute time step. It cannot be replayed after the window expires.
* A human-readable reference number (6 uppercase letters) is also generated and emailed
* so the user can confirm they received the correct OTP request.
* A random reference number (6 uppercase letters) is also generated and emailed so the
* user can confirm they received the correct OTP request. It is not derived from the
* OTP: a derived reference let anyone who saw it recover the OTP offline.
*
* HTTP handler methods for thin AJAX endpoint wrappers:
* handleRequestOtp($user_id, $company_id)
* handleRequestOtp($user_id, $company_id) — signed-in profile page
* handleRequestOtpPublic($user_id, $company_id) — login page; same answer whether
* or not the account exists
* handleConfirmReset($user_id, $data)
*
* Session keys used (prefixed with 'reset_' to avoid collision with login OTP):
* reset_otp, reset_otp_time, reset_reference, reset_user_id
* reset_otp, reset_otp_time, reset_reference, reset_user_id, reset_attempts
*
* Security:
* - OTP is HMAC-derived from the current password hash — it changes when the password changes.
* - OTP is valid for OTP_EXPIRY_MINUTES (5) only; older OTPs are rejected with clearSession().
* - reset_user_id in session is verified against $user_id to prevent cross-user OTP reuse.
* - At most OTP_MAX_ATTEMPTS wrong entries per issued OTP, then it is discarded.
* - OTPs are compared with hash_equals().
* - Session is fully destroyed on successful reset, forcing re-authentication.
* - All DB queries use PDO prepared statements with bound parameters.
* - AJAX handler methods output JSON via json_encode (XSS-safe).
@@ -43,6 +48,12 @@ class PasswordResetManager {
/** OTP validity window in minutes — matches the login OTP window. */
const OTP_EXPIRY_MINUTES = 5;
/** Wrong OTP entries allowed per issued OTP before it is discarded. */
const OTP_MAX_ATTEMPTS = 5;
/** Answer shown on the login page whether or not the account exists. */
const PUBLIC_REQUEST_MESSAGE = "If an account matches, we've sent an OTP to its email.";
/**
* @param PDO $pdo1 PDO connection to the wms database (user table).
* @param PDO $pdo2 PDO connection to the company database (smtp_setting table).
@@ -94,10 +105,10 @@ class PasswordResetManager {
throw new \RuntimeException('No email address found for this account.');
}
// Generate 6-digit TOTP and a human-readable 6-letter reference number
// Generate 6-digit TOTP and a random 6-letter reference number
$otp_time = time();
$otp = $this->generateOTP($user['password'], $otp_time);
$reference_number = $this->numberToLetters((int) $this->generateOTP($otp, $otp_time));
$reference_number = $this->randomReference();
// Send via the mailer module (uses company SMTP or falls back to system default)
require_once $this->include_url . '/assets/utils/module/mailer.php';
@@ -124,6 +135,7 @@ class PasswordResetManager {
$_SESSION['reset_otp_time'] = $otp_time;
$_SESSION['reset_reference'] = $reference_number;
$_SESSION['reset_user_id'] = $user_id;
$_SESSION['reset_attempts'] = 0;
return [
'masked_email' => $this->maskEmail($user['email']),
@@ -131,6 +143,24 @@ class PasswordResetManager {
];
}
/**
* Start a reset that can never succeed, for a login-page request whose
* username/email matches no account. The session then looks exactly like a
* real request (random unguessable OTP, reset_user_id 0), so the confirm step
* answers "Incorrect OTP" instead of revealing that the account is missing.
*
* @return string Random 6-letter reference, same shape as a real one.
*/
public function startDecoy(): string {
$reference = $this->randomReference();
$_SESSION['reset_otp'] = bin2hex(random_bytes(16));
$_SESSION['reset_otp_time'] = time();
$_SESSION['reset_reference'] = $reference;
$_SESSION['reset_user_id'] = 0;
$_SESSION['reset_attempts'] = 0;
return $reference;
}
/**
* Verify the OTP and force-set a new password via PasswordManager.
*
@@ -170,8 +200,14 @@ class PasswordResetManager {
throw new \InvalidArgumentException('OTP has expired. Please request a new one.');
}
// Verify OTP value
if (trim($otp_input) !== $_SESSION['reset_otp']) {
// Verify OTP value — at most OTP_MAX_ATTEMPTS wrong entries per issued OTP,
// so the 6-digit code cannot be brute-forced inside its 5-minute window.
if (!hash_equals((string)$_SESSION['reset_otp'], trim($otp_input)) || $user_id <= 0) {
$_SESSION['reset_attempts'] = (int)($_SESSION['reset_attempts'] ?? 0) + 1;
if ($_SESSION['reset_attempts'] >= self::OTP_MAX_ATTEMPTS) {
$this->clearSession();
throw new \InvalidArgumentException('Too many incorrect OTP attempts. Please request a new OTP.');
}
throw new \InvalidArgumentException('Incorrect OTP. Please try again.');
}
@@ -234,6 +270,39 @@ class PasswordResetManager {
exit;
}
/**
* Handle the login-page request-OTP call. The answer is the same whether or
* not the username/email matches an account (no account enumeration): no
* masked email, a generic message and a reference number. Mail failures are
* logged, not reported, for the same reason.
*
* On success (always): { success: 1, message: PUBLIC_REQUEST_MESSAGE, reference: "ABCDEF" }
*
* @param int|null $user_id Resolved account, or null when nothing matched.
* @param int $company_id Company SMTP scope (0 = use system default).
*/
public function handleRequestOtpPublic(?int $user_id, int $company_id = 0): void {
$reference = null;
if ($user_id) {
try {
$reference = $this->requestOtp($user_id, $company_id)['reference'];
} catch (\Exception $e) {
error_log('[PasswordResetManager::handleRequestOtpPublic] ' . $e->getMessage());
}
}
if ($reference === null) {
$reference = $this->startDecoy();
}
echo json_encode([
'success' => 1,
'message' => self::PUBLIC_REQUEST_MESSAGE,
'reference' => $reference,
]);
exit;
}
/**
* Handle an AJAX confirm-reset call and echo a JSON response.
*
@@ -312,23 +381,17 @@ class PasswordResetManager {
}
/**
* Convert a positive integer into a base-26 uppercase letter string.
* Random 6-letter uppercase reference code (e.g. "BCDFHJ") for the reset email
* and the confirmation screen. Carries no information about the OTP.
*
* Used to turn the numeric reference OTP into a human-friendly 6-letter
* reference code (e.g. 123456 → "BCDFHJ") for inclusion in the reset email.
* The result is left-padded with 'A' to always return a 6-character string.
*
* @param int $num Positive integer to convert.
* @return string 6-character uppercase string (e.g. "AAAABC").
* @return string 6-character uppercase string.
*/
private function numberToLetters(int $num): string {
private function randomReference(): string {
$result = '';
while ($num > 0) {
$mod = ($num - 1) % 26;
$result = chr(65 + $mod) . $result;
$num = intval(($num - $mod) / 26);
for ($i = 0; $i < 6; $i++) {
$result .= chr(65 + random_int(0, 25));
}
return str_pad($result, 6, 'A', STR_PAD_LEFT);
return $result;
}
/**
@@ -356,14 +419,15 @@ class PasswordResetManager {
*
* Called on OTP expiry (to invalidate the request) and on successful
* reset (before session_destroy). Does not destroy the full session —
* only the 4 reset-specific keys are unset.
* only the reset-specific keys are unset.
*/
private function clearSession(): void {
unset(
$_SESSION['reset_otp'],
$_SESSION['reset_otp_time'],
$_SESSION['reset_reference'],
$_SESSION['reset_user_id']
$_SESSION['reset_user_id'],
$_SESSION['reset_attempts']
);
}
}
@@ -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']);
}
}
?>
+12 -12
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
@@ -569,9 +569,9 @@ class ProductManager {
ON p.company_id = r.company_id
AND p.sku = r.product_sku
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"
ORDER BY mw.warehouse_name, REGEXP_REPLACE(r.zone, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(r.zone, '[0-9]+$') AS UNSIGNED), r.zone,
REGEXP_REPLACE(r.aisle, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(r.aisle, '[0-9]+$') AS UNSIGNED), r.aisle,
REGEXP_REPLACE(r.bin, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(r.bin, '[0-9]+$') AS UNSIGNED), r.bin"
);
$sth->execute([':company_id' => $this->company_id]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
+204 -59
View File
@@ -1,4 +1,9 @@
<?php
require_once __DIR__ . '/DocumentNumberManager.php';
require_once __DIR__ . '/DocumentValidator.php';
require_once __DIR__ . '/WarehouseManager.php';
require_once __DIR__ . '/StockManager.php';
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
/**
* PurchaseOrderManager
@@ -23,7 +28,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 +63,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 +100,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 +140,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;
}
@@ -204,13 +196,16 @@ class PurchaseOrderManager {
{
$sth = $this->pdo->prepare(
"SELECT p.*,
COALESCE(c.contact_name, '') AS contact_name
COALESCE(c.contact_name, '') AS contact_name,
(SELECT COUNT(*) FROM td_purchase_order_item i
WHERE i.company_id = p.company_id
AND i.order_id = p.id) AS item_count
FROM td_purchase_order p
LEFT JOIN md_contact c
ON c.company_id = p.company_id
AND c.id = p.contact_id
WHERE p.company_id = :company_id
ORDER BY p.created_at DESC"
ORDER BY p.po_date DESC, p.id DESC"
);
$sth->execute([':company_id' => $this->company_id]);
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
@@ -288,7 +283,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'] ?? '',
]);
}
}
@@ -331,7 +326,12 @@ class PurchaseOrderManager {
public function savePo(array $data, array $logging): int
{
$id = (int)($data['id'] ?? 0);
$items = $data['items'] ?? [];
$items = DocumentValidator::normaliseLines($data['items'] ?? [], 'Purchase order');
$data['items'] = $items;
DocumentValidator::requireId($data['contact_id'] ?? 0, 'Supplier is required.');
if ($id === 0 || array_key_exists('department_id', $data)) {
$data['department_id'] = DocumentValidator::requireDepartment($this->pdo, $this->company_id, $data['department_id'] ?? 0);
}
$skus = array_filter(array_column($items, 'product_sku'));
if (count($skus) !== count(array_unique($skus))) {
@@ -341,7 +341,7 @@ class PurchaseOrderManager {
$subtotal = array_reduce($items, fn($carry, $item) =>
$carry + (float)($item['total_price'] ?? 0), 0.0
);
$discount = (float)($data['discount'] ?? 0);
$discount = DocumentValidator::discount($data['discount'] ?? 0, $subtotal);
$tax_adjustment = (float)($data['tax_adjustment'] ?? 0);
if (abs($tax_adjustment) > 0.30) {
throw new Exception("Tax adjustment cannot exceed ±0.30.");
@@ -349,7 +349,7 @@ class PurchaseOrderManager {
$tax = round(array_reduce($items, fn($carry, $item) =>
$carry + (float)($item['tax_amount'] ?? 0), 0.0
), 2) + $tax_adjustment;
$shipping_fee = (float)($data['shipping_fee'] ?? 0);
$shipping_fee = DocumentValidator::amount($data['shipping_fee'] ?? 0, 'Shipping fee');
$grand_total = $subtotal - $discount + $tax + $shipping_fee;
if ($id > 0) {
@@ -432,7 +432,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 +506,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,13 +560,38 @@ 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'] ?? '';
$warehouse_id = (int)($recv['warehouse_id'] ?? $po['warehouse_id']);
$quantity = (float)($recv['quantity'] ?? 0);
if ($quantity <= 0) continue;
// A blank line is a line the user chose not to receive — skip it.
if ($quantity == 0) continue;
// Anything positive has to survive the decimal(18,4) columns it is
// about to be written to. Without this, 0.0000001 was accepted, was
// stored as 0.0000, produced a stock movement of nothing, and still
// advanced received_qty enough to leave the PO stuck on "Partial".
$name = $po_items[$po_items_by_id[$item_id] ?? -1]['product_name'] ?? $product_sku;
$quantity = StockManager::normaliseQuantity($quantity, "Receiving quantity for \"{$name}\"");
if (!$product_sku) throw new Exception("Item #{$j}: missing product_sku.");
if (!$warehouse_id) throw new Exception("Item #{$j}: missing warehouse_id.");
@@ -587,16 +612,26 @@ 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
// Expiry dates live on md_lot, keyed by lot number, so an expiry
// entered without one is silently dropped and the received stock
// shows no expiry at all. Say so instead of discarding it.
if (trim((string)($recv['expiry_date'] ?? '')) !== ''
&& trim((string)($recv['lot_number'] ?? '')) === '') {
throw new Exception(
"Enter a lot number for \"{$name}\" — an expiry date is recorded against its lot."
);
}
// 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 +648,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 +664,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 +678,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 +760,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 +781,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 +801,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 +809,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 +828,11 @@ class PurchaseOrderManager {
':company_id' => $this->company_id,
]);
$whMgmt->releaseRack(
$whMgmt->releaseBin(
$warehouse_id,
$row['zone'],
$row['aisle'],
$row['rack']
$row['bin']
);
}
}
@@ -851,4 +882,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,7 @@
<?php
require_once __DIR__ . '/DocumentNumberManager.php';
require_once __DIR__ . '/DocumentValidator.php';
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
/**
* PurchaseRequestManager
@@ -30,18 +33,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');
}
/**
@@ -100,7 +95,10 @@ class PurchaseRequestManager
WHERE p.company_id = r.company_id
AND p.source = 'purchase_request'
AND p.source_id = r.id
AND p.status != -1) AS linked_po_count
AND p.status != -1) AS linked_po_count,
(SELECT COUNT(*) FROM td_purchase_request_item i
WHERE i.company_id = r.company_id
AND i.request_id = r.id) AS item_count
FROM td_purchase_request r
LEFT JOIN md_contact c
ON c.company_id = r.company_id AND c.id = r.contact_id
@@ -166,16 +164,26 @@ class PurchaseRequestManager
public function save(array $data, array $logging): int
{
$id = (int)($data['id'] ?? 0);
$items = $data['items'] ?? [];
$discount = (float)($data['discount'] ?? 0);
$shipping_fee = (float)($data['shipping_fee'] ?? 0);
$items = DocumentValidator::normaliseLines($data['items'] ?? [], 'Purchase request');
$data['items'] = $items;
$discount = DocumentValidator::discount($data['discount'] ?? 0, array_sum(array_column($items, 'total_price')));
$shipping_fee = DocumentValidator::amount($data['shipping_fee'] ?? 0, 'Shipping fee');
if ($id === 0 || array_key_exists('department_id', $data)) {
$data['department_id'] = DocumentValidator::requireDepartment($this->pdo, $this->company_id, $data['department_id'] ?? 0);
}
if (empty($items)) throw new Exception('At least one item is required.');
$request_date = (string)($data['request_date'] ?? '');
$required_date = (string)($data['required_date'] ?? '');
if ($request_date !== '' && $required_date !== '' && $required_date < $request_date) {
throw new Exception('Required date cannot be earlier than the request date.');
}
[$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 +355,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]);
}
}
+71 -15
View File
@@ -1,4 +1,7 @@
<?php
require_once __DIR__ . '/DocumentNumberManager.php';
require_once __DIR__ . '/DocumentValidator.php';
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
/**
* QuotationManager
@@ -30,18 +33,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');
}
/**
@@ -95,7 +90,10 @@ class QuotationManager
public function getList(): array
{
$sth = $this->pdo->prepare(
"SELECT q.*, COALESCE(c.contact_name, '') AS contact_name
"SELECT q.*, COALESCE(c.contact_name, '') AS contact_name,
(SELECT COUNT(*) FROM td_quotation_item i
WHERE i.company_id = q.company_id
AND i.quotation_id = q.id) AS item_count
FROM td_quotation q
LEFT JOIN md_contact c
ON c.id = q.contact_id AND c.company_id = q.company_id
@@ -176,13 +174,32 @@ class QuotationManager
public function save(array $data, array $logging): int
{
$id = (int)($data['id'] ?? 0);
$items = $data['items'] ?? [];
$discount = (float)($data['discount'] ?? 0);
$items = DocumentValidator::normaliseLines($data['items'] ?? [], 'Quotation');
$data['items'] = $items;
$discount = DocumentValidator::discount($data['discount'] ?? 0, array_sum(array_column($items, 'total_price')));
$quotation_date = (string)($data['quotation_date'] ?? '');
$valid_until = (string)($data['valid_until'] ?? '');
if ((int)($data['contact_id'] ?? 0) <= 0) {
throw new Exception('Contact is required.');
}
if ($quotation_date === '') {
throw new Exception('Quotation date is required.');
}
if ($valid_until !== '' && $valid_until < $quotation_date) {
throw new Exception('Valid until cannot be earlier than the quotation date.');
}
if ((int)($data['department_id'] ?? 0) <= 0) {
throw new Exception('Department is required.');
}
if (empty($items)) {
throw new Exception('At least one line item is required.');
}
[$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 +321,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']);
}
}
?>
+220 -143
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;
}
@@ -35,6 +35,8 @@ class ReportManager
// Private helpers
// ─────────────────────────────────────────────────────────────
private ?array $transfer_totals = null;
private function stockTableNameFromWarehouseId(int $warehouse_id): string
{
if ($warehouse_id <= 0) {
@@ -44,13 +46,69 @@ class ReportManager
return 'td_stock_' . $warehouse_id;
}
/**
* Approved warehouse-to-warehouse transfer quantities, per month and SKU.
*
* A transfer is stored as an `out` row in the source warehouse and an `in`
* row in the destination, and both reach etl_stock_summary, which is right
* for each warehouse's balance. Company-wide "Stock In / Stock Out" figures
* must leave them out: the goods were already counted when first received,
* and moving them between warehouses is neither a receipt nor an issue.
*
* @return array [month => [sku => ['in' => float, 'out' => float]]]
*/
private function transferTotals(): array
{
if ($this->transfer_totals !== null) return $this->transfer_totals;
$totals = [];
$sth = $this->pdo->prepare("SELECT id FROM md_warehouse WHERE company_id = :company_id");
$sth->execute([':company_id' => $this->company_id]);
foreach ($sth->fetchAll(PDO::FETCH_COLUMN) as $wh_id) {
$table = $this->stockTableNameFromWarehouseId((int)$wh_id);
try {
$rows = $this->fetchAll(
"SELECT DATE_FORMAT(`date`, '%Y-%m') AS month, product_sku,
SUM(`in`) AS qty_in, SUM(`out`) AS qty_out
FROM `{$table}`
WHERE company_id = :company_id AND status = 1 AND type = 'transfer'
GROUP BY month, product_sku"
);
} catch (PDOException $e) {
continue; // warehouse without a stock table yet
}
foreach ($rows as $r) {
$slot = &$totals[$r['month']][$r['product_sku']];
$slot['in'] = ($slot['in'] ?? 0) + (float)$r['qty_in'];
$slot['out'] = ($slot['out'] ?? 0) + (float)$r['qty_out'];
unset($slot);
}
}
return $this->transfer_totals = $totals;
}
/** Transfer in/out summed over the given month (null = all months). */
private function transferSum(?string $month = null, ?string $sku = null): array
{
$in = 0.0; $out = 0.0;
foreach ($this->transferTotals() as $m => $by_sku) {
if ($month !== null && $m !== $month) continue;
foreach ($by_sku as $k => $t) {
if ($sku !== null && (string)$k !== $sku) continue;
$in += $t['in']; $out += $t['out'];
}
}
return ['in' => $in, 'out' => $out];
}
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 +127,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 +143,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 +216,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 +254,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 +322,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 +336,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
@@ -296,15 +354,7 @@ class ReportManager
*/
public function getLowStockCount(): int
{
$products = $this->getStockBalance();
$count = 0;
foreach ($products as $product) {
$balance = (float) $product["total_in"] - (float) $product["total_out"];
if ($balance < (float) $product["min_stock"]) {
$count++;
}
}
return $count;
return count($this->getLowStockItems());
}
public function getDashboardStockTotals(): array
@@ -313,22 +363,29 @@ 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]);
return $sth->fetch(PDO::FETCH_ASSOC) ?: ['total_in' => 0, 'total_out' => 0];
$sth->execute([':company_id' => $this->company_id]);
$row = $sth->fetch(PDO::FETCH_ASSOC) ?: ['total_in' => 0, 'total_out' => 0];
$transfers = $this->transferSum();
return [
'total_in' => round(max(0, (float)$row['total_in'] - $transfers['in']), 2),
'total_out' => round(max(0, (float)$row['total_out'] - $transfers['out']), 2),
];
}
public function getDashboardOrderStats(): array
{
$sth = $this->pdo->prepare(
"SELECT COUNT(*), COALESCE(SUM(subtotal), 0)
// Orders are counted unless cancelled; revenue only once confirmed —
// a draft or pending order is not a sale yet.
"SELECT COUNT(*), COALESCE(SUM(CASE WHEN status >= 1 THEN subtotal ELSE 0 END), 0)
FROM td_order
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 +402,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 +410,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 [
@@ -399,6 +456,13 @@ class ReportManager
*
* @return array Low/critical stock items with warehouse_name, product_name, balance, status.
*/
/*
* The one definition of "low stock", shared by the dashboard tile, the Low
* Stock page, the warehouse overview tile and the daily alert: an active
* product in an active warehouse whose balance there is at or below its
* reorder point (or minimum stock, whichever is higher). Each screen used
* to apply its own threshold and grouping, so the counts never matched.
*/
public function getLowStockItems(): array
{
$sql = "SELECT
@@ -411,7 +475,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
@@ -419,11 +483,13 @@ class ReportManager
ON wb.company_id = mw.company_id
AND wb.warehouse_id = mw.id
WHERE wb.company_id = :company_id
AND mp.reorder_point > 0
AND mp.status > 0
AND mw.status = 1
AND GREATEST(mp.reorder_point, mp.min_stock) > 0
GROUP BY
wb.warehouse_id, wb.product_sku, mw.warehouse_name,
mp.product_name, mp.min_stock, mp.reorder_point, mp.product_image, mp.cost_price
HAVING balance <= mp.reorder_point
HAVING balance <= GREATEST(mp.reorder_point, mp.min_stock)
ORDER BY mp.product_name ASC, mw.warehouse_name ASC";
$rows = $this->fetchAll($sql);
@@ -492,14 +558,18 @@ 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]);
return $sth->fetch(PDO::FETCH_ASSOC) ?: [
$sth->execute([':company_id' => $this->company_id, ':month' => $month]);
$row = $sth->fetch(PDO::FETCH_ASSOC) ?: [
'total_in' => 0, 'total_out' => 0, 'active_products' => 0
];
$transfers = $this->transferSum($month);
$row['total_in'] = round(max(0, (float)$row['total_in'] - $transfers['in']), 2);
$row['total_out'] = round(max(0, (float)$row['total_out'] - $transfers['out']), 2);
return $row;
}
/**
@@ -516,14 +586,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 +606,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 +622,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,
]);
@@ -567,6 +637,9 @@ class ReportManager
$dataByMonth = [];
foreach ($rows as $row) {
$transfers = $this->transferSum($row['month']);
$row['stock_in'] = round(max(0, (float)$row['stock_in'] - $transfers['in']), 2);
$row['stock_out'] = round(max(0, (float)$row['stock_out'] - $transfers['out']), 2);
$dataByMonth[$row['month']] = $row;
}
@@ -601,7 +674,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
@@ -610,19 +683,26 @@ class ReportManager
AND pc.id = p.category
WHERE wb.company_id = :company_id
AND wb.month = :month
GROUP BY wb.product_sku, p.product_name, pc.category
ORDER BY total_out DESC
LIMIT {$limit}"
GROUP BY wb.product_sku, p.product_name, pc.category"
);
$sth->execute([
':company_id' => $this->companyId,
':company_id' => $this->company_id,
':month' => $month,
]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as &$row) {
$transfers = $this->transferSum($month, (string)$row['product_sku']);
$row['total_in'] = round(max(0, (float)$row['total_in'] - $transfers['in']), 2);
$row['total_out'] = round(max(0, (float)$row['total_out'] - $transfers['out']), 2);
}
unset($row);
$rows = array_values(array_filter($rows, fn($r) => $r['total_in'] > 0 || $r['total_out'] > 0));
usort($rows, fn($a, $b) => $b['total_out'] <=> $a['total_out'] ?: $b['total_in'] <=> $a['total_in']);
return array_slice($rows, 0, $limit);
}
/**
* 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 +712,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 +736,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,11 +744,11 @@ 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,
ROUND(COALESCE(s.`out`, 0), 2) AS stock_out,
COALESCE(s.`in`, 0) AS stock_in,
COALESCE(s.`out`, 0) AS stock_out,
p.product_name,
{$warehouse_name} AS warehouse_name
FROM `{$table}` s
@@ -676,7 +756,8 @@ class ReportManager
ON p.company_id = s.company_id
AND p.sku = s.product_sku
WHERE s.company_id = {$cid}
AND s.status = 1";
AND s.status = 1
AND (s.`in` > 0 OR s.`out` > 0)";
},
$warehouses
));
@@ -690,6 +771,8 @@ class ReportManager
$items = [];
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
// Decided on the unrounded quantity: a receipt of 0.004 used to round
// to 0.00, fall through to "out" and show as -0.
$is_in = (float)$row['stock_in'] > 0;
$items[] = [
'product_name' => $row['product_name'] ?: $row['product_sku'],
@@ -709,40 +792,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,48 +836,33 @@ 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.
*/
public function getWarehouseLowStockCount(int $warehouse_id): int
{
$sth = $this->pdo->prepare(
"SELECT wb.product_sku,
ROUND(SUM(wb.total_in) - SUM(wb.total_out), 2) AS balance,
mp.min_stock
FROM warehouse_balance 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
AND wb.warehouse_id = :warehouse_id
GROUP BY wb.product_sku, mp.min_stock"
);
$sth->execute([':company_id' => $this->companyId, ':warehouse_id' => $warehouse_id]);
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
$count = 0;
foreach ($rows as $row) {
if ((float)$row['balance'] < (float)$row['min_stock']) $count++;
}
return $count;
return count(array_filter(
$this->getLowStockItems(),
fn($item) => $item['warehouse_id'] === $warehouse_id
));
}
/**
* 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 +890,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 +937,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 +952,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 +1002,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 +1053,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 +1075,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 +1093,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 +1140,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 +1152,7 @@ class ReportManager
}
// ─────────────────────────────────────────────────────────────
// REPORT BASIS — Lot / Rack reports
// REPORT BASIS — Lot / Bin reports
// ─────────────────────────────────────────────────────────────
/**
@@ -1096,7 +1164,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 +1174,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 +1190,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 +1204,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,28 +1236,31 @@ 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']);
$sth = $this->pdo->query(
"SELECT lot_number,
ROUND(SUM(COALESCE(`in`, 0)) - SUM(COALESCE(`out`, 0)), 2) AS lot_balance
// Every row of the lot makes it listable; only approved rows
// count towards the balance. A lot received but not yet
// approved used to vanish here while the lot master showed it.
// Keyed by SKU as well: two products may share a lot number.
"SELECT product_sku, lot_number,
ROUND(SUM(CASE WHEN status = 1 THEN COALESCE(`in`, 0) - COALESCE(`out`, 0) ELSE 0 END), 4) AS lot_balance
FROM `{$table}`
WHERE company_id = {$cid}
AND status = 1
AND lot_number IS NOT NULL
GROUP BY lot_number"
AND lot_number <> ''
GROUP BY product_sku, lot_number"
);
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $lb) {
$key = $lb['lot_number'];
$key = $lb['product_sku'] . "\0" . $lb['lot_number'];
$lot_balance[$key] = ($lot_balance[$key] ?? 0) + (float)$lb['lot_balance'];
}
}
@@ -1210,22 +1281,28 @@ 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;
// Exclude lots that have no td_stock record at all (e.g. all rows were soft-deleted)
$rows = array_values(array_filter($rows, fn($r) => array_key_exists($r['lot_number'], $lot_balance)));
$lot_key = fn($r) => $r['product_sku'] . "\0" . $r['lot_number'];
$rows = array_values(array_filter($rows, fn($r) => array_key_exists($lot_key($r), $lot_balance)));
foreach ($rows as &$row) {
$days = (int)$row['days_remaining'];
$balance = round($lot_balance[$row['lot_number']] ?? 0, 2);
$balance = round($lot_balance[$lot_key($row)] ?? 0, 4);
$row['balance'] = $balance;
$row['is_active'] = $balance > 0 ? 1 : 0;
if ($balance > 0) $active++;
if ($row['expiry_date'] === null || $row['expiry_date'] === '') {
// No expiry recorded: not "expiring today"
$row['status'] = 'ok';
continue;
}
if ($days < 0) $expired++;
if ($days >= 0 && $days <= 30) $near++;
@@ -1246,21 +1323,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 +1353,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 +1385,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
@@ -1323,11 +1400,11 @@ class ReportManager
ON p.company_id = r.company_id
AND p.sku = r.product_sku
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"
ORDER BY mw.warehouse_name, REGEXP_REPLACE(r.zone, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(r.zone, '[0-9]+$') AS UNSIGNED), r.zone,
REGEXP_REPLACE(r.aisle, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(r.aisle, '[0-9]+$') AS UNSIGNED), r.aisle,
REGEXP_REPLACE(r.bin, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(r.bin, '[0-9]+$') 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 +1426,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 +1440,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 +1485,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 +1499,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 +1553,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 +1564,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 +1586,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 +1614,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.
+130 -37
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;
}
@@ -122,13 +122,28 @@ class ReturnManager {
$sth = $this->pdo->prepare(
"INSERT INTO td_return_item
(company_id, return_id, item_id, product_sku, product_name,
quantity, unit_price, total_price, tax_amount, tax_rate, warehouse_id, stock_out_id, stock_out_warehouse_id)
quantity, unit_price, total_price, tax_amount, tax_rate, warehouse_id, stock_out_id, stock_out_warehouse_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_out_id, :stock_out_warehouse_id)"
:quantity, :unit_price, :total_price, :tax_amount, :tax_rate, :warehouse_id, :stock_out_id, :stock_out_warehouse_id,
:zone, :aisle, :bin)"
);
foreach ($items as $pos => $item) {
// Put-away location chosen on the form. In simple location mode
// the page sends only the bin; zone and aisle mirror it, the same
// convention stock-in and goods receipt use, because md_bin is
// looked up on all three.
$bin = trim((string)($item['bin'] ?? ''));
$zone = trim((string)($item['zone'] ?? ''));
$aisle = trim((string)($item['aisle'] ?? ''));
if ($zone === '' && $bin !== '') $zone = $bin;
if ($aisle === '' && $bin !== '') $aisle = $bin;
$sth->execute([
':zone' => $zone,
':aisle' => $aisle,
':bin' => $bin,
':company_id' => $this->company_id,
':return_id' => $return_id,
':item_id' => $pos + 1,
@@ -169,7 +184,10 @@ class ReturnManager {
$sth = $this->pdo->prepare(
"SELECT r.*,
COALESCE(c.contact_name, '') AS contact_name,
o.order_number
o.order_number,
(SELECT COUNT(*) FROM td_return_item i
WHERE i.company_id = r.company_id
AND i.return_id = r.id) AS item_count
FROM td_return r
LEFT JOIN md_contact c
ON c.company_id = r.company_id
@@ -252,7 +270,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 +390,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 +417,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 +428,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.
*
@@ -471,6 +489,16 @@ class ReturnManager {
throw new Exception("Item #{$i}: missing linked stock-out record.");
}
// Returned goods are put back into a specific bin. A return saved
// before the location columns existed has none recorded; name the
// fix rather than letting occupyBin() fail on an empty location.
if (trim((string)($item['bin'] ?? '')) === '') {
$name = $item['product_name'] ?: $product_sku;
throw new Exception(
"\"{$name}\" has no return location. Open the return, choose where it goes back to, save, then confirm."
);
}
$stock_out_table = $this->stockTableNameFromWarehouseId($stock_out_wh);
$stock_out_sth = $this->pdo->prepare(
"SELECT status, lot_number, serial_number, price
@@ -505,13 +533,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 +551,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 +566,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 +621,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 +674,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 +695,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 +717,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 +741,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]);
}
}
+2 -1
View File
@@ -1,5 +1,6 @@
<?php
require_once __DIR__ . '/../module/mailer.php';
require_once __DIR__ . '/../secret_box.php';
class SmtpManager
{
@@ -173,7 +174,7 @@ class SmtpManager
private function encryptPassword(string $plain): string
{
return openssl_encrypt($plain, $this->method, $this->pinkey, 0, $this->iv);
return secret_encrypt($plain, $this->pinkey);
}
}
?>
+248 -107
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.
@@ -23,6 +26,18 @@
*/
class StockManager {
/**
* Scale of every stock quantity column (`in`, `out`, td_*_item.quantity are
* all decimal(18,4)). Anything finer than this cannot be stored: MySQL
* rounds it on insert, so a quantity of 0.0000001 silently became 0.0000
* and produced a movement of nothing that still left the source document
* "partially received".
*/
public const QTY_SCALE = 4;
/** Smallest quantity the schema can represent — 0.0001. */
public const QTY_MIN = 0.0001;
private PDO $pdo;
private int $company_id;
@@ -41,9 +56,56 @@ 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;
}
/**
* Round a quantity to the stored scale and reject values that cannot be
* represented.
*
* A positive input that rounds to zero is a mistake worth naming — the
* caller asked to move some stock and would otherwise get a zero-quantity
* movement that looks successful and reports as "0.00" everywhere.
*
* @param mixed $value Raw client input.
* @param string $label Field name used in the error message.
* @return float Quantity rounded to QTY_SCALE.
* @throws Exception When the value is not a usable quantity.
*/
public static function normaliseQuantity($value, string $label = 'Quantity'): float
{
$raw = (float)$value;
if ($raw <= 0) {
throw new Exception("{$label} must be greater than zero.");
}
$rounded = round($raw, self::QTY_SCALE);
if ($rounded < self::QTY_MIN) {
throw new Exception(
"{$label} of {$raw} is smaller than the minimum the system records (" .
rtrim(rtrim(number_format(self::QTY_MIN, self::QTY_SCALE), '0'), '.') . ")."
);
}
return $rounded;
}
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
// ─────────────────────────────────────────────────────────────
@@ -55,34 +117,93 @@ class StockManager {
* The 'quantity' alias resolves to the correct column (in or out) depending
* on the type. For transfers, only the outbound row is listed (out > 0).
*
* @param int $warehouse_id The md_warehouse.id to query.
* @param int $warehouse_id The md_warehouse.id to query, or 0 for every
* warehouse of this company.
* @param string $type Movement type: 'in' | 'out' | 'transfer'.
* @return array Stock rows ordered by date DESC, each with 'quantity' and 'product_name'.
* @return array Stock rows ordered by date DESC, each with 'quantity',
* 'product_name', 'warehouse_id' and 'warehouse_name'.
*/
public function getStockList(int $warehouse_id, string $type): array
{
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
$column = $type === 'out' ? 'ROUND(a.out, 2)' : 'ROUND(a.in, 2)';
// warehouse_id 0 = every warehouse. Stock lives in one table per
// warehouse, so a single-warehouse list hides the rest of a receipt
// that was split across warehouses — a 4-line PO received into two of
// them looked like only 3 lines had been received.
$warehouses = $warehouse_id > 0
? [$warehouse_id]
: $this->warehouseIdsWithStockTable();
// The transfer list shows the outbound row, whose quantity is in `out` (its `in` is always 0).
// Quantity is NOT rounded for display here: rounding to 2 dp reports a
// small-but-real quantity as "0.00", which reads as missing data.
$column = in_array($type, ['out', 'transfer'], true) ? 'a.out' : 'a.in';
$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' : '';
$rows = [];
foreach ($warehouses as $wh_id) {
$table = $this->stockTableNameFromWarehouseId($wh_id);
$sth = $this->pdo->prepare(
"SELECT a.*, {$stock_ref} AS stock_reference, {$column} AS quantity,
b.product_name, b.uom,
w.warehouse_name
FROM `{$table}` a
LEFT JOIN md_product b
ON a.company_id = b.company_id
AND a.product_sku = b.sku
LEFT JOIN md_warehouse w
ON w.company_id = a.company_id
AND w.id = :warehouse_id
WHERE a.company_id = :company_id
AND a.type = :type
{$extra_cond}
ORDER BY a.date DESC"
);
$sth->execute([
':company_id' => $this->company_id,
':warehouse_id' => $wh_id,
':type' => $type,
]);
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
// The row's own warehouse, so the list can link each Action
// back to the right td_stock_<id> table when showing them all.
$row['warehouse_id'] = $wh_id;
$rows[] = $row;
}
}
// Re-sort across warehouses — each table was only ordered internally.
usort($rows, fn($x, $y) => strcmp((string)($y['date'] ?? ''), (string)($x['date'] ?? '')));
return $rows;
}
/**
* Warehouse ids of this company that actually have a stock table.
*
* td_stock_<id> tables are created lazily on first use, so a warehouse with
* no movements yet has none and must be skipped rather than queried.
*
* @return int[]
*/
private function warehouseIdsWithStockTable(): array
{
$sth = $this->pdo->prepare(
"SELECT a.*, {$column} AS quantity, b.product_name, b.uom
FROM `{$table}` a
LEFT JOIN md_product b
ON a.company_id = b.company_id
AND a.product_sku = b.sku
WHERE a.company_id = :company_id
AND a.type = :type
{$extra_cond}
ORDER BY a.date DESC"
"SELECT w.id
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
ORDER BY w.id"
);
$sth->execute([
':company_id' => $this->company_id,
':type' => $type,
]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
$sth->execute([':company_id' => $this->company_id]);
return array_map('intval', $sth->fetchAll(PDO::FETCH_COLUMN));
}
/**
@@ -99,9 +220,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 +256,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 +290,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
@@ -183,14 +307,17 @@ class StockManager {
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
$output = $sth->fetch(PDO::FETCH_ASSOC);
if (!$output) return false;
// Only a transfer's outbound row names a destination. Any other row
// (a stock-in or stock-out reached through a stale or edited link) has
// no ref_warehouse, and resolving it threw "Invalid warehouse id."
if (!$output || $output['type'] !== 'transfer' || (int)$output['ref_warehouse'] <= 0) return false;
// Resolve the inbound (to) row via ref_warehouse + uuid
$to_warehouse_id = (int)$output['ref_warehouse'];
$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 +343,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 +360,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 = self::normaliseQuantity($quantity);
}
$whMgmt = new WarehouseManager($this->pdo, $this->company_id);
$ctx = $whMgmt->getStockContext($warehouse_id, $id);
@@ -267,8 +399,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 +418,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 +462,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,49 +515,50 @@ 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']}'."
);
}
// Quantity and identifiers come from the existing stock_in row (immutable)
$quantity = (int)$source_stock['in'];
// Quantity and identifiers come from the existing stock_in row (immutable).
// `in` is decimal(18,4) — an int cast would drop fractional quantities.
$quantity = (float)$source_stock['in'];
$ref_id = (int)$source_stock['id'];
$lot_number = $source_stock['lot_number'] ?? null;
$serial_number = $source_stock['serial_number'] ?? null;
$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 +567,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 +578,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 +591,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 +604,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,44 +682,45 @@ 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']}'."
);
}
// Quantity and identifiers come from the source stock_in row (immutable)
$quantity = (int)$source_stock['in'];
// Quantity and identifiers come from the source stock_in row (immutable).
// `in` is decimal(18,4) — an int cast would drop fractional quantities.
$quantity = (float)$source_stock['in'];
$lot_number = $source_stock['lot_number'] ?? null;
$serial_number = $source_stock['serial_number'] ?? null;
@@ -598,12 +732,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 +747,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 +760,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 +776,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 +795,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 +804,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 +844,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 +883,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 +922,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 +933,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 +952,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 +960,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'] ?? '',
]);
}
}
@@ -157,7 +154,10 @@ class SupplierReturnManager {
$sth = $this->pdo->prepare(
"SELECT r.*,
COALESCE(c.contact_name, '') AS contact_name,
p.po_number
p.po_number,
(SELECT COUNT(*) FROM td_supplier_return_item i
WHERE i.company_id = r.company_id
AND i.return_id = r.id) AS item_count
FROM td_supplier_return r
LEFT JOIN md_contact c
ON c.company_id = r.company_id
@@ -332,7 +332,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 +358,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 +443,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 +453,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 +471,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 +485,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 +570,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 +592,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 +616,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]);
}
}
}
+410 -158
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.
@@ -445,7 +697,7 @@ class FinancialReports
COALESCE(d.dept_code, '') AS dept_code,
COALESCE(d.dept_name, '') AS dept_name,
COALESCE(g.journal_date, DATE(g.created_at)) AS entry_date,
DATE_FORMAT(g.created_at, '%d/%m/%Y %H:%i') AS posted_at,
DATE_FORMAT(g.created_at, '%Y-%m-%d %H:%i:%s') AS posted_at,
i.debit,
i.credit,
COALESCE(i.description, '') AS line_description
+59 -10
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
@@ -78,7 +81,11 @@ class GlManager
$now = date('Y-m-d H:i:s');
$sth = $this->pdo->prepare(
"SELECT id, current_version, formula_id, history
// `period` is read below as $old_period to reverse the old ETL
// totals. It was missing from this list, so it was always null and
// upsertEtl(string $period) threw a TypeError — every edit of a
// manual journal ended in HTTP 500.
"SELECT id, current_version, formula_id, history, period
FROM td_gl
WHERE company_id = :cid AND id = :gl_id AND source_type = 'manual'
FOR UPDATE"
@@ -97,6 +104,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 +130,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 +158,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 +167,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 +190,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 +224,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 +280,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 +344,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)) {
@@ -75,8 +75,8 @@ class GlQueryManager
g.current_version,
g.formula_id,
COALESCE(f.formula_name, '') AS formula_name,
DATE_FORMAT(g.created_at, '%d/%m/%Y %H:%i') AS posted_at,
DATE_FORMAT(g.updated_at, '%d/%m/%Y %H:%i') AS updated_at,
DATE_FORMAT(g.created_at, '%Y-%m-%d %H:%i:%s') AS posted_at,
DATE_FORMAT(g.updated_at, '%Y-%m-%d %H:%i:%s') AS updated_at,
CASE g.source_type
WHEN 'receipt' THEN r.receipt_number
WHEN 'payment' THEN p.payment_number
@@ -142,8 +142,8 @@ class GlQueryManager
$sth = $this->pdo->prepare(
"SELECT g.*,
DATE_FORMAT(g.created_at, '%d/%m/%Y %H:%i') AS posted_at,
DATE_FORMAT(g.updated_at, '%d/%m/%Y %H:%i') AS updated_at_fmt,
DATE_FORMAT(g.created_at, '%Y-%m-%d %H:%i:%s') AS posted_at,
DATE_FORMAT(g.updated_at, '%Y-%m-%d %H:%i:%s') AS updated_at_fmt,
DATE_FORMAT(g.journal_date, '%d/%m/%Y') AS journal_date_fmt,
COALESCE(f.formula_name, '') AS formula_name
FROM td_gl g
@@ -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.");
}
+22
View File
@@ -0,0 +1,22 @@
<?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';
// An empty configured secret must never match an empty header.
$secret = (string)($_SERVER['HTTP_X_CRON_SECRET'] ?? '');
if (!defined('NODE_EMIT_SECRET') || NODE_EMIT_SECRET === '' || !hash_equals((string)NODE_EMIT_SECRET, $secret)) {
http_response_code(403);
exit(json_encode(['success' => 0, 'message' => 'Forbidden']));
}
$data = json_decode(file_get_contents('php://input'), true) ?? [];
$answer = ['success' => 0];
+85 -4
View File
@@ -7,9 +7,38 @@ ini_set('display_errors', 0);
ini_set('log_errors', 1);
header('Content-Type: application/json; charset=utf-8');
// Last-resort handler for exceptions an engine does not catch itself. Many
// engines call a manager with no try/catch, so any exception — including the
// managers' own deliberate validation messages — used to end as a PHP fatal
// with an empty 500 body, which the browser could only report as "Server
// error occurred." This mirrors the convention the catching engines already
// use: a manager's Exception carries a user-facing message (400); a database
// or engine fault stays generic (500) and goes to the server log.
set_exception_handler(function (Throwable $e) {
while (ob_get_level() > 0) ob_end_clean();
if (!headers_sent()) header('Content-Type: application/json; charset=utf-8');
if ($e instanceof PDOException) {
error_log('Uncaught PDOException: ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
http_response_code(500);
$message = 'Database error, please try again.';
} elseif ($e instanceof Exception) {
http_response_code(400);
$message = $e->getMessage();
} else {
// Error / TypeError: a programming fault, not something to show users.
error_log('Uncaught ' . get_class($e) . ': ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
http_response_code(500);
$message = 'Server error occurred.';
}
echo json_encode(['success' => 0, 'message' => $message]);
});
require_once __DIR__."/../../config.php";
require_once __DIR__."/../../dbconn.php";
require_once __DIR__."/db_helpers.php";
require_once __DIR__."/app_access.php";
if (!function_exists('require_role')) {
function require_role(string $user_role, array $allowed): void {
@@ -25,14 +54,14 @@ if(!empty($_SESSION["login_company_id"])){
// CSRF Validation — add right at the top of the logged-in block
if($_SERVER['REQUEST_METHOD'] === 'POST'){
$csrf_token = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if(empty($csrf_token) || $csrf_token !== $_SESSION['csrf_token']){
if(empty($csrf_token) || !hash_equals((string)($_SESSION['csrf_token'] ?? ''), $csrf_token)){
http_response_code(403);
exit(json_encode(["message" => "Invalid request"]));
}
}
// validate otp
$sql = "SELECT `password`
$sql = "SELECT `password`, license, app_access
FROM user
WHERE user_id = :company_id";
$sth = $pdo1->prepare($sql);
@@ -40,7 +69,8 @@ if(!empty($_SESSION["login_company_id"])){
":company_id" => $_SESSION["login_user_id"]
]);
db_check($sth, $answer);
$password = $sth->fetchColumn();
$user_row = $sth->fetch(PDO::FETCH_ASSOC) ?: [];
$password = $user_row['password'] ?? '';
/** Generate OTP */
function generateOTP($sercet_key, $time_step = 180, $length = 6){
$counter = floor($_SESSION["otpTime"] / $time_step);
@@ -55,7 +85,9 @@ if(!empty($_SESSION["login_company_id"])){
$otp = generateOTP($password);
if( $_SESSION["otp"]!=$otp ){
http_response_code(401);
$answer["message"] = "Your password has been reset, Please logout and login again.";
$answer["code"] = "password_changed";
exit(json_encode($answer));
}
@@ -70,20 +102,62 @@ if(!empty($_SESSION["login_company_id"])){
$map = $sth->fetchAll(PDO::FETCH_ASSOC);
if( count($map)==0 ){
http_response_code(403);
$answer["message"] = "Your accessibility to this company has been removed.";
$answer["code"] = "access_removed";
exit(json_encode($answer));
}
$user_role = $map[0]['role'] ?? 'viewer';
$_SESSION['login_role'] = $user_role;
// App access (WMS / Accounting), re-read on every request so a change made in
// Setting → Users applies at once. Owners hold it on their own user row;
// invited users per company (same rule as login_confirm.php).
$app_access = (($user_row['license'] ?? 'owner') === 'owner')
? ($user_row['app_access'] ?? 'wms')
: ($map[0]['app_access'] ?? 'wms');
$_SESSION['login_app_access'] = $app_access;
$required_app = app_access_app_for($_SERVER['SCRIPT_NAME'] ?? '');
if ($required_app !== null && !app_access_allows($app_access, $required_app)) {
http_response_code(403);
exit(json_encode(['success' => 0, 'message' => 'Your account does not have access to this module.']));
}
// 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
// unless the engine explicitly declared itself a pre-auth route.
if (empty($_SESSION['login_company_id']) && !defined('UNAUTHENTICATED_ROUTE')) {
http_response_code(401);
exit(json_encode(['success' => 0, 'message' => 'Authentication required.']));
$expired = !empty($_SESSION['_idle_expired']);
exit(json_encode([
'success' => 0,
'message' => $expired ? 'Your session has expired. Please sign in again.' : 'Authentication required.',
'code' => $expired ? 'session_expired' : 'auth_required',
]));
}
// set up ANSWER
@@ -92,12 +166,19 @@ $answer = array("success"=>0, "message"=>"");
if (isset($_POST['json'])) {
// Old Method: Data is wrapped in a JSON string
$data = json_decode($_POST['json'], true);
if (!is_array($data)) {
// An undecodable payload used to carry on as an empty request.
http_response_code(400);
$answer["message"] = "The request could not be read. Please reload the page and try again.";
exit(json_encode($answer));
}
} else if (isset($_POST['otp'])) {
// New Method: Data is sent directly (FormData)
// We check for 'otp' because every request should have one
$data = $_POST;
} else {
// Truly no data received
http_response_code(400);
$answer["message"] = "Request denied: No valid JSON payload or Form Data detected.";
exit(json_encode($answer));
}

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