Compare commits

..
23 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
458 changed files with 7107 additions and 6319 deletions
+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>
+1 -1
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../../../session.php';
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes/UsageGuard.php';
+1 -1
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../../../session.php';
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes/UsageGuard.php';
+1 -1
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../../../session.php';
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes/UsageGuard.php';
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../../../session.php';
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes/CompanySettingManager.php';
+1 -1
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../../../session.php';
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes/UsageGuard.php';
+1 -1
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../../../session.php';
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes/UsageGuard.php';
+4 -12
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../session.php';
require '../config.php';
require '../include_header.php';
?>
@@ -179,7 +179,7 @@
<?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 {
@@ -199,11 +199,7 @@
supplier_credit_note: 'Supplier Credit Note',
receipt: 'Receipt',
payment: 'Payment',
manual: 'Manual',
asset_capitalization: 'Asset Mgmt · Capitalization',
asset_depreciation: 'Asset Mgmt · Depreciation',
asset_amortization: 'Asset Mgmt · Amortization',
asset_disposal: 'Asset Mgmt · Disposal'
manual: 'Manual'
};
var source_badges = {
@@ -213,11 +209,7 @@
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',
asset_capitalization: 'bg-success text-white',
asset_depreciation: 'bg-success text-white',
asset_amortization: 'bg-success text-white',
asset_disposal: 'bg-success text-white'
manual: 'bg-dark-subtle text-dark'
};
function fmt_num(n) {
+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_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes_ac/ChartOfAccounts.php';
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../../../session.php';
require_once '../../../assets/utils/db_auth.php';
require_role($user_role, ['owner', 'admin', 'staff']);
require_once '../../../assets/utils/classes_ac/AccountFormulaManager.php';
+1 -1
View File
@@ -1,5 +1,5 @@
<?php
session_start();
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';
+12 -1
View File
@@ -1,8 +1,11 @@
<?php
session_start();
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);
$result = $lock_manager->acquire(
@@ -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();
}
+1 -1
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../../../session.php';
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes_ac/DepartmentManager.php';
@@ -1,5 +1,5 @@
<?php
session_start();
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';
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../../../session.php';
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes/UsageGuard.php';
@@ -1,5 +1,5 @@
<?php
session_start();
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';
@@ -8,6 +8,7 @@ $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));
}
@@ -27,8 +28,13 @@ try {
$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,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../../../session.php';
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes_ac/GlQueryManager.php';
@@ -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,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../../../session.php';
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes/UsageGuard.php';
@@ -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,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../../../session.php';
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes_ac/GlQueryManager.php';
@@ -7,8 +7,13 @@ 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,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../../../session.php';
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes_ac/GlQueryManager.php';
@@ -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,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../../../session.php';
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes/UsageGuard.php';
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../../../session.php';
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes/UsageGuard.php';
+1 -1
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../../../session.php';
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes/UsageGuard.php';
@@ -1,14 +1,22 @@
<?php
session_start();
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();
}
+2 -1
View File
@@ -1,11 +1,12 @@
<?php
session_start();
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,11 +1,12 @@
<?php
session_start();
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));
}
+13 -19
View File
@@ -1,5 +1,5 @@
<?php
session_start();
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';
@@ -11,7 +11,6 @@ require_once '../../../assets/utils/classes_ac/posting/SupplierCreditNotePosting
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_once '../../../assets/utils/classes_as/AssetPosting.php';
require_role($user_role, ['owner', 'admin']);
require_once '../../../assets/utils/notify_node.php';
@@ -30,31 +29,20 @@ $posting_map = [
'purchase_order' => PurchasePosting::class,
];
$is_asset_source = AssetPosting::isAssetSource($doc_type);
if ((!isset($posting_map[$doc_type]) && !$is_asset_source) || $id <= 0) {
if (!isset($posting_map[$doc_type]) || $id <= 0) {
http_response_code(400);
$answer['message'] = 'Invalid doc_type or id.';
exit(json_encode($answer));
}
try {
if ($is_asset_source) {
// Asset Management entries carry their own reference and "[Asset Mgmt]" description.
$built = (new AssetPosting($pdo2, $company_id))->build($doc_type, $id);
$meta = [
'journal_date' => $built['doc_date'],
'reference' => $built['reference'],
'description' => $built['description'],
];
} else {
$posting_class = $posting_map[$doc_type];
$posting = new $posting_class($pdo2, $company_id);
$built = $posting->build($id, $formula_id);
$meta = ['journal_date' => $built['doc_date'] ?? null];
}
$posting_class = $posting_map[$doc_type];
$posting = new $posting_class($pdo2, $company_id);
$built = $posting->build($id, $formula_id);
$guard = new PostingWindowGuard($pdo1, $company_id);
$gl = new GlManager($pdo2, $company_id, $guard);
$meta = ['journal_date' => $built['doc_date'] ?? null];
$pdo2->beginTransaction();
@@ -86,9 +74,15 @@ try {
'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));
+5 -1
View File
@@ -1,5 +1,5 @@
<?php
session_start();
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']);
@@ -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,5 +1,5 @@
<?php
session_start();
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';
@@ -1,14 +1,22 @@
<?php
session_start();
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();
}
+2 -1
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../../../session.php';
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes_ac/ChartOfAccounts.php';
@@ -7,6 +7,7 @@ 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,12 +1,12 @@
<?php
session_start();
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_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_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,5 +1,5 @@
<?php
session_start();
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';
@@ -25,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));
}
@@ -51,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));
}
@@ -83,9 +86,15 @@ try {
$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));
+4 -4
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../session.php';
require '../config.php';
require '../include_header.php';
?>
@@ -221,9 +221,9 @@
}
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js"></script>
<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';
?>
+20 -84
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../session.php';
require '../config.php';
require '../include_header.php';
?>
@@ -39,22 +39,6 @@
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#tab-payment">Payment</a>
</li>
<!-- Entries prepared in Asset Management -->
<li class="nav-item d-flex align-items-center ms-lg-3 px-2">
<span class="badge bg-success text-white"><i class="ti ti-building-warehouse me-1"></i>Asset Management</span>
</li>
<li class="nav-item">
<a class="nav-link text-success" data-bs-toggle="tab" href="#tab-asset-capitalization">Capitalization</a>
</li>
<li class="nav-item">
<a class="nav-link text-success" data-bs-toggle="tab" href="#tab-asset-depreciation">Depreciation</a>
</li>
<li class="nav-item">
<a class="nav-link text-success" data-bs-toggle="tab" href="#tab-asset-amortization">Amortization</a>
</li>
<li class="nav-item">
<a class="nav-link text-success" data-bs-toggle="tab" href="#tab-asset-disposal">Disposal</a>
</li>
</ul>
<div class="tab-content">
@@ -76,18 +60,6 @@
<div class="tab-pane fade" id="tab-payment">
<?php echo gl_tab_html('payment', 'Payment'); ?>
</div>
<div class="tab-pane fade" id="tab-asset-capitalization">
<?php echo gl_tab_html('asset_capitalization', 'Asset Capitalization', true); ?>
</div>
<div class="tab-pane fade" id="tab-asset-depreciation">
<?php echo gl_tab_html('asset_depreciation', 'Asset Depreciation', true); ?>
</div>
<div class="tab-pane fade" id="tab-asset-amortization">
<?php echo gl_tab_html('asset_amortization', 'Asset Amortization', true); ?>
</div>
<div class="tab-pane fade" id="tab-asset-disposal">
<?php echo gl_tab_html('asset_disposal', 'Asset Disposal', true); ?>
</div>
</div>
</div>
</div>
@@ -104,17 +76,9 @@
'purchase_invoice': 'purchase_invoice',
'supplier_credit_note': 'supplier_credit_note',
'receipt': 'receipt',
'payment': 'payment',
'asset_capitalization': 'asset_capitalization',
'asset_depreciation': 'asset_depreciation',
'asset_amortization': 'asset_amortization',
'asset_disposal': 'asset_disposal'
'payment': 'payment'
};
// Prepared in Asset Management: no GL formula, accounts come from the asset category.
var ASSET_TYPES = ['asset_capitalization', 'asset_depreciation', 'asset_amortization', 'asset_disposal'];
function is_asset_type(doc_type) { return ASSET_TYPES.indexOf(doc_type) !== -1; }
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
@@ -127,13 +91,11 @@
if (_inited[doc_type]) return;
_inited[doc_type] = true;
if (!is_asset_type(doc_type)) {
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');
});
}
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);
@@ -155,7 +117,7 @@
// ── load documents ────────────────────────────────────────────────────────
function load_gl_tab(doc_type) {
var formula_id = is_asset_type(doc_type) ? '0' : $('#gl-formula-' + doc_type).val();
var formula_id = $('#gl-formula-' + doc_type).val();
if (!formula_id) { bootbox.alert('Please select a GL formula first.'); return; }
if (formula_id === 'all') {
@@ -294,15 +256,13 @@
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 = is_asset_type(doc_type)
? '<span class="badge bg-success-subtle text-success">Asset category</span>'
: product_mapping_badge(r);
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>' + escape_html(format_date(r.doc_date)) + '</td>' +
'<td>' + mapping_badge + '</td>' +
'<td>' + status_badge + '</td>' +
'</tr>';
@@ -342,15 +302,11 @@
purchase_invoice: 'Purchase Invoice',
supplier_credit_note: 'Supplier Credit Note',
receipt: 'Receipt',
payment: 'Payment',
asset_capitalization: '[Asset Mgmt] Capitalization',
asset_depreciation: '[Asset Mgmt] Depreciation',
asset_amortization: '[Asset Mgmt] Amortization',
asset_disposal: '[Asset Mgmt] Disposal'
payment: 'Payment'
};
function run_gl_batch(doc_type) {
var formula_id = is_asset_type(doc_type) ? '0' : $('#gl-formula-' + doc_type).val();
var formula_id = $('#gl-formula-' + doc_type).val();
if (formula_id === 'all') {
run_all_formula_batches(doc_type);
@@ -437,7 +393,7 @@
// ── core batch processor ──────────────────────────────────────────────────
function _start_gl_batch(doc_type, ids, onComplete, formula_id_override) {
var formula_id = formula_id_override || (is_asset_type(doc_type) ? 0 : $('#gl-formula-' + doc_type).val());
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);
@@ -560,34 +516,10 @@
</html>
<?php
function gl_tab_html(string $doc_type, string $label, bool $from_asset = false): string {
// Asset Management entries have no GL formula: say where they come from instead.
$source_field = $from_asset
? <<<HTML
<div class="col-auto">
<label class="form-label mb-1 small">Source</label>
<div><span class="badge bg-success text-white py-2 px-3"><i class="ti ti-building-warehouse me-1"></i>From Asset Management</span></div>
</div>
HTML
: <<<HTML
<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;">
</select>
</div>
HTML;
$source_note = $from_asset
? '<div class="alert alert-success small py-2 mb-3"><i class="ti ti-info-circle me-1"></i>'
. 'Prepared in <strong>Asset Management</strong>. Accounts come from each asset category; '
. 'entries are labelled <strong>[Asset Mgmt]</strong> in the GL Journal.</div>'
: '';
$accounts_header = $from_asset ? 'Accounts' : 'Product Accounts';
function gl_tab_html(string $doc_type, string $label): string {
return <<<HTML
<div class="card p-4">
{$source_note}
<!-- Filters + action buttons -->
<div class="row g-2 align-items-end mb-3">
<div class="col-auto">
@@ -600,7 +532,11 @@ HTML;
<input type="text" id="gl-date-to-{$doc_type}" class="form-control form-control-sm"
placeholder="d/m/Y" style="width:140px;">
</div>
{$source_field}
<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;">
</select>
</div>
<div class="col-auto">
<button id="gl-btn-load-{$doc_type}" class="btn btn-secondary btn-sm"
onclick="load_gl_tab('{$doc_type}')">
@@ -629,7 +565,7 @@ HTML;
<th>Contact</th>
<th class="text-end">Amount</th>
<th>Date</th>
<th>{$accounts_header}</th>
<th>Product Accounts</th>
<th>GL Status</th>
</tr>
</thead>
+5 -5
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../session.php';
require '../config.php';
require '../include_header.php';
?>
@@ -198,7 +198,7 @@
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>' +
@@ -246,9 +246,9 @@
}
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js"></script>
<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>
+5 -19
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../session.php';
require '../config.php';
require '../include_header.php';
?>
@@ -48,12 +48,6 @@
<option value="payment">Payment</option>
<option value="manual">Manual</option>
<option value="reversal">Reversal</option>
<optgroup label="Asset Management">
<option value="asset_capitalization">[Asset Mgmt] Capitalization</option>
<option value="asset_depreciation">[Asset Mgmt] Depreciation</option>
<option value="asset_amortization">[Asset Mgmt] Amortization</option>
<option value="asset_disposal">[Asset Mgmt] Disposal</option>
</optgroup>
</select>
</div>
<div class="col-auto">
@@ -194,11 +188,7 @@
receipt: 'Receipt',
payment: 'Payment',
manual: 'Manual',
reversal: 'Reversal',
asset_capitalization: 'Asset Mgmt · Capitalization',
asset_depreciation: 'Asset Mgmt · Depreciation',
asset_amortization: 'Asset Mgmt · Amortization',
asset_disposal: 'Asset Mgmt · Disposal'
reversal: 'Reversal'
};
var SOURCE_BADGE = {
@@ -209,11 +199,7 @@
receipt: 'bg-info-subtle text-info',
payment: 'bg-danger-subtle text-danger',
manual: 'bg-dark-subtle text-dark',
reversal: 'bg-danger-subtle text-danger',
asset_capitalization: 'bg-success text-white',
asset_depreciation: 'bg-success text-white',
asset_amortization: 'bg-success text-white',
asset_disposal: 'bg-success text-white'
reversal: 'bg-danger-subtle text-danger'
};
function source_badge(type) {
@@ -295,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>' +
@@ -521,7 +507,7 @@
? '<span class="badge bg-success-subtle text-success ms-2">Balanced</span>'
: '<span class="badge bg-danger-subtle text-danger ms-2">Unbalanced</span>';
var extra_fields = (h.source_type === 'manual' || String(h.source_type).indexOf('asset_') === 0)
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(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>'
+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);
+4 -4
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../session.php';
require '../config.php';
require '../include_header.php';
?>
@@ -204,9 +204,9 @@
}
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js"></script>
<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('', '');
}
+4 -4
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../session.php';
require '../config.php';
require '../include_header.php';
?>
@@ -209,9 +209,9 @@
}
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js"></script>
<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>
+5 -7
View File
@@ -1,5 +1,5 @@
<?php
session_start();
require_once __DIR__ . '/../session.php';
require '../config.php';
require '../include_header.php';
?>
@@ -139,8 +139,6 @@
invoice: 'Invoice', credit_note: 'Credit Note',
purchase_invoice: 'Purchase Invoice', supplier_credit_note: 'Supplier Credit Note',
receipt: 'Receipt', payment: 'Payment', manual: 'Manual',
asset_capitalization: 'Asset Mgmt · Capitalization', asset_depreciation: 'Asset Mgmt · Depreciation',
asset_amortization: 'Asset Mgmt · Amortization', asset_disposal: 'Asset Mgmt · Disposal',
};
load_departments();
@@ -212,7 +210,7 @@
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>' +
@@ -254,9 +252,9 @@
}
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js"></script>
<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>
-9
View File
@@ -1,9 +0,0 @@
<?php
session_start();
require_once '../../../assets/utils/db_auth.php';
require_app_access('asset');
require_once '../../../assets/utils/classes_as/AssetManager.php';
$answer['output'] = (new AssetManager($pdo2, $company_id))->getList((int)($data['invoice_id'] ?? 0));
$answer['success'] = 1;
exit(json_encode($answer));
-21
View File
@@ -1,21 +0,0 @@
<?php
session_start();
require_once '../../../assets/utils/db_auth.php';
require_app_access('asset');
require_once '../../../assets/utils/classes_as/AssetManager.php';
require_once '../../../assets/utils/classes_as/AssetRunManager.php';
try {
$answer['output'] = (new AssetManager($pdo2, $company_id))->getStats();
$answer['due'] = (new AssetRunManager($pdo2, $company_id))->dueSummary();
$answer['success'] = 1;
} catch (PDOException $e) {
error_log('[asset stats] ' . $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));
-11
View File
@@ -1,11 +0,0 @@
<?php
session_start();
require_once '../../../assets/utils/db_auth.php';
require_app_access('asset');
require_once '../../../assets/utils/classes_as/AssetCategoryManager.php';
$categories = new AssetCategoryManager($pdo2, $company_id);
$answer['output'] = $categories->getAll();
$answer['stats'] = $categories->getStats();
$answer['success'] = 1;
exit(json_encode($answer));
-63
View File
@@ -1,63 +0,0 @@
<?php
session_start();
require_once '../../../assets/utils/db_auth.php';
require_app_access('asset');
require_once '../../../assets/utils/classes/UsageGuard.php';
require_once '../../../assets/utils/classes_ac/PostingWindowGuard.php';
require_once '../../../assets/utils/classes_ac/GlManager.php';
require_once '../../../assets/utils/classes_as/AssetRunManager.php';
$action = (string)($data['action'] ?? 'list');
$class = trim((string)($data['asset_class'] ?? ''));
$through = trim((string)($data['through_period'] ?? ''));
$run_id = (int)($data['run_id'] ?? 0);
if (in_array($action, ['run', 'void'], true)) {
require_role($user_role, ['owner', 'admin']);
}
try {
$runs = new AssetRunManager($pdo2, $company_id);
switch ($action) {
case 'list':
$answer['output'] = $runs->getList($class);
break;
case 'preview':
$answer['output'] = $runs->preview($class, $through);
break;
case 'detail':
$answer['output'] = $runs->getDetail($run_id);
break;
case 'run':
$created = dbTransaction($pdo2, fn($pdo) => (new AssetRunManager($pdo, $company_id))->run($class, $through, $user_id));
(new UsageGuard($pdo1, $company_id, $packages))->increment();
$answer['output'] = $created;
$answer['message'] = count($created) . ' run(s) created. Accounting posts them from Batch GL Entries.';
break;
case 'void':
dbTransaction($pdo2, function ($pdo) use ($company_id, $run_id, $pdo1) {
$gl = new GlManager($pdo, $company_id, new PostingWindowGuard($pdo1, $company_id));
(new AssetRunManager($pdo, $company_id))->void($run_id, $gl);
});
$answer['message'] = 'Run voided.';
break;
default:
throw new Exception('Invalid action.');
}
$answer['success'] = 1;
} catch (PDOException $e) {
$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));
-28
View File
@@ -1,28 +0,0 @@
<?php
session_start();
require_once '../../../assets/utils/db_auth.php';
require_app_access('asset');
require_once '../../../assets/utils/classes_as/AssetManager.php';
try {
$assets = new AssetManager($pdo2, $company_id);
if (($data['action'] ?? '') === 'line') {
$answer['output'] = $assets->getInvoiceLine((int)($data['invoice_id'] ?? 0), (int)($data['item_id'] ?? 0));
} else {
$answer['output'] = $assets->getCapitalizableLines(
(string)($data['date_from'] ?? ''),
(string)($data['date_to'] ?? '')
);
}
$answer['success'] = 1;
} catch (PDOException $e) {
error_log('[asset invoice lines] ' . $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));
-70
View File
@@ -1,70 +0,0 @@
<?php
session_start();
require_once '../../../assets/utils/db_auth.php';
require_app_access('asset');
require_once '../../../assets/utils/classes/UsageGuard.php';
require_once '../../../assets/utils/classes_ac/PostingWindowGuard.php';
require_once '../../../assets/utils/classes_ac/GlManager.php';
require_once '../../../assets/utils/classes_as/AssetManager.php';
$action = (string)($data['action'] ?? '');
$id = (int)($data['id'] ?? 0);
$messages = [
'create' => 'Asset saved as a draft.',
'update' => 'Asset saved.',
'activate' => 'Asset activated. Accounting posts its capitalization from Batch GL Entries.',
'delete' => 'Draft asset deleted.',
'void' => 'Asset voided.',
'dispose' => 'Disposal recorded. Accounting posts it from Batch GL Entries.',
'undo_disposal' => 'Disposal undone.',
];
if (!isset($messages[$action])) {
$answer['message'] = 'Invalid action.';
http_response_code(400);
exit(json_encode($answer));
}
// Staff may prepare assets; everything that changes the books is owner/admin.
require_role($user_role, in_array($action, ['create', 'update'], true) ? ['owner', 'admin', 'staff'] : ['owner', 'admin']);
if ($action !== 'create' && $id <= 0) {
$answer['message'] = 'Missing asset id.';
http_response_code(400);
exit(json_encode($answer));
}
try {
$result_id = dbTransaction($pdo2, function ($pdo) use ($action, $id, $data, $company_id, $user_id, $pdo1) {
$assets = new AssetManager($pdo, $company_id);
$gl = new GlManager($pdo, $company_id, new PostingWindowGuard($pdo1, $company_id));
switch ($action) {
case 'create':
case 'update': return $assets->save($data, $user_id);
case 'activate': $assets->activate($id); break;
case 'delete': $assets->delete($id); break;
case 'void': $assets->void($id, $gl); break;
case 'dispose': $assets->dispose($id, $data); break;
case 'undo_disposal': $assets->undoDisposal($id, $gl); break;
}
return $id;
});
if ($action === 'create') {
(new UsageGuard($pdo1, $company_id, $packages))->increment();
}
$answer['success'] = 1;
$answer['output'] = ['id' => $result_id];
$answer['message'] = $messages[$action];
} catch (PDOException $e) {
$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));
-22
View File
@@ -1,22 +0,0 @@
<?php
session_start();
require_once '../../../assets/utils/db_auth.php';
require_app_access('asset');
require_role($user_role, ['owner', 'admin']);
require_once '../../../assets/utils/classes_as/AssetCategoryManager.php';
try {
$id = (new AssetCategoryManager($pdo2, $company_id))->save($data);
$answer['success'] = 1;
$answer['output'] = ['id' => $id];
$answer['message'] = !empty($data['id']) ? 'Category updated.' : 'Category created.';
} catch (PDOException $e) {
error_log('[asset category] ' . $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));
-15
View File
@@ -1,15 +0,0 @@
<?php
session_start();
require_once '../../../assets/utils/db_auth.php';
require_app_access('asset');
require_role($user_role, ['owner', 'admin']);
require_once '../../../assets/utils/classes_as/AssetCategoryManager.php';
$id = (int)($data['id'] ?? 0);
if (!$id) { $answer['message'] = 'Missing id'; exit(json_encode($answer)); }
(new AssetCategoryManager($pdo2, $company_id))->deactivate($id);
$answer['success'] = 1;
$answer['message'] = 'Category deactivated.';
exit(json_encode($answer));
-19
View File
@@ -1,19 +0,0 @@
<?php
session_start();
require_once '../../../assets/utils/db_auth.php';
require_app_access('asset');
require_once '../../../assets/utils/classes_as/AssetManager.php';
try {
$answer['output'] = (new AssetManager($pdo2, $company_id))->getDetail((int)($data['id'] ?? 0));
$answer['success'] = 1;
} catch (PDOException $e) {
error_log('[asset retrieve] ' . $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,15 +0,0 @@
<?php
session_start();
require_once '../../../assets/utils/db_auth.php';
require_app_access('asset');
require_once '../../../assets/utils/classes_as/AssetCategoryManager.php';
$id = (int)($data['id'] ?? 0);
if (!$id) { $answer['message'] = 'Missing id'; exit(json_encode($answer)); }
$row = (new AssetCategoryManager($pdo2, $company_id))->getById($id);
if (!$row) { $answer['message'] = 'Category not found'; exit(json_encode($answer)); }
$answer['output'] = $row;
$answer['success'] = 1;
exit(json_encode($answer));
-158
View File
@@ -1,158 +0,0 @@
<?php
session_start();
require '../config.php';
require '../include_header.php';
?>
<body>
<?php require '../include_topbar.php'; ?>
<?php require '../include_sidebar_asset.php'; ?>
<main id="content" class="content py-15">
<div class="container-fluid">
<!-- Page header -->
<div class="row">
<div class="col-12">
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center mb-5 gap-3">
<div>
<h1 class="fs-3 mb-1">Asset Categories</h1>
<p class="mb-0">Useful life and GL accounts used by each kind of asset</p>
</div>
<div>
<a href="<?php echo $server_url?>asset/manage_category.php" class="btn btn-primary" data-min-role="admin">
<i class="ti ti-plus me-1"></i>Add Category
</a>
</div>
</div>
</div>
</div>
<!-- Table -->
<div class="row">
<div class="col-12">
<div class="card overflow-hidden">
<div class="card-body p-6">
<div class="mb-5 d-flex justify-content-between align-items-center gap-3">
<span class="text-muted small" id="category_summary"></span>
<div class="position-relative" style="min-width:240px;">
<input type="search" id="search_category" class="form-control ps-9" placeholder="Search code or name…">
<span class="position-absolute top-25 ms-4">
<i class="ti ti-search text-muted"></i>
</span>
</div>
</div>
<div class="table-responsive">
<table class="table mb-0 text-nowrap table-hover table-centered" id="category_table">
<thead class="table-primary border-light">
<tr>
<th>Code</th>
<th>Name</th>
<th>Class</th>
<th class="text-end">Life (months)</th>
<th>Cost / Accumulated / Expense</th>
<th class="text-end">Assets</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody></tbody>
<tfoot></tfoot>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<?php require '../include_ending.php'; ?>
<script src="<?php echo $server_url?>assets/js/asset.js"></script>
<script>
var search_term = '';
function retrieve_categories() {
return ajax_request({
url: server_url + 'asset/api/engine/category.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
onSuccess: function(res) {
alasql('CREATE TABLE IF NOT EXISTS md_asset_category');
alasql.tables.md_asset_category.data = res.output || [];
var s = res.stats || {};
$('#category_summary').text((s.total || 0) + ' categories · ' + (s.active || 0) + ' active');
change_page(1);
}
});
}
function change_page(page_num) {
var offset = (page_num - 1) * prop_limit;
var kw = search_term.toLowerCase();
var rows = alasql('SELECT * FROM md_asset_category ORDER BY category_code').filter(function(r) {
return !kw || String(r.category_code).toLowerCase().indexOf(kw) !== -1 || String(r.category_name).toLowerCase().indexOf(kw) !== -1;
});
$('table#category_table tfoot').html(generate_pagination('category_table', rows.length));
if (!rows.length) {
$('table#category_table tbody').html('<tr><td colspan="8" class="text-center py-5 text-muted">No categories yet. Add one before creating assets.</td></tr>');
return;
}
var body = '';
rows.slice(offset, offset + parseInt(prop_limit, 10)).forEach(function(item) {
body += `<tr>
<td class="py-3 fw-semibold">${escape_html(item.category_code)}</td>
<td class="py-3">${escape_html(item.category_name)}</td>
<td class="py-3">${asset_class_badge(item.asset_class)}</td>
<td class="py-3 text-end">${item.useful_life_months}</td>
<td class="py-3 small text-muted">${escape_html(item.cost_account_code)} / ${escape_html(item.accum_account_code)} / ${escape_html(item.expense_account_code)}</td>
<td class="py-3 text-end">${item.asset_count}</td>
<td class="py-3">${String(item.status) === '1' ? '<span class="badge bg-success">Active</span>' : '<span class="badge bg-secondary">Inactive</span>'}</td>
<td class="py-3">
<a href="${server_url}asset/manage_category.php?id=${item.id}"><i class="ti ti-eye fs-5"></i></a>
${String(item.status) === '1' ? `<a href="javascript:;" class="link-danger ms-2" data-min-role="admin" onclick="remove_category(${item.id})"><i class="ti ti-ban fs-5"></i></a>` : ''}
</td>
</tr>`;
});
$('table#category_table tbody').html(body);
}
function remove_category(id) {
bootbox.confirm({
message: 'Deactivate this category? Existing assets keep using its accounts.',
buttons: { confirm: { label: 'Deactivate', className: 'btn-danger' }, cancel: { label: 'Back', className: 'btn-secondary' } },
callback: function(ok) {
if (!ok) return;
ajax_request({
url: server_url + 'asset/api/engine/remove_category.php',
autoPrepare: true,
checkRequired: 0,
action: 'delete',
data: { id: id },
onSuccess: function() { retrieve_categories(); }
});
}
});
}
$('#search_category').on('input', debounce(function() {
search_term = $(this).val().trim();
change_page(1);
}, 200));
$(async function() {
try { await retrieve_categories(); } catch (e) { console.log(e); }
});
</script>
</body>
</html>
-317
View File
@@ -1,317 +0,0 @@
<?php
session_start();
require '../config.php';
require '../include_header.php';
?>
<body>
<?php require '../include_topbar.php'; ?>
<?php require '../include_sidebar_asset.php'; ?>
<main id="content" class="content py-15">
<div class="container-fluid">
<div class="row">
<div class="col-12">
<div class="mb-5">
<h1 class="fs-3 mb-1">Depreciation &amp; Amortization Runs</h1>
<p class="mb-0">Charge each month's straight-line depreciation (tangible) or amortization (intangible)</p>
</div>
</div>
</div>
<!-- New run -->
<div class="card mb-5" data-min-role="admin">
<div class="card-body p-5">
<div class="row g-3 align-items-end">
<div class="col-md-4">
<label class="form-label mb-1">Type</label>
<select id="asset_class" class="form-select">
<option value="tangible">Depreciation — tangible assets</option>
<option value="intangible">Amortization — intangible assets</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label mb-1">Run Through Month</label>
<input type="text" id="through_period" class="form-control" placeholder="YYYY-MM">
</div>
<div class="col-md-5 d-flex gap-2">
<button class="btn btn-secondary" onclick="preview_run()"><i class="ti ti-eye me-1"></i>Preview</button>
<button class="btn btn-primary" id="btn_run" onclick="create_run()" disabled><i class="ti ti-player-play me-1"></i>Create Runs</button>
</div>
</div>
<p class="text-muted small mt-3 mb-0">
One run is created per month still due, oldest first. The in-service month counts as a full month and the
disposal month is not charged. Each run becomes one GL entry that Accounting posts from
<strong>Batch GL Entries</strong>, labelled <strong>[Asset Mgmt]</strong>.
</p>
<div id="preview_area" class="mt-4"></div>
</div>
</div>
<!-- Runs -->
<div class="card">
<div class="card-body p-5">
<div class="d-flex justify-content-between align-items-center mb-4 gap-3">
<h2 class="fs-5 mb-0">Runs</h2>
<select id="filter_class" class="form-select form-select-sm" style="width:220px;">
<option value="">All types</option>
<option value="tangible">Depreciation</option>
<option value="intangible">Amortization</option>
</select>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0 table-centered text-nowrap" id="run_table">
<thead class="table-primary border-light">
<tr>
<th>Run No.</th>
<th>Type</th>
<th>Month</th>
<th>Run Date</th>
<th class="text-end">Assets</th>
<th class="text-end">Amount</th>
<th>GL</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
<tr><td colspan="9" class="text-center text-muted py-5">Loading...</td></tr>
</tbody>
<tfoot></tfoot>
</table>
</div>
</div>
</div>
</div>
</main>
<!-- Run detail -->
<div class="modal fade" id="run_detail_modal" tabindex="-1">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="run_detail_title">Run</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="run_detail_body"></div>
</div>
</div>
</div>
<?php require '../include_ending.php'; ?>
<script src="<?php echo $server_url?>assets/js/asset.js"></script>
<script>
var can_admin = ['owner', 'admin'].includes(user_role);
var all_runs = [];
var last_preview = null;
function run_type_label(asset_class) {
return asset_class === 'intangible' ? 'Amortization' : 'Depreciation';
}
function preview_run() {
last_preview = null;
set_btn_state('#btn_run', false);
return ajax_request({
url: server_url + 'asset/api/engine/depreciation_run.php',
autoPrepare: true,
checkRequired: 0,
action: 'preview',
data: { asset_class: $('#asset_class').val(), through_period: $('#through_period').val() },
onSuccess: function(res) {
var runs = res.output || [];
last_preview = { asset_class: $('#asset_class').val(), through_period: $('#through_period').val(), runs: runs };
render_preview(runs);
set_btn_state('#btn_run', runs.length > 0);
}
});
}
function render_preview(runs) {
if (!runs.length) {
$('#preview_area').html('<div class="alert alert-success small mb-0"><i class="ti ti-circle-check me-1"></i>Nothing is due through this month.</div>');
return;
}
var total = 0;
var html = '<div class="table-responsive"><table class="table table-sm mb-0 table-centered">'
+ '<thead class="table-light"><tr><th>Month</th><th>Run Date</th><th class="text-end">Assets</th><th class="text-end">Amount</th><th></th></tr></thead><tbody>';
runs.forEach(function(r, idx) {
total += parseFloat(r.total) || 0;
html += '<tr>'
+ '<td class="fw-semibold">' + escape_html(r.period) + '</td>'
+ '<td>' + format_date(r.run_date) + '</td>'
+ '<td class="text-end">' + r.asset_count + '</td>'
+ '<td class="text-end">' + format_number(r.total, 2) + '</td>'
+ '<td class="text-end"><a href="javascript:;" class="small" onclick="$(\'#preview_items_' + idx + '\').toggleClass(\'d-none\')">Assets</a></td>'
+ '</tr>'
+ '<tr id="preview_items_' + idx + '" class="d-none"><td colspan="5" class="bg-light">'
+ items_table(r.items, false)
+ '</td></tr>';
});
html += '</tbody><tfoot><tr class="fw-semibold"><td colspan="3">Total</td><td class="text-end">' + format_number(total, 2) + '</td><td></td></tr></tfoot></table></div>';
$('#preview_area').html(html);
}
function items_table(items, with_dept) {
var html = '<table class="table table-sm mb-0"><thead><tr><th>Asset</th><th>Category</th>'
+ (with_dept ? '<th>Dept</th>' : '')
+ '<th class="text-end">Amount</th><th class="text-end">Accumulated</th><th class="text-end">Book Value</th></tr></thead><tbody>';
(items || []).forEach(function(i) {
html += '<tr>'
+ '<td>' + escape_html(i.asset_number) + ' <span class="text-muted">' + escape_html(i.asset_name) + '</span></td>'
+ '<td>' + escape_html((i.category_code ? i.category_code + ' ' : '') + (i.category_name || '')) + '</td>'
+ (with_dept ? '<td>' + escape_html(i.dept_code || '—') + '</td>' : '')
+ '<td class="text-end">' + format_number(i.amount, 2) + '</td>'
+ '<td class="text-end">' + format_number(i.accumulated_after, 2) + '</td>'
+ '<td class="text-end">' + format_number(i.book_value_after, 2) + '</td>'
+ '</tr>';
});
return html + '</tbody></table>';
}
function create_run() {
if (!last_preview || !last_preview.runs.length) return;
var total = last_preview.runs.reduce(function(s, r) { return s + (parseFloat(r.total) || 0); }, 0);
bootbox.confirm({
message: 'Create <strong>' + last_preview.runs.length + '</strong> ' + run_type_label(last_preview.asset_class).toLowerCase()
+ ' run(s) through ' + escape_html(last_preview.through_period) + ', totalling <strong>' + format_number(total, 2) + '</strong>?',
buttons: { confirm: { label: 'Create Runs', className: 'btn-primary' }, cancel: { label: 'Back', className: 'btn-secondary' } },
callback: function(ok) {
if (!ok) return;
ajax_request({
url: server_url + 'asset/api/engine/depreciation_run.php',
autoPrepare: true,
checkRequired: 0,
action: 'run',
data: { asset_class: last_preview.asset_class, through_period: last_preview.through_period },
onSuccess: function(res) {
toastr.success(res.message || 'Runs created.');
$('#preview_area').empty();
set_btn_state('#btn_run', false);
last_preview = null;
load_runs();
}
});
}
});
}
function load_runs() {
return ajax_request({
url: server_url + 'asset/api/engine/depreciation_run.php',
autoPrepare: true,
checkRequired: 0,
action: 'list',
data: { asset_class: '' },
onSuccess: function(res) {
all_runs = res.output || [];
change_page(1);
}
});
}
function change_page(page_num) {
var filter = $('#filter_class').val();
var rows = all_runs.filter(function(r) { return !filter || r.asset_class === filter; });
var limit = parseInt(prop_limit, 10);
var offset = (page_num - 1) * limit;
$('#run_table tfoot').html(generate_pagination('run_table', rows.length));
if (!rows.length) {
$('#run_table tbody').html('<tr><td colspan="9" class="text-center text-muted py-5">No runs yet.</td></tr>');
return;
}
var html = '';
rows.slice(offset, offset + limit).forEach(function(r) {
var active = String(r.status) === '1';
html += '<tr class="' + (active ? '' : 'text-muted') + '">'
+ '<td class="fw-semibold">' + escape_html(r.run_number) + '</td>'
+ '<td>' + run_type_label(r.asset_class) + '</td>'
+ '<td>' + escape_html(r.period) + '</td>'
+ '<td>' + format_date(r.run_date) + '</td>'
+ '<td class="text-end">' + r.asset_count + '</td>'
+ '<td class="text-end">' + format_number(r.total_amount, 2) + '</td>'
+ '<td>' + (active ? asset_gl_badge(r.gl_status) : '—') + '</td>'
+ '<td>' + (active ? '<span class="badge bg-success">Active</span>' : '<span class="badge bg-light text-dark">Void</span>') + '</td>'
+ '<td class="text-end">'
+ (active ? '<a href="javascript:;" onclick="show_run(' + r.id + ')"><i class="ti ti-eye fs-5"></i></a>' : '')
+ (active && can_admin ? '<a href="javascript:;" class="link-danger ms-2" title="Void run" onclick="void_run(' + r.id + ', \'' + escape_html(r.run_number) + '\', ' + r.gl_status + ')"><i class="ti ti-ban fs-5"></i></a>' : '')
+ '</td></tr>';
});
$('#run_table tbody').html(html);
}
function show_run(run_id) {
$('#run_detail_body').html('<div class="text-center text-muted py-5">Loading...</div>');
$('#run_detail_modal').modal('show');
ajax_request({
url: server_url + 'asset/api/engine/depreciation_run.php',
autoPrepare: true,
checkRequired: 0,
action: 'detail',
data: { run_id: run_id },
onSuccess: function(res) {
var run = res.output.run;
$('#run_detail_title').text(run.run_number + ' · ' + run_type_label(run.asset_class) + ' ' + run.period);
$('#run_detail_body').html(
'<div class="small text-muted mb-3">Run date ' + format_date(run.run_date) + ' · '
+ run.asset_count + ' assets · total ' + format_number(run.total_amount, 2) + '</div>'
+ items_table(res.output.items, true)
);
}
});
}
function void_run(run_id, run_number, gl_status) {
var note = String(gl_status) === '1'
? '<br><span class="text-danger">Its GL entry is posted, so a reversal entry will be created.</span>'
: '';
bootbox.confirm({
message: '<strong>Void run ' + run_number + '?</strong><br>Its charges are removed from the assets.' + note,
buttons: { confirm: { label: 'Void', className: 'btn-danger' }, cancel: { label: 'Back', className: 'btn-secondary' } },
callback: function(ok) {
if (!ok) return;
ajax_request({
url: server_url + 'asset/api/engine/depreciation_run.php',
autoPrepare: true,
checkRequired: 0,
action: 'void',
data: { run_id: run_id },
onSuccess: function(res) {
toastr.success(res.message || 'Run voided.');
load_runs();
}
});
}
});
}
$('#filter_class').on('change', function() { change_page(1); });
$('#asset_class, #through_period').on('change', function() {
last_preview = null;
set_btn_state('#btn_run', false);
$('#preview_area').empty();
});
$(async function() {
var params = new URLSearchParams(window.location.search);
if (params.get('class') === 'intangible' || params.get('class') === 'tangible') {
$('#asset_class').val(params.get('class'));
}
flatpickr('#through_period', {
plugins: [new monthSelectPlugin({ shorthand: true, dateFormat: 'Y-m', altFormat: 'M Y' })],
defaultDate: current_period() + '-01',
maxDate: new Date()
});
$('#through_period').val(current_period());
try { await load_runs(); } catch (e) { console.log(e); }
});
</script>
</body>
</html>
-174
View File
@@ -1,174 +0,0 @@
<?php
session_start();
require '../config.php';
require '../include_header.php';
?>
<body>
<?php require '../include_topbar.php'; ?>
<?php require '../include_sidebar_asset.php'; ?>
<main id="content" class="content py-15">
<div class="container-fluid">
<!-- Page header -->
<div class="row">
<div class="col-12">
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center mb-5 gap-3">
<div>
<h1 class="fs-3 mb-1">Asset Register</h1>
<p class="mb-0">Every asset with its cost, accumulated depreciation and book value</p>
</div>
<div class="d-flex gap-2">
<a href="<?php echo $server_url?>asset/purchase_invoices.php" class="btn btn-outline-primary">
<i class="ti ti-file-invoice me-1"></i>From Purchase Invoice
</a>
<a href="<?php echo $server_url?>asset/manage_asset.php" class="btn btn-primary">
<i class="ti ti-plus me-1"></i>New Asset
</a>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card overflow-hidden">
<div class="card-body p-6">
<div class="mb-5 d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3">
<ul class="nav nav-pills small" id="status_filter">
<li class="nav-item"><a class="nav-link active" href="javascript:;" data-status="">All <span class="count"></span></a></li>
<li class="nav-item"><a class="nav-link" href="javascript:;" data-status="0">Draft <span class="count"></span></a></li>
<li class="nav-item"><a class="nav-link" href="javascript:;" data-status="1">Active <span class="count"></span></a></li>
<li class="nav-item"><a class="nav-link" href="javascript:;" data-status="2">Fully depreciated <span class="count"></span></a></li>
<li class="nav-item"><a class="nav-link" href="javascript:;" data-status="3">Disposed <span class="count"></span></a></li>
<li class="nav-item"><a class="nav-link" href="javascript:;" data-status="4">Void <span class="count"></span></a></li>
</ul>
<div class="position-relative" style="min-width:240px;">
<input type="search" id="search_asset" class="form-control ps-9" placeholder="Search number, name, category…">
<span class="position-absolute top-25 ms-4">
<i class="ti ti-search text-muted"></i>
</span>
</div>
</div>
<div class="table-responsive">
<table class="table mb-0 text-nowrap table-hover table-centered" id="asset_table">
<thead class="table-primary border-light">
<tr>
<th>Asset No.</th>
<th>Name</th>
<th>Category</th>
<th>In Service</th>
<th class="text-end">Cost</th>
<th class="text-end">Accumulated</th>
<th class="text-end">Book Value</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody></tbody>
<tfoot></tfoot>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<?php require '../include_ending.php'; ?>
<script src="<?php echo $server_url?>assets/js/asset.js"></script>
<script>
var all_assets = [];
var search_term = '';
var status_filter = new URLSearchParams(window.location.search).get('status') || '';
function retrieve_assets() {
return ajax_request({
url: server_url + 'asset/api/engine/asset.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
onSuccess: function(res) {
all_assets = res.output || [];
update_counts();
change_page(1);
}
});
}
function update_counts() {
$('#status_filter a').each(function() {
var s = String($(this).data('status'));
var n = s === '' ? all_assets.length : all_assets.filter(function(a) { return String(a.status) === s; }).length;
$(this).find('.count').text('(' + n + ')');
$(this).toggleClass('active', s === status_filter);
});
}
function filtered_assets() {
var kw = search_term.toLowerCase();
return all_assets.filter(function(a) {
if (status_filter !== '' && String(a.status) !== status_filter) return false;
if (!kw) return true;
return [a.asset_number, a.asset_name, a.category_code, a.category_name, a.invoice_number]
.some(function(v) { return String(v || '').toLowerCase().indexOf(kw) !== -1; });
});
}
function change_page(page_num) {
var rows = filtered_assets();
var limit = parseInt(prop_limit, 10);
var offset = (page_num - 1) * limit;
$('table#asset_table tfoot').html(generate_pagination('asset_table', rows.length));
if (!rows.length) {
$('table#asset_table tbody').html('<tr><td colspan="9" class="text-center py-5 text-muted">No assets found.</td></tr>');
return;
}
var body = '';
rows.slice(offset, offset + limit).forEach(function(a) {
var source = a.invoice_number
? '<div class="small text-muted"><i class="ti ti-file-invoice me-1"></i>' + escape_html(a.invoice_number) + '</div>'
: '';
body += `<tr>
<td class="py-3 fw-semibold">${escape_html(a.asset_number)}</td>
<td class="py-3">${escape_html(a.asset_name)}${source}</td>
<td class="py-3">${escape_html(a.category_code)} <span class="text-muted">${escape_html(a.category_name)}</span></td>
<td class="py-3">${format_date(a.in_service_date)}</td>
<td class="py-3 text-end">${format_number(a.cost, 2)}</td>
<td class="py-3 text-end">${format_number(a.accumulated, 2)}</td>
<td class="py-3 text-end">${format_number(a.book_value, 2)}</td>
<td class="py-3">${asset_status_badge(a.status, a.asset_class)}</td>
<td class="py-3"><a href="${server_url}asset/manage_asset.php?id=${a.id}"><i class="ti ti-eye fs-5"></i></a></td>
</tr>`;
});
$('table#asset_table tbody').html(body);
}
$('#status_filter').on('click', 'a', function() {
status_filter = String($(this).data('status'));
update_counts();
change_page(1);
});
$('#search_asset').on('input', debounce(function() {
search_term = $(this).val().trim();
change_page(1);
}, 200));
$(async function() {
try { await retrieve_assets(); } catch (e) { console.log(e); }
});
</script>
</body>
</html>
-595
View File
@@ -1,595 +0,0 @@
<?php
session_start();
require '../config.php';
require '../include_header.php';
$asset_id = (int)($_GET['id'] ?? 0);
$invoice_id = (int)($_GET['invoice_id'] ?? 0);
$item_id = (int)($_GET['item_id'] ?? 0);
$pi_link_ok = in_array($_SESSION['login_app_access'] ?? 'wms', ['all', 'accounting'], true);
?>
<body>
<?php require '../include_topbar.php'; ?>
<?php require '../include_sidebar_asset.php'; ?>
<main id="content" class="content py-15">
<div class="container-fluid">
<div class="row">
<div class="col-12">
<div class="mb-6 d-flex justify-content-between align-items-center gap-3">
<div>
<h1 class="fs-3 mb-1" id="page_title">New Asset</h1>
<p class="mb-0 text-muted" id="page_subtitle">Straight-line depreciation with your own salvage value</p>
</div>
<a href="<?php echo $server_url?>asset/index.php" class="btn btn-light">
<i class="ti ti-arrow-left me-1"></i>Back
</a>
</div>
</div>
</div>
<div class="row g-5">
<!-- Left: form + schedule -->
<div class="col-lg-8">
<div class="alert alert-info small d-none" id="source_alert"></div>
<div class="card mb-5" id="assetForm">
<div class="card-body p-5">
<h2 class="fs-5 mb-4">Asset Details</h2>
<input type="hidden" id="id" value="<?php echo $asset_id ?: ''; ?>">
<div class="row g-4">
<div class="col-md-8">
<label class="form-label">Asset Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="asset_name" required>
</div>
<div class="col-md-4">
<label class="form-label">Department</label>
<select class="form-select" id="department_id"></select>
</div>
<div class="col-md-6">
<label class="form-label">Category <span class="text-danger">*</span></label>
<select class="form-select" id="category_id" required></select>
<div class="form-text" id="class_hint"></div>
</div>
<div class="col-md-6">
<label class="form-label">Clearing Account</label>
<select class="form-select" id="clearing_account_code"></select>
<div class="form-text">Credited when the asset is capitalized — the account the purchase was posted to. Leave empty if the asset is already on the books.</div>
</div>
<div class="col-md-3">
<label class="form-label">Acquisition Date <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="acquisition_date" placeholder="DD/MM/YYYY" required>
</div>
<div class="col-md-3">
<label class="form-label">In-Service Date <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="in_service_date" placeholder="DD/MM/YYYY" required>
</div>
<div class="col-md-6"></div>
<div class="col-md-4">
<label class="form-label">Cost <span class="text-danger">*</span></label>
<input type="number" class="form-control text-end" id="cost" min="0" step="0.01" required>
</div>
<div class="col-md-4">
<label class="form-label">Salvage Value</label>
<input type="number" class="form-control text-end" id="salvage_value" min="0" step="0.01" value="0">
</div>
<div class="col-md-4">
<label class="form-label">Useful Life (months) <span class="text-danger">*</span></label>
<input type="number" class="form-control text-end" id="useful_life_months" min="1" max="1200" step="1" required>
</div>
<div class="col-12">
<div class="bg-light rounded-2 px-4 py-3 d-flex flex-wrap gap-4 small">
<div><span class="text-muted">Amount to <span class="label-charge">depreciate</span>:</span> <strong id="calc_base">—</strong></div>
<div><span class="text-muted">Per month:</span> <strong id="calc_monthly">—</strong></div>
<div><span class="text-muted">Last month:</span> <strong id="calc_last">—</strong></div>
</div>
</div>
<div class="col-12">
<label class="form-label">Description</label>
<textarea class="form-control" id="description" rows="2" placeholder="Serial number, location, notes"></textarea>
</div>
</div>
<div class="form-text mt-3 d-none" id="locked_hint">
<i class="ti ti-lock me-1"></i>Cost, salvage value, life, dates and category are locked because months have already been charged.
</div>
</div>
</div>
<div class="card d-none" id="schedule_card">
<div class="card-body p-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="fs-5 mb-0"><span class="label-schedule">Depreciation</span> Schedule</h2>
<span class="small text-muted" id="schedule_summary"></span>
</div>
<div class="table-responsive" style="max-height:480px;">
<table class="table table-sm mb-0 table-centered">
<thead class="table-light" style="position:sticky; top:0;">
<tr>
<th>#</th>
<th>Month</th>
<th class="text-end">Amount</th>
<th class="text-end">Accumulated</th>
<th class="text-end">Book Value</th>
<th>State</th>
</tr>
</thead>
<tbody id="schedule_tbody"></tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Right: status + actions -->
<div class="col-lg-4">
<div class="card mb-5 d-none" id="status_card">
<div class="card-body p-4">
<h2 class="fs-5 mb-3">Status</h2>
<div class="mb-3 d-flex gap-2 flex-wrap" id="status_badges"></div>
<div class="small mb-2"><span class="text-muted">Accumulated:</span> <span class="fw-semibold ms-1" id="display_accumulated">—</span></div>
<div class="small mb-2"><span class="text-muted">Book value:</span> <span class="fw-semibold ms-1" id="display_book_value">—</span></div>
<div class="small mb-2"><span class="text-muted">Last month charged:</span> <span class="ms-1" id="display_last_period">—</span></div>
<div class="small mb-2 d-none" id="source_row"><span class="text-muted">Source:</span> <span class="ms-1" id="display_source"></span></div>
<hr>
<h3 class="fs-6 mb-2">GL Entries <span class="badge bg-success text-white ms-1">[Asset Mgmt]</span></h3>
<div class="small mb-2"><div class="text-muted">Capitalization</div><div id="gl_capitalization"></div></div>
<div class="small mb-2 d-none" id="gl_disposal_row"><div class="text-muted">Disposal</div><div id="gl_disposal"></div></div>
<div class="d-none" id="disposal_info">
<hr>
<h3 class="fs-6 mb-2">Disposal</h3>
<div class="small mb-1"><span class="text-muted">Date:</span> <span class="ms-1" id="display_disposal_date"></span></div>
<div class="small mb-1"><span class="text-muted">Sale price:</span> <span class="ms-1" id="display_sale_price"></span></div>
<div class="small mb-1"><span class="text-muted">Gain / (loss):</span> <span class="ms-1 fw-semibold" id="display_gain_loss"></span></div>
<div class="small mb-1 text-muted" id="display_disposal_notes"></div>
</div>
</div>
</div>
<div class="card">
<div class="card-body p-4 d-flex flex-column gap-2">
<button class="btn btn-primary w-100" id="btn_save" onclick="save_asset()">
<i class="ti ti-device-floppy me-1"></i><span>Save as Draft</span>
</button>
<button class="btn btn-success w-100 d-none" id="btn_activate" onclick="asset_action('activate')">
<i class="ti ti-circle-check me-1"></i>Activate
</button>
<button class="btn btn-outline-dark w-100 d-none" id="btn_dispose" onclick="open_disposal()">
<i class="ti ti-receipt-refund me-1"></i>Sell / Dispose
</button>
<button class="btn btn-outline-warning w-100 d-none" id="btn_undo_disposal" onclick="asset_action('undo_disposal')">
<i class="ti ti-arrow-back-up me-1"></i>Undo Disposal
</button>
<button class="btn btn-outline-danger w-100 d-none" id="btn_void" onclick="asset_action('void')">
<i class="ti ti-ban me-1"></i>Void
</button>
<button class="btn btn-outline-danger w-100 d-none" id="btn_delete" onclick="asset_action('delete')">
<i class="ti ti-trash me-1"></i>Delete Draft
</button>
</div>
</div>
</div>
</div>
</div>
</main>
<!-- Disposal modal -->
<div class="modal fade" id="disposal_modal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Sell / Dispose of Asset</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Disposal Date</label>
<input type="text" class="form-control" id="disposal_date" placeholder="DD/MM/YYYY">
</div>
<div class="col-md-6">
<label class="form-label">Sale Price</label>
<input type="number" class="form-control text-end" id="sale_price" min="0" step="0.01" value="0">
</div>
<div class="col-12">
<label class="form-label">Proceeds Account</label>
<select class="form-select" id="proceeds_account_code"></select>
<div class="form-text">Cash, bank or receivable that received the money. Not needed for a write-off (price 0).</div>
</div>
<div class="col-12">
<label class="form-label">Notes</label>
<textarea class="form-control" id="disposal_notes" rows="2" placeholder="Buyer, reason"></textarea>
</div>
<div class="col-12">
<div class="bg-light rounded-2 px-3 py-2 small">
<div class="d-flex justify-content-between"><span class="text-muted">Cost</span><span id="dp_cost"></span></div>
<div class="d-flex justify-content-between"><span class="text-muted">Accumulated (months charged so far)</span><span id="dp_accumulated"></span></div>
<div class="d-flex justify-content-between"><span class="text-muted">Book value</span><span id="dp_book"></span></div>
<div class="d-flex justify-content-between fw-semibold"><span id="dp_result_label">Gain / (loss)</span><span id="dp_result"></span></div>
</div>
<div class="form-text">Every month before the disposal month must already be charged. The disposal month itself is not charged.</div>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" data-bs-dismiss="modal">Back</button>
<button class="btn btn-dark" onclick="submit_disposal()"><i class="ti ti-receipt-refund me-1"></i>Record Disposal</button>
</div>
</div>
</div>
</div>
<?php require '../include_ending.php'; ?>
<script src="<?php echo $server_url?>assets/js/asset.js"></script>
<script>
var asset_id = <?php echo $asset_id; ?>;
var invoice_id = <?php echo $invoice_id; ?>;
var item_id = <?php echo $item_id; ?>;
var pi_link_ok = <?php echo $pi_link_ok ? 'true' : 'false'; ?>;
var can_admin = ['owner', 'admin'].includes(user_role);
var can_edit = ['owner', 'admin', 'staff'].includes(user_role);
var categories = [];
var accounts = [];
var detail = null;
var invoice_line = null;
var life_touched = false;
var FINANCIAL_FIELDS = '#category_id, #acquisition_date, #in_service_date, #cost, #salvage_value, #useful_life_months, #clearing_account_code';
// ── lookups ────────────────────────────────────────────────────────────────
function load_categories() {
return ajax_request({
url: server_url + 'asset/api/engine/category.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
queueLock: false,
noLoading: true,
onSuccess: function(res) { categories = res.output || []; }
});
}
function category_by_id(id) {
return categories.find(function(c) { return String(c.id) === String(id); }) || null;
}
function fill_category_select(selected) {
var html = '<option value="">— Select category —</option>';
categories.forEach(function(c) {
if (String(c.status) !== '1' && String(c.id) !== String(selected)) return;
html += '<option value="' + c.id + '">' + escape_html(c.category_code + ' — ' + c.category_name)
+ (c.asset_class === 'intangible' ? ' (intangible)' : '') + '</option>';
});
$('#category_id').html(html).val(selected ? String(selected) : '');
}
function update_class_hint() {
var c = category_by_id($('#category_id').val());
var intangible = c && c.asset_class === 'intangible';
$('#class_hint').html(c ? asset_class_badge(c.asset_class) : '');
$('.label-charge').text(intangible ? 'amortize' : 'depreciate');
$('.label-schedule').text(intangible ? 'Amortization' : 'Depreciation');
}
// ── live calculation ───────────────────────────────────────────────────────
function update_calc() {
var cost = parseFloat($('#cost').val()) || 0;
var salvage = parseFloat($('#salvage_value').val()) || 0;
var life = parseInt($('#useful_life_months').val(), 10) || 0;
var base = round_dp(cost - salvage, 2);
if (base <= 0 || life <= 0) {
$('#calc_base, #calc_monthly, #calc_last').text('—');
return;
}
var monthly = round_dp(base / life, 2);
var last = round_dp(base - monthly * (life - 1), 2);
$('#calc_base').text(format_number(base, 2));
$('#calc_monthly').text(format_number(monthly, 2));
$('#calc_last').text(life > 1 ? format_number(Math.max(last, 0), 2) : format_number(base, 2));
}
// ── load ───────────────────────────────────────────────────────────────────
function retrieve_asset() {
return ajax_request({
url: server_url + 'asset/api/engine/retrieve_asset.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
data: { id: asset_id },
onSuccess: function(res) {
detail = res.output;
render_detail();
}
});
}
function load_invoice_line() {
return ajax_request({
url: server_url + 'asset/api/engine/invoice_lines.php',
autoPrepare: true,
checkRequired: 0,
action: 'line',
data: { invoice_id: invoice_id, item_id: item_id },
onSuccess: function(res) {
invoice_line = res.output;
var l = invoice_line;
$('#asset_name').val(l.product_name || l.product_sku);
$('#acquisition_date, #in_service_date').val(format_date_input(l.issued_date));
$('#cost').val(Math.max(parseFloat(l.remaining) || 0, 0).toFixed(2));
$('#clearing_account_code').html(account_options(accounts, [], l.clearing_account_code, '— None —'));
load_departments('department_id', l.department_id);
$('#source_alert').removeClass('d-none').html(
'<i class="ti ti-file-invoice me-1"></i>From purchase invoice <strong>' + escape_html(l.invoice_number) + '</strong>'
+ ' (' + escape_html(l.contact_name || '—') + '), line <strong>' + escape_html(l.product_sku) + '</strong>: '
+ 'amount ' + format_number(l.line_amount, 2) + ', already capitalized ' + format_number(l.capitalized, 2)
+ ', remaining <strong>' + format_number(l.remaining, 2) + '</strong>.'
+ (l.clearing_account_code ? '' : ' <span class="text-danger">Select the account the invoice line was posted to as the clearing account.</span>')
);
$('#page_subtitle').text('Capitalizing a purchase invoice line');
update_calc();
}
});
}
function render_detail() {
var a = detail.asset;
var status = parseInt(a.status, 10);
var recorded = detail.schedule.some(function(r) { return r.state === 'recorded'; });
var closed = status === 3 || status === 4;
$('#page_title').text(a.asset_number + ' · ' + a.asset_name);
$('#page_subtitle').text(a.category_code + ' ' + a.category_name);
$('#id').val(a.id);
$('#asset_name').val(a.asset_name);
fill_category_select(a.category_id);
update_class_hint();
$('#clearing_account_code').html(account_options(accounts, [], a.clearing_account_code, '— None —'));
$('#acquisition_date').val(format_date_input(a.acquisition_date));
$('#in_service_date').val(format_date_input(a.in_service_date));
$('#cost').val(parseFloat(a.cost).toFixed(2));
$('#salvage_value').val(parseFloat(a.salvage_value).toFixed(2));
$('#useful_life_months').val(a.useful_life_months);
$('#description').val(a.description || '');
load_departments('department_id', a.department_id);
life_touched = true;
update_calc();
// Status card
$('#status_card').removeClass('d-none');
$('#status_badges').html(asset_status_badge(a.status, a.asset_class) + asset_class_badge(a.asset_class));
$('#display_accumulated').text(format_number(a.accumulated, 2));
$('#display_book_value').text(format_number(a.book_value, 2));
$('#display_last_period').text(a.last_period || '—');
if (parseInt(a.invoice_id, 10) > 0) {
var ref = escape_html(a.invoice_number || ('PI #' + a.invoice_id));
$('#display_source').html(pi_link_ok
? '<a href="' + server_url + 'expense/manage_purchase_invoice.php?id=' + a.invoice_id + '">' + ref + '</a>'
: ref);
$('#source_row').removeClass('d-none');
}
// GL entries
var cap_html;
if (!a.clearing_account_code) {
cap_html = '<span class="text-muted">No entry — no clearing account.</span>';
} else if (status === 0) {
cap_html = '<span class="text-muted">Activate the asset, then Accounting posts it.</span>';
} else {
cap_html = gl_entry_html(detail.gl.capitalization);
}
$('#gl_capitalization').html(cap_html);
$('#gl_disposal_row').toggleClass('d-none', status !== 3);
$('#gl_disposal').html(gl_entry_html(detail.gl.disposal));
// Disposal
if (status === 3) {
var gain = round_dp(parseFloat(a.sale_price) - (parseFloat(a.cost) - parseFloat(a.accumulated)), 2);
$('#display_disposal_date').text(format_date(a.disposal_date));
$('#display_sale_price').text(format_number(a.sale_price, 2));
$('#display_gain_loss').text(gain < 0 ? '(' + format_number(-gain, 2) + ')' : format_number(gain, 2))
.toggleClass('text-danger', gain < 0).toggleClass('text-success', gain > 0);
$('#display_disposal_notes').text(a.disposal_notes || '');
$('#disposal_info').removeClass('d-none');
} else {
$('#disposal_info').addClass('d-none');
}
render_schedule();
// Editing rules
$(FINANCIAL_FIELDS).prop('disabled', closed || recorded || !can_edit);
$('#asset_name, #description, #department_id').prop('disabled', closed || !can_edit);
$('#locked_hint').toggleClass('d-none', !(recorded && !closed));
$('#btn_save').toggleClass('d-none', closed || !can_edit).find('span').text(status === 0 ? 'Save Draft' : 'Save');
$('#btn_activate').toggleClass('d-none', !(status === 0 && can_admin));
$('#btn_dispose').toggleClass('d-none', !((status === 1 || status === 2) && can_admin));
$('#btn_undo_disposal').toggleClass('d-none', !(status === 3 && can_admin));
$('#btn_void').toggleClass('d-none', !((status === 1 || status === 2) && can_admin && !recorded));
$('#btn_delete').toggleClass('d-none', !(status === 0 && can_admin));
}
function gl_entry_html(entry) {
if (entry) {
return '<span class="badge bg-success-subtle text-success">Posted</span>'
+ ' <span class="text-muted ms-1">' + escape_html(entry.reference) + ' · ' + format_date(entry.journal_date) + '</span>';
}
return '<span class="badge bg-danger-subtle text-danger">Not posted</span>'
+ ' <span class="text-muted ms-1">Accounting → Batch GL Entries</span>';
}
function render_schedule() {
var rows = detail.schedule || [];
if (!rows.length) { $('#schedule_card').addClass('d-none'); return; }
var state_badge = {
recorded: function(r) { return '<span class="badge bg-success-subtle text-success">Charged</span> <span class="small text-muted">' + escape_html(r.run_number || '') + '</span>'; },
projected: function() { return '<span class="badge bg-light text-dark">Projected</span>'; },
stopped: function() { return '<span class="badge bg-secondary-subtle text-secondary">Not charged</span>'; }
};
var html = '';
var charged = 0;
rows.forEach(function(r, i) {
if (r.state === 'recorded') charged++;
html += '<tr class="' + (r.state === 'stopped' ? 'text-muted' : '') + '">'
+ '<td>' + (i + 1) + '</td>'
+ '<td>' + escape_html(r.period) + '</td>'
+ '<td class="text-end">' + format_number(r.amount, 2) + '</td>'
+ '<td class="text-end">' + format_number(r.accumulated, 2) + '</td>'
+ '<td class="text-end">' + format_number(r.book_value, 2) + '</td>'
+ '<td>' + state_badge[r.state](r) + '</td>'
+ '</tr>';
});
$('#schedule_tbody').html(html);
$('#schedule_summary').text(charged + ' of ' + rows.length + ' months charged · ' + format_number(detail.monthly_amount, 2) + ' per month');
$('#schedule_card').removeClass('d-none');
}
// ── actions ────────────────────────────────────────────────────────────────
function save_asset() {
return ajax_request({
url: server_url + 'asset/api/engine/manage_asset.php',
autoPrepare: true,
checkRequired: 1,
action: 'manage',
data: { id: asset_id || '', invoice_id: invoice_id, invoice_item_id: item_id },
onSuccess: function(res) {
var saved_id = res.output && res.output.id;
if (!asset_id && saved_id) {
window.location.href = server_url + 'asset/manage_asset.php?id=' + saved_id;
return;
}
toastr.success(res.message || 'Saved.');
retrieve_asset();
}
});
}
var ACTION_CONFIRM = {
activate: ['Activate this asset? It will start appearing in depreciation runs from its in-service month.', 'Activate', 'btn-success'],
undo_disposal: ['Undo the disposal? If its GL entry was posted, a reversal entry is created.', 'Undo Disposal', 'btn-warning'],
void: ['<strong>Void this asset?</strong><br>If its capitalization was posted, a reversal entry is created. This cannot be undone.', 'Void', 'btn-danger'],
delete: ['Delete this draft asset?', 'Delete', 'btn-danger']
};
function asset_action(action) {
var c = ACTION_CONFIRM[action];
bootbox.confirm({
message: c[0],
buttons: { confirm: { label: c[1], className: c[2] }, cancel: { label: 'Back', className: 'btn-secondary' } },
callback: function(ok) {
if (!ok) return;
ajax_request({
url: server_url + 'asset/api/engine/manage_asset.php',
autoPrepare: false,
action: action,
data: { json: JSON.stringify({
otp: document.getElementById('session-context').dataset.otp,
company_id: company_id,
action: action,
id: asset_id
}) },
onSuccess: function(res) {
if (action === 'delete') { window.location.href = server_url + 'asset/index.php'; return; }
toastr.success(res.message || 'Done.');
retrieve_asset();
}
});
}
});
}
function open_disposal() {
var a = detail.asset;
$('#proceeds_account_code').html(account_options(accounts, ['asset'], '', '— None (write-off) —'));
$('#sale_price').val('0');
$('#disposal_notes').val('');
$('#dp_cost').text(format_number(a.cost, 2));
$('#dp_accumulated').text(format_number(a.accumulated, 2));
$('#dp_book').text(format_number(a.book_value, 2));
update_disposal_result();
$('#disposal_modal').modal('show');
}
function update_disposal_result() {
if (!detail) return;
var gain = round_dp((parseFloat($('#sale_price').val()) || 0) - parseFloat(detail.asset.book_value), 2);
$('#dp_result_label').text(gain < 0 ? 'Loss' : 'Gain');
$('#dp_result').text(format_number(Math.abs(gain), 2)).toggleClass('text-danger', gain < 0).toggleClass('text-success', gain > 0);
}
function submit_disposal() {
ajax_request({
url: server_url + 'asset/api/engine/manage_asset.php',
autoPrepare: false,
action: 'dispose',
data: { json: JSON.stringify({
otp: document.getElementById('session-context').dataset.otp,
company_id: company_id,
action: 'dispose',
id: asset_id,
disposal_date: $('#disposal_date').val(),
sale_price: $('#sale_price').val(),
proceeds_account_code: $('#proceeds_account_code').val(),
disposal_notes: $('#disposal_notes').val()
}) },
onSuccess: function(res) {
$('#disposal_modal').modal('hide');
toastr.success(res.message || 'Disposal recorded.');
retrieve_asset();
}
});
}
// ── events ─────────────────────────────────────────────────────────────────
$('#cost, #salvage_value, #useful_life_months').on('input', update_calc);
$('#useful_life_months').on('input', function() { life_touched = true; });
$('#category_id').on('change', function() {
var c = category_by_id($(this).val());
if (c && !life_touched) {
$('#useful_life_months').val(c.useful_life_months);
update_calc();
}
update_class_hint();
});
$('#sale_price').on('input', update_disposal_result);
$(async function() {
flatpickr('#acquisition_date, #in_service_date, #disposal_date', { dateFormat: 'd/m/Y', allowInput: true });
try {
await load_categories();
await load_posting_accounts(function(rows) { accounts = rows; });
fill_category_select('');
$('#clearing_account_code').html(account_options(accounts, [], '', '— None —'));
if (asset_id) {
await retrieve_asset();
} else {
load_departments('department_id', 0);
if (invoice_id && item_id) await load_invoice_line();
if (!can_edit) $('#btn_save').addClass('d-none');
}
} catch (e) { console.log(e); }
});
</script>
</body>
</html>
-179
View File
@@ -1,179 +0,0 @@
<?php
session_start();
require '../config.php';
require '../include_header.php';
?>
<body>
<?php require '../include_topbar.php'; ?>
<?php require '../include_sidebar_asset.php'; ?>
<main id="content" class="content py-15">
<div class="container-fluid">
<div class="row">
<div class="col-12">
<div class="d-flex align-items-center mb-5 gap-3">
<a href="<?php echo $server_url?>asset/categories.php" class="btn btn-light btn-sm">
<i class="ti ti-arrow-left me-1"></i>Back
</a>
<h1 class="fs-3 mb-0">Manage Asset Category</h1>
</div>
</div>
</div>
<div class="row" id="categoryForm">
<div class="col-lg-8">
<div class="card">
<div class="card-body p-6">
<input type="hidden" id="id">
<div class="row g-4">
<div class="col-md-3">
<label class="form-label">Code <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="category_code" placeholder="e.g. IT" required>
</div>
<div class="col-md-5">
<label class="form-label">Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="category_name" placeholder="e.g. Computer equipment" required>
</div>
<div class="col-md-4">
<label class="form-label">Status</label>
<select class="form-select" id="status">
<option value="1">Active</option>
<option value="0">Inactive</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Asset Class <span class="text-danger">*</span></label>
<select class="form-select" id="asset_class" required>
<option value="tangible">Tangible — straight-line depreciation</option>
<option value="intangible">Intangible — straight-line amortization</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Default Useful Life (months) <span class="text-danger">*</span></label>
<input type="number" class="form-control" id="useful_life_months" min="1" max="1200" step="1" value="60" required>
</div>
<div class="col-12">
<h2 class="fs-6 mb-0 mt-2">GL Accounts</h2>
<p class="text-muted small mb-0">Used when Accounting posts this category's entries from Batch GL Entries.</p>
</div>
<div class="col-md-6">
<label class="form-label">Asset Cost Account <span class="text-danger">*</span></label>
<select class="form-select" id="cost_account_code" required></select>
</div>
<div class="col-md-6">
<label class="form-label"><span class="label-accum">Accumulated Depreciation</span> Account <span class="text-danger">*</span></label>
<select class="form-select" id="accum_account_code" required></select>
</div>
<div class="col-md-6">
<label class="form-label"><span class="label-expense">Depreciation</span> Expense Account <span class="text-danger">*</span></label>
<select class="form-select" id="expense_account_code" required></select>
</div>
<div class="col-md-6"></div>
<div class="col-md-6">
<label class="form-label">Gain on Disposal Account <span class="text-danger">*</span></label>
<select class="form-select" id="gain_account_code" required></select>
</div>
<div class="col-md-6">
<label class="form-label">Loss on Disposal Account <span class="text-danger">*</span></label>
<select class="form-select" id="loss_account_code" required></select>
</div>
<div class="col-12">
<label class="form-label">Description</label>
<textarea class="form-control" id="description" rows="2" placeholder="Optional"></textarea>
</div>
</div>
<div class="d-flex gap-2 mt-5">
<button class="btn btn-primary" onclick="manage_category();" id="btn_submit" data-min-role="admin">Create Category</button>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<?php require '../include_ending.php'; ?>
<script src="<?php echo $server_url?>assets/js/asset.js"></script>
<script>
var category_id = <?php echo (int)($_GET['id'] ?? 0); ?>;
var accounts = [];
var ACCOUNT_SELECTS = {
cost_account_code: ['asset'],
accum_account_code: ['asset'],
expense_account_code: ['expense'],
gain_account_code: ['revenue'],
loss_account_code: ['expense']
};
function fill_account_selects(values) {
Object.keys(ACCOUNT_SELECTS).forEach(function(field) {
$('#' + field).html(account_options(accounts, ACCOUNT_SELECTS[field], (values || {})[field] || ''));
});
}
function update_class_labels() {
var intangible = $('#asset_class').val() === 'intangible';
$('.label-accum').text(intangible ? 'Accumulated Amortization' : 'Accumulated Depreciation');
$('.label-expense').text(intangible ? 'Amortization' : 'Depreciation');
}
function manage_category() {
return ajax_request({
url: server_url + 'asset/api/engine/manage_category.php',
autoPrepare: true,
checkRequired: 1,
action: 'manage',
onSuccess: function() {
window.location.href = server_url + 'asset/categories.php';
}
});
}
function retrieve_for_edit() {
if (!category_id) return Promise.resolve();
return ajax_request({
url: server_url + 'asset/api/engine/retrieve_category.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
data: { id: category_id },
onSuccess: function(res) {
var c = res.output;
$('#id').val(c.id);
$('#category_code').val(c.category_code);
$('#category_name').val(c.category_name);
$('#asset_class').val(c.asset_class);
$('#useful_life_months').val(c.useful_life_months);
$('#description').val(c.description || '');
$('#status').val(c.status);
fill_account_selects(c);
update_class_labels();
$('#btn_submit').text('Update Category');
}
});
}
$('#asset_class').on('change', update_class_labels);
$(async function() {
try {
await load_posting_accounts(function(rows) { accounts = rows; fill_account_selects({}); });
await retrieve_for_edit();
} catch (e) { console.log(e); }
});
</script>
</body>
</html>
-173
View File
@@ -1,173 +0,0 @@
<?php
session_start();
require '../config.php';
require '../include_header.php';
$pi_link_ok = in_array($_SESSION['login_app_access'] ?? 'wms', ['all', 'accounting'], true);
?>
<body>
<?php require '../include_topbar.php'; ?>
<?php require '../include_sidebar_asset.php'; ?>
<main id="content" class="content py-15">
<div class="container-fluid">
<div class="row">
<div class="col-12">
<div class="mb-5">
<h1 class="fs-3 mb-1">Assets from Purchase Invoices</h1>
<p class="mb-0">Issued purchase invoice lines (AP) that can be capitalized as assets</p>
</div>
</div>
</div>
<div class="card mb-5">
<div class="card-body p-4">
<div class="row g-2 align-items-end">
<div class="col-auto">
<label class="form-label mb-1 small">Invoice Date From</label>
<input type="text" id="date_from" class="form-control form-control-sm" placeholder="d/m/Y" style="width:140px;">
</div>
<div class="col-auto">
<label class="form-label mb-1 small">Invoice Date To</label>
<input type="text" id="date_to" class="form-control form-control-sm" placeholder="d/m/Y" style="width:140px;">
</div>
<div class="col-auto">
<button class="btn btn-secondary btn-sm" onclick="load_lines()">
<i class="ti ti-search me-1"></i>Load
</button>
</div>
<div class="col-auto ms-3">
<div class="form-check mb-1">
<input class="form-check-input" type="checkbox" id="show_capitalized">
<label class="form-check-label small" for="show_capitalized">Show fully capitalized lines</label>
</div>
</div>
<div class="col ms-auto d-flex justify-content-end">
<div class="position-relative" style="min-width:220px;">
<input type="search" id="search_line" class="form-control form-control-sm ps-8" placeholder="Search invoice, supplier, item…">
<span class="position-absolute top-25 ms-3"><i class="ti ti-search text-muted small"></i></span>
</div>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0 table-centered text-nowrap" id="line_table">
<thead class="table-primary border-light">
<tr>
<th>Invoice</th>
<th>Date</th>
<th>Supplier</th>
<th>Item</th>
<th class="text-end">Qty</th>
<th class="text-end">Line Amount</th>
<th class="text-end">Capitalized</th>
<th class="text-end">Remaining</th>
<th></th>
</tr>
</thead>
<tbody>
<tr><td colspan="9" class="text-center text-muted py-5">Use the filters above and click Load.</td></tr>
</tbody>
<tfoot></tfoot>
</table>
</div>
</div>
</div>
</div>
</main>
<?php require '../include_ending.php'; ?>
<script src="<?php echo $server_url?>assets/js/asset.js"></script>
<script>
var pi_link_ok = <?php echo $pi_link_ok ? 'true' : 'false'; ?>;
var all_lines = [];
var search_term = '';
function load_lines() {
return ajax_request({
url: server_url + 'asset/api/engine/invoice_lines.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
data: { date_from: $('#date_from').val(), date_to: $('#date_to').val() },
onSuccess: function(res) {
all_lines = res.output || [];
change_page(1);
}
});
}
function visible_lines() {
var show_all = $('#show_capitalized').is(':checked');
var kw = search_term.toLowerCase();
return all_lines.filter(function(l) {
if (!show_all && parseFloat(l.remaining) <= 0) return false;
if (!kw) return true;
return [l.invoice_number, l.contact_name, l.product_sku, l.product_name]
.some(function(v) { return String(v || '').toLowerCase().indexOf(kw) !== -1; });
});
}
function change_page(page_num) {
var rows = visible_lines();
var limit = parseInt(prop_limit, 10);
var offset = (page_num - 1) * limit;
$('#line_table tfoot').html(generate_pagination('line_table', rows.length));
if (!rows.length) {
$('#line_table tbody').html('<tr><td colspan="9" class="text-center text-muted py-5">No invoice lines to capitalize in this date range.</td></tr>');
return;
}
var html = '';
rows.slice(offset, offset + limit).forEach(function(l) {
var invoice = pi_link_ok
? '<a href="' + server_url + 'expense/manage_purchase_invoice.php?id=' + l.invoice_id + '">' + escape_html(l.invoice_number) + '</a>'
: escape_html(l.invoice_number);
var action = parseFloat(l.remaining) > 0
? '<a class="btn btn-sm btn-primary" href="' + server_url + 'asset/manage_asset.php?invoice_id=' + l.invoice_id + '&item_id=' + l.item_id + '">'
+ '<i class="ti ti-plus me-1"></i>Create Asset</a>'
: '<span class="badge bg-success-subtle text-success">Capitalized</span>';
var assets = parseInt(l.asset_count, 10) > 0
? ' <span class="small text-muted">(' + l.asset_count + ' asset' + (l.asset_count == 1 ? '' : 's') + ')</span>'
: '';
html += '<tr>'
+ '<td>' + invoice + '</td>'
+ '<td>' + format_date(l.issued_date) + '</td>'
+ '<td>' + escape_html(l.contact_name || '—') + '</td>'
+ '<td>' + escape_html(l.product_name || l.product_sku) + '<div class="small text-muted">' + escape_html(l.product_sku) + '</div></td>'
+ '<td class="text-end">' + format_number(l.quantity, 2) + '</td>'
+ '<td class="text-end">' + format_number(l.line_amount, 2) + '</td>'
+ '<td class="text-end">' + format_number(l.capitalized, 2) + assets + '</td>'
+ '<td class="text-end fw-semibold">' + format_number(l.remaining, 2) + '</td>'
+ '<td class="text-end">' + action + '</td>'
+ '</tr>';
});
$('#line_table tbody').html(html);
}
$('#show_capitalized').on('change', function() { change_page(1); });
$('#search_line').on('input', debounce(function() {
search_term = $(this).val().trim();
change_page(1);
}, 200));
$(function() {
var today = new Date();
var start = new Date(today.getFullYear(), 0, 1);
flatpickr('#date_from', { dateFormat: 'd/m/Y', allowInput: true, defaultDate: start });
flatpickr('#date_to', { dateFormat: 'd/m/Y', allowInput: true, defaultDate: today });
load_lines().catch(function(e) { console.log(e); });
});
</script>
</body>
</html>
-267
View File
@@ -1,267 +0,0 @@
<?php
session_start();
require '../config.php';
require '../include_header.php';
?>
<body>
<?php require '../include_topbar.php'; ?>
<?php require '../include_sidebar_asset.php'; ?>
<main id="content" class="content py-15">
<div class="container-fluid">
<!-- Page header -->
<div class="row">
<div class="col-12">
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center mb-6 gap-3">
<div>
<h1 class="fs-3 mb-1">Asset Management</h1>
<p class="mb-0">Fixed assets, depreciation and amortization</p>
</div>
<div class="d-flex gap-2">
<a href="<?php echo $server_url?>asset/purchase_invoices.php" class="btn btn-outline-primary">
<i class="ti ti-file-invoice me-1"></i>From Purchase Invoice
</a>
<a href="<?php echo $server_url?>asset/manage_asset.php" class="btn btn-primary">
<i class="ti ti-plus me-1"></i>New Asset
</a>
</div>
</div>
</div>
</div>
<!-- Summary cards -->
<div class="row g-5 mb-5">
<div class="col-lg-3 col-sm-6 col-12">
<div class="card p-4 bg-success bg-opacity-10 border border-success border-opacity-25 rounded-2">
<div class="d-flex gap-3">
<div class="icon-shape icon-md bg-success text-white rounded-2">
<i class="ti ti-building-warehouse fs-4"></i>
</div>
<div>
<h2 class="mb-3 fs-6">Assets in Use</h2>
<h3 class="fw-bold mb-0" id="stat_in_use">—</h3>
<p class="text-success mb-0 small" id="stat_in_use_note">&nbsp;</p>
</div>
</div>
</div>
</div>
<div class="col-lg-3 col-sm-6 col-12">
<div class="card p-4 bg-info bg-opacity-10 border border-info border-opacity-25 rounded-2">
<div class="d-flex gap-3">
<div class="icon-shape icon-md bg-info text-white rounded-2">
<i class="ti ti-cash fs-4"></i>
</div>
<div>
<h2 class="mb-3 fs-6">Cost</h2>
<h3 class="fw-bold mb-0" id="stat_cost">—</h3>
<p class="text-info mb-0 small">Assets in use</p>
</div>
</div>
</div>
</div>
<div class="col-lg-3 col-sm-6 col-12">
<div class="card p-4 bg-warning bg-opacity-10 border border-warning border-opacity-25 rounded-2">
<div class="d-flex gap-3">
<div class="icon-shape icon-md bg-warning text-white rounded-2">
<i class="ti ti-trending-down fs-4"></i>
</div>
<div>
<h2 class="mb-3 fs-6">Accumulated</h2>
<h3 class="fw-bold mb-0" id="stat_accumulated">—</h3>
<p class="text-warning mb-0 small">Depreciation + amortization</p>
</div>
</div>
</div>
</div>
<div class="col-lg-3 col-sm-6 col-12">
<div class="card p-4 bg-primary bg-opacity-10 border border-primary border-opacity-25 rounded-2">
<div class="d-flex gap-3">
<div class="icon-shape icon-md bg-primary text-white rounded-2">
<i class="ti ti-scale fs-4"></i>
</div>
<div>
<h2 class="mb-3 fs-6">Net Book Value</h2>
<h3 class="fw-bold mb-0" id="stat_book">—</h3>
<p class="text-primary mb-0 small">Cost minus accumulated</p>
</div>
</div>
</div>
</div>
</div>
<div class="row g-5 mb-5">
<!-- Due -->
<div class="col-lg-5">
<div class="card h-100">
<div class="card-body p-5">
<h2 class="fs-5 mb-1">Due this month</h2>
<p class="text-muted small mb-4">Months not yet charged, up to <span id="due_month"></span></p>
<div id="due_list"><div class="text-muted small">Loading...</div></div>
<div class="border-top pt-3 mt-3 small">
<span class="text-muted">Drafts waiting to be activated:</span>
<a href="<?php echo $server_url?>asset/index.php?status=0" class="fw-semibold ms-1" id="stat_draft">—</a>
</div>
</div>
</div>
</div>
<!-- Recent runs -->
<div class="col-lg-7">
<div class="card h-100">
<div class="card-body p-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="fs-5 mb-0">Recent runs</h2>
<a href="<?php echo $server_url?>asset/depreciation.php" class="small">All runs</a>
</div>
<div class="table-responsive">
<table class="table table-sm mb-0 table-centered">
<thead class="table-light">
<tr>
<th>Run No.</th>
<th>Month</th>
<th class="text-end">Amount</th>
<th>GL</th>
</tr>
</thead>
<tbody id="runs_tbody">
<tr><td colspan="4" class="text-center text-muted py-4">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- By category -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-body p-5">
<h2 class="fs-5 mb-4">Assets in use by category</h2>
<div class="table-responsive">
<table class="table mb-0 table-hover table-centered">
<thead class="table-primary border-light">
<tr>
<th>Category</th>
<th>Class</th>
<th class="text-end">Assets</th>
<th class="text-end">Cost</th>
<th class="text-end">Accumulated</th>
<th class="text-end">Net Book Value</th>
</tr>
</thead>
<tbody id="category_tbody">
<tr><td colspan="6" class="text-center text-muted py-4">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<?php require '../include_ending.php'; ?>
<script src="<?php echo $server_url?>assets/js/asset.js"></script>
<script>
function load_stats() {
return ajax_request({
url: server_url + 'asset/api/engine/asset_stats.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
onSuccess: function(res) {
var s = res.output || {};
$('#stat_in_use').text(parseInt(s.active || 0, 10) + parseInt(s.fully_depreciated || 0, 10));
$('#stat_in_use_note').text((s.fully_depreciated || 0) + ' fully depreciated · ' + (s.disposed || 0) + ' disposed');
$('#stat_cost').text(format_number(s.cost_in_use || 0, 2));
$('#stat_accumulated').text(format_number(s.accumulated_in_use || 0, 2));
$('#stat_book').text(format_number(s.book_value_in_use || 0, 2));
$('#stat_draft').text(s.draft || 0);
render_due(res.due || {});
render_categories(s.by_category || []);
}
});
}
function render_due(due) {
$('#due_month').text(current_period());
var html = '';
['tangible', 'intangible'].forEach(function(asset_class) {
var d = due[asset_class] || {};
var body = d.period_count > 0
? '<div class="fw-semibold">' + format_number(d.total, 2) + '</div>'
+ '<div class="small text-muted">' + d.period_count + ' month(s): ' + escape_html(d.from) + (d.to !== d.from ? ' → ' + escape_html(d.to) : '') + '</div>'
: '<div class="small text-success"><i class="ti ti-circle-check me-1"></i>Up to date</div>';
var button = d.period_count > 0
? '<a class="btn btn-sm btn-primary" href="' + server_url + 'asset/depreciation.php?class=' + asset_class + '">Run</a>'
: '';
html += '<div class="d-flex align-items-center justify-content-between py-2 border-bottom">'
+ '<div><div class="small text-muted">' + escape_html(d.label || asset_class) + '</div>' + body + '</div>'
+ button + '</div>';
});
$('#due_list').html(html);
}
function render_categories(rows) {
if (!rows.length) {
$('#category_tbody').html('<tr><td colspan="6" class="text-center text-muted py-4">No active assets yet.</td></tr>');
return;
}
var html = '';
rows.forEach(function(r) {
html += '<tr>'
+ '<td><span class="fw-semibold">' + escape_html(r.category_code) + '</span> ' + escape_html(r.category_name) + '</td>'
+ '<td>' + asset_class_badge(r.asset_class) + '</td>'
+ '<td class="text-end">' + r.asset_count + '</td>'
+ '<td class="text-end">' + format_number(r.cost, 2) + '</td>'
+ '<td class="text-end">' + format_number(r.accumulated, 2) + '</td>'
+ '<td class="text-end">' + format_number((parseFloat(r.cost) || 0) - (parseFloat(r.accumulated) || 0), 2) + '</td>'
+ '</tr>';
});
$('#category_tbody').html(html);
}
function load_runs() {
return ajax_request({
url: server_url + 'asset/api/engine/depreciation_run.php',
autoPrepare: true,
checkRequired: 0,
action: 'list',
onSuccess: function(res) {
var rows = (res.output || []).filter(function(r) { return String(r.status) === '1'; }).slice(0, 6);
if (!rows.length) {
$('#runs_tbody').html('<tr><td colspan="4" class="text-center text-muted py-4">No runs yet.</td></tr>');
return;
}
var html = '';
rows.forEach(function(r) {
html += '<tr>'
+ '<td>' + escape_html(r.run_number) + '</td>'
+ '<td>' + escape_html(r.period) + '</td>'
+ '<td class="text-end">' + format_number(r.total_amount, 2) + '</td>'
+ '<td>' + asset_gl_badge(r.gl_status) + '</td>'
+ '</tr>';
});
$('#runs_tbody').html(html);
}
});
}
$(async function() {
try {
await load_stats();
await load_runs();
} catch (e) { console.log(e); }
});
</script>
</body>
</html>
-7
View File
@@ -1,13 +1,6 @@
html, body { overflow-x: hidden; }
/* Soft badges (app access badges: WMS orange, Accounting blue, Asset Management green) */
.bg-label-primary { background-color: var(--bs-primary-bg-subtle) !important; color: var(--bs-primary-text-emphasis) !important; }
.bg-label-info { background-color: #e0f2fe !important; color: #0369a1 !important; }
.bg-label-success { background-color: #dcfce7 !important; color: #15803d !important; }
.bg-label-secondary,
.bg-label-dark { background-color: #e9ecef !important; color: #343a40 !important; }
.flatpickr-day.selected {
background: var(--bs-primary) !important;
border-color: var(--bs-primary) !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)
+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');
}
});
});
});
-74
View File
@@ -1,74 +0,0 @@
// Shared helpers for the Asset Management pages.
var ASSET_STATUS = {
0: ['Draft', 'bg-secondary'],
1: ['Active', 'bg-success'],
2: ['Fully depreciated', 'bg-info text-white'],
3: ['Disposed', 'bg-dark'],
4: ['Void', 'bg-light text-dark']
};
function asset_status_badge(status, asset_class) {
var code = parseInt(status, 10);
var entry = ASSET_STATUS[code] || ['—', 'bg-light text-dark'];
var label = (code === 2 && asset_class === 'intangible') ? 'Fully amortized' : entry[0];
return '<span class="badge ' + entry[1] + '">' + label + '</span>';
}
function asset_class_badge(asset_class) {
return asset_class === 'intangible'
? '<span class="badge bg-warning-subtle text-warning">Intangible · Amortization</span>'
: '<span class="badge bg-success-subtle text-success">Tangible · Depreciation</span>';
}
function asset_gl_badge(posted) {
return String(posted) === '1'
? '<span class="badge bg-success-subtle text-success">Posted</span>'
: '<span class="badge bg-danger-subtle text-danger">Unposted</span>';
}
// Active posting accounts from the chart of accounts.
function load_posting_accounts(onLoaded) {
return ajax_request({
url: server_url + 'accounting/api/engine/account.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
queueLock: false,
noLoading: true,
onSuccess: function(res) {
var rows = (res.output || []).filter(function(a) {
return String(a.is_posting) === '1' && String(a.status) === '1';
});
onLoaded(rows);
}
});
}
// <option> list for accounts, narrowed to account_types when any match.
function account_options(accounts, account_types, selected, empty_label) {
var list = accounts;
if (account_types && account_types.length) {
var narrowed = accounts.filter(function(a) { return account_types.indexOf(a.account_type) !== -1; });
if (narrowed.length) list = narrowed;
}
var html = '<option value="">' + escape_html(empty_label || '— Select account —') + '</option>';
var found = false;
list.forEach(function(a) {
var is_selected = String(a.account_code) === String(selected || '');
if (is_selected) found = true;
html += '<option value="' + escape_html(a.account_code) + '"' + (is_selected ? ' selected' : '') + '>'
+ escape_html(a.account_code + ' — ' + a.account_name) + '</option>';
});
// Keep a saved code visible even if it is no longer an active posting account.
if (selected && !found) {
html += '<option value="' + escape_html(selected) + '" selected>' + escape_html(selected) + ' (not active)</option>';
}
return html;
}
function current_period() {
var d = new Date();
return d.getFullYear() + '-' + ('0' + (d.getMonth() + 1)).slice(-2);
}
+61 -267
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();
@@ -581,9 +572,19 @@ function load_formula_options(select_id, document_type, selected_id, onLoaded) {
});
}
// 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,
@@ -638,242 +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;
}
// Session displaced — another login took over this account
if (xhr?.status === 401 && xhr?.responseJSON?.code === 'signed_elsewhere') {
bootbox.alert('<strong>Signed in from another device.</strong><br>Your session has been ended because this account was signed in elsewhere.', function() {
window.location.href = server_url + 'index.php';
});
return;
}
// File / payload too large (nginx 413)
if (xhr?.status === 413) {
bootbox.alert('The file you selected is too large. Please use an image under 2 MB and try again.');
options.onError?.(xhr, 'payload_too_large');
throw xhr;
}
// Usage limit reached — show upgrade notice instead of generic error
if (xhr?.status === 402) {
const d = xhr?.responseJSON ?? {};
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;
});
}
/** =========================
@@ -970,26 +735,6 @@ 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');
}
});
});
});
/**
@@ -1028,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;
@@ -1063,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 '--';
@@ -1174,7 +968,7 @@ function show_stock_rows(source, source_id, label) {
<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;
}
+10 -40
View File
@@ -2,51 +2,21 @@
// 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, badge colour and home
// page of each. Used by the app switcher, the login redirect and the Users
// Access page.
// 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. Configs written before Asset
// Management existed (every Docker-generated config.php among them) list fewer
// apps and have no 'home', so missing apps and keys are filled in from the
// defaults below. Without a registry the Add User dialog breaks
// 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', 'accounting', 'asset'
// Keys must stay within the user.app_access enum: 'wms' and 'accounting'
// ('all' is implied and never listed here).
$_default_app_registry = [
'wms' => ['label' => 'WMS', 'icon' => 'ti-box', 'color' => 'bg-label-primary', 'home' => 'dashboard/index.php'],
'accounting' => ['label' => 'Accounting', 'icon' => 'ti-calculator', 'color' => 'bg-label-info', 'home' => 'ac_dashboard/index.php'],
'asset' => ['label' => 'Asset Management', 'icon' => 'ti-building-warehouse', 'color' => 'bg-label-success', 'home' => 'asset_dashboard/index.php'],
];
if (!isset($app_registry) || !is_array($app_registry) || !$app_registry) {
$app_registry = $_default_app_registry;
} else {
foreach ($_default_app_registry as $_registry_key => $_registry_defaults) {
$app_registry[$_registry_key] = array_merge($_registry_defaults, $app_registry[$_registry_key] ?? []);
}
}
unset($_default_app_registry, $_registry_key, $_registry_defaults);
if (!function_exists('app_can_access')) {
/** 'all' opens every app. */
function app_can_access(string $access, string $app): bool
{
return $access === 'all' || $access === $app;
}
/** The app a user lands in: their single app, or WMS for 'all'. */
function app_default_for_access(array $registry, string $access): string
{
return ($access !== 'all' && isset($registry[$access])) ? $access : 'wms';
}
/** An app's home page, relative to $server_url. */
function app_home_path(array $registry, string $app): string
{
return $registry[$app]['home'] ?? 'dashboard/index.php';
}
$app_registry = [
'wms' => ['label' => 'WMS', 'icon' => 'ti-box', 'color' => 'bg-label-primary'],
'accounting' => ['label' => 'Accounting', 'icon' => 'ti-calculator', 'color' => 'bg-label-success'],
];
}
@@ -33,7 +33,6 @@ class DocumentNumberManager
'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],
'asset' => ['name' => 'Fixed Asset', 'prefix' => 'FA', 'format_type' => 'YYMM', 'sequence_digits' => 5, 'allow_manual' => 0],
];
public function __construct(PDO $pdo, int $company_id)
@@ -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;
}
}
+88 -7
View File
@@ -403,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]);
@@ -428,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),
@@ -525,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) ?: [];
@@ -852,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,
@@ -873,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),
]);
+34 -9
View File
@@ -1,5 +1,6 @@
<?php
require_once __DIR__ . '/DocumentNumberManager.php';
require_once __DIR__ . '/DocumentValidator.php';
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
/**
@@ -260,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);
@@ -403,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
@@ -471,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.");
@@ -485,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;
@@ -493,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]);
@@ -502,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.");
@@ -541,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,
@@ -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']
);
}
}
+3 -3
View File
@@ -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.bin AS UNSIGNED), r.bin"
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);
@@ -1,6 +1,8 @@
<?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';
/**
@@ -194,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);
@@ -321,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))) {
@@ -331,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.");
@@ -339,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) {
@@ -572,7 +582,16 @@ class PurchaseOrderManager {
$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.");
@@ -597,6 +616,16 @@ class PurchaseOrderManager {
$zone = $recv['zone'] ?? '';
$aisle = $recv['aisle'] ?? '';
// 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).
// occupyBin() looks up md_bin WHERE zone=:zone AND aisle=:aisle AND bin=:bin,
@@ -1,5 +1,6 @@
<?php
require_once __DIR__ . '/DocumentNumberManager.php';
require_once __DIR__ . '/DocumentValidator.php';
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
/**
@@ -94,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
@@ -160,12 +164,22 @@ 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) {
+26 -3
View File
@@ -1,5 +1,6 @@
<?php
require_once __DIR__ . '/DocumentNumberManager.php';
require_once __DIR__ . '/DocumentValidator.php';
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
/**
@@ -89,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
@@ -170,8 +174,27 @@ 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);
+127 -51
View File
@@ -35,6 +35,8 @@ class ReportManager
// Private helpers
// ─────────────────────────────────────────────────────────────
private ?array $transfer_totals = null;
private function stockTableNameFromWarehouseId(int $warehouse_id): string
{
if ($warehouse_id <= 0) {
@@ -45,6 +47,61 @@ class ReportManager
}
/**
* 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(
@@ -297,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
@@ -318,13 +367,20 @@ class ReportManager
WHERE company_id = :company_id"
);
$sth->execute([':company_id' => $this->company_id]);
return $sth->fetch(PDO::FETCH_ASSOC) ?: ['total_in' => 0, 'total_out' => 0];
$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"
@@ -400,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
@@ -420,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);
@@ -498,9 +563,13 @@ class ReportManager
AND month = :month"
);
$sth->execute([':company_id' => $this->company_id, ':month' => $month]);
return $sth->fetch(PDO::FETCH_ASSOC) ?: [
$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;
}
/**
@@ -568,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;
}
@@ -611,15 +683,22 @@ 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->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);
}
/**
@@ -668,8 +747,8 @@ class ReportManager
$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
@@ -677,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
));
@@ -691,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'],
@@ -771,25 +853,10 @@ class ReportManager
*/
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 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
AND wb.warehouse_id = :warehouse_id
GROUP BY wb.product_sku, mp.min_stock"
);
$sth->execute([':company_id' => $this->company_id, ':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
));
}
/**
@@ -1181,16 +1248,19 @@ class ReportManager
$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'];
}
}
@@ -1217,16 +1287,22 @@ class ReportManager
$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++;
@@ -1324,9 +1400,9 @@ 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.bin AS UNSIGNED), r.bin"
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);
+31 -3
View File
@@ -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
@@ -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
+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);
}
}
?>
+126 -21
View File
@@ -26,6 +26,18 @@ require_once __DIR__ . '/../notify_node.php';
*/
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;
@@ -56,6 +68,39 @@ class StockManager {
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'))";
@@ -72,36 +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);
// 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).
$column = in_array($type, ['out', 'transfer'], true) ? 'ROUND(a.out, 2)' : 'ROUND(a.in, 2)';
// 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.*, {$stock_ref} AS stock_reference, {$column} AS quantity, b.product_name, b.uom
FROM `{$table}` a
LEFT JOIN md_product b
ON a.company_id = b.company_id
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));
}
/**
@@ -205,7 +307,10 @@ 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'];
@@ -258,8 +363,8 @@ class StockManager {
$warehouse_id = (int)($data["warehouse"] ?? 0);
$quantity = (float)($data['quantity'] ?? 0);
if ($id === 0 && $quantity <= 0) {
throw new Exception("Quantity must be greater than zero.");
if ($id === 0) {
$quantity = self::normaliseQuantity($quantity);
}
$whMgmt = new WarehouseManager($this->pdo, $this->company_id);
@@ -154,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
+1 -1
View File
@@ -426,7 +426,7 @@ class UserManager {
* within the owner's license.
*
* @param int $map_id The company_map_user.map_id to update.
* @param string $app_access New value: 'wms', 'accounting', 'asset', or 'all'.
* @param string $app_access New value: 'wms', 'accounting', or 'all'.
* @throws Exception If the member is not found or is the owner.
*/
public function updateAppAccess(int $map_id, string $app_access): void {
+11 -11
View File
@@ -1035,7 +1035,7 @@ class WarehouseManager {
$sth = $this->pdo->prepare(
"SELECT DISTINCT zone FROM md_bin
WHERE company_id = :company_id AND warehouse = :warehouse
ORDER BY CAST(zone AS UNSIGNED), zone"
ORDER BY REGEXP_REPLACE(zone, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(zone, '[0-9]+$') AS UNSIGNED), zone"
);
$sth->execute([':company_id' => $this->company_id, ':warehouse' => $warehouse_id]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
@@ -1055,7 +1055,7 @@ class WarehouseManager {
$sth = $this->pdo->prepare(
"SELECT DISTINCT aisle FROM md_bin
WHERE company_id = :company_id AND warehouse = :warehouse AND zone = :zone
ORDER BY CAST(aisle AS UNSIGNED), aisle"
ORDER BY REGEXP_REPLACE(aisle, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(aisle, '[0-9]+$') AS UNSIGNED), aisle"
);
$sth->execute([':company_id' => $this->company_id, ':warehouse' => $warehouse_id, ':zone' => $zone]);
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'aisle');
@@ -1077,7 +1077,7 @@ class WarehouseManager {
$sth = $this->pdo->prepare(
"SELECT DISTINCT bin FROM md_bin
WHERE company_id = :company_id AND warehouse = :warehouse
ORDER BY CAST(bin AS UNSIGNED), bin"
ORDER BY REGEXP_REPLACE(bin, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(bin, '[0-9]+$') AS UNSIGNED), bin"
);
$sth->execute([':company_id' => $this->company_id, ':warehouse' => $warehouse_id]);
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'bin');
@@ -1086,7 +1086,7 @@ class WarehouseManager {
$sth = $this->pdo->prepare(
"SELECT DISTINCT bin FROM md_bin
WHERE company_id = :company_id AND warehouse = :warehouse AND zone = :zone AND aisle = :aisle
ORDER BY CAST(bin AS UNSIGNED), bin"
ORDER BY REGEXP_REPLACE(bin, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(bin, '[0-9]+$') AS UNSIGNED), bin"
);
$sth->execute([':company_id' => $this->company_id, ':warehouse' => $warehouse_id, ':zone' => $zone, ':aisle' => $aisle]);
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'bin');
@@ -1915,7 +1915,7 @@ class WarehouseManager {
WHERE company_id = :company_id
AND warehouse = :warehouse
AND product_sku IS NULL
ORDER BY CAST(zone AS UNSIGNED), zone"
ORDER BY REGEXP_REPLACE(zone, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(zone, '[0-9]+$') AS UNSIGNED), zone"
);
$sth->execute([':company_id' => $this->company_id, ':warehouse' => $warehouse_id]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
@@ -1961,7 +1961,7 @@ class WarehouseManager {
{$lot_cond}
{$serial_cond}
)
ORDER BY CAST(r.zone AS UNSIGNED), r.zone"
ORDER BY REGEXP_REPLACE(r.zone, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(r.zone, '[0-9]+$') AS UNSIGNED), r.zone"
);
$sth->execute($params);
return $sth->fetchAll(PDO::FETCH_ASSOC);
@@ -1982,7 +1982,7 @@ class WarehouseManager {
AND warehouse = :warehouse
AND zone = :zone
AND product_sku IS NULL
ORDER BY CAST(aisle AS UNSIGNED), aisle"
ORDER BY REGEXP_REPLACE(aisle, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(aisle, '[0-9]+$') AS UNSIGNED), aisle"
);
$sth->execute([
':company_id' => $this->company_id,
@@ -2033,7 +2033,7 @@ class WarehouseManager {
{$lot_cond}
{$serial_cond}
)
ORDER BY CAST(r.aisle AS UNSIGNED), r.aisle"
ORDER BY REGEXP_REPLACE(r.aisle, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(r.aisle, '[0-9]+$') AS UNSIGNED), r.aisle"
);
$sth->execute($params);
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'aisle');
@@ -2055,7 +2055,7 @@ class WarehouseManager {
WHERE company_id = :company_id
AND warehouse = :warehouse
AND product_sku IS NULL
ORDER BY CAST(bin AS UNSIGNED), bin"
ORDER BY REGEXP_REPLACE(bin, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(bin, '[0-9]+$') AS UNSIGNED), bin"
);
$sth->execute([
':company_id' => $this->company_id,
@@ -2071,7 +2071,7 @@ class WarehouseManager {
AND zone = :zone
AND aisle = :aisle
AND product_sku IS NULL
ORDER BY CAST(bin AS UNSIGNED), bin"
ORDER BY REGEXP_REPLACE(bin, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(bin, '[0-9]+$') AS UNSIGNED), bin"
);
$sth->execute([
':company_id' => $this->company_id,
@@ -2130,7 +2130,7 @@ class WarehouseManager {
{$lot_cond}
{$serial_cond}
)
ORDER BY CAST(r.bin AS UNSIGNED), r.bin"
ORDER BY REGEXP_REPLACE(r.bin, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(r.bin, '[0-9]+$') AS UNSIGNED), r.bin"
);
$sth->execute($params);
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'bin');
+5 -1
View File
@@ -81,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"
+3 -21
View File
@@ -1,6 +1,4 @@
<?php
require_once __DIR__ . '/../classes_as/AssetPosting.php';
class GlQueryManager
{
private PDO $pdo;
@@ -8,10 +6,8 @@ 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',
'asset_capitalization', 'asset_depreciation', 'asset_amortization', 'asset_disposal'];
private array $journalTypes = ['invoice', 'credit_note', 'purchase_invoice', 'supplier_credit_note', 'receipt', 'payment', 'manual', 'purchase_order', 'reversal',
'asset_capitalization', 'asset_depreciation', 'asset_amortization', 'asset_disposal'];
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', 'reversal'];
public function __construct(PDO $pdo, int $company_id)
{
@@ -29,11 +25,6 @@ class GlQueryManager
throw new Exception('Invalid doc_type.');
}
// Asset Management sources have no formula; accounts come from the asset category.
if (AssetPosting::isAssetSource($doc_type)) {
return (new AssetPosting($this->pdo, $this->companyId))->getPostableDocuments($doc_type, $date_from, $date_to);
}
if (in_array($doc_type, $this->invoiceTypes, true)) {
$rows = $this->getInvoicePostableDocuments($doc_type, $date_from, $date_to, $formula_id);
} elseif ($doc_type === 'receipt') {
@@ -91,10 +82,6 @@ class GlQueryManager
WHEN 'payment' THEN p.payment_number
WHEN 'manual' THEN IF(g.reference != '', g.reference, CONCAT('MJE-', g.id))
WHEN 'purchase_order' THEN po.po_number
WHEN 'asset_capitalization' THEN g.reference
WHEN 'asset_depreciation' THEN g.reference
WHEN 'asset_amortization' THEN g.reference
WHEN 'asset_disposal' THEN g.reference
ELSE i.invoice_number
END AS doc_number,
COALESCE(
@@ -103,10 +90,6 @@ class GlQueryManager
WHEN 'payment' THEN cp.contact_name
WHEN 'manual' THEN g.description
WHEN 'purchase_order' THEN cpo.contact_name
WHEN 'asset_capitalization' THEN g.description
WHEN 'asset_depreciation' THEN g.description
WHEN 'asset_amortization' THEN g.description
WHEN 'asset_disposal' THEN g.description
ELSE ci.contact_name
END, ''
) AS contact_name,
@@ -118,8 +101,7 @@ class GlQueryManager
LEFT JOIN td_gl_item gi
ON gi.company_id = g.company_id AND gi.gl_id = g.id
LEFT JOIN td_invoice i
ON g.source_type NOT IN ('receipt','payment','manual','purchase_order',
'asset_capitalization','asset_depreciation','asset_amortization','asset_disposal')
ON g.source_type NOT IN ('receipt','payment','manual','purchase_order')
AND i.company_id = g.company_id AND i.id = g.source_id
LEFT JOIN md_contact ci
ON ci.company_id = g.company_id AND ci.id = i.contact_id
@@ -1,173 +0,0 @@
<?php
/**
* AssetCategoryManager
*
* Asset categories decide the asset class (tangible = depreciation,
* intangible = amortization), the default useful life, and every GL account
* the Asset Management postings use.
*/
class AssetCategoryManager
{
public const CLASSES = ['tangible', 'intangible'];
public const ACCOUNT_FIELDS = [
'cost_account_code' => 'Asset cost account',
'accum_account_code' => 'Accumulated depreciation / amortization account',
'expense_account_code' => 'Depreciation / amortization expense account',
'gain_account_code' => 'Gain on disposal account',
'loss_account_code' => 'Loss on disposal account',
];
private PDO $pdo;
private int $companyId;
public function __construct(PDO $pdo, int $company_id)
{
$this->pdo = $pdo;
$this->companyId = $company_id;
}
public static function isPostingAccount(PDO $pdo, int $company_id, string $account_code): bool
{
if ($account_code === '') return false;
$sth = $pdo->prepare(
"SELECT COUNT(*) FROM md_account
WHERE company_id = :cid AND account_code = :code AND is_posting = 1 AND status = 1"
);
$sth->execute([':cid' => $company_id, ':code' => $account_code]);
return (int)$sth->fetchColumn() > 0;
}
public function getAll(): array
{
$sth = $this->pdo->prepare(
"SELECT c.*, COUNT(a.id) AS asset_count
FROM md_asset_category c
LEFT JOIN td_asset a
ON a.company_id = c.company_id
AND a.category_id = c.id
AND a.status <> 4
WHERE c.company_id = :cid
GROUP BY c.id
ORDER BY c.category_code ASC"
);
$sth->execute([':cid' => $this->companyId]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
public function getById(int $id): ?array
{
$sth = $this->pdo->prepare(
"SELECT * FROM md_asset_category WHERE company_id = :cid AND id = :id LIMIT 1"
);
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
return $sth->fetch(PDO::FETCH_ASSOC) ?: null;
}
public function save(array $data): int
{
$id = (int)($data['id'] ?? 0);
$code = strtoupper(trim((string)($data['category_code'] ?? '')));
$name = trim((string)($data['category_name'] ?? ''));
$class = trim((string)($data['asset_class'] ?? ''));
$life = (int)($data['useful_life_months'] ?? 0);
if ($code === '' || $name === '') throw new Exception('Category code and name are required.');
if (!in_array($class, self::CLASSES, true)) throw new Exception('Select tangible or intangible.');
if ($life < 1 || $life > 1200) throw new Exception('Default useful life must be between 1 and 1200 months.');
$accounts = [];
foreach (self::ACCOUNT_FIELDS as $field => $label) {
$account_code = trim((string)($data[$field] ?? ''));
if ($account_code === '') throw new Exception("{$label} is required.");
if (!self::isPostingAccount($this->pdo, $this->companyId, $account_code)) {
throw new Exception("{$label} {$account_code} is not an active posting account.");
}
$accounts[$field] = $account_code;
}
$sth = $this->pdo->prepare(
"SELECT COUNT(*) FROM md_asset_category
WHERE company_id = :cid AND category_code = :code AND id <> :id"
);
$sth->execute([':cid' => $this->companyId, ':code' => $code, ':id' => $id]);
if ((int)$sth->fetchColumn() > 0) throw new Exception("Category code {$code} already exists.");
$params = array_merge([
':cid' => $this->companyId,
':code' => $code,
':name' => $name,
':class' => $class,
':life' => $life,
':desc' => trim((string)($data['description'] ?? '')),
':status' => (int)($data['status'] ?? 1) === 1 ? 1 : 0,
], [
':cost_acc' => $accounts['cost_account_code'],
':accum_acc' => $accounts['accum_account_code'],
':expense_acc' => $accounts['expense_account_code'],
':gain_acc' => $accounts['gain_account_code'],
':loss_acc' => $accounts['loss_account_code'],
]);
if ($id) {
$existing = $this->getById($id);
if (!$existing) throw new Exception('Category not found.');
if ($existing['asset_class'] !== $class && $this->countAssets($id) > 0) {
throw new Exception('The asset class cannot change once assets use this category.');
}
$params[':id'] = $id;
$this->pdo->prepare(
"UPDATE md_asset_category SET
category_code = :code, category_name = :name, asset_class = :class,
useful_life_months = :life, description = :desc, status = :status,
cost_account_code = :cost_acc, accum_account_code = :accum_acc,
expense_account_code = :expense_acc, gain_account_code = :gain_acc,
loss_account_code = :loss_acc, updated_at = NOW()
WHERE company_id = :cid AND id = :id"
)->execute($params);
return $id;
}
$this->pdo->prepare(
"INSERT INTO md_asset_category
(company_id, category_code, category_name, asset_class, useful_life_months, description, status,
cost_account_code, accum_account_code, expense_account_code, gain_account_code, loss_account_code,
created_at)
VALUES
(:cid, :code, :name, :class, :life, :desc, :status,
:cost_acc, :accum_acc, :expense_acc, :gain_acc, :loss_acc,
NOW())"
)->execute($params);
return (int)$this->pdo->lastInsertId();
}
/** Categories are only deactivated: existing assets keep their accounts. */
public function deactivate(int $id): void
{
$this->pdo->prepare(
"UPDATE md_asset_category SET status = 0, updated_at = NOW() WHERE company_id = :cid AND id = :id"
)->execute([':cid' => $this->companyId, ':id' => $id]);
}
public function getStats(): array
{
$sth = $this->pdo->prepare(
"SELECT COUNT(*) AS total,
COALESCE(SUM(status = 1), 0) AS active,
COALESCE(SUM(status = 0), 0) AS inactive
FROM md_asset_category WHERE company_id = :cid"
);
$sth->execute([':cid' => $this->companyId]);
return $sth->fetch(PDO::FETCH_ASSOC) ?: [];
}
private function countAssets(int $category_id): int
{
$sth = $this->pdo->prepare(
"SELECT COUNT(*) FROM td_asset WHERE company_id = :cid AND category_id = :id AND status <> 4"
);
$sth->execute([':cid' => $this->companyId, ':id' => $category_id]);
return (int)$sth->fetchColumn();
}
}
@@ -1,596 +0,0 @@
<?php
require_once __DIR__ . '/DepreciationCalculator.php';
require_once __DIR__ . '/AssetCategoryManager.php';
require_once __DIR__ . '/../classes/DocumentNumberManager.php';
/**
* AssetManager
*
* The asset register: create (manually or from a purchase invoice line),
* activate, void, dispose of, and undo a disposal.
*
* Status: 0 draft, 1 active, 2 fully depreciated, 3 disposed, 4 void.
* GL entries are not posted here — Accounting posts them from Batch GL Entries.
* Voiding reverses entries that were already posted.
*/
class AssetManager
{
public const STATUS_DRAFT = 0;
public const STATUS_ACTIVE = 1;
public const STATUS_FULLY = 2;
public const STATUS_DISPOSED = 3;
public const STATUS_VOID = 4;
private PDO $pdo;
private int $companyId;
public function __construct(PDO $pdo, int $company_id)
{
$this->pdo = $pdo;
$this->companyId = $company_id;
}
// ── read ─────────────────────────────────────────────────────────────────
public function getList(int $invoice_id = 0): array
{
$where = ['a.company_id = :cid'];
$params = [':cid' => $this->companyId, ':cid_d' => $this->companyId];
if ($invoice_id > 0) {
$where[] = 'a.invoice_id = :invoice_id';
$params[':invoice_id'] = $invoice_id;
}
$sth = $this->pdo->prepare($this->baseSelect() . ' WHERE ' . implode(' AND ', $where) . ' ORDER BY a.id DESC');
$sth->execute($params);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
public function getById(int $id): ?array
{
$sth = $this->pdo->prepare($this->baseSelect() . ' WHERE a.company_id = :cid AND a.id = :id LIMIT 1');
$sth->execute([':cid' => $this->companyId, ':cid_d' => $this->companyId, ':id' => $id]);
return $sth->fetch(PDO::FETCH_ASSOC) ?: null;
}
/** Asset, month-by-month schedule (recorded + projected) and its GL entries. */
public function getDetail(int $id): array
{
$asset = $this->getById($id);
if (!$asset) throw new Exception('Asset not found.');
$sth = $this->pdo->prepare(
"SELECT d.period, d.amount, d.accumulated_after, d.book_value_after, d.run_id,
COALESCE(r.run_number, '') AS run_number
FROM td_asset_depreciation d
LEFT JOIN td_asset_run r ON r.company_id = d.company_id AND r.id = d.run_id
WHERE d.company_id = :cid AND d.asset_id = :id
ORDER BY d.period ASC"
);
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
$recorded = [];
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
$recorded[$row['period']] = $row;
}
$cost = (float)$asset['cost'];
$salvage = (float)$asset['salvage_value'];
$life = (int)$asset['useful_life_months'];
$disposal_period = $asset['disposal_date'] ? DepreciationCalculator::periodOf($asset['disposal_date']) : '';
$schedule = [];
if ($asset['in_service_date'] && $life > 0 && $cost > $salvage) {
foreach (DepreciationCalculator::schedule($cost, $salvage, $life, $asset['in_service_date']) as $row) {
if (isset($recorded[$row['period']])) {
$r = $recorded[$row['period']];
$row = [
'period' => $row['period'],
'amount' => (float)$r['amount'],
'accumulated' => (float)$r['accumulated_after'],
'book_value' => (float)$r['book_value_after'],
'state' => 'recorded',
'run_id' => (int)$r['run_id'],
'run_number' => $r['run_number'],
];
} else {
$stopped = $disposal_period !== '' && $row['period'] >= $disposal_period;
$row['state'] = $stopped || (int)$asset['status'] === self::STATUS_VOID ? 'stopped' : 'projected';
}
$schedule[] = $row;
}
}
return [
'asset' => $asset,
'schedule' => $schedule,
'monthly_amount' => $life > 0 ? DepreciationCalculator::monthlyAmount($cost, $salvage, $life) : 0,
'gl' => [
'capitalization' => $this->glEntry('asset_capitalization', $id),
'disposal' => $this->glEntry('asset_disposal', $id),
],
];
}
/**
* One purchase invoice line, the part of it already capitalized, and the
* account its purchase posting debited (the default clearing account).
*/
public function getInvoiceLine(int $invoice_id, int $item_id, int $exclude_asset_id = 0): array
{
$sth = $this->pdo->prepare(
"SELECT i.id AS invoice_id, i.invoice_number, i.issued_date, i.contact_id, i.department_id,
i.status AS invoice_status, i.formula_id, i.grand_total, i.tax,
COALESCE(c.contact_name, '') AS contact_name,
it.id AS item_id, it.product_sku, it.product_name, it.quantity, it.unit_price, it.total_price,
COALESCE(NULLIF(p.purchase_account_code, ''), '') AS product_account_code
FROM td_invoice i
JOIN td_invoice_item it
ON it.company_id = i.company_id AND it.invoice_id = i.id
LEFT JOIN md_contact c
ON c.company_id = i.company_id AND c.id = i.contact_id
LEFT JOIN md_product p
ON p.company_id = it.company_id AND p.sku = it.product_sku
WHERE i.company_id = :cid AND i.id = :invoice_id AND it.id = :item_id
AND i.doc_type = 'purchase_invoice'
LIMIT 1"
);
$sth->execute([':cid' => $this->companyId, ':invoice_id' => $invoice_id, ':item_id' => $item_id]);
$line = $sth->fetch(PDO::FETCH_ASSOC);
if (!$line) throw new Exception('Purchase invoice line not found.');
if (!in_array((int)$line['invoice_status'], [1, 2], true)) {
throw new Exception('Only issued or paid purchase invoices can be turned into assets.');
}
$line['line_amount'] = $this->postedLineAmount($invoice_id, (float)$line['total_price'], (float)$line['grand_total'], (float)$line['tax']);
$sth = $this->pdo->prepare(
"SELECT COALESCE(SUM(cost), 0) FROM td_asset
WHERE company_id = :cid AND invoice_item_id = :item_id AND status <> 4 AND id <> :exclude"
);
$sth->execute([':cid' => $this->companyId, ':item_id' => $item_id, ':exclude' => $exclude_asset_id]);
$line['capitalized'] = round((float)$sth->fetchColumn(), 2);
$line['remaining'] = round($line['line_amount'] - $line['capitalized'], 2);
$line['clearing_account_code'] = $line['product_account_code'] !== ''
? $line['product_account_code']
: $this->formulaTotalAccount((int)$line['formula_id']);
return $line;
}
/** Issued purchase invoice lines with how much of each has been capitalized. */
public function getCapitalizableLines(string $date_from = '', string $date_to = ''): array
{
$where = ['i.company_id = :cid', "i.doc_type = 'purchase_invoice'", 'i.status IN (1, 2)'];
$params = [':cid' => $this->companyId, ':cid_s' => $this->companyId, ':cid_x' => $this->companyId];
if ($date_from = $this->parseDate($date_from)) { $where[] = 'i.issued_date >= :date_from'; $params[':date_from'] = $date_from; }
if ($date_to = $this->parseDate($date_to)) { $where[] = 'i.issued_date <= :date_to'; $params[':date_to'] = $date_to; }
$sth = $this->pdo->prepare(
"SELECT i.id AS invoice_id, i.invoice_number, i.issued_date, i.status AS invoice_status,
i.grand_total, i.tax, s.sum_total,
COALESCE(c.contact_name, '') AS contact_name,
it.id AS item_id, it.product_sku, it.product_name, it.quantity, it.total_price,
COALESCE(x.capitalized, 0) AS capitalized,
COALESCE(x.asset_count, 0) AS asset_count
FROM td_invoice i
JOIN td_invoice_item it
ON it.company_id = i.company_id AND it.invoice_id = i.id
JOIN (SELECT invoice_id, SUM(ABS(total_price)) AS sum_total
FROM td_invoice_item WHERE company_id = :cid_s GROUP BY invoice_id) s
ON s.invoice_id = i.id
LEFT JOIN md_contact c
ON c.company_id = i.company_id AND c.id = i.contact_id
LEFT JOIN (SELECT invoice_item_id, SUM(cost) AS capitalized, COUNT(*) AS asset_count
FROM td_asset WHERE company_id = :cid_x AND status <> 4 GROUP BY invoice_item_id) x
ON x.invoice_item_id = it.id
WHERE " . implode(' AND ', $where) . "
ORDER BY i.issued_date DESC, i.id DESC, it.item_id ASC"
);
$sth->execute($params);
$rows = [];
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
$sum = (float)$row['sum_total'];
$row['line_amount'] = $sum > 0
? round(((float)$row['grand_total'] - (float)$row['tax']) * abs((float)$row['total_price']) / $sum, 2)
: 0.0;
$row['capitalized'] = round((float)$row['capitalized'], 2);
$row['remaining'] = round($row['line_amount'] - $row['capitalized'], 2);
unset($row['grand_total'], $row['tax'], $row['sum_total']);
$rows[] = $row;
}
return $rows;
}
public function getStats(): array
{
$sth = $this->pdo->prepare(
"SELECT COUNT(*) AS total,
COALESCE(SUM(a.status = 0), 0) AS draft,
COALESCE(SUM(a.status = 1), 0) AS active,
COALESCE(SUM(a.status = 2), 0) AS fully_depreciated,
COALESCE(SUM(a.status = 3), 0) AS disposed,
COALESCE(SUM(IF(a.status IN (1, 2), a.cost, 0)), 0) AS cost_in_use,
COALESCE(SUM(IF(a.status IN (1, 2), COALESCE(d.accumulated, 0), 0)), 0) AS accumulated_in_use
FROM td_asset a
LEFT JOIN (SELECT asset_id, SUM(amount) AS accumulated
FROM td_asset_depreciation WHERE company_id = :cid_d GROUP BY asset_id) d
ON d.asset_id = a.id
WHERE a.company_id = :cid AND a.status <> 4"
);
$sth->execute([':cid' => $this->companyId, ':cid_d' => $this->companyId]);
$stats = $sth->fetch(PDO::FETCH_ASSOC) ?: [];
$stats['book_value_in_use'] = round((float)($stats['cost_in_use'] ?? 0) - (float)($stats['accumulated_in_use'] ?? 0), 2);
$sth = $this->pdo->prepare(
"SELECT c.id, c.category_code, c.category_name, c.asset_class,
COUNT(a.id) AS asset_count,
COALESCE(SUM(a.cost), 0) AS cost,
COALESCE(SUM(COALESCE(d.accumulated, 0)), 0) AS accumulated
FROM td_asset a
JOIN md_asset_category c
ON c.company_id = a.company_id AND c.id = a.category_id
LEFT JOIN (SELECT asset_id, SUM(amount) AS accumulated
FROM td_asset_depreciation WHERE company_id = :cid_d GROUP BY asset_id) d
ON d.asset_id = a.id
WHERE a.company_id = :cid AND a.status IN (1, 2)
GROUP BY c.id, c.category_code, c.category_name, c.asset_class
ORDER BY c.category_code ASC"
);
$sth->execute([':cid' => $this->companyId, ':cid_d' => $this->companyId]);
$stats['by_category'] = $sth->fetchAll(PDO::FETCH_ASSOC);
return $stats;
}
// ── write ────────────────────────────────────────────────────────────────
public function save(array $data, int $user_id): int
{
$id = (int)($data['id'] ?? 0);
$existing = $id ? $this->getById($id) : null;
if ($id && !$existing) throw new Exception('Asset not found.');
if ($existing && in_array((int)$existing['status'], [self::STATUS_DISPOSED, self::STATUS_VOID], true)) {
throw new Exception('Disposed or void assets cannot be edited.');
}
$name = trim((string)($data['asset_name'] ?? ''));
if ($name === '') throw new Exception('Asset name is required.');
$description = trim((string)($data['description'] ?? ''));
$department_id = (int)($data['department_id'] ?? 0);
// Once a month has been charged, the numbers behind the schedule are fixed.
if ($existing && $this->hasDepreciation($id)) {
$this->pdo->prepare(
"UPDATE td_asset SET asset_name = :name, description = :desc, department_id = :dept, updated_at = NOW()
WHERE company_id = :cid AND id = :id"
)->execute([':name' => $name, ':desc' => $description, ':dept' => $department_id, ':cid' => $this->companyId, ':id' => $id]);
return $id;
}
$category = (new AssetCategoryManager($this->pdo, $this->companyId))->getById((int)($data['category_id'] ?? 0));
if (!$category) throw new Exception('Select an asset category.');
$same_category = $existing && (int)$existing['category_id'] === (int)$category['id'];
if ((int)$category['status'] !== 1 && !$same_category) throw new Exception('The selected category is inactive.');
$cost = round((float)($data['cost'] ?? 0), 2);
$salvage = round((float)($data['salvage_value'] ?? 0), 2);
$life = (int)($data['useful_life_months'] ?? 0);
DepreciationCalculator::assertInputs($cost, $salvage, $life);
$acquisition = $this->parseDate((string)($data['acquisition_date'] ?? ''));
$in_service = $this->parseDate((string)($data['in_service_date'] ?? '')) ?: $acquisition;
if ($acquisition === '') throw new Exception('Acquisition date is required.');
if ($in_service < $acquisition) throw new Exception('The in-service date cannot be before the acquisition date.');
$clearing = trim((string)($data['clearing_account_code'] ?? ''));
if ($clearing !== '' && !AssetCategoryManager::isPostingAccount($this->pdo, $this->companyId, $clearing)) {
throw new Exception("Clearing account {$clearing} is not an active posting account.");
}
$invoice_id = $existing ? (int)$existing['invoice_id'] : (int)($data['invoice_id'] ?? 0);
$item_id = $existing ? (int)$existing['invoice_item_id'] : (int)($data['invoice_item_id'] ?? 0);
$contact_id = $existing ? (int)$existing['contact_id'] : 0;
$source = 'manual';
if ($item_id > 0) {
$line = $this->getInvoiceLine($invoice_id, $item_id, $id);
if ($cost > $line['remaining'] + 0.004) {
throw new Exception('Cost is more than the part of this invoice line not yet capitalized (' . number_format($line['remaining'], 2) . ').');
}
if ($clearing === '') {
throw new Exception('Assets from a purchase invoice need the clearing account the invoice was posted to.');
}
$contact_id = (int)$line['contact_id'];
$source = 'purchase_invoice';
}
$params = [
':cid' => $this->companyId,
':name' => $name,
':category_id' => (int)$category['id'],
':class' => $category['asset_class'],
':desc' => $description,
':dept' => $department_id,
':acquisition' => $acquisition,
':in_service' => $in_service,
':cost' => $cost,
':salvage' => $salvage,
':life' => $life,
':clearing' => $clearing,
];
if ($existing) {
$params[':id'] = $id;
$this->pdo->prepare(
"UPDATE td_asset SET
asset_name = :name, category_id = :category_id, asset_class = :class, description = :desc,
department_id = :dept, acquisition_date = :acquisition, in_service_date = :in_service,
cost = :cost, salvage_value = :salvage, useful_life_months = :life,
clearing_account_code = :clearing, updated_at = NOW()
WHERE company_id = :cid AND id = :id"
)->execute($params);
return $id;
}
$params += [
':number' => (new DocumentNumberManager($this->pdo, $this->companyId))->generate('asset'),
':source' => $source,
':invoice_id' => $invoice_id,
':item_id' => $item_id,
':contact_id' => $contact_id,
':user_id' => $user_id,
];
$this->pdo->prepare(
"INSERT INTO td_asset
(company_id, asset_number, asset_name, category_id, asset_class, description, source,
invoice_id, invoice_item_id, contact_id, department_id, acquisition_date, in_service_date,
cost, salvage_value, useful_life_months, clearing_account_code, status, created_by, created_at)
VALUES
(:cid, :number, :name, :category_id, :class, :desc, :source,
:invoice_id, :item_id, :contact_id, :dept, :acquisition, :in_service,
:cost, :salvage, :life, :clearing, 0, :user_id, NOW())"
)->execute($params);
return (int)$this->pdo->lastInsertId();
}
public function activate(int $id): void
{
$asset = $this->requireAsset($id);
if ((int)$asset['status'] !== self::STATUS_DRAFT) throw new Exception('Only draft assets can be activated.');
$category = (new AssetCategoryManager($this->pdo, $this->companyId))->getById((int)$asset['category_id']);
if (!$category || (int)$category['status'] !== 1) throw new Exception('The asset category is missing or inactive.');
DepreciationCalculator::assertInputs((float)$asset['cost'], (float)$asset['salvage_value'], (int)$asset['useful_life_months']);
$this->setStatus($id, self::STATUS_ACTIVE);
}
/** Drafts are removed outright (soft delete, like other documents). */
public function delete(int $id): void
{
$asset = $this->requireAsset($id);
if ((int)$asset['status'] !== self::STATUS_DRAFT) throw new Exception('Only draft assets can be deleted. Void an active asset instead.');
$this->pdo->prepare(
"UPDATE td_asset SET company_id = company_id * -1, updated_at = NOW() WHERE company_id = :cid AND id = :id"
)->execute([':cid' => $this->companyId, ':id' => $id]);
}
/** Void an active asset recorded in error. Reverses its capitalization entry if posted. */
public function void(int $id, GlManager $gl): void
{
$asset = $this->requireAsset($id);
if (!in_array((int)$asset['status'], [self::STATUS_ACTIVE, self::STATUS_FULLY], true)) {
throw new Exception('Only active assets can be voided.');
}
if ($this->hasDepreciation($id)) {
throw new Exception('This asset has depreciation recorded. Void its depreciation runs first.');
}
$gl->delete('asset_capitalization', $id);
$this->setStatus($id, self::STATUS_VOID);
}
/**
* Record a sale or write-off. Every month before the disposal month must
* already be charged; the disposal month itself gets no charge.
*/
public function dispose(int $id, array $data): void
{
$asset = $this->requireAsset($id);
if (!in_array((int)$asset['status'], [self::STATUS_ACTIVE, self::STATUS_FULLY], true)) {
throw new Exception('Only active assets can be disposed of.');
}
$date = $this->parseDate((string)($data['disposal_date'] ?? ''));
if ($date === '') throw new Exception('Disposal date is required.');
if ($date < $asset['in_service_date']) throw new Exception('The disposal date cannot be before the in-service date.');
$sale_price = round((float)($data['sale_price'] ?? 0), 2);
if ($sale_price < 0) throw new Exception('Sale price cannot be negative.');
$proceeds = trim((string)($data['proceeds_account_code'] ?? ''));
if ($sale_price > 0 && $proceeds === '') throw new Exception('Select the account that received the sale proceeds.');
if ($proceeds !== '' && !AssetCategoryManager::isPostingAccount($this->pdo, $this->companyId, $proceeds)) {
throw new Exception("Proceeds account {$proceeds} is not an active posting account.");
}
$disposal_period = DepreciationCalculator::periodOf($date);
$sth = $this->pdo->prepare(
"SELECT COUNT(*) FROM td_asset_depreciation WHERE company_id = :cid AND asset_id = :id AND period >= :period"
);
$sth->execute([':cid' => $this->companyId, ':id' => $id, ':period' => $disposal_period]);
if ((int)$sth->fetchColumn() > 0) {
throw new Exception("Depreciation is already recorded for {$disposal_period} or later. Void those runs before disposing of the asset on this date.");
}
$sth = $this->pdo->prepare("SELECT period FROM td_asset_depreciation WHERE company_id = :cid AND asset_id = :id");
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
$recorded = array_flip($sth->fetchAll(PDO::FETCH_COLUMN));
$schedule = DepreciationCalculator::schedule(
(float)$asset['cost'], (float)$asset['salvage_value'], (int)$asset['useful_life_months'], $asset['in_service_date']
);
foreach ($schedule as $row) {
if ($row['period'] >= $disposal_period) break;
if ($row['amount'] > 0 && !isset($recorded[$row['period']])) {
$label = $asset['asset_class'] === 'intangible' ? 'amortization' : 'depreciation';
throw new Exception("Run {$label} through " . DepreciationCalculator::addMonths($disposal_period, -1)
. " before disposing of this asset (first month not charged: {$row['period']}).");
}
}
$this->pdo->prepare(
"UPDATE td_asset SET status = :status, disposal_date = :date, sale_price = :price,
proceeds_account_code = :proceeds, disposal_notes = :notes, updated_at = NOW()
WHERE company_id = :cid AND id = :id"
)->execute([
':status' => self::STATUS_DISPOSED,
':date' => $date,
':price' => $sale_price,
':proceeds' => $proceeds,
':notes' => trim((string)($data['disposal_notes'] ?? '')),
':cid' => $this->companyId,
':id' => $id,
]);
}
/** Reverse a disposal (and its GL entry if posted). */
public function undoDisposal(int $id, GlManager $gl): void
{
$asset = $this->requireAsset($id);
if ((int)$asset['status'] !== self::STATUS_DISPOSED) throw new Exception('This asset has not been disposed of.');
$gl->delete('asset_disposal', $id);
$this->pdo->prepare(
"UPDATE td_asset SET status = :status, disposal_date = NULL, sale_price = 0,
proceeds_account_code = '', disposal_notes = '', updated_at = NOW()
WHERE company_id = :cid AND id = :id"
)->execute([':status' => self::STATUS_ACTIVE, ':cid' => $this->companyId, ':id' => $id]);
$this->syncStatuses([$id]);
}
/** Set active / fully depreciated from what has been charged so far. */
public function syncStatuses(array $asset_ids): void
{
$ids = array_values(array_unique(array_filter(array_map('intval', $asset_ids))));
if (!$ids) return;
$this->pdo->prepare(
"UPDATE td_asset a
LEFT JOIN (SELECT asset_id, SUM(amount) AS accumulated
FROM td_asset_depreciation WHERE company_id = :cid_d GROUP BY asset_id) d
ON d.asset_id = a.id
SET a.status = IF(COALESCE(d.accumulated, 0) >= ROUND(a.cost - a.salvage_value, 2) - 0.004, 2, 1),
a.updated_at = NOW()
WHERE a.company_id = :cid AND a.status IN (1, 2) AND a.id IN (" . implode(',', $ids) . ")"
)->execute([':cid' => $this->companyId, ':cid_d' => $this->companyId]);
}
// ── private ──────────────────────────────────────────────────────────────
private function baseSelect(): string
{
return "SELECT a.*,
COALESCE(c.category_code, '') AS category_code,
COALESCE(c.category_name, '') AS category_name,
COALESCE(ct.contact_name, '') AS contact_name,
COALESCE(i.invoice_number, '') AS invoice_number,
COALESCE(d.accumulated, 0) AS accumulated,
COALESCE(d.last_period, '') AS last_period,
ROUND(a.cost - COALESCE(d.accumulated, 0), 4) AS book_value
FROM td_asset a
LEFT JOIN md_asset_category c
ON c.company_id = a.company_id AND c.id = a.category_id
LEFT JOIN md_contact ct
ON ct.company_id = a.company_id AND ct.id = a.contact_id
LEFT JOIN td_invoice i
ON i.company_id = a.company_id AND i.id = a.invoice_id
LEFT JOIN (SELECT asset_id, SUM(amount) AS accumulated, MAX(period) AS last_period
FROM td_asset_depreciation WHERE company_id = :cid_d GROUP BY asset_id) d
ON d.asset_id = a.id";
}
private function requireAsset(int $id): array
{
$sth = $this->pdo->prepare("SELECT * FROM td_asset WHERE company_id = :cid AND id = :id FOR UPDATE");
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
$asset = $sth->fetch(PDO::FETCH_ASSOC);
if (!$asset) throw new Exception('Asset not found.');
return $asset;
}
private function setStatus(int $id, int $status): void
{
$this->pdo->prepare("UPDATE td_asset SET status = :status, updated_at = NOW() WHERE company_id = :cid AND id = :id")
->execute([':status' => $status, ':cid' => $this->companyId, ':id' => $id]);
}
private function hasDepreciation(int $id): bool
{
$sth = $this->pdo->prepare("SELECT COUNT(*) FROM td_asset_depreciation WHERE company_id = :cid AND asset_id = :id");
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
return (int)$sth->fetchColumn() > 0;
}
private function glEntry(string $source_type, int $source_id): ?array
{
$sth = $this->pdo->prepare(
"SELECT id, reference, journal_date, period, current_version,
DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS posted_at
FROM td_gl WHERE company_id = :cid AND source_type = :type AND source_id = :id LIMIT 1"
);
$sth->execute([':cid' => $this->companyId, ':type' => $source_type, ':id' => $source_id]);
return $sth->fetch(PDO::FETCH_ASSOC) ?: null;
}
/**
* The part of the invoice's pre-tax total the purchase posting put on this
* line — PurchaseInvoicePosting splits (grand_total - tax) by line total.
*/
private function postedLineAmount(int $invoice_id, float $line_total, float $grand_total, float $tax): float
{
$sth = $this->pdo->prepare(
"SELECT COALESCE(SUM(ABS(total_price)), 0) FROM td_invoice_item WHERE company_id = :cid AND invoice_id = :id"
);
$sth->execute([':cid' => $this->companyId, ':id' => $invoice_id]);
$sum = (float)$sth->fetchColumn();
if ($sum <= 0) return 0.0;
return round((abs($grand_total) - abs($tax)) * abs($line_total) / $sum, 2);
}
private function formulaTotalAccount(int $formula_id): string
{
if ($formula_id <= 0) {
$sth = $this->pdo->prepare(
"SELECT id FROM md_account_formula
WHERE company_id = :cid AND document_type = 'purchase_invoice' AND is_default = 1 AND status = 1
LIMIT 1"
);
$sth->execute([':cid' => $this->companyId]);
$formula_id = (int)$sth->fetchColumn();
}
if ($formula_id <= 0) return '';
$sth = $this->pdo->prepare(
"SELECT account_code FROM md_account_formula_item
WHERE company_id = :cid AND formula_id = :fid AND amount_key = 'total' AND drcr = 'D'
ORDER BY sort_order ASC LIMIT 1"
);
$sth->execute([':cid' => $this->companyId, ':fid' => $formula_id]);
return (string)($sth->fetchColumn() ?: '');
}
private function parseDate(string $value): string
{
$value = trim($value);
if ($value === '') return '';
if (preg_match('#^(\d{2})/(\d{2})/(\d{4})$#', $value, $m)) return "{$m[3]}-{$m[2]}-{$m[1]}";
if (preg_match('#^\d{4}-\d{2}-\d{2}$#', $value)) return $value;
throw new Exception("Invalid date '{$value}'.");
}
}
@@ -1,267 +0,0 @@
<?php
/**
* AssetPosting
*
* Builds the GL lines for the four Asset Management sources. Unlike the
* formula-based BasePosting classes, the accounts come from the asset category.
* Every entry's reference is the asset or run number and every description
* starts with "[Asset Mgmt]" so it is recognisable in Accounting.
*
* asset_capitalization DR asset cost / CR clearing account (the account the purchase invoice hit)
* asset_depreciation DR depreciation expense / CR accumulated depreciation (one run, grouped by category + department)
* asset_amortization DR amortization expense / CR accumulated amortization
* asset_disposal DR proceeds + accumulated (+ loss) / CR asset cost (+ gain)
*/
class AssetPosting
{
public const SOURCE_TYPES = ['asset_capitalization', 'asset_depreciation', 'asset_amortization', 'asset_disposal'];
public const LABEL = '[Asset Mgmt]';
private PDO $pdo;
private int $companyId;
public function __construct(PDO $pdo, int $company_id)
{
$this->pdo = $pdo;
$this->companyId = $company_id;
}
public static function isAssetSource(string $source_type): bool
{
return in_array($source_type, self::SOURCE_TYPES, true);
}
/**
* @return array{formula_id: int, period: string, doc_date: string, lines: array, reference: string, description: string}
*/
public function build(string $source_type, int $id): array
{
switch ($source_type) {
case 'asset_capitalization': $built = $this->buildCapitalization($id); break;
case 'asset_depreciation': $built = $this->buildRun($id, 'tangible'); break;
case 'asset_amortization': $built = $this->buildRun($id, 'intangible'); break;
case 'asset_disposal': $built = $this->buildDisposal($id); break;
default: throw new Exception("Unknown asset source '{$source_type}'.");
}
$this->assertBalanced($built['lines'], $built['reference']);
return [
'formula_id' => 0,
'period' => substr($built['doc_date'], 0, 7),
'doc_date' => $built['doc_date'],
'lines' => $built['lines'],
'reference' => mb_substr($built['reference'], 0, 100),
'description' => mb_substr($built['description'], 0, 255),
];
}
/** Rows for the Batch GL Entries tabs, in the same shape GlQueryManager returns. */
public function getPostableDocuments(string $source_type, string $date_from, string $date_to): array
{
$params = [':cid' => $this->companyId, ':source_type' => $source_type];
if ($source_type === 'asset_depreciation' || $source_type === 'asset_amortization') {
$where = ['r.company_id = :cid', 'r.status = 1', 'r.asset_class = :class'];
$params[':class'] = $source_type === 'asset_amortization' ? 'intangible' : 'tangible';
if ($date_from) { $where[] = 'r.run_date >= :date_from'; $params[':date_from'] = $date_from; }
if ($date_to) { $where[] = 'r.run_date <= :date_to'; $params[':date_to'] = $date_to; }
$sql = "SELECT r.id,
r.run_number AS doc_number,
CONCAT(r.asset_count, IF(r.asset_count = 1, ' asset', ' assets'), ' · ', r.period) AS contact_name,
r.total_amount AS grand_total,
r.run_date AS doc_date,
IF(g.id IS NULL, 0, 1) AS gl_status,
0 AS gl_formula_id, 0 AS product_mapping_checked, 0 AS product_mapping_missing
FROM td_asset_run r
LEFT JOIN td_gl g
ON g.company_id = r.company_id AND g.source_type = :source_type AND g.source_id = r.id
WHERE " . implode(' AND ', $where) . "
ORDER BY r.run_date DESC, r.id DESC";
} else {
$is_disposal = $source_type === 'asset_disposal';
$date_col = $is_disposal ? 'a.disposal_date' : 'a.acquisition_date';
$where = ['a.company_id = :cid'];
$where[] = $is_disposal ? 'a.status = 3' : "a.status IN (1, 2, 3) AND a.clearing_account_code <> ''";
if ($date_from) { $where[] = "{$date_col} >= :date_from"; $params[':date_from'] = $date_from; }
if ($date_to) { $where[] = "{$date_col} <= :date_to"; $params[':date_to'] = $date_to; }
$amount = $is_disposal ? 'a.sale_price' : 'a.cost';
$sql = "SELECT a.id,
a.asset_number AS doc_number,
a.asset_name AS contact_name,
{$amount} AS grand_total,
{$date_col} AS doc_date,
IF(g.id IS NULL, 0, 1) AS gl_status,
0 AS gl_formula_id, 0 AS product_mapping_checked, 0 AS product_mapping_missing
FROM td_asset a
LEFT JOIN td_gl g
ON g.company_id = a.company_id AND g.source_type = :source_type AND g.source_id = a.id
WHERE " . implode(' AND ', $where) . "
ORDER BY {$date_col} DESC, a.id DESC";
}
$sth = $this->pdo->prepare($sql);
$sth->execute($params);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
// ── builders ─────────────────────────────────────────────────────────────
private function buildCapitalization(int $id): array
{
$asset = $this->fetchAsset($id);
if (!in_array((int)$asset['status'], [1, 2, 3], true)) {
throw new Exception("Asset {$asset['asset_number']} is not active.");
}
if ($asset['clearing_account_code'] === '') {
throw new Exception("Asset {$asset['asset_number']} has no clearing account, so it has no capitalization entry.");
}
$cost = round((float)$asset['cost'], 2);
$text = self::LABEL . " Capitalize {$asset['asset_number']} {$asset['asset_name']}";
$dept = (int)$asset['department_id'];
return [
'doc_date' => $asset['acquisition_date'],
'reference' => $asset['asset_number'],
'description' => $text,
'lines' => [
$this->line($this->account($asset, 'cost_account_code'), $dept, $cost, 0, $text),
$this->line($asset['clearing_account_code'], $dept, 0, $cost, $text),
],
];
}
private function buildRun(int $run_id, string $asset_class): array
{
$sth = $this->pdo->prepare("SELECT * FROM td_asset_run WHERE company_id = :cid AND id = :id LIMIT 1");
$sth->execute([':cid' => $this->companyId, ':id' => $run_id]);
$run = $sth->fetch(PDO::FETCH_ASSOC);
if (!$run || (int)$run['status'] !== 1) throw new Exception("Asset run #{$run_id} not found or void.");
if ($run['asset_class'] !== $asset_class) throw new Exception("Asset run {$run['run_number']} is not a {$asset_class} run.");
$sth = $this->pdo->prepare(
"SELECT d.category_id, d.department_id, SUM(d.amount) AS amount,
c.category_code, c.category_name, c.expense_account_code, c.accum_account_code
FROM td_asset_depreciation d
LEFT JOIN md_asset_category c
ON c.company_id = d.company_id AND c.id = d.category_id
WHERE d.company_id = :cid AND d.run_id = :run_id
GROUP BY d.category_id, d.department_id, c.category_code, c.category_name,
c.expense_account_code, c.accum_account_code
ORDER BY c.category_code ASC, d.department_id ASC"
);
$sth->execute([':cid' => $this->companyId, ':run_id' => $run_id]);
$groups = $sth->fetchAll(PDO::FETCH_ASSOC);
if (!$groups) throw new Exception("Asset run {$run['run_number']} has no lines.");
$label = $asset_class === 'intangible' ? 'Amortization' : 'Depreciation';
$lines = [];
foreach ($groups as $g) {
$amount = round((float)$g['amount'], 2);
$text = self::LABEL . " {$label} {$run['period']} · {$g['category_code']} {$g['category_name']}";
$dept = (int)$g['department_id'];
$lines[] = $this->line($this->account($g, 'expense_account_code'), $dept, $amount, 0, $text);
$lines[] = $this->line($this->account($g, 'accum_account_code'), $dept, 0, $amount, $text);
}
return [
'doc_date' => $run['run_date'],
'reference' => $run['run_number'],
'description' => self::LABEL . " {$label} {$run['period']} ({$run['asset_count']} assets)",
'lines' => $lines,
];
}
private function buildDisposal(int $id): array
{
$asset = $this->fetchAsset($id);
if ((int)$asset['status'] !== 3) throw new Exception("Asset {$asset['asset_number']} has not been disposed of.");
$sth = $this->pdo->prepare(
"SELECT COALESCE(SUM(amount), 0) FROM td_asset_depreciation WHERE company_id = :cid AND asset_id = :id"
);
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
$accumulated = round((float)$sth->fetchColumn(), 2);
$cost = round((float)$asset['cost'], 2);
$sale_price = round((float)$asset['sale_price'], 2);
$gain = round($sale_price - ($cost - $accumulated), 2);
$dept = (int)$asset['department_id'];
$text = self::LABEL . " Disposal {$asset['asset_number']} {$asset['asset_name']}";
$lines = [];
if ($sale_price > 0) {
if ($asset['proceeds_account_code'] === '') throw new Exception("Asset {$asset['asset_number']} has a sale price but no proceeds account.");
$lines[] = $this->line($asset['proceeds_account_code'], $dept, $sale_price, 0, $text . ' · proceeds');
}
if ($accumulated > 0) {
$lines[] = $this->line($this->account($asset, 'accum_account_code'), $dept, $accumulated, 0, $text . ' · accumulated');
}
if ($gain < 0) {
$lines[] = $this->line($this->account($asset, 'loss_account_code'), $dept, -$gain, 0, $text . ' · loss');
}
$lines[] = $this->line($this->account($asset, 'cost_account_code'), $dept, 0, $cost, $text . ' · cost');
if ($gain > 0) {
$lines[] = $this->line($this->account($asset, 'gain_account_code'), $dept, 0, $gain, $text . ' · gain');
}
return [
'doc_date' => $asset['disposal_date'],
'reference' => $asset['asset_number'],
'description' => $text,
'lines' => $lines,
];
}
// ── helpers ──────────────────────────────────────────────────────────────
private function fetchAsset(int $id): array
{
$sth = $this->pdo->prepare(
"SELECT a.*, c.category_code, c.category_name, c.cost_account_code, c.accum_account_code,
c.expense_account_code, c.gain_account_code, c.loss_account_code
FROM td_asset a
LEFT JOIN md_asset_category c
ON c.company_id = a.company_id AND c.id = a.category_id
WHERE a.company_id = :cid AND a.id = :id
LIMIT 1"
);
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
$asset = $sth->fetch(PDO::FETCH_ASSOC);
if (!$asset) throw new Exception("Asset #{$id} not found.");
return $asset;
}
private function account(array $row, string $field): string
{
$code = trim((string)($row[$field] ?? ''));
if ($code === '') {
$category = trim(($row['category_code'] ?? '') . ' ' . ($row['category_name'] ?? ''));
throw new Exception("Asset category {$category} has no " . str_replace('_', ' ', $field) . '.');
}
return $code;
}
private function line(string $account_code, int $department_id, float $debit, float $credit, string $description): array
{
return [
'account_code' => $account_code,
'department_id' => $department_id,
'debit' => round($debit, 2),
'credit' => round($credit, 2),
'description' => mb_substr($description, 0, 255),
];
}
private function assertBalanced(array $lines, string $reference): void
{
$debit = round(array_sum(array_column($lines, 'debit')), 2);
$credit = round(array_sum(array_column($lines, 'credit')), 2);
if (!$lines || abs($debit - $credit) > 0.004) {
throw new Exception("GL entry for {$reference} does not balance (debit {$debit}, credit {$credit}).");
}
}
}
@@ -1,334 +0,0 @@
<?php
require_once __DIR__ . '/DepreciationCalculator.php';
require_once __DIR__ . '/AssetManager.php';
/**
* AssetRunManager
*
* Monthly depreciation (tangible) and amortization (intangible) runs. A run
* covers one class and one month; each becomes one GL entry that Accounting
* posts from Batch GL Entries. Running "through" a month creates one run per
* month that still has charges due, oldest first.
*/
class AssetRunManager
{
private const CLASS_META = [
'tangible' => ['source_type' => 'asset_depreciation', 'prefix' => 'AM-DEP', 'label' => 'Depreciation'],
'intangible' => ['source_type' => 'asset_amortization', 'prefix' => 'AM-AMT', 'label' => 'Amortization'],
];
private PDO $pdo;
private int $companyId;
public function __construct(PDO $pdo, int $company_id)
{
$this->pdo = $pdo;
$this->companyId = $company_id;
}
public static function sourceTypeFor(string $asset_class): string
{
return self::meta($asset_class)['source_type'];
}
public static function labelFor(string $asset_class): string
{
return self::meta($asset_class)['label'];
}
/**
* Charges due for every active asset of the class, from each asset's next
* uncharged month up to $through_period.
*
* @return array<int, array{period: string, run_date: string, asset_count: int, total: float, items: array}>
*/
public function preview(string $asset_class, string $through_period): array
{
self::meta($asset_class);
DepreciationCalculator::addMonths($through_period, 0); // validates the format
if (DepreciationCalculator::monthsBetween(date('Y-m'), $through_period) > 0) {
throw new Exception('You cannot run ' . strtolower(self::labelFor($asset_class)) . ' for a future month.');
}
$by_period = [];
foreach ($this->activeAssets($asset_class) as $asset) {
$cost = (float)$asset['cost'];
$salvage = (float)$asset['salvage_value'];
$life = (int)$asset['useful_life_months'];
$accumulated = round((float)$asset['accumulated'], 2);
$period = $asset['last_period'] !== ''
? DepreciationCalculator::addMonths($asset['last_period'], 1)
: DepreciationCalculator::firstPeriod($asset['in_service_date']);
while (DepreciationCalculator::monthsBetween($period, $through_period) >= 0) {
$index = DepreciationCalculator::periodIndex($asset['in_service_date'], $period);
$amount = DepreciationCalculator::amountFor($cost, $salvage, $life, $index, $accumulated);
if ($amount <= 0) break;
$accumulated = round($accumulated + $amount, 2);
$by_period[$period][] = [
'asset_id' => (int)$asset['id'],
'asset_number' => $asset['asset_number'],
'asset_name' => $asset['asset_name'],
'category_id' => (int)$asset['category_id'],
'category_name' => $asset['category_name'],
'department_id' => (int)$asset['department_id'],
'amount' => $amount,
'accumulated_after' => $accumulated,
'book_value_after' => round($cost - $accumulated, 2),
];
$period = DepreciationCalculator::addMonths($period, 1);
}
}
ksort($by_period);
$runs = [];
foreach ($by_period as $period => $items) {
$runs[] = [
'period' => $period,
'run_date' => DepreciationCalculator::periodEndDate($period),
'asset_count' => count($items),
'total' => round(array_sum(array_column($items, 'amount')), 2),
'items' => $items,
];
}
return $runs;
}
/** Create the runs shown by preview(). Call inside a transaction. */
public function run(string $asset_class, string $through_period, int $user_id): array
{
// Serialize concurrent runs for the same class.
$this->pdo->prepare(
"SELECT id FROM td_asset WHERE company_id = :cid AND asset_class = :class AND status = 1 FOR UPDATE"
)->execute([':cid' => $this->companyId, ':class' => $asset_class]);
$preview = $this->preview($asset_class, $through_period);
if (!$preview) {
throw new Exception('No ' . strtolower(self::labelFor($asset_class)) . " is due through {$through_period}.");
}
$insert_run = $this->pdo->prepare(
"INSERT INTO td_asset_run
(company_id, run_number, asset_class, period, run_date, asset_count, total_amount, status, created_by, created_at)
VALUES
(:cid, :number, :class, :period, :run_date, :count, :total, 1, :user_id, NOW())"
);
$insert_row = $this->pdo->prepare(
"INSERT INTO td_asset_depreciation
(company_id, asset_id, run_id, period, category_id, department_id,
amount, accumulated_after, book_value_after, created_at)
VALUES
(:cid, :asset_id, :run_id, :period, :category_id, :department_id,
:amount, :accumulated, :book_value, NOW())"
);
$created = [];
$asset_ids = [];
foreach ($preview as $run) {
$number = $this->nextRunNumber($asset_class, $run['period']);
$insert_run->execute([
':cid' => $this->companyId,
':number' => $number,
':class' => $asset_class,
':period' => $run['period'],
':run_date' => $run['run_date'],
':count' => $run['asset_count'],
':total' => $run['total'],
':user_id' => $user_id,
]);
$run_id = (int)$this->pdo->lastInsertId();
foreach ($run['items'] as $item) {
$insert_row->execute([
':cid' => $this->companyId,
':asset_id' => $item['asset_id'],
':run_id' => $run_id,
':period' => $run['period'],
':category_id' => $item['category_id'],
':department_id' => $item['department_id'],
':amount' => $item['amount'],
':accumulated' => $item['accumulated_after'],
':book_value' => $item['book_value_after'],
]);
$asset_ids[] = $item['asset_id'];
}
$created[] = [
'id' => $run_id,
'run_number' => $number,
'period' => $run['period'],
'asset_count' => $run['asset_count'],
'total' => $run['total'],
];
}
(new AssetManager($this->pdo, $this->companyId))->syncStatuses($asset_ids);
return $created;
}
public function getList(string $asset_class = ''): array
{
$where = ['r.company_id = :cid'];
$params = [':cid' => $this->companyId];
if ($asset_class !== '') {
self::meta($asset_class);
$where[] = 'r.asset_class = :class';
$params[':class'] = $asset_class;
}
$sth = $this->pdo->prepare(
"SELECT r.*,
DATE_FORMAT(r.created_at, '%Y-%m-%d %H:%i:%s') AS created_at_fmt,
IF(g.id IS NULL, 0, 1) AS gl_status,
COALESCE(g.id, 0) AS gl_id
FROM td_asset_run r
LEFT JOIN td_gl g
ON g.company_id = r.company_id
AND g.source_id = r.id
AND g.source_type = IF(r.asset_class = 'intangible', 'asset_amortization', 'asset_depreciation')
WHERE " . implode(' AND ', $where) . "
ORDER BY r.period DESC, r.id DESC"
);
$sth->execute($params);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
public function getDetail(int $run_id): array
{
$sth = $this->pdo->prepare("SELECT * FROM td_asset_run WHERE company_id = :cid AND id = :id LIMIT 1");
$sth->execute([':cid' => $this->companyId, ':id' => $run_id]);
$run = $sth->fetch(PDO::FETCH_ASSOC);
if (!$run) throw new Exception('Run not found.');
$sth = $this->pdo->prepare(
"SELECT d.asset_id, d.amount, d.accumulated_after, d.book_value_after, d.department_id,
a.asset_number, a.asset_name,
COALESCE(c.category_code, '') AS category_code,
COALESCE(c.category_name, '') AS category_name,
COALESCE(dp.dept_code, '') AS dept_code
FROM td_asset_depreciation d
JOIN td_asset a
ON a.id = d.asset_id AND ABS(a.company_id) = d.company_id
LEFT JOIN md_asset_category c
ON c.company_id = d.company_id AND c.id = d.category_id
LEFT JOIN md_department dp
ON dp.company_id = d.company_id AND dp.id = d.department_id
WHERE d.company_id = :cid AND d.run_id = :run_id
ORDER BY a.asset_number ASC"
);
$sth->execute([':cid' => $this->companyId, ':run_id' => $run_id]);
$items = $sth->fetchAll(PDO::FETCH_ASSOC);
// A void run's rows are gone; show what it held from the header only.
return ['run' => $run, 'items' => $items];
}
/**
* Void a run: reverse its GL entry if posted and remove its charges. Only
* the latest month of each asset can be removed, and not after a disposal.
*/
public function void(int $run_id, GlManager $gl): void
{
$sth = $this->pdo->prepare("SELECT * FROM td_asset_run WHERE company_id = :cid AND id = :id FOR UPDATE");
$sth->execute([':cid' => $this->companyId, ':id' => $run_id]);
$run = $sth->fetch(PDO::FETCH_ASSOC);
if (!$run) throw new Exception('Run not found.');
if ((int)$run['status'] !== 1) throw new Exception('This run is already void.');
$sth = $this->pdo->prepare(
"SELECT a.asset_number
FROM td_asset_depreciation d
JOIN td_asset a ON a.company_id = d.company_id AND a.id = d.asset_id
WHERE d.company_id = :cid AND d.run_id = :run_id AND a.status = 3
LIMIT 1"
);
$sth->execute([':cid' => $this->companyId, ':run_id' => $run_id]);
if ($number = $sth->fetchColumn()) {
throw new Exception("Asset {$number} in this run has been disposed of. Undo the disposal first.");
}
$sth = $this->pdo->prepare(
"SELECT later.period
FROM td_asset_depreciation d
JOIN td_asset_depreciation later
ON later.company_id = d.company_id AND later.asset_id = d.asset_id AND later.period > d.period
WHERE d.company_id = :cid AND d.run_id = :run_id
ORDER BY later.period DESC
LIMIT 1"
);
$sth->execute([':cid' => $this->companyId, ':run_id' => $run_id]);
if ($later = $sth->fetchColumn()) {
throw new Exception("Assets in this run were charged again in {$later}. Void the later runs first.");
}
$gl->delete(self::sourceTypeFor($run['asset_class']), $run_id);
$sth = $this->pdo->prepare("SELECT asset_id FROM td_asset_depreciation WHERE company_id = :cid AND run_id = :run_id");
$sth->execute([':cid' => $this->companyId, ':run_id' => $run_id]);
$asset_ids = $sth->fetchAll(PDO::FETCH_COLUMN);
$this->pdo->prepare("DELETE FROM td_asset_depreciation WHERE company_id = :cid AND run_id = :run_id")
->execute([':cid' => $this->companyId, ':run_id' => $run_id]);
$this->pdo->prepare("UPDATE td_asset_run SET status = 4, voided_at = NOW() WHERE company_id = :cid AND id = :id")
->execute([':cid' => $this->companyId, ':id' => $run_id]);
(new AssetManager($this->pdo, $this->companyId))->syncStatuses($asset_ids);
}
/** What is due through the current month, per class (for the dashboard). */
public function dueSummary(): array
{
$summary = [];
foreach (array_keys(self::CLASS_META) as $asset_class) {
$runs = $this->preview($asset_class, date('Y-m'));
$summary[$asset_class] = [
'label' => self::labelFor($asset_class),
'period_count' => count($runs),
'from' => $runs ? $runs[0]['period'] : '',
'to' => $runs ? $runs[count($runs) - 1]['period'] : '',
'total' => round(array_sum(array_column($runs, 'total')), 2),
];
}
return $summary;
}
private function activeAssets(string $asset_class): array
{
$sth = $this->pdo->prepare(
"SELECT a.id, a.asset_number, a.asset_name, a.category_id, a.department_id,
a.cost, a.salvage_value, a.useful_life_months, a.in_service_date,
COALESCE(c.category_name, '') AS category_name,
COALESCE(d.accumulated, 0) AS accumulated,
COALESCE(d.last_period, '') AS last_period
FROM td_asset a
LEFT JOIN md_asset_category c
ON c.company_id = a.company_id AND c.id = a.category_id
LEFT JOIN (SELECT asset_id, SUM(amount) AS accumulated, MAX(period) AS last_period
FROM td_asset_depreciation WHERE company_id = :cid_d GROUP BY asset_id) d
ON d.asset_id = a.id
WHERE a.company_id = :cid AND a.asset_class = :class AND a.status = 1
ORDER BY a.asset_number ASC"
);
$sth->execute([':cid' => $this->companyId, ':cid_d' => $this->companyId, ':class' => $asset_class]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
private function nextRunNumber(string $asset_class, string $period): string
{
$sth = $this->pdo->prepare(
"SELECT COUNT(*) FROM td_asset_run WHERE company_id = :cid AND asset_class = :class AND period = :period"
);
$sth->execute([':cid' => $this->companyId, ':class' => $asset_class, ':period' => $period]);
$existing = (int)$sth->fetchColumn();
$number = self::meta($asset_class)['prefix'] . '-' . $period;
return $existing > 0 ? $number . '-' . ($existing + 1) : $number;
}
private static function meta(string $asset_class): array
{
if (!isset(self::CLASS_META[$asset_class])) throw new Exception('Invalid asset class.');
return self::CLASS_META[$asset_class];
}
}
@@ -1,135 +0,0 @@
<?php
/**
* DepreciationCalculator
*
* Straight-line depreciation (tangible) and amortization (intangible) maths.
* No database access — AssetManager and AssetRunManager call it.
*
* Conventions:
* - Amounts are rounded to 2 decimals per month; the last month of the life
* takes whatever is left so the book value ends exactly at salvage value.
* - The month the asset is put in service counts as a full month.
* - The month an asset is disposed of gets no charge.
* - Periods are 'YYYY-MM' strings.
*/
final class DepreciationCalculator
{
public static function periodOf(string $date): string
{
if (!preg_match('/^(\d{4})-(\d{2})/', $date, $m)) {
throw new Exception("Invalid date '{$date}'.");
}
return "{$m[1]}-{$m[2]}";
}
public static function addMonths(string $period, int $months): string
{
[$y, $m] = self::splitPeriod($period);
$index = $y * 12 + ($m - 1) + $months;
return sprintf('%04d-%02d', intdiv($index, 12), $index % 12 + 1);
}
/** Number of months from $from to $to (0 when equal, negative when $to is earlier). */
public static function monthsBetween(string $from, string $to): int
{
[$fy, $fm] = self::splitPeriod($from);
[$ty, $tm] = self::splitPeriod($to);
return ($ty * 12 + $tm) - ($fy * 12 + $fm);
}
public static function periodEndDate(string $period): string
{
[$y, $m] = self::splitPeriod($period);
return date('Y-m-t', mktime(0, 0, 0, $m, 1, $y));
}
public static function depreciableBase(float $cost, float $salvage): float
{
return round($cost - $salvage, 2);
}
public static function monthlyAmount(float $cost, float $salvage, int $life_months): float
{
if ($life_months <= 0) return 0.0;
return round(self::depreciableBase($cost, $salvage) / $life_months, 2);
}
public static function firstPeriod(string $in_service_date): string
{
return self::periodOf($in_service_date);
}
public static function lastPeriod(string $in_service_date, int $life_months): string
{
return self::addMonths(self::firstPeriod($in_service_date), max(1, $life_months) - 1);
}
/**
* Charge for one period.
*
* @param int $period_index 1 for the in-service month, up to $life_months.
* @param float $accumulated Total already charged before this period.
*/
public static function amountFor(float $cost, float $salvage, int $life_months, int $period_index, float $accumulated): float
{
if ($life_months <= 0 || $period_index < 1 || $period_index > $life_months) return 0.0;
$remaining = round(self::depreciableBase($cost, $salvage) - $accumulated, 2);
if ($remaining <= 0) return 0.0;
if ($period_index === $life_months) return $remaining;
return min(self::monthlyAmount($cost, $salvage, $life_months), $remaining);
}
/**
* Full projected schedule.
*
* @return array<int, array{period: string, amount: float, accumulated: float, book_value: float}>
*/
public static function schedule(float $cost, float $salvage, int $life_months, string $in_service_date): array
{
$rows = [];
$accumulated = 0.0;
$period = self::firstPeriod($in_service_date);
for ($i = 1; $i <= $life_months; $i++) {
$amount = self::amountFor($cost, $salvage, $life_months, $i, $accumulated);
$accumulated = round($accumulated + $amount, 2);
$rows[] = [
'period' => $period,
'amount' => $amount,
'accumulated' => $accumulated,
'book_value' => round($cost - $accumulated, 2),
];
$period = self::addMonths($period, 1);
}
return $rows;
}
/** 1-based position of $period in the asset's life (0 or less = before in service). */
public static function periodIndex(string $in_service_date, string $period): int
{
return self::monthsBetween(self::firstPeriod($in_service_date), $period) + 1;
}
public static function assertInputs(float $cost, float $salvage, int $life_months): void
{
if ($cost <= 0) throw new Exception('Cost must be greater than zero.');
if ($salvage < 0) throw new Exception('Salvage value cannot be negative.');
if ($salvage >= $cost) throw new Exception('Salvage value must be less than cost.');
if ($life_months < 1) throw new Exception('Useful life must be at least 1 month.');
if ($life_months > 1200) throw new Exception('Useful life cannot exceed 1200 months.');
if (self::monthlyAmount($cost, $salvage, $life_months) <= 0) {
throw new Exception('The amount to depreciate is too small for this useful life.');
}
}
private static function splitPeriod(string $period): array
{
if (!preg_match('/^(\d{4})-(\d{2})$/', $period, $m) || (int)$m[2] < 1 || (int)$m[2] > 12) {
throw new Exception("Invalid period '{$period}'.");
}
return [(int)$m[1], (int)$m[2]];
}
}
+3 -2
View File
@@ -11,8 +11,9 @@ header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../../config.php';
require_once __DIR__ . '/../../dbconn.php';
$secret = $_SERVER['HTTP_X_CRON_SECRET'] ?? '';
if (!defined('NODE_EMIT_SECRET') || $secret !== NODE_EMIT_SECRET) {
// 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']));
}
+64 -15
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 {
@@ -20,30 +49,19 @@ if (!function_exists('require_role')) {
}
}
// Blocks users whose app_access does not include $app ('all' includes every app).
if (!function_exists('require_app_access')) {
function require_app_access(string $app): void {
$access = $_SESSION['login_app_access'] ?? 'wms';
if ($access !== 'all' && $access !== $app) {
http_response_code(403);
exit(json_encode(['success' => 0, 'message' => 'You do not have access to this app.']));
}
}
}
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);
@@ -51,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);
@@ -66,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));
}
@@ -81,13 +102,29 @@ 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'])) {
@@ -115,7 +152,12 @@ if(!empty($_SESSION["login_company_id"])){
// 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
@@ -124,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));
}
+2 -1
View File
@@ -72,7 +72,8 @@ class mailer{
"input" => $input
]);
return openssl_decrypt(trim($input["data"]), "AES-256-CBC", $input["key"], 0, "1234567890123456" );
require_once __DIR__ . '/../secret_box.php';
return secret_decrypt((string)$input["data"], (string)$input["key"]);
}
+54
View File
@@ -0,0 +1,54 @@
<?php
/**
* page_headers.php — security headers for HTML pages (app pages, sign-in pages).
*
* Call send_page_security_headers() before any output. The Content-Security-Policy
* lists what the pages actually load:
* - scripts, styles, fonts and data files are all self-hosted under assets/vendor/
* (versions in assets/vendor/VERSIONS.json), so no CDN host is allowed;
* - the Node.js real-time server (NODE_PUBLIC_URL) serves socket.io.js and the
* WebSocket connection;
* - 'unsafe-inline' because pages use inline <script> blocks and onclick=
* handlers; 'unsafe-eval' because alasql compiles its queries with new Function.
*/
if (!function_exists('send_page_security_headers')) {
function send_page_security_headers(): void {
if (headers_sent()) return;
$script = ["'self'", "'unsafe-inline'", "'unsafe-eval'"];
$connect = ["'self'"];
if (defined('NODE_PUBLIC_URL')) {
$node = parse_url(NODE_PUBLIC_URL);
if (!empty($node['scheme']) && !empty($node['host'])) {
$origin = $node['host'] . (isset($node['port']) ? ':' . $node['port'] : '');
$secure = strtolower($node['scheme']) === 'https';
$script[] = ($secure ? 'https://' : 'http://') . $origin;
$connect[] = ($secure ? 'https://' : 'http://') . $origin;
$connect[] = ($secure ? 'wss://' : 'ws://') . $origin;
}
}
$csp = implode('; ', [
"default-src 'self'",
'script-src ' . implode(' ', $script),
"style-src 'self' 'unsafe-inline'",
"font-src 'self' data:",
"img-src 'self' data: blob:",
"media-src 'self' blob:",
'connect-src ' . implode(' ', $connect),
"worker-src 'self' blob:",
"frame-src 'self' blob:",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'self'",
]);
header('Content-Security-Policy: ' . $csp, true);
header('X-Content-Type-Options: nosniff', true);
header('X-Frame-Options: SAMEORIGIN', true);
header('Referrer-Policy: strict-origin-when-cross-origin', true);
}
}

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