Compare commits
23
Commits
931d95bafc
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6e5ab2841 | ||
|
|
9bb92bc20a | ||
|
|
f11af6e949 | ||
|
|
8705be0d1b | ||
|
|
73c680e844 | ||
|
|
ae98dcdcdd | ||
|
|
e579dd596c | ||
|
|
5cc43cff92 | ||
|
|
c89b28da4c | ||
|
|
de760d02da | ||
|
|
3668f22e55 | ||
|
|
114cf73748 | ||
|
|
9a8998796c | ||
|
|
80c7eab0d2 | ||
|
|
033846dece | ||
|
|
c14822add6 | ||
|
|
c60b705d98 | ||
|
|
d8363f6ca7 | ||
|
|
f70f226bd1 | ||
|
|
f14c850c70 | ||
|
|
c915379e2e | ||
|
|
78d69d81df | ||
|
|
693c72f9ea |
@@ -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,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,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,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,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,5 +1,5 @@
|
||||
<?php
|
||||
session_start();
|
||||
require_once __DIR__ . '/../session.php';
|
||||
require '../config.php';
|
||||
require '../include_header.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/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,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';
|
||||
|
||||
@@ -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,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,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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
<?php
|
||||
session_start();
|
||||
require_once __DIR__ . '/../session.php';
|
||||
require '../config.php';
|
||||
require '../include_header.php';
|
||||
?>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<?php
|
||||
session_start();
|
||||
require_once __DIR__ . '/../session.php';
|
||||
require '../config.php';
|
||||
require '../include_header.php';
|
||||
?>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>'
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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('', '');
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
@@ -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));
|
||||
@@ -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));
|
||||
@@ -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));
|
||||
@@ -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));
|
||||
@@ -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));
|
||||
@@ -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));
|
||||
@@ -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));
|
||||
@@ -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));
|
||||
@@ -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>
|
||||
@@ -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 & 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"> </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>
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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 {'&':'&','<':'<','>':'>','"':'"',"'":'''}[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" pipe <spare>. 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(' | ');
|
||||
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');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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
@@ -1,8 +1,3 @@
|
||||
function escape_html(value) {
|
||||
return String(value ?? '').replace(/[&<>"']/g, function(c) {
|
||||
return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[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(' | ');
|
||||
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
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
]);
|
||||
|
||||
@@ -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']
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
?>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]];
|
||||
}
|
||||
}
|
||||
@@ -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']));
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user