Add Asset Management app

Assets from purchase invoice lines, straight-line depreciation/amortization with salvage value, disposals, and [Asset Mgmt] sources in Batch GL Entries. Green third workspace with an app access guard.
This commit is contained in:
Thanakorn
2026-09-15 09:50:55 +07:00
parent 9512d440dd
commit c957280aa5
46 changed files with 4316 additions and 131 deletions
+10 -2
View File
@@ -199,7 +199,11 @@
supplier_credit_note: 'Supplier Credit Note',
receipt: 'Receipt',
payment: 'Payment',
manual: 'Manual'
manual: 'Manual',
asset_capitalization: 'Asset Mgmt · Capitalization',
asset_depreciation: 'Asset Mgmt · Depreciation',
asset_amortization: 'Asset Mgmt · Amortization',
asset_disposal: 'Asset Mgmt · Disposal'
};
var source_badges = {
@@ -209,7 +213,11 @@
supplier_credit_note: 'bg-secondary-subtle text-secondary',
receipt: 'bg-info-subtle text-info',
payment: 'bg-danger-subtle text-danger',
manual: 'bg-dark-subtle text-dark'
manual: 'bg-dark-subtle text-dark',
asset_capitalization: 'bg-success text-white',
asset_depreciation: 'bg-success text-white',
asset_amortization: 'bg-success text-white',
asset_disposal: 'bg-success text-white'
};
function fmt_num(n) {
+18 -5
View File
@@ -11,6 +11,7 @@ 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';
@@ -29,19 +30,31 @@ $posting_map = [
'purchase_order' => PurchasePosting::class,
];
if (!isset($posting_map[$doc_type]) || $id <= 0) {
$is_asset_source = AssetPosting::isAssetSource($doc_type);
if ((!isset($posting_map[$doc_type]) && !$is_asset_source) || $id <= 0) {
$answer['message'] = 'Invalid doc_type or id.';
exit(json_encode($answer));
}
try {
$posting_class = $posting_map[$doc_type];
$posting = new $posting_class($pdo2, $company_id);
$built = $posting->build($id, $formula_id);
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];
}
$guard = new PostingWindowGuard($pdo1, $company_id);
$gl = new GlManager($pdo2, $company_id, $guard);
$meta = ['journal_date' => $built['doc_date'] ?? null];
$pdo2->beginTransaction();
+82 -18
View File
@@ -39,6 +39,22 @@
<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">
@@ -60,6 +76,18 @@
<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>
@@ -76,9 +104,17 @@
'purchase_invoice': 'purchase_invoice',
'supplier_credit_note': 'supplier_credit_note',
'receipt': 'receipt',
'payment': 'payment'
'payment': 'payment',
'asset_capitalization': 'asset_capitalization',
'asset_depreciation': 'asset_depreciation',
'asset_amortization': 'asset_amortization',
'asset_disposal': 'asset_disposal'
};
// 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
@@ -91,11 +127,13 @@
if (_inited[doc_type]) return;
_inited[doc_type] = true;
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');
});
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');
});
}
var today = new Date();
var firstDay = new Date(today.getFullYear(), today.getMonth(), 1);
@@ -117,7 +155,7 @@
// ── load documents ────────────────────────────────────────────────────────
function load_gl_tab(doc_type) {
var formula_id = $('#gl-formula-' + doc_type).val();
var formula_id = is_asset_type(doc_type) ? '0' : $('#gl-formula-' + doc_type).val();
if (!formula_id) { bootbox.alert('Please select a GL formula first.'); return; }
if (formula_id === 'all') {
@@ -256,7 +294,9 @@
var status_badge = r.gl_status == 1
? '<span class="badge bg-success-subtle text-success">Posted</span>'
: '<span class="badge bg-danger-subtle text-danger">Unposted</span>';
var mapping_badge = product_mapping_badge(r);
var mapping_badge = is_asset_type(doc_type)
? '<span class="badge bg-success-subtle text-success">Asset category</span>'
: product_mapping_badge(r);
html +=
'<tr>' +
'<td>' + escape_html(r.doc_number) + '</td>' +
@@ -302,11 +342,15 @@
purchase_invoice: 'Purchase Invoice',
supplier_credit_note: 'Supplier Credit Note',
receipt: 'Receipt',
payment: 'Payment'
payment: 'Payment',
asset_capitalization: '[Asset Mgmt] Capitalization',
asset_depreciation: '[Asset Mgmt] Depreciation',
asset_amortization: '[Asset Mgmt] Amortization',
asset_disposal: '[Asset Mgmt] Disposal'
};
function run_gl_batch(doc_type) {
var formula_id = $('#gl-formula-' + doc_type).val();
var formula_id = is_asset_type(doc_type) ? '0' : $('#gl-formula-' + doc_type).val();
if (formula_id === 'all') {
run_all_formula_batches(doc_type);
@@ -393,7 +437,7 @@
// ── core batch processor ──────────────────────────────────────────────────
function _start_gl_batch(doc_type, ids, onComplete, formula_id_override) {
var formula_id = formula_id_override || $('#gl-formula-' + doc_type).val();
var formula_id = formula_id_override || (is_asset_type(doc_type) ? 0 : $('#gl-formula-' + doc_type).val());
var $msg = $('#gl-msg-' + doc_type);
set_btn_state('#gl-btn-load-' + doc_type, false);
@@ -516,10 +560,34 @@
</html>
<?php
function gl_tab_html(string $doc_type, string $label): string {
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';
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">
@@ -532,11 +600,7 @@ function gl_tab_html(string $doc_type, string $label): string {
<input type="text" id="gl-date-to-{$doc_type}" 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">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>
{$source_field}
<div class="col-auto">
<button id="gl-btn-load-{$doc_type}" class="btn btn-secondary btn-sm"
onclick="load_gl_tab('{$doc_type}')">
@@ -565,7 +629,7 @@ function gl_tab_html(string $doc_type, string $label): string {
<th>Contact</th>
<th class="text-end">Amount</th>
<th>Date</th>
<th>Product Accounts</th>
<th>{$accounts_header}</th>
<th>GL Status</th>
</tr>
</thead>
+17 -3
View File
@@ -48,6 +48,12 @@
<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">
@@ -188,7 +194,11 @@
receipt: 'Receipt',
payment: 'Payment',
manual: 'Manual',
reversal: 'Reversal'
reversal: 'Reversal',
asset_capitalization: 'Asset Mgmt · Capitalization',
asset_depreciation: 'Asset Mgmt · Depreciation',
asset_amortization: 'Asset Mgmt · Amortization',
asset_disposal: 'Asset Mgmt · Disposal'
};
var SOURCE_BADGE = {
@@ -199,7 +209,11 @@
receipt: 'bg-info-subtle text-info',
payment: 'bg-danger-subtle text-danger',
manual: 'bg-dark-subtle text-dark',
reversal: 'bg-danger-subtle text-danger'
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'
};
function source_badge(type) {
@@ -507,7 +521,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'
var extra_fields = (h.source_type === 'manual' || String(h.source_type).indexOf('asset_') === 0)
? '<div class="col-sm-4"><div class="text-muted small">Reference</div><div class="fw-semibold">' + escape_html(h.reference || ('MJE-' + h.id)) + '</div></div>' +
'<div class="col-sm-4"><div class="text-muted small">Journal Date</div><div>' + escape_html(h.journal_date_fmt || '—') + '</div></div>' +
'<div class="col-sm-4"><div class="text-muted small">Description</div><div>' + escape_html(h.description || '—') + '</div></div>'
+2
View File
@@ -139,6 +139,8 @@
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();
+9
View File
@@ -0,0 +1,9 @@
<?php
session_start();
require_once '../../../assets/utils/db_auth.php';
require_app_access('asset');
require_once '../../../assets/utils/classes_as/AssetManager.php';
$answer['output'] = (new AssetManager($pdo2, $company_id))->getList((int)($data['invoice_id'] ?? 0));
$answer['success'] = 1;
exit(json_encode($answer));
+21
View File
@@ -0,0 +1,21 @@
<?php
session_start();
require_once '../../../assets/utils/db_auth.php';
require_app_access('asset');
require_once '../../../assets/utils/classes_as/AssetManager.php';
require_once '../../../assets/utils/classes_as/AssetRunManager.php';
try {
$answer['output'] = (new AssetManager($pdo2, $company_id))->getStats();
$answer['due'] = (new AssetRunManager($pdo2, $company_id))->dueSummary();
$answer['success'] = 1;
} catch (PDOException $e) {
error_log('[asset stats] ' . $e->getMessage());
$answer['message'] = 'Database error, please try again.';
http_response_code(500);
} catch (Exception $e) {
$answer['message'] = $e->getMessage();
http_response_code(400);
}
exit(json_encode($answer));
+11
View File
@@ -0,0 +1,11 @@
<?php
session_start();
require_once '../../../assets/utils/db_auth.php';
require_app_access('asset');
require_once '../../../assets/utils/classes_as/AssetCategoryManager.php';
$categories = new AssetCategoryManager($pdo2, $company_id);
$answer['output'] = $categories->getAll();
$answer['stats'] = $categories->getStats();
$answer['success'] = 1;
exit(json_encode($answer));
+63
View File
@@ -0,0 +1,63 @@
<?php
session_start();
require_once '../../../assets/utils/db_auth.php';
require_app_access('asset');
require_once '../../../assets/utils/classes/UsageGuard.php';
require_once '../../../assets/utils/classes_ac/PostingWindowGuard.php';
require_once '../../../assets/utils/classes_ac/GlManager.php';
require_once '../../../assets/utils/classes_as/AssetRunManager.php';
$action = (string)($data['action'] ?? 'list');
$class = trim((string)($data['asset_class'] ?? ''));
$through = trim((string)($data['through_period'] ?? ''));
$run_id = (int)($data['run_id'] ?? 0);
if (in_array($action, ['run', 'void'], true)) {
require_role($user_role, ['owner', 'admin']);
}
try {
$runs = new AssetRunManager($pdo2, $company_id);
switch ($action) {
case 'list':
$answer['output'] = $runs->getList($class);
break;
case 'preview':
$answer['output'] = $runs->preview($class, $through);
break;
case 'detail':
$answer['output'] = $runs->getDetail($run_id);
break;
case 'run':
$created = dbTransaction($pdo2, fn($pdo) => (new AssetRunManager($pdo, $company_id))->run($class, $through, $user_id));
(new UsageGuard($pdo1, $company_id, $packages))->increment();
$answer['output'] = $created;
$answer['message'] = count($created) . ' run(s) created. Accounting posts them from Batch GL Entries.';
break;
case 'void':
dbTransaction($pdo2, function ($pdo) use ($company_id, $run_id, $pdo1) {
$gl = new GlManager($pdo, $company_id, new PostingWindowGuard($pdo1, $company_id));
(new AssetRunManager($pdo, $company_id))->void($run_id, $gl);
});
$answer['message'] = 'Run voided.';
break;
default:
throw new Exception('Invalid action.');
}
$answer['success'] = 1;
} catch (PDOException $e) {
$answer['message'] = 'Database error, please try again.';
http_response_code(500);
} catch (Exception $e) {
$answer['message'] = $e->getMessage();
http_response_code(400);
}
exit(json_encode($answer));
+28
View File
@@ -0,0 +1,28 @@
<?php
session_start();
require_once '../../../assets/utils/db_auth.php';
require_app_access('asset');
require_once '../../../assets/utils/classes_as/AssetManager.php';
try {
$assets = new AssetManager($pdo2, $company_id);
if (($data['action'] ?? '') === 'line') {
$answer['output'] = $assets->getInvoiceLine((int)($data['invoice_id'] ?? 0), (int)($data['item_id'] ?? 0));
} else {
$answer['output'] = $assets->getCapitalizableLines(
(string)($data['date_from'] ?? ''),
(string)($data['date_to'] ?? '')
);
}
$answer['success'] = 1;
} catch (PDOException $e) {
error_log('[asset invoice lines] ' . $e->getMessage());
$answer['message'] = 'Database error, please try again.';
http_response_code(500);
} catch (Exception $e) {
$answer['message'] = $e->getMessage();
http_response_code(400);
}
exit(json_encode($answer));
+70
View File
@@ -0,0 +1,70 @@
<?php
session_start();
require_once '../../../assets/utils/db_auth.php';
require_app_access('asset');
require_once '../../../assets/utils/classes/UsageGuard.php';
require_once '../../../assets/utils/classes_ac/PostingWindowGuard.php';
require_once '../../../assets/utils/classes_ac/GlManager.php';
require_once '../../../assets/utils/classes_as/AssetManager.php';
$action = (string)($data['action'] ?? '');
$id = (int)($data['id'] ?? 0);
$messages = [
'create' => 'Asset saved as a draft.',
'update' => 'Asset saved.',
'activate' => 'Asset activated. Accounting posts its capitalization from Batch GL Entries.',
'delete' => 'Draft asset deleted.',
'void' => 'Asset voided.',
'dispose' => 'Disposal recorded. Accounting posts it from Batch GL Entries.',
'undo_disposal' => 'Disposal undone.',
];
if (!isset($messages[$action])) {
$answer['message'] = 'Invalid action.';
http_response_code(400);
exit(json_encode($answer));
}
// Staff may prepare assets; everything that changes the books is owner/admin.
require_role($user_role, in_array($action, ['create', 'update'], true) ? ['owner', 'admin', 'staff'] : ['owner', 'admin']);
if ($action !== 'create' && $id <= 0) {
$answer['message'] = 'Missing asset id.';
http_response_code(400);
exit(json_encode($answer));
}
try {
$result_id = dbTransaction($pdo2, function ($pdo) use ($action, $id, $data, $company_id, $user_id, $pdo1) {
$assets = new AssetManager($pdo, $company_id);
$gl = new GlManager($pdo, $company_id, new PostingWindowGuard($pdo1, $company_id));
switch ($action) {
case 'create':
case 'update': return $assets->save($data, $user_id);
case 'activate': $assets->activate($id); break;
case 'delete': $assets->delete($id); break;
case 'void': $assets->void($id, $gl); break;
case 'dispose': $assets->dispose($id, $data); break;
case 'undo_disposal': $assets->undoDisposal($id, $gl); break;
}
return $id;
});
if ($action === 'create') {
(new UsageGuard($pdo1, $company_id, $packages))->increment();
}
$answer['success'] = 1;
$answer['output'] = ['id' => $result_id];
$answer['message'] = $messages[$action];
} catch (PDOException $e) {
$answer['message'] = 'Database error, please try again.';
http_response_code(500);
} catch (Exception $e) {
$answer['message'] = $e->getMessage();
http_response_code(400);
}
exit(json_encode($answer));
+22
View File
@@ -0,0 +1,22 @@
<?php
session_start();
require_once '../../../assets/utils/db_auth.php';
require_app_access('asset');
require_role($user_role, ['owner', 'admin']);
require_once '../../../assets/utils/classes_as/AssetCategoryManager.php';
try {
$id = (new AssetCategoryManager($pdo2, $company_id))->save($data);
$answer['success'] = 1;
$answer['output'] = ['id' => $id];
$answer['message'] = !empty($data['id']) ? 'Category updated.' : 'Category created.';
} catch (PDOException $e) {
error_log('[asset category] ' . $e->getMessage());
$answer['message'] = 'Database error, please try again.';
http_response_code(500);
} catch (Exception $e) {
$answer['message'] = $e->getMessage();
http_response_code(400);
}
exit(json_encode($answer));
+15
View File
@@ -0,0 +1,15 @@
<?php
session_start();
require_once '../../../assets/utils/db_auth.php';
require_app_access('asset');
require_role($user_role, ['owner', 'admin']);
require_once '../../../assets/utils/classes_as/AssetCategoryManager.php';
$id = (int)($data['id'] ?? 0);
if (!$id) { $answer['message'] = 'Missing id'; exit(json_encode($answer)); }
(new AssetCategoryManager($pdo2, $company_id))->deactivate($id);
$answer['success'] = 1;
$answer['message'] = 'Category deactivated.';
exit(json_encode($answer));
+19
View File
@@ -0,0 +1,19 @@
<?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));
@@ -0,0 +1,15 @@
<?php
session_start();
require_once '../../../assets/utils/db_auth.php';
require_app_access('asset');
require_once '../../../assets/utils/classes_as/AssetCategoryManager.php';
$id = (int)($data['id'] ?? 0);
if (!$id) { $answer['message'] = 'Missing id'; exit(json_encode($answer)); }
$row = (new AssetCategoryManager($pdo2, $company_id))->getById($id);
if (!$row) { $answer['message'] = 'Category not found'; exit(json_encode($answer)); }
$answer['output'] = $row;
$answer['success'] = 1;
exit(json_encode($answer));
+158
View File
@@ -0,0 +1,158 @@
<?php
session_start();
require '../config.php';
require '../include_header.php';
?>
<body>
<?php require '../include_topbar.php'; ?>
<?php require '../include_sidebar_asset.php'; ?>
<main id="content" class="content py-15">
<div class="container-fluid">
<!-- Page header -->
<div class="row">
<div class="col-12">
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center mb-5 gap-3">
<div>
<h1 class="fs-3 mb-1">Asset Categories</h1>
<p class="mb-0">Useful life and GL accounts used by each kind of asset</p>
</div>
<div>
<a href="<?php echo $server_url?>asset/manage_category.php" class="btn btn-primary" data-min-role="admin">
<i class="ti ti-plus me-1"></i>Add Category
</a>
</div>
</div>
</div>
</div>
<!-- Table -->
<div class="row">
<div class="col-12">
<div class="card overflow-hidden">
<div class="card-body p-6">
<div class="mb-5 d-flex justify-content-between align-items-center gap-3">
<span class="text-muted small" id="category_summary"></span>
<div class="position-relative" style="min-width:240px;">
<input type="search" id="search_category" class="form-control ps-9" placeholder="Search code or name…">
<span class="position-absolute top-25 ms-4">
<i class="ti ti-search text-muted"></i>
</span>
</div>
</div>
<div class="table-responsive">
<table class="table mb-0 text-nowrap table-hover table-centered" id="category_table">
<thead class="table-primary border-light">
<tr>
<th>Code</th>
<th>Name</th>
<th>Class</th>
<th class="text-end">Life (months)</th>
<th>Cost / Accumulated / Expense</th>
<th class="text-end">Assets</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody></tbody>
<tfoot></tfoot>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<?php require '../include_ending.php'; ?>
<script src="<?php echo $server_url?>assets/js/asset.js"></script>
<script>
var search_term = '';
function retrieve_categories() {
return ajax_request({
url: server_url + 'asset/api/engine/category.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
onSuccess: function(res) {
alasql('CREATE TABLE IF NOT EXISTS md_asset_category');
alasql.tables.md_asset_category.data = res.output || [];
var s = res.stats || {};
$('#category_summary').text((s.total || 0) + ' categories · ' + (s.active || 0) + ' active');
change_page(1);
}
});
}
function change_page(page_num) {
var offset = (page_num - 1) * prop_limit;
var kw = search_term.toLowerCase();
var rows = alasql('SELECT * FROM md_asset_category ORDER BY category_code').filter(function(r) {
return !kw || String(r.category_code).toLowerCase().indexOf(kw) !== -1 || String(r.category_name).toLowerCase().indexOf(kw) !== -1;
});
$('table#category_table tfoot').html(generate_pagination('category_table', rows.length));
if (!rows.length) {
$('table#category_table tbody').html('<tr><td colspan="8" class="text-center py-5 text-muted">No categories yet. Add one before creating assets.</td></tr>');
return;
}
var body = '';
rows.slice(offset, offset + parseInt(prop_limit, 10)).forEach(function(item) {
body += `<tr>
<td class="py-3 fw-semibold">${escape_html(item.category_code)}</td>
<td class="py-3">${escape_html(item.category_name)}</td>
<td class="py-3">${asset_class_badge(item.asset_class)}</td>
<td class="py-3 text-end">${item.useful_life_months}</td>
<td class="py-3 small text-muted">${escape_html(item.cost_account_code)} / ${escape_html(item.accum_account_code)} / ${escape_html(item.expense_account_code)}</td>
<td class="py-3 text-end">${item.asset_count}</td>
<td class="py-3">${String(item.status) === '1' ? '<span class="badge bg-success">Active</span>' : '<span class="badge bg-secondary">Inactive</span>'}</td>
<td class="py-3">
<a href="${server_url}asset/manage_category.php?id=${item.id}"><i class="ti ti-eye fs-5"></i></a>
${String(item.status) === '1' ? `<a href="javascript:;" class="link-danger ms-2" data-min-role="admin" onclick="remove_category(${item.id})"><i class="ti ti-ban fs-5"></i></a>` : ''}
</td>
</tr>`;
});
$('table#category_table tbody').html(body);
}
function remove_category(id) {
bootbox.confirm({
message: 'Deactivate this category? Existing assets keep using its accounts.',
buttons: { confirm: { label: 'Deactivate', className: 'btn-danger' }, cancel: { label: 'Back', className: 'btn-secondary' } },
callback: function(ok) {
if (!ok) return;
ajax_request({
url: server_url + 'asset/api/engine/remove_category.php',
autoPrepare: true,
checkRequired: 0,
action: 'delete',
data: { id: id },
onSuccess: function() { retrieve_categories(); }
});
}
});
}
$('#search_category').on('input', debounce(function() {
search_term = $(this).val().trim();
change_page(1);
}, 200));
$(async function() {
try { await retrieve_categories(); } catch (e) { console.log(e); }
});
</script>
</body>
</html>
+317
View File
@@ -0,0 +1,317 @@
<?php
session_start();
require '../config.php';
require '../include_header.php';
?>
<body>
<?php require '../include_topbar.php'; ?>
<?php require '../include_sidebar_asset.php'; ?>
<main id="content" class="content py-15">
<div class="container-fluid">
<div class="row">
<div class="col-12">
<div class="mb-5">
<h1 class="fs-3 mb-1">Depreciation &amp; Amortization Runs</h1>
<p class="mb-0">Charge each month's straight-line depreciation (tangible) or amortization (intangible)</p>
</div>
</div>
</div>
<!-- New run -->
<div class="card mb-5" data-min-role="admin">
<div class="card-body p-5">
<div class="row g-3 align-items-end">
<div class="col-md-4">
<label class="form-label mb-1">Type</label>
<select id="asset_class" class="form-select">
<option value="tangible">Depreciation — tangible assets</option>
<option value="intangible">Amortization — intangible assets</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label mb-1">Run Through Month</label>
<input type="text" id="through_period" class="form-control" placeholder="YYYY-MM">
</div>
<div class="col-md-5 d-flex gap-2">
<button class="btn btn-secondary" onclick="preview_run()"><i class="ti ti-eye me-1"></i>Preview</button>
<button class="btn btn-primary" id="btn_run" onclick="create_run()" disabled><i class="ti ti-player-play me-1"></i>Create Runs</button>
</div>
</div>
<p class="text-muted small mt-3 mb-0">
One run is created per month still due, oldest first. The in-service month counts as a full month and the
disposal month is not charged. Each run becomes one GL entry that Accounting posts from
<strong>Batch GL Entries</strong>, labelled <strong>[Asset Mgmt]</strong>.
</p>
<div id="preview_area" class="mt-4"></div>
</div>
</div>
<!-- Runs -->
<div class="card">
<div class="card-body p-5">
<div class="d-flex justify-content-between align-items-center mb-4 gap-3">
<h2 class="fs-5 mb-0">Runs</h2>
<select id="filter_class" class="form-select form-select-sm" style="width:220px;">
<option value="">All types</option>
<option value="tangible">Depreciation</option>
<option value="intangible">Amortization</option>
</select>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0 table-centered text-nowrap" id="run_table">
<thead class="table-primary border-light">
<tr>
<th>Run No.</th>
<th>Type</th>
<th>Month</th>
<th>Run Date</th>
<th class="text-end">Assets</th>
<th class="text-end">Amount</th>
<th>GL</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
<tr><td colspan="9" class="text-center text-muted py-5">Loading...</td></tr>
</tbody>
<tfoot></tfoot>
</table>
</div>
</div>
</div>
</div>
</main>
<!-- Run detail -->
<div class="modal fade" id="run_detail_modal" tabindex="-1">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="run_detail_title">Run</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="run_detail_body"></div>
</div>
</div>
</div>
<?php require '../include_ending.php'; ?>
<script src="<?php echo $server_url?>assets/js/asset.js"></script>
<script>
var can_admin = ['owner', 'admin'].includes(user_role);
var all_runs = [];
var last_preview = null;
function run_type_label(asset_class) {
return asset_class === 'intangible' ? 'Amortization' : 'Depreciation';
}
function preview_run() {
last_preview = null;
set_btn_state('#btn_run', false);
return ajax_request({
url: server_url + 'asset/api/engine/depreciation_run.php',
autoPrepare: true,
checkRequired: 0,
action: 'preview',
data: { asset_class: $('#asset_class').val(), through_period: $('#through_period').val() },
onSuccess: function(res) {
var runs = res.output || [];
last_preview = { asset_class: $('#asset_class').val(), through_period: $('#through_period').val(), runs: runs };
render_preview(runs);
set_btn_state('#btn_run', runs.length > 0);
}
});
}
function render_preview(runs) {
if (!runs.length) {
$('#preview_area').html('<div class="alert alert-success small mb-0"><i class="ti ti-circle-check me-1"></i>Nothing is due through this month.</div>');
return;
}
var total = 0;
var html = '<div class="table-responsive"><table class="table table-sm mb-0 table-centered">'
+ '<thead class="table-light"><tr><th>Month</th><th>Run Date</th><th class="text-end">Assets</th><th class="text-end">Amount</th><th></th></tr></thead><tbody>';
runs.forEach(function(r, idx) {
total += parseFloat(r.total) || 0;
html += '<tr>'
+ '<td class="fw-semibold">' + escape_html(r.period) + '</td>'
+ '<td>' + format_date(r.run_date) + '</td>'
+ '<td class="text-end">' + r.asset_count + '</td>'
+ '<td class="text-end">' + format_number(r.total, 2) + '</td>'
+ '<td class="text-end"><a href="javascript:;" class="small" onclick="$(\'#preview_items_' + idx + '\').toggleClass(\'d-none\')">Assets</a></td>'
+ '</tr>'
+ '<tr id="preview_items_' + idx + '" class="d-none"><td colspan="5" class="bg-light">'
+ items_table(r.items, false)
+ '</td></tr>';
});
html += '</tbody><tfoot><tr class="fw-semibold"><td colspan="3">Total</td><td class="text-end">' + format_number(total, 2) + '</td><td></td></tr></tfoot></table></div>';
$('#preview_area').html(html);
}
function items_table(items, with_dept) {
var html = '<table class="table table-sm mb-0"><thead><tr><th>Asset</th><th>Category</th>'
+ (with_dept ? '<th>Dept</th>' : '')
+ '<th class="text-end">Amount</th><th class="text-end">Accumulated</th><th class="text-end">Book Value</th></tr></thead><tbody>';
(items || []).forEach(function(i) {
html += '<tr>'
+ '<td>' + escape_html(i.asset_number) + ' <span class="text-muted">' + escape_html(i.asset_name) + '</span></td>'
+ '<td>' + escape_html((i.category_code ? i.category_code + ' ' : '') + (i.category_name || '')) + '</td>'
+ (with_dept ? '<td>' + escape_html(i.dept_code || '—') + '</td>' : '')
+ '<td class="text-end">' + format_number(i.amount, 2) + '</td>'
+ '<td class="text-end">' + format_number(i.accumulated_after, 2) + '</td>'
+ '<td class="text-end">' + format_number(i.book_value_after, 2) + '</td>'
+ '</tr>';
});
return html + '</tbody></table>';
}
function create_run() {
if (!last_preview || !last_preview.runs.length) return;
var total = last_preview.runs.reduce(function(s, r) { return s + (parseFloat(r.total) || 0); }, 0);
bootbox.confirm({
message: 'Create <strong>' + last_preview.runs.length + '</strong> ' + run_type_label(last_preview.asset_class).toLowerCase()
+ ' run(s) through ' + escape_html(last_preview.through_period) + ', totalling <strong>' + format_number(total, 2) + '</strong>?',
buttons: { confirm: { label: 'Create Runs', className: 'btn-primary' }, cancel: { label: 'Back', className: 'btn-secondary' } },
callback: function(ok) {
if (!ok) return;
ajax_request({
url: server_url + 'asset/api/engine/depreciation_run.php',
autoPrepare: true,
checkRequired: 0,
action: 'run',
data: { asset_class: last_preview.asset_class, through_period: last_preview.through_period },
onSuccess: function(res) {
toastr.success(res.message || 'Runs created.');
$('#preview_area').empty();
set_btn_state('#btn_run', false);
last_preview = null;
load_runs();
}
});
}
});
}
function load_runs() {
return ajax_request({
url: server_url + 'asset/api/engine/depreciation_run.php',
autoPrepare: true,
checkRequired: 0,
action: 'list',
data: { asset_class: '' },
onSuccess: function(res) {
all_runs = res.output || [];
change_page(1);
}
});
}
function change_page(page_num) {
var filter = $('#filter_class').val();
var rows = all_runs.filter(function(r) { return !filter || r.asset_class === filter; });
var limit = parseInt(prop_limit, 10);
var offset = (page_num - 1) * limit;
$('#run_table tfoot').html(generate_pagination('run_table', rows.length));
if (!rows.length) {
$('#run_table tbody').html('<tr><td colspan="9" class="text-center text-muted py-5">No runs yet.</td></tr>');
return;
}
var html = '';
rows.slice(offset, offset + limit).forEach(function(r) {
var active = String(r.status) === '1';
html += '<tr class="' + (active ? '' : 'text-muted') + '">'
+ '<td class="fw-semibold">' + escape_html(r.run_number) + '</td>'
+ '<td>' + run_type_label(r.asset_class) + '</td>'
+ '<td>' + escape_html(r.period) + '</td>'
+ '<td>' + format_date(r.run_date) + '</td>'
+ '<td class="text-end">' + r.asset_count + '</td>'
+ '<td class="text-end">' + format_number(r.total_amount, 2) + '</td>'
+ '<td>' + (active ? asset_gl_badge(r.gl_status) : '—') + '</td>'
+ '<td>' + (active ? '<span class="badge bg-success">Active</span>' : '<span class="badge bg-light text-dark">Void</span>') + '</td>'
+ '<td class="text-end">'
+ (active ? '<a href="javascript:;" onclick="show_run(' + r.id + ')"><i class="ti ti-eye fs-5"></i></a>' : '')
+ (active && can_admin ? '<a href="javascript:;" class="link-danger ms-2" title="Void run" onclick="void_run(' + r.id + ', \'' + escape_html(r.run_number) + '\', ' + r.gl_status + ')"><i class="ti ti-ban fs-5"></i></a>' : '')
+ '</td></tr>';
});
$('#run_table tbody').html(html);
}
function show_run(run_id) {
$('#run_detail_body').html('<div class="text-center text-muted py-5">Loading...</div>');
$('#run_detail_modal').modal('show');
ajax_request({
url: server_url + 'asset/api/engine/depreciation_run.php',
autoPrepare: true,
checkRequired: 0,
action: 'detail',
data: { run_id: run_id },
onSuccess: function(res) {
var run = res.output.run;
$('#run_detail_title').text(run.run_number + ' · ' + run_type_label(run.asset_class) + ' ' + run.period);
$('#run_detail_body').html(
'<div class="small text-muted mb-3">Run date ' + format_date(run.run_date) + ' · '
+ run.asset_count + ' assets · total ' + format_number(run.total_amount, 2) + '</div>'
+ items_table(res.output.items, true)
);
}
});
}
function void_run(run_id, run_number, gl_status) {
var note = String(gl_status) === '1'
? '<br><span class="text-danger">Its GL entry is posted, so a reversal entry will be created.</span>'
: '';
bootbox.confirm({
message: '<strong>Void run ' + run_number + '?</strong><br>Its charges are removed from the assets.' + note,
buttons: { confirm: { label: 'Void', className: 'btn-danger' }, cancel: { label: 'Back', className: 'btn-secondary' } },
callback: function(ok) {
if (!ok) return;
ajax_request({
url: server_url + 'asset/api/engine/depreciation_run.php',
autoPrepare: true,
checkRequired: 0,
action: 'void',
data: { run_id: run_id },
onSuccess: function(res) {
toastr.success(res.message || 'Run voided.');
load_runs();
}
});
}
});
}
$('#filter_class').on('change', function() { change_page(1); });
$('#asset_class, #through_period').on('change', function() {
last_preview = null;
set_btn_state('#btn_run', false);
$('#preview_area').empty();
});
$(async function() {
var params = new URLSearchParams(window.location.search);
if (params.get('class') === 'intangible' || params.get('class') === 'tangible') {
$('#asset_class').val(params.get('class'));
}
flatpickr('#through_period', {
plugins: [new monthSelectPlugin({ shorthand: true, dateFormat: 'Y-m', altFormat: 'M Y' })],
defaultDate: current_period() + '-01',
maxDate: new Date()
});
$('#through_period').val(current_period());
try { await load_runs(); } catch (e) { console.log(e); }
});
</script>
</body>
</html>
+174
View File
@@ -0,0 +1,174 @@
<?php
session_start();
require '../config.php';
require '../include_header.php';
?>
<body>
<?php require '../include_topbar.php'; ?>
<?php require '../include_sidebar_asset.php'; ?>
<main id="content" class="content py-15">
<div class="container-fluid">
<!-- Page header -->
<div class="row">
<div class="col-12">
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center mb-5 gap-3">
<div>
<h1 class="fs-3 mb-1">Asset Register</h1>
<p class="mb-0">Every asset with its cost, accumulated depreciation and book value</p>
</div>
<div class="d-flex gap-2">
<a href="<?php echo $server_url?>asset/purchase_invoices.php" class="btn btn-outline-primary">
<i class="ti ti-file-invoice me-1"></i>From Purchase Invoice
</a>
<a href="<?php echo $server_url?>asset/manage_asset.php" class="btn btn-primary">
<i class="ti ti-plus me-1"></i>New Asset
</a>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card overflow-hidden">
<div class="card-body p-6">
<div class="mb-5 d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3">
<ul class="nav nav-pills small" id="status_filter">
<li class="nav-item"><a class="nav-link active" href="javascript:;" data-status="">All <span class="count"></span></a></li>
<li class="nav-item"><a class="nav-link" href="javascript:;" data-status="0">Draft <span class="count"></span></a></li>
<li class="nav-item"><a class="nav-link" href="javascript:;" data-status="1">Active <span class="count"></span></a></li>
<li class="nav-item"><a class="nav-link" href="javascript:;" data-status="2">Fully depreciated <span class="count"></span></a></li>
<li class="nav-item"><a class="nav-link" href="javascript:;" data-status="3">Disposed <span class="count"></span></a></li>
<li class="nav-item"><a class="nav-link" href="javascript:;" data-status="4">Void <span class="count"></span></a></li>
</ul>
<div class="position-relative" style="min-width:240px;">
<input type="search" id="search_asset" class="form-control ps-9" placeholder="Search number, name, category…">
<span class="position-absolute top-25 ms-4">
<i class="ti ti-search text-muted"></i>
</span>
</div>
</div>
<div class="table-responsive">
<table class="table mb-0 text-nowrap table-hover table-centered" id="asset_table">
<thead class="table-primary border-light">
<tr>
<th>Asset No.</th>
<th>Name</th>
<th>Category</th>
<th>In Service</th>
<th class="text-end">Cost</th>
<th class="text-end">Accumulated</th>
<th class="text-end">Book Value</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody></tbody>
<tfoot></tfoot>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<?php require '../include_ending.php'; ?>
<script src="<?php echo $server_url?>assets/js/asset.js"></script>
<script>
var all_assets = [];
var search_term = '';
var status_filter = new URLSearchParams(window.location.search).get('status') || '';
function retrieve_assets() {
return ajax_request({
url: server_url + 'asset/api/engine/asset.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
onSuccess: function(res) {
all_assets = res.output || [];
update_counts();
change_page(1);
}
});
}
function update_counts() {
$('#status_filter a').each(function() {
var s = String($(this).data('status'));
var n = s === '' ? all_assets.length : all_assets.filter(function(a) { return String(a.status) === s; }).length;
$(this).find('.count').text('(' + n + ')');
$(this).toggleClass('active', s === status_filter);
});
}
function filtered_assets() {
var kw = search_term.toLowerCase();
return all_assets.filter(function(a) {
if (status_filter !== '' && String(a.status) !== status_filter) return false;
if (!kw) return true;
return [a.asset_number, a.asset_name, a.category_code, a.category_name, a.invoice_number]
.some(function(v) { return String(v || '').toLowerCase().indexOf(kw) !== -1; });
});
}
function change_page(page_num) {
var rows = filtered_assets();
var limit = parseInt(prop_limit, 10);
var offset = (page_num - 1) * limit;
$('table#asset_table tfoot').html(generate_pagination('asset_table', rows.length));
if (!rows.length) {
$('table#asset_table tbody').html('<tr><td colspan="9" class="text-center py-5 text-muted">No assets found.</td></tr>');
return;
}
var body = '';
rows.slice(offset, offset + limit).forEach(function(a) {
var source = a.invoice_number
? '<div class="small text-muted"><i class="ti ti-file-invoice me-1"></i>' + escape_html(a.invoice_number) + '</div>'
: '';
body += `<tr>
<td class="py-3 fw-semibold">${escape_html(a.asset_number)}</td>
<td class="py-3">${escape_html(a.asset_name)}${source}</td>
<td class="py-3">${escape_html(a.category_code)} <span class="text-muted">${escape_html(a.category_name)}</span></td>
<td class="py-3">${format_date(a.in_service_date)}</td>
<td class="py-3 text-end">${format_number(a.cost, 2)}</td>
<td class="py-3 text-end">${format_number(a.accumulated, 2)}</td>
<td class="py-3 text-end">${format_number(a.book_value, 2)}</td>
<td class="py-3">${asset_status_badge(a.status, a.asset_class)}</td>
<td class="py-3"><a href="${server_url}asset/manage_asset.php?id=${a.id}"><i class="ti ti-eye fs-5"></i></a></td>
</tr>`;
});
$('table#asset_table tbody').html(body);
}
$('#status_filter').on('click', 'a', function() {
status_filter = String($(this).data('status'));
update_counts();
change_page(1);
});
$('#search_asset').on('input', debounce(function() {
search_term = $(this).val().trim();
change_page(1);
}, 200));
$(async function() {
try { await retrieve_assets(); } catch (e) { console.log(e); }
});
</script>
</body>
</html>
+595
View File
@@ -0,0 +1,595 @@
<?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(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(a.acquisition_date));
$('#in_service_date').val(format_date(a.in_service_date));
$('#cost').val(parseFloat(a.cost).toFixed(2));
$('#salvage_value').val(parseFloat(a.salvage_value).toFixed(2));
$('#useful_life_months').val(a.useful_life_months);
$('#description').val(a.description || '');
load_departments('department_id', a.department_id);
life_touched = true;
update_calc();
// Status card
$('#status_card').removeClass('d-none');
$('#status_badges').html(asset_status_badge(a.status, a.asset_class) + asset_class_badge(a.asset_class));
$('#display_accumulated').text(format_number(a.accumulated, 2));
$('#display_book_value').text(format_number(a.book_value, 2));
$('#display_last_period').text(a.last_period || '—');
if (parseInt(a.invoice_id, 10) > 0) {
var ref = escape_html(a.invoice_number || ('PI #' + a.invoice_id));
$('#display_source').html(pi_link_ok
? '<a href="' + server_url + 'expense/manage_purchase_invoice.php?id=' + a.invoice_id + '">' + ref + '</a>'
: ref);
$('#source_row').removeClass('d-none');
}
// GL entries
var cap_html;
if (!a.clearing_account_code) {
cap_html = '<span class="text-muted">No entry — no clearing account.</span>';
} else if (status === 0) {
cap_html = '<span class="text-muted">Activate the asset, then Accounting posts it.</span>';
} else {
cap_html = gl_entry_html(detail.gl.capitalization);
}
$('#gl_capitalization').html(cap_html);
$('#gl_disposal_row').toggleClass('d-none', status !== 3);
$('#gl_disposal').html(gl_entry_html(detail.gl.disposal));
// Disposal
if (status === 3) {
var gain = round_dp(parseFloat(a.sale_price) - (parseFloat(a.cost) - parseFloat(a.accumulated)), 2);
$('#display_disposal_date').text(format_date(a.disposal_date));
$('#display_sale_price').text(format_number(a.sale_price, 2));
$('#display_gain_loss').text(gain < 0 ? '(' + format_number(-gain, 2) + ')' : format_number(gain, 2))
.toggleClass('text-danger', gain < 0).toggleClass('text-success', gain > 0);
$('#display_disposal_notes').text(a.disposal_notes || '');
$('#disposal_info').removeClass('d-none');
} else {
$('#disposal_info').addClass('d-none');
}
render_schedule();
// Editing rules
$(FINANCIAL_FIELDS).prop('disabled', closed || recorded || !can_edit);
$('#asset_name, #description, #department_id').prop('disabled', closed || !can_edit);
$('#locked_hint').toggleClass('d-none', !(recorded && !closed));
$('#btn_save').toggleClass('d-none', closed || !can_edit).find('span').text(status === 0 ? 'Save Draft' : 'Save');
$('#btn_activate').toggleClass('d-none', !(status === 0 && can_admin));
$('#btn_dispose').toggleClass('d-none', !((status === 1 || status === 2) && can_admin));
$('#btn_undo_disposal').toggleClass('d-none', !(status === 3 && can_admin));
$('#btn_void').toggleClass('d-none', !((status === 1 || status === 2) && can_admin && !recorded));
$('#btn_delete').toggleClass('d-none', !(status === 0 && can_admin));
}
function gl_entry_html(entry) {
if (entry) {
return '<span class="badge bg-success-subtle text-success">Posted</span>'
+ ' <span class="text-muted ms-1">' + escape_html(entry.reference) + ' · ' + format_date(entry.journal_date) + '</span>';
}
return '<span class="badge bg-danger-subtle text-danger">Not posted</span>'
+ ' <span class="text-muted ms-1">Accounting → Batch GL Entries</span>';
}
function render_schedule() {
var rows = detail.schedule || [];
if (!rows.length) { $('#schedule_card').addClass('d-none'); return; }
var state_badge = {
recorded: function(r) { return '<span class="badge bg-success-subtle text-success">Charged</span> <span class="small text-muted">' + escape_html(r.run_number || '') + '</span>'; },
projected: function() { return '<span class="badge bg-light text-dark">Projected</span>'; },
stopped: function() { return '<span class="badge bg-secondary-subtle text-secondary">Not charged</span>'; }
};
var html = '';
var charged = 0;
rows.forEach(function(r, i) {
if (r.state === 'recorded') charged++;
html += '<tr class="' + (r.state === 'stopped' ? 'text-muted' : '') + '">'
+ '<td>' + (i + 1) + '</td>'
+ '<td>' + escape_html(r.period) + '</td>'
+ '<td class="text-end">' + format_number(r.amount, 2) + '</td>'
+ '<td class="text-end">' + format_number(r.accumulated, 2) + '</td>'
+ '<td class="text-end">' + format_number(r.book_value, 2) + '</td>'
+ '<td>' + state_badge[r.state](r) + '</td>'
+ '</tr>';
});
$('#schedule_tbody').html(html);
$('#schedule_summary').text(charged + ' of ' + rows.length + ' months charged · ' + format_number(detail.monthly_amount, 2) + ' per month');
$('#schedule_card').removeClass('d-none');
}
// ── actions ────────────────────────────────────────────────────────────────
function save_asset() {
return ajax_request({
url: server_url + 'asset/api/engine/manage_asset.php',
autoPrepare: true,
checkRequired: 1,
action: 'manage',
data: { id: asset_id || '', invoice_id: invoice_id, invoice_item_id: item_id },
onSuccess: function(res) {
var saved_id = res.output && res.output.id;
if (!asset_id && saved_id) {
window.location.href = server_url + 'asset/manage_asset.php?id=' + saved_id;
return;
}
toastr.success(res.message || 'Saved.');
retrieve_asset();
}
});
}
var ACTION_CONFIRM = {
activate: ['Activate this asset? It will start appearing in depreciation runs from its in-service month.', 'Activate', 'btn-success'],
undo_disposal: ['Undo the disposal? If its GL entry was posted, a reversal entry is created.', 'Undo Disposal', 'btn-warning'],
void: ['<strong>Void this asset?</strong><br>If its capitalization was posted, a reversal entry is created. This cannot be undone.', 'Void', 'btn-danger'],
delete: ['Delete this draft asset?', 'Delete', 'btn-danger']
};
function asset_action(action) {
var c = ACTION_CONFIRM[action];
bootbox.confirm({
message: c[0],
buttons: { confirm: { label: c[1], className: c[2] }, cancel: { label: 'Back', className: 'btn-secondary' } },
callback: function(ok) {
if (!ok) return;
ajax_request({
url: server_url + 'asset/api/engine/manage_asset.php',
autoPrepare: false,
action: action,
data: { json: JSON.stringify({
otp: document.getElementById('session-context').dataset.otp,
company_id: company_id,
action: action,
id: asset_id
}) },
onSuccess: function(res) {
if (action === 'delete') { window.location.href = server_url + 'asset/index.php'; return; }
toastr.success(res.message || 'Done.');
retrieve_asset();
}
});
}
});
}
function open_disposal() {
var a = detail.asset;
$('#proceeds_account_code').html(account_options(accounts, ['asset'], '', '— None (write-off) —'));
$('#sale_price').val('0');
$('#disposal_notes').val('');
$('#dp_cost').text(format_number(a.cost, 2));
$('#dp_accumulated').text(format_number(a.accumulated, 2));
$('#dp_book').text(format_number(a.book_value, 2));
update_disposal_result();
$('#disposal_modal').modal('show');
}
function update_disposal_result() {
if (!detail) return;
var gain = round_dp((parseFloat($('#sale_price').val()) || 0) - parseFloat(detail.asset.book_value), 2);
$('#dp_result_label').text(gain < 0 ? 'Loss' : 'Gain');
$('#dp_result').text(format_number(Math.abs(gain), 2)).toggleClass('text-danger', gain < 0).toggleClass('text-success', gain > 0);
}
function submit_disposal() {
ajax_request({
url: server_url + 'asset/api/engine/manage_asset.php',
autoPrepare: false,
action: 'dispose',
data: { json: JSON.stringify({
otp: document.getElementById('session-context').dataset.otp,
company_id: company_id,
action: 'dispose',
id: asset_id,
disposal_date: $('#disposal_date').val(),
sale_price: $('#sale_price').val(),
proceeds_account_code: $('#proceeds_account_code').val(),
disposal_notes: $('#disposal_notes').val()
}) },
onSuccess: function(res) {
$('#disposal_modal').modal('hide');
toastr.success(res.message || 'Disposal recorded.');
retrieve_asset();
}
});
}
// ── events ─────────────────────────────────────────────────────────────────
$('#cost, #salvage_value, #useful_life_months').on('input', update_calc);
$('#useful_life_months').on('input', function() { life_touched = true; });
$('#category_id').on('change', function() {
var c = category_by_id($(this).val());
if (c && !life_touched) {
$('#useful_life_months').val(c.useful_life_months);
update_calc();
}
update_class_hint();
});
$('#sale_price').on('input', update_disposal_result);
$(async function() {
flatpickr('#acquisition_date, #in_service_date, #disposal_date', { dateFormat: 'd/m/Y', allowInput: true });
try {
await load_categories();
await load_posting_accounts(function(rows) { accounts = rows; });
fill_category_select('');
$('#clearing_account_code').html(account_options(accounts, [], '', '— None —'));
if (asset_id) {
await retrieve_asset();
} else {
load_departments('department_id', 0);
if (invoice_id && item_id) await load_invoice_line();
if (!can_edit) $('#btn_save').addClass('d-none');
}
} catch (e) { console.log(e); }
});
</script>
</body>
</html>
+179
View File
@@ -0,0 +1,179 @@
<?php
session_start();
require '../config.php';
require '../include_header.php';
?>
<body>
<?php require '../include_topbar.php'; ?>
<?php require '../include_sidebar_asset.php'; ?>
<main id="content" class="content py-15">
<div class="container-fluid">
<div class="row">
<div class="col-12">
<div class="d-flex align-items-center mb-5 gap-3">
<a href="<?php echo $server_url?>asset/categories.php" class="btn btn-light btn-sm">
<i class="ti ti-arrow-left me-1"></i>Back
</a>
<h1 class="fs-3 mb-0">Manage Asset Category</h1>
</div>
</div>
</div>
<div class="row" id="categoryForm">
<div class="col-lg-8">
<div class="card">
<div class="card-body p-6">
<input type="hidden" id="id">
<div class="row g-4">
<div class="col-md-3">
<label class="form-label">Code <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="category_code" placeholder="e.g. IT" required>
</div>
<div class="col-md-5">
<label class="form-label">Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="category_name" placeholder="e.g. Computer equipment" required>
</div>
<div class="col-md-4">
<label class="form-label">Status</label>
<select class="form-select" id="status">
<option value="1">Active</option>
<option value="0">Inactive</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Asset Class <span class="text-danger">*</span></label>
<select class="form-select" id="asset_class" required>
<option value="tangible">Tangible — straight-line depreciation</option>
<option value="intangible">Intangible — straight-line amortization</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Default Useful Life (months) <span class="text-danger">*</span></label>
<input type="number" class="form-control" id="useful_life_months" min="1" max="1200" step="1" value="60" required>
</div>
<div class="col-12">
<h2 class="fs-6 mb-0 mt-2">GL Accounts</h2>
<p class="text-muted small mb-0">Used when Accounting posts this category's entries from Batch GL Entries.</p>
</div>
<div class="col-md-6">
<label class="form-label">Asset Cost Account <span class="text-danger">*</span></label>
<select class="form-select" id="cost_account_code" required></select>
</div>
<div class="col-md-6">
<label class="form-label"><span class="label-accum">Accumulated Depreciation</span> Account <span class="text-danger">*</span></label>
<select class="form-select" id="accum_account_code" required></select>
</div>
<div class="col-md-6">
<label class="form-label"><span class="label-expense">Depreciation</span> Expense Account <span class="text-danger">*</span></label>
<select class="form-select" id="expense_account_code" required></select>
</div>
<div class="col-md-6"></div>
<div class="col-md-6">
<label class="form-label">Gain on Disposal Account <span class="text-danger">*</span></label>
<select class="form-select" id="gain_account_code" required></select>
</div>
<div class="col-md-6">
<label class="form-label">Loss on Disposal Account <span class="text-danger">*</span></label>
<select class="form-select" id="loss_account_code" required></select>
</div>
<div class="col-12">
<label class="form-label">Description</label>
<textarea class="form-control" id="description" rows="2" placeholder="Optional"></textarea>
</div>
</div>
<div class="d-flex gap-2 mt-5">
<button class="btn btn-primary" onclick="manage_category();" id="btn_submit" data-min-role="admin">Create Category</button>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<?php require '../include_ending.php'; ?>
<script src="<?php echo $server_url?>assets/js/asset.js"></script>
<script>
var category_id = <?php echo (int)($_GET['id'] ?? 0); ?>;
var accounts = [];
var ACCOUNT_SELECTS = {
cost_account_code: ['asset'],
accum_account_code: ['asset'],
expense_account_code: ['expense'],
gain_account_code: ['revenue'],
loss_account_code: ['expense']
};
function fill_account_selects(values) {
Object.keys(ACCOUNT_SELECTS).forEach(function(field) {
$('#' + field).html(account_options(accounts, ACCOUNT_SELECTS[field], (values || {})[field] || ''));
});
}
function update_class_labels() {
var intangible = $('#asset_class').val() === 'intangible';
$('.label-accum').text(intangible ? 'Accumulated Amortization' : 'Accumulated Depreciation');
$('.label-expense').text(intangible ? 'Amortization' : 'Depreciation');
}
function manage_category() {
return ajax_request({
url: server_url + 'asset/api/engine/manage_category.php',
autoPrepare: true,
checkRequired: 1,
action: 'manage',
onSuccess: function() {
window.location.href = server_url + 'asset/categories.php';
}
});
}
function retrieve_for_edit() {
if (!category_id) return Promise.resolve();
return ajax_request({
url: server_url + 'asset/api/engine/retrieve_category.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
data: { id: category_id },
onSuccess: function(res) {
var c = res.output;
$('#id').val(c.id);
$('#category_code').val(c.category_code);
$('#category_name').val(c.category_name);
$('#asset_class').val(c.asset_class);
$('#useful_life_months').val(c.useful_life_months);
$('#description').val(c.description || '');
$('#status').val(c.status);
fill_account_selects(c);
update_class_labels();
$('#btn_submit').text('Update Category');
}
});
}
$('#asset_class').on('change', update_class_labels);
$(async function() {
try {
await load_posting_accounts(function(rows) { accounts = rows; fill_account_selects({}); });
await retrieve_for_edit();
} catch (e) { console.log(e); }
});
</script>
</body>
</html>
+173
View File
@@ -0,0 +1,173 @@
<?php
session_start();
require '../config.php';
require '../include_header.php';
$pi_link_ok = in_array($_SESSION['login_app_access'] ?? 'wms', ['all', 'accounting'], true);
?>
<body>
<?php require '../include_topbar.php'; ?>
<?php require '../include_sidebar_asset.php'; ?>
<main id="content" class="content py-15">
<div class="container-fluid">
<div class="row">
<div class="col-12">
<div class="mb-5">
<h1 class="fs-3 mb-1">Assets from Purchase Invoices</h1>
<p class="mb-0">Issued purchase invoice lines (AP) that can be capitalized as assets</p>
</div>
</div>
</div>
<div class="card mb-5">
<div class="card-body p-4">
<div class="row g-2 align-items-end">
<div class="col-auto">
<label class="form-label mb-1 small">Invoice Date From</label>
<input type="text" id="date_from" class="form-control form-control-sm" placeholder="d/m/Y" style="width:140px;">
</div>
<div class="col-auto">
<label class="form-label mb-1 small">Invoice Date To</label>
<input type="text" id="date_to" class="form-control form-control-sm" placeholder="d/m/Y" style="width:140px;">
</div>
<div class="col-auto">
<button class="btn btn-secondary btn-sm" onclick="load_lines()">
<i class="ti ti-search me-1"></i>Load
</button>
</div>
<div class="col-auto ms-3">
<div class="form-check mb-1">
<input class="form-check-input" type="checkbox" id="show_capitalized">
<label class="form-check-label small" for="show_capitalized">Show fully capitalized lines</label>
</div>
</div>
<div class="col ms-auto d-flex justify-content-end">
<div class="position-relative" style="min-width:220px;">
<input type="search" id="search_line" class="form-control form-control-sm ps-8" placeholder="Search invoice, supplier, item…">
<span class="position-absolute top-25 ms-3"><i class="ti ti-search text-muted small"></i></span>
</div>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0 table-centered text-nowrap" id="line_table">
<thead class="table-primary border-light">
<tr>
<th>Invoice</th>
<th>Date</th>
<th>Supplier</th>
<th>Item</th>
<th class="text-end">Qty</th>
<th class="text-end">Line Amount</th>
<th class="text-end">Capitalized</th>
<th class="text-end">Remaining</th>
<th></th>
</tr>
</thead>
<tbody>
<tr><td colspan="9" class="text-center text-muted py-5">Use the filters above and click Load.</td></tr>
</tbody>
<tfoot></tfoot>
</table>
</div>
</div>
</div>
</div>
</main>
<?php require '../include_ending.php'; ?>
<script src="<?php echo $server_url?>assets/js/asset.js"></script>
<script>
var pi_link_ok = <?php echo $pi_link_ok ? 'true' : 'false'; ?>;
var all_lines = [];
var search_term = '';
function load_lines() {
return ajax_request({
url: server_url + 'asset/api/engine/invoice_lines.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
data: { date_from: $('#date_from').val(), date_to: $('#date_to').val() },
onSuccess: function(res) {
all_lines = res.output || [];
change_page(1);
}
});
}
function visible_lines() {
var show_all = $('#show_capitalized').is(':checked');
var kw = search_term.toLowerCase();
return all_lines.filter(function(l) {
if (!show_all && parseFloat(l.remaining) <= 0) return false;
if (!kw) return true;
return [l.invoice_number, l.contact_name, l.product_sku, l.product_name]
.some(function(v) { return String(v || '').toLowerCase().indexOf(kw) !== -1; });
});
}
function change_page(page_num) {
var rows = visible_lines();
var limit = parseInt(prop_limit, 10);
var offset = (page_num - 1) * limit;
$('#line_table tfoot').html(generate_pagination('line_table', rows.length));
if (!rows.length) {
$('#line_table tbody').html('<tr><td colspan="9" class="text-center text-muted py-5">No invoice lines to capitalize in this date range.</td></tr>');
return;
}
var html = '';
rows.slice(offset, offset + limit).forEach(function(l) {
var invoice = pi_link_ok
? '<a href="' + server_url + 'expense/manage_purchase_invoice.php?id=' + l.invoice_id + '">' + escape_html(l.invoice_number) + '</a>'
: escape_html(l.invoice_number);
var action = parseFloat(l.remaining) > 0
? '<a class="btn btn-sm btn-primary" href="' + server_url + 'asset/manage_asset.php?invoice_id=' + l.invoice_id + '&item_id=' + l.item_id + '">'
+ '<i class="ti ti-plus me-1"></i>Create Asset</a>'
: '<span class="badge bg-success-subtle text-success">Capitalized</span>';
var assets = parseInt(l.asset_count, 10) > 0
? ' <span class="small text-muted">(' + l.asset_count + ' asset' + (l.asset_count == 1 ? '' : 's') + ')</span>'
: '';
html += '<tr>'
+ '<td>' + invoice + '</td>'
+ '<td>' + format_date(l.issued_date) + '</td>'
+ '<td>' + escape_html(l.contact_name || '—') + '</td>'
+ '<td>' + escape_html(l.product_name || l.product_sku) + '<div class="small text-muted">' + escape_html(l.product_sku) + '</div></td>'
+ '<td class="text-end">' + format_number(l.quantity, 2) + '</td>'
+ '<td class="text-end">' + format_number(l.line_amount, 2) + '</td>'
+ '<td class="text-end">' + format_number(l.capitalized, 2) + assets + '</td>'
+ '<td class="text-end fw-semibold">' + format_number(l.remaining, 2) + '</td>'
+ '<td class="text-end">' + action + '</td>'
+ '</tr>';
});
$('#line_table tbody').html(html);
}
$('#show_capitalized').on('change', function() { change_page(1); });
$('#search_line').on('input', debounce(function() {
search_term = $(this).val().trim();
change_page(1);
}, 200));
$(function() {
var today = new Date();
var start = new Date(today.getFullYear(), 0, 1);
flatpickr('#date_from', { dateFormat: 'd/m/Y', allowInput: true, defaultDate: start });
flatpickr('#date_to', { dateFormat: 'd/m/Y', allowInput: true, defaultDate: today });
load_lines().catch(function(e) { console.log(e); });
});
</script>
</body>
</html>
+267
View File
@@ -0,0 +1,267 @@
<?php
session_start();
require '../config.php';
require '../include_header.php';
?>
<body>
<?php require '../include_topbar.php'; ?>
<?php require '../include_sidebar_asset.php'; ?>
<main id="content" class="content py-15">
<div class="container-fluid">
<!-- Page header -->
<div class="row">
<div class="col-12">
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center mb-6 gap-3">
<div>
<h1 class="fs-3 mb-1">Asset Management</h1>
<p class="mb-0">Fixed assets, depreciation and amortization</p>
</div>
<div class="d-flex gap-2">
<a href="<?php echo $server_url?>asset/purchase_invoices.php" class="btn btn-outline-primary">
<i class="ti ti-file-invoice me-1"></i>From Purchase Invoice
</a>
<a href="<?php echo $server_url?>asset/manage_asset.php" class="btn btn-primary">
<i class="ti ti-plus me-1"></i>New Asset
</a>
</div>
</div>
</div>
</div>
<!-- Summary cards -->
<div class="row g-5 mb-5">
<div class="col-lg-3 col-sm-6 col-12">
<div class="card p-4 bg-success bg-opacity-10 border border-success border-opacity-25 rounded-2">
<div class="d-flex gap-3">
<div class="icon-shape icon-md bg-success text-white rounded-2">
<i class="ti ti-building-warehouse fs-4"></i>
</div>
<div>
<h2 class="mb-3 fs-6">Assets in Use</h2>
<h3 class="fw-bold mb-0" id="stat_in_use">—</h3>
<p class="text-success mb-0 small" id="stat_in_use_note">&nbsp;</p>
</div>
</div>
</div>
</div>
<div class="col-lg-3 col-sm-6 col-12">
<div class="card p-4 bg-info bg-opacity-10 border border-info border-opacity-25 rounded-2">
<div class="d-flex gap-3">
<div class="icon-shape icon-md bg-info text-white rounded-2">
<i class="ti ti-cash fs-4"></i>
</div>
<div>
<h2 class="mb-3 fs-6">Cost</h2>
<h3 class="fw-bold mb-0" id="stat_cost">—</h3>
<p class="text-info mb-0 small">Assets in use</p>
</div>
</div>
</div>
</div>
<div class="col-lg-3 col-sm-6 col-12">
<div class="card p-4 bg-warning bg-opacity-10 border border-warning border-opacity-25 rounded-2">
<div class="d-flex gap-3">
<div class="icon-shape icon-md bg-warning text-white rounded-2">
<i class="ti ti-trending-down fs-4"></i>
</div>
<div>
<h2 class="mb-3 fs-6">Accumulated</h2>
<h3 class="fw-bold mb-0" id="stat_accumulated">—</h3>
<p class="text-warning mb-0 small">Depreciation + amortization</p>
</div>
</div>
</div>
</div>
<div class="col-lg-3 col-sm-6 col-12">
<div class="card p-4 bg-primary bg-opacity-10 border border-primary border-opacity-25 rounded-2">
<div class="d-flex gap-3">
<div class="icon-shape icon-md bg-primary text-white rounded-2">
<i class="ti ti-scale fs-4"></i>
</div>
<div>
<h2 class="mb-3 fs-6">Net Book Value</h2>
<h3 class="fw-bold mb-0" id="stat_book">—</h3>
<p class="text-primary mb-0 small">Cost minus accumulated</p>
</div>
</div>
</div>
</div>
</div>
<div class="row g-5 mb-5">
<!-- Due -->
<div class="col-lg-5">
<div class="card h-100">
<div class="card-body p-5">
<h2 class="fs-5 mb-1">Due this month</h2>
<p class="text-muted small mb-4">Months not yet charged, up to <span id="due_month"></span></p>
<div id="due_list"><div class="text-muted small">Loading...</div></div>
<div class="border-top pt-3 mt-3 small">
<span class="text-muted">Drafts waiting to be activated:</span>
<a href="<?php echo $server_url?>asset/index.php?status=0" class="fw-semibold ms-1" id="stat_draft">—</a>
</div>
</div>
</div>
</div>
<!-- Recent runs -->
<div class="col-lg-7">
<div class="card h-100">
<div class="card-body p-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="fs-5 mb-0">Recent runs</h2>
<a href="<?php echo $server_url?>asset/depreciation.php" class="small">All runs</a>
</div>
<div class="table-responsive">
<table class="table table-sm mb-0 table-centered">
<thead class="table-light">
<tr>
<th>Run No.</th>
<th>Month</th>
<th class="text-end">Amount</th>
<th>GL</th>
</tr>
</thead>
<tbody id="runs_tbody">
<tr><td colspan="4" class="text-center text-muted py-4">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- By category -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-body p-5">
<h2 class="fs-5 mb-4">Assets in use by category</h2>
<div class="table-responsive">
<table class="table mb-0 table-hover table-centered">
<thead class="table-primary border-light">
<tr>
<th>Category</th>
<th>Class</th>
<th class="text-end">Assets</th>
<th class="text-end">Cost</th>
<th class="text-end">Accumulated</th>
<th class="text-end">Net Book Value</th>
</tr>
</thead>
<tbody id="category_tbody">
<tr><td colspan="6" class="text-center text-muted py-4">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<?php require '../include_ending.php'; ?>
<script src="<?php echo $server_url?>assets/js/asset.js"></script>
<script>
function load_stats() {
return ajax_request({
url: server_url + 'asset/api/engine/asset_stats.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
onSuccess: function(res) {
var s = res.output || {};
$('#stat_in_use').text(parseInt(s.active || 0, 10) + parseInt(s.fully_depreciated || 0, 10));
$('#stat_in_use_note').text((s.fully_depreciated || 0) + ' fully depreciated · ' + (s.disposed || 0) + ' disposed');
$('#stat_cost').text(format_number(s.cost_in_use || 0, 2));
$('#stat_accumulated').text(format_number(s.accumulated_in_use || 0, 2));
$('#stat_book').text(format_number(s.book_value_in_use || 0, 2));
$('#stat_draft').text(s.draft || 0);
render_due(res.due || {});
render_categories(s.by_category || []);
}
});
}
function render_due(due) {
$('#due_month').text(current_period());
var html = '';
['tangible', 'intangible'].forEach(function(asset_class) {
var d = due[asset_class] || {};
var body = d.period_count > 0
? '<div class="fw-semibold">' + format_number(d.total, 2) + '</div>'
+ '<div class="small text-muted">' + d.period_count + ' month(s): ' + escape_html(d.from) + (d.to !== d.from ? ' → ' + escape_html(d.to) : '') + '</div>'
: '<div class="small text-success"><i class="ti ti-circle-check me-1"></i>Up to date</div>';
var button = d.period_count > 0
? '<a class="btn btn-sm btn-primary" href="' + server_url + 'asset/depreciation.php?class=' + asset_class + '">Run</a>'
: '';
html += '<div class="d-flex align-items-center justify-content-between py-2 border-bottom">'
+ '<div><div class="small text-muted">' + escape_html(d.label || asset_class) + '</div>' + body + '</div>'
+ button + '</div>';
});
$('#due_list').html(html);
}
function render_categories(rows) {
if (!rows.length) {
$('#category_tbody').html('<tr><td colspan="6" class="text-center text-muted py-4">No active assets yet.</td></tr>');
return;
}
var html = '';
rows.forEach(function(r) {
html += '<tr>'
+ '<td><span class="fw-semibold">' + escape_html(r.category_code) + '</span> ' + escape_html(r.category_name) + '</td>'
+ '<td>' + asset_class_badge(r.asset_class) + '</td>'
+ '<td class="text-end">' + r.asset_count + '</td>'
+ '<td class="text-end">' + format_number(r.cost, 2) + '</td>'
+ '<td class="text-end">' + format_number(r.accumulated, 2) + '</td>'
+ '<td class="text-end">' + format_number((parseFloat(r.cost) || 0) - (parseFloat(r.accumulated) || 0), 2) + '</td>'
+ '</tr>';
});
$('#category_tbody').html(html);
}
function load_runs() {
return ajax_request({
url: server_url + 'asset/api/engine/depreciation_run.php',
autoPrepare: true,
checkRequired: 0,
action: 'list',
onSuccess: function(res) {
var rows = (res.output || []).filter(function(r) { return String(r.status) === '1'; }).slice(0, 6);
if (!rows.length) {
$('#runs_tbody').html('<tr><td colspan="4" class="text-center text-muted py-4">No runs yet.</td></tr>');
return;
}
var html = '';
rows.forEach(function(r) {
html += '<tr>'
+ '<td>' + escape_html(r.run_number) + '</td>'
+ '<td>' + escape_html(r.period) + '</td>'
+ '<td class="text-end">' + format_number(r.total_amount, 2) + '</td>'
+ '<td>' + asset_gl_badge(r.gl_status) + '</td>'
+ '</tr>';
});
$('#runs_tbody').html(html);
}
});
}
$(async function() {
try {
await load_stats();
await load_runs();
} catch (e) { console.log(e); }
});
</script>
</body>
</html>
+7
View File
@@ -1,6 +1,13 @@
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;
+74
View File
@@ -0,0 +1,74 @@
// 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);
}
+40 -10
View File
@@ -2,21 +2,51 @@
// app/assets/utils/app_registry.php
//
// The apps a user can be given access to (user.app_access and
// company_map_user.app_access), with the label, icon and badge colour the
// Users Access page shows for each.
// 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.
//
// 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
// 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
// (Object.entries(null) in setting/users.php) and inviting a user fails
// (array_keys(null) in setting/api/engine/manage_users.php).
//
// Keys must stay within the user.app_access enum: 'wms' and 'accounting'
// Keys must stay within the user.app_access enum: 'wms', 'accounting', 'asset'
// ('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 = [
'wms' => ['label' => 'WMS', 'icon' => 'ti-box', 'color' => 'bg-label-primary'],
'accounting' => ['label' => 'Accounting', 'icon' => 'ti-calculator', 'color' => 'bg-label-success'],
];
$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';
}
}
@@ -33,6 +33,7 @@ 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)
+1 -1
View File
@@ -426,7 +426,7 @@ class UserManager {
* within the owner's license.
*
* @param int $map_id The company_map_user.map_id to update.
* @param string $app_access New value: 'wms', 'accounting', or 'all'.
* @param string $app_access New value: 'wms', 'accounting', 'asset', or 'all'.
* @throws Exception If the member is not found or is the owner.
*/
public function updateAppAccess(int $map_id, string $app_access): void {
+21 -3
View File
@@ -1,4 +1,6 @@
<?php
require_once __DIR__ . '/../classes_as/AssetPosting.php';
class GlQueryManager
{
private PDO $pdo;
@@ -6,8 +8,10 @@ class GlQueryManager
private array $invoiceTypes = ['invoice', 'credit_note', 'purchase_invoice', 'supplier_credit_note'];
private array $mappingTypes = ['invoice', 'credit_note', 'purchase_invoice', 'supplier_credit_note', 'purchase_order'];
private array $postingTypes = ['invoice', 'credit_note', 'purchase_invoice', 'supplier_credit_note', 'receipt', 'payment', 'purchase_order'];
private array $journalTypes = ['invoice', 'credit_note', 'purchase_invoice', 'supplier_credit_note', 'receipt', 'payment', 'manual', 'purchase_order', 'reversal'];
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'];
public function __construct(PDO $pdo, int $company_id)
{
@@ -25,6 +29,11 @@ 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') {
@@ -82,6 +91,10 @@ 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(
@@ -90,6 +103,10 @@ 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,
@@ -101,7 +118,8 @@ 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')
ON g.source_type NOT IN ('receipt','payment','manual','purchase_order',
'asset_capitalization','asset_depreciation','asset_amortization','asset_disposal')
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
@@ -0,0 +1,173 @@
<?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();
}
}
@@ -0,0 +1,596 @@
<?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, '%d/%m/%Y %H:%i') 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}'.");
}
}
@@ -0,0 +1,267 @@
<?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}).");
}
}
}
@@ -0,0 +1,334 @@
<?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, '%d/%m/%Y %H:%i') 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];
}
}
@@ -0,0 +1,135 @@
<?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
View File
@@ -20,6 +20,17 @@ 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
+6 -4
View File
@@ -49,11 +49,13 @@ if (!defined('OTP_REQUIRED')) {
// ── App registry ─────────────────────────────────────────────────────────────
// Apps a user can be given access to, as shown on Setting → Users Access. Keys
// must match the user.app_access enum ('wms', 'accounting'). If this is left
// out, assets/utils/app_registry.php supplies the same default.
// must match the user.app_access enum ('wms', 'accounting', 'asset'). If this is
// left out, assets/utils/app_registry.php supplies the same default and fills in
// any app or 'home' page missing here.
$app_registry = [
'wms' => ['label' => 'WMS', 'icon' => 'ti-box', 'color' => 'bg-label-primary'],
'accounting' => ['label' => 'Accounting', 'icon' => 'ti-calculator', 'color' => 'bg-label-success'],
'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'],
];
// ── Usage packages ───────────────────────────────────────────────────────────
+70 -1
View File
@@ -4,6 +4,8 @@
require '../include_header.php';
$invoice_id = (int)($_GET['id'] ?? 0);
// Users with Asset Management can turn issued invoice lines into assets.
$pi_asset_access = in_array($_SESSION['login_app_access'] ?? 'wms', ['all', 'asset'], true);
?>
<body>
@@ -73,6 +75,30 @@
</div>
</div>
</div>
<!-- Assets created from this invoice (Asset Management) -->
<div class="card mt-5 d-none" id="asset_card">
<div class="card-body p-5">
<h2 class="fs-5 mb-4">
<span class="badge bg-success text-white me-2"><i class="ti ti-building-warehouse me-1"></i>Asset Management</span>
Assets from this invoice
</h2>
<div class="table-responsive">
<table class="table mb-0 table-hover">
<thead class="table-light">
<tr>
<th>Asset No.</th>
<th>Name</th>
<th class="text-end">Cost</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody id="asset_tbody"></tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Right: Info + actions -->
@@ -160,6 +186,44 @@
var invoice_id = <?php echo $invoice_id; ?>;
var invoice_data = null;
var show_asset_actions = <?php echo $pi_asset_access ? 'true' : 'false'; ?>;
var ASSET_STATUS_BADGE = {
'0': '<span class="badge bg-secondary">Draft</span>',
'1': '<span class="badge bg-success">Active</span>',
'2': '<span class="badge bg-info text-white">Fully depreciated</span>',
'3': '<span class="badge bg-dark">Disposed</span>',
'4': '<span class="badge bg-light text-dark">Void</span>',
};
function load_invoice_assets() {
return ajax_request({
url: '<?php echo $server_url?>asset/api/engine/asset.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
queueLock: false,
noLoading: true,
noAlert: true,
data: { invoice_id: invoice_id },
onSuccess: function(res) {
var rows = res.output || [];
if (!rows.length) { $('#asset_card').addClass('d-none'); return; }
var html = '';
$.each(rows, function(i, a) {
html += `<tr>
<td>${escape_html(a.asset_number)}</td>
<td>${escape_html(a.asset_name)}</td>
<td class="text-end">${format_number(a.cost, 2)}</td>
<td>${ASSET_STATUS_BADGE[String(a.status)] || '—'}</td>
<td><a href="<?php echo $server_url?>asset/manage_asset.php?id=${a.id}"><i class="ti ti-eye fs-5"></i></a></td>
</tr>`;
});
$('#asset_tbody').html(html);
$('#asset_card').removeClass('d-none');
}
});
}
function doc_type_badge(doc_type) {
const map = {
@@ -245,10 +309,14 @@
}
// Items
var can_capitalize = show_asset_actions && inv.doc_type === 'purchase_invoice' && [1, 2].includes(parseInt(inv.status));
var tbody = '';
$.each(inv.items || [], function(i, item) {
var asset_link = can_capitalize
? `<div><a class="small link-success" href="<?php echo $server_url?>asset/manage_asset.php?invoice_id=${invoice_id}&item_id=${item.id}"><i class="ti ti-building-warehouse me-1"></i>Create asset</a></div>`
: '';
tbody += `<tr>
<td>${escape_html(item.product_name || item.product_sku)}</td>
<td>${escape_html(item.product_name || item.product_sku)}${asset_link}</td>
<td class="text-end">${format_number(item.quantity, 2)}</td>
<td class="text-end">${format_number(item.unit_price, 2)}</td>
<td class="text-end">${format_number(item.tax_rate, 2)}</td>
@@ -310,6 +378,7 @@
$('#btn_save_formula').prop('disabled', parseInt(inv.status) === 4);
set_mode(inv);
if (show_asset_actions && inv.doc_type === 'purchase_invoice') load_invoice_assets();
}
});
}
+4 -6
View File
@@ -9,12 +9,10 @@
$master_can_admin = in_array($master_role, ['owner', 'admin'], true);
$master_app_access = $_SESSION['login_app_access'] ?? 'wms';
$master_show_accounting = in_array($master_app_access, ['all', 'accounting']);
$master_back_url = ($_SESSION['login_current_app'] ?? 'wms') === 'accounting'
? $server_url . 'ac_dashboard/index.php'
: $server_url . 'dashboard/index.php';
$master_back_label = ($_SESSION['login_current_app'] ?? 'wms') === 'accounting'
? 'Back to Accounting'
: 'Back to WMS';
require_once __DIR__ . '/assets/utils/app_registry.php';
$master_current_app = $_SESSION['login_current_app'] ?? 'wms';
$master_back_url = $server_url . app_home_path($app_registry, $master_current_app);
$master_back_label = 'Back to ' . ($app_registry[$master_current_app]['label'] ?? 'WMS');
?>
<!-- SIDEBAR -->
+2 -1
View File
@@ -93,8 +93,9 @@
<small class="nav-text text-muted">Navigation</small>
</li>
<?php require_once __DIR__ . '/assets/utils/app_registry.php'; ?>
<li>
<a class="nav-link" href="<?php echo $server_url?>dashboard/index.php">
<a class="nav-link" href="<?php echo $server_url . app_home_path($app_registry, $_SESSION['login_current_app'] ?? 'wms'); ?>">
<i class="ti ti-arrow-left"></i>
<span class="nav-text">Back to App</span>
</a>
+94
View File
@@ -0,0 +1,94 @@
<?php
$cp = $_SERVER['PHP_SELF'];
function asset_active(...$frags) {
global $cp;
foreach ($frags as $f) { if (str_contains($cp, $f)) return 'active'; }
return '';
}
$asset_show_accounting = in_array($_SESSION['login_app_access'] ?? 'wms', ['all', 'accounting'], true);
?>
<!-- SIDEBAR -->
<aside id="sidebar" class="sidebar overflow-y-auto">
<div class="logo-area">
<a href="<?php echo $server_url?>asset_dashboard/index.php" class="d-inline-flex">
<span class="logo-text">
<img src="<?php echo $server_url?>assets/images/logo.png" alt="" height="48"/>
</span>
</a>
</div>
<ul class="nav flex-column">
<!-- Main -->
<li class="nav-text-space">
<small class="nav-text text-muted">Main</small>
</li>
<li>
<a class="nav-link <?php echo asset_active('/asset_dashboard/'); ?>" href="<?php echo $server_url?>asset_dashboard/index.php">
<i class="ti ti-home"></i>
<span class="nav-text">Dashboard</span>
</a>
</li>
<!-- Assets -->
<li class="nav-text-space">
<small class="nav-text text-muted">Assets</small>
</li>
<li>
<a class="nav-link <?php echo asset_active('/asset/index', '/asset/manage_asset'); ?>" href="<?php echo $server_url?>asset/index.php">
<i class="ti ti-building-warehouse"></i>
<span class="nav-text">Asset Register</span>
</a>
</li>
<li>
<a class="nav-link <?php echo asset_active('/asset/purchase_invoices'); ?>" href="<?php echo $server_url?>asset/purchase_invoices.php">
<i class="ti ti-file-invoice"></i>
<span class="nav-text">From Purchase Invoices</span>
</a>
</li>
<!-- Depreciation -->
<li class="nav-text-space">
<small class="nav-text text-muted">Depreciation</small>
</li>
<li>
<a class="nav-link <?php echo asset_active('/asset/depreciation'); ?>" href="<?php echo $server_url?>asset/depreciation.php">
<i class="ti ti-calendar-stats"></i>
<span class="nav-text">Depreciation Runs</span>
</a>
</li>
<!-- Setup -->
<li class="nav-text-space">
<small class="nav-text text-muted">Setup</small>
</li>
<li>
<a class="nav-link <?php echo asset_active('/asset/categories', '/asset/manage_category'); ?>" href="<?php echo $server_url?>asset/categories.php">
<i class="ti ti-category"></i>
<span class="nav-text">Asset Categories</span>
</a>
</li>
<?php if ($asset_show_accounting): ?>
<!-- Accounting -->
<li class="nav-text-space">
<small class="nav-text text-muted">Accounting</small>
</li>
<li>
<a class="nav-link" href="<?php echo $server_url?>accounting/gl_entries.php">
<i class="ti ti-stack-2"></i>
<span class="nav-text">Batch GL Entries</span>
</a>
</li>
<?php endif; ?>
<li><br><br><br><br></li>
</ul>
</aside>
+87 -69
View File
@@ -29,34 +29,58 @@ $topbar_avatar_src = $topbar_picture
: htmlspecialchars($server_url . 'assets/images/avatar-1.jpg', ENT_QUOTES, 'UTF-8');
// ── App switcher ──────────────────────────────────────────────────────────────
require_once __DIR__ . '/assets/utils/app_registry.php';
$topbar_app_access = $_SESSION['login_app_access'] ?? 'wms';
// Detect which app this page belongs to.
// Master-data pages (/accounting/, /inventory/, /contact/, /setting/) are neutral — don't change the session.
$_wms_paths = ['/dashboard/', '/ics/', '/order/', '/po/', '/reports/'];
$_ac_paths = ['/ac_dashboard/', '/revenue/', '/expense/', '/finance/', '/journal/'];
// '/asset_dashboard/' and '/ac_dashboard/' do not match '/dashboard/' (no slash before "dashboard").
$_app_paths = [
'wms' => ['/dashboard/', '/ics/', '/order/', '/po/', '/reports/'],
'accounting' => ['/ac_dashboard/', '/revenue/', '/expense/', '/finance/', '/journal/'],
'asset' => ['/asset_dashboard/', '/asset/'],
];
$topbar_current_app = null; // null = neutral page (master data / setting)
foreach ($_wms_paths as $_p) {
if (str_contains($_SERVER['PHP_SELF'], $_p)) { $topbar_current_app = 'wms'; break; }
}
if (!$topbar_current_app) {
foreach ($_ac_paths as $_p) {
if (str_contains($_SERVER['PHP_SELF'], $_p)) { $topbar_current_app = 'accounting'; break; }
foreach ($_app_paths as $_app_key => $_paths) {
foreach ($_paths as $_p) {
if (str_contains($_SERVER['PHP_SELF'], $_p)) { $topbar_current_app = $_app_key; break 2; }
}
}
// Asset Management pages are only for users given that app.
if ($topbar_current_app === 'asset' && !app_can_access($topbar_app_access, 'asset')) {
while (ob_get_level() > 0) {
ob_end_clean();
}
header('Location: ' . $server_url . app_home_path($app_registry, app_default_for_access($app_registry, $topbar_app_access)));
exit;
}
// Only persist to session when on a definitive app page
if ($topbar_current_app) {
$_SESSION['login_current_app'] = $topbar_current_app;
}
// Default session on first visit or neutral pages
if (empty($_SESSION['login_current_app'])) {
$_SESSION['login_current_app'] = ($topbar_app_access === 'accounting') ? 'accounting' : 'wms';
$_SESSION['login_current_app'] = app_default_for_access($app_registry, $topbar_app_access);
}
$topbar_show_wms = in_array($topbar_app_access, ['all', 'wms']);
$topbar_show_accounting = in_array($topbar_app_access, ['all', 'accounting']);
$topbar_show_wms = app_can_access($topbar_app_access, 'wms');
$topbar_show_accounting = app_can_access($topbar_app_access, 'accounting');
$topbar_switch_apps = array_filter(
$app_registry,
fn($key) => app_can_access($topbar_app_access, $key),
ARRAY_FILTER_USE_KEY
);
// Per-app colour. WMS keeps the compiled orange theme (#E66239); the others override it.
$_app_themes = [
'accounting' => ['main' => '#0284c7', 'rgb' => '2, 132, 199', 'dark' => '#0369a1', 'subtle' => '#e0f2fe', 'border' => '#bae6fd'],
'asset' => ['main' => '#16a34a', 'rgb' => '22, 163, 74', 'dark' => '#15803d', 'subtle' => '#dcfce7', 'border' => '#bbf7d0'],
];
$_theme_app = $_SESSION['login_current_app'] ?? 'wms';
$_theme = $_app_themes[$_theme_app] ?? null;
$_usage = (new UsageGuard($pdo1, (int)$_SESSION['login_company_id'], $packages))->getStatus();
$_usage_max_pct = max($_usage['daily_pct'], $_usage['weekly_pct']);
@@ -64,67 +88,67 @@ $_usage_warn = $_usage_max_pct >= 80 && $_usage_max_pct < 100;
$_usage_full = $_usage_max_pct >= 100;
?>
<?php if (($_SESSION['login_current_app'] ?? 'wms') === 'accounting'): ?>
<style id="theme-accounting">
<?php if ($_theme): ?>
<style id="theme-<?php echo $_theme_app; ?>">
:root {
--bs-primary: #0284c7;
--bs-primary-rgb: 2, 132, 199;
--bs-primary-bg-subtle: #e0f2fe;
--bs-primary-border-subtle: #bae6fd;
--bs-primary-text-emphasis: #0369a1;
--bs-focus-ring-color: rgba(2, 132, 199, .25);
--bs-primary: <?php echo $_theme['main']; ?>;
--bs-primary-rgb: <?php echo $_theme['rgb']; ?>;
--bs-primary-bg-subtle: <?php echo $_theme['subtle']; ?>;
--bs-primary-border-subtle: <?php echo $_theme['border']; ?>;
--bs-primary-text-emphasis: <?php echo $_theme['dark']; ?>;
--bs-focus-ring-color: rgba(<?php echo $_theme['rgb']; ?>, .25);
}
/* Sidebar active/hover — hardcoded in theme */
.sidebar .nav-link:hover,
.sidebar .nav-link.active {
color: #0284c7 !important;
background-color: rgba(2, 132, 199, 0.09) !important;
color: <?php echo $_theme['main']; ?> !important;
background-color: rgba(<?php echo $_theme['rgb']; ?>, 0.09) !important;
}
/* Buttons */
.btn-primary {
--bs-btn-bg: #0284c7;
--bs-btn-border-color: #0284c7;
--bs-btn-hover-bg: #0369a1;
--bs-btn-hover-border-color: #0369a1;
--bs-btn-active-bg: #0369a1;
--bs-btn-active-border-color: #0369a1;
--bs-btn-disabled-bg: #0284c7;
--bs-btn-disabled-border-color: #0284c7;
--bs-btn-focus-shadow-rgb: 2, 132, 199;
--bs-btn-bg: <?php echo $_theme['main']; ?>;
--bs-btn-border-color: <?php echo $_theme['main']; ?>;
--bs-btn-hover-bg: <?php echo $_theme['dark']; ?>;
--bs-btn-hover-border-color: <?php echo $_theme['dark']; ?>;
--bs-btn-active-bg: <?php echo $_theme['dark']; ?>;
--bs-btn-active-border-color: <?php echo $_theme['dark']; ?>;
--bs-btn-disabled-bg: <?php echo $_theme['main']; ?>;
--bs-btn-disabled-border-color: <?php echo $_theme['main']; ?>;
--bs-btn-focus-shadow-rgb: <?php echo $_theme['rgb']; ?>;
}
.btn-outline-primary {
--bs-btn-color: #0284c7;
--bs-btn-border-color: #0284c7;
--bs-btn-hover-bg: #0284c7;
--bs-btn-hover-border-color: #0284c7;
--bs-btn-active-bg: #0284c7;
--bs-btn-active-border-color: #0284c7;
--bs-btn-disabled-color: #0284c7;
--bs-btn-disabled-border-color: #0284c7;
--bs-btn-color: <?php echo $_theme['main']; ?>;
--bs-btn-border-color: <?php echo $_theme['main']; ?>;
--bs-btn-hover-bg: <?php echo $_theme['main']; ?>;
--bs-btn-hover-border-color: <?php echo $_theme['main']; ?>;
--bs-btn-active-bg: <?php echo $_theme['main']; ?>;
--bs-btn-active-border-color: <?php echo $_theme['main']; ?>;
--bs-btn-disabled-color: <?php echo $_theme['main']; ?>;
--bs-btn-disabled-border-color: <?php echo $_theme['main']; ?>;
}
.pagination {
--bs-pagination-active-bg: #0284c7;
--bs-pagination-active-border-color: #0284c7;
--bs-pagination-active-bg: <?php echo $_theme['main']; ?>;
--bs-pagination-active-border-color: <?php echo $_theme['main']; ?>;
}
.nav-pills { --bs-nav-pills-link-active-bg: #0284c7; }
.dropdown-menu { --bs-dropdown-link-active-bg: #0284c7; }
.nav-pills { --bs-nav-pills-link-active-bg: <?php echo $_theme['main']; ?>; }
.dropdown-menu { --bs-dropdown-link-active-bg: <?php echo $_theme['main']; ?>; }
.list-group {
--bs-list-group-active-bg: #0284c7;
--bs-list-group-active-border-color: #0284c7;
--bs-list-group-active-bg: <?php echo $_theme['main']; ?>;
--bs-list-group-active-border-color: <?php echo $_theme['main']; ?>;
}
.progress, .progress-stacked { --bs-progress-bar-bg: #0284c7; }
.progress, .progress-stacked { --bs-progress-bar-bg: <?php echo $_theme['main']; ?>; }
.table-primary {
--bs-table-color: #0284c7;
--bs-table-bg: rgba(2, 132, 199, 0.09);
--bs-table-border-color: rgba(2, 132, 199, 0.15);
--bs-table-striped-bg: rgba(2, 132, 199, 0.12);
--bs-table-active-bg: rgba(2, 132, 199, 0.15);
--bs-table-hover-bg: rgba(2, 132, 199, 0.12);
--bs-table-color: <?php echo $_theme['main']; ?>;
--bs-table-bg: rgba(<?php echo $_theme['rgb']; ?>, 0.09);
--bs-table-border-color: rgba(<?php echo $_theme['rgb']; ?>, 0.15);
--bs-table-striped-bg: rgba(<?php echo $_theme['rgb']; ?>, 0.12);
--bs-table-active-bg: rgba(<?php echo $_theme['rgb']; ?>, 0.15);
--bs-table-hover-bg: rgba(<?php echo $_theme['rgb']; ?>, 0.12);
}
thead.border-light th {
background-color: rgba(2, 132, 199, 0.09) !important;
color: #0284c7 !important;
border-color: rgba(2, 132, 199, 0.15) !important;
background-color: rgba(<?php echo $_theme['rgb']; ?>, 0.09) !important;
color: <?php echo $_theme['main']; ?> !important;
border-color: rgba(<?php echo $_theme['rgb']; ?>, 0.15) !important;
}
</style>
<?php endif; ?>
@@ -171,30 +195,24 @@ $_usage_full = $_usage_max_pct >= 100;
<ul class="list-unstyled d-flex align-items-center mb-0 gap-1">
<!-- App switcher -->
<?php if ($topbar_show_wms && $topbar_show_accounting): ?>
<?php if (count($topbar_switch_apps) > 1): ?>
<li class="dropdown">
<a class="position-relative btn-icon btn-sm btn-light btn rounded-circle" data-bs-toggle="dropdown"
aria-expanded="false" href="#" role="button" title="Switch App">
<i class="ti ti-layout-grid" style="font-size:18px;"></i>
</a>
<div class="dropdown-menu dropdown-menu-end p-2" style="min-width:170px;">
<div class="dropdown-menu dropdown-menu-end p-2" style="min-width:210px;">
<?php $_sess_app = $_SESSION['login_current_app'] ?? 'wms'; ?>
<a href="<?php echo $server_url?>dashboard/index.php"
class="dropdown-item rounded d-flex align-items-center gap-2 py-2 <?php echo $_sess_app === 'wms' ? 'active' : ''; ?>">
<i class="ti ti-box"></i>
<span>WMS</span>
<?php if ($_sess_app === 'wms'): ?>
<i class="ti ti-check ms-auto text-success"></i>
<?php endif; ?>
</a>
<a href="<?php echo $server_url?>ac_dashboard/index.php"
class="dropdown-item rounded d-flex align-items-center gap-2 py-2 <?php echo $_sess_app === 'accounting' ? 'active' : ''; ?>">
<i class="ti ti-calculator"></i>
<span>Accounting</span>
<?php if ($_sess_app === 'accounting'): ?>
<?php foreach ($topbar_switch_apps as $_app_key => $_app): ?>
<a href="<?php echo $server_url . app_home_path($app_registry, $_app_key); ?>"
class="dropdown-item rounded d-flex align-items-center gap-2 py-2 <?php echo $_sess_app === $_app_key ? 'active' : ''; ?>">
<i class="ti <?php echo htmlspecialchars($_app['icon'] ?? 'ti-apps', ENT_QUOTES, 'UTF-8'); ?>"></i>
<span><?php echo htmlspecialchars($_app['label'] ?? $_app_key, ENT_QUOTES, 'UTF-8'); ?></span>
<?php if ($_sess_app === $_app_key): ?>
<i class="ti ti-check ms-auto text-success"></i>
<?php endif; ?>
</a>
<?php endforeach; ?>
</div>
</li>
<?php endif; ?>
+12
View File
@@ -43,6 +43,12 @@
<option value="receipt">Receipt</option>
<option value="payment">Payment</option>
<option value="manual">Manual</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">
@@ -105,6 +111,8 @@
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',
};
var type_badge_cls = {
invoice: 'bg-primary-subtle text-primary',
@@ -114,6 +122,10 @@
supplier_credit_note: 'bg-secondary-subtle text-secondary',
credit_note: 'bg-info-subtle text-info',
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',
};
function load_listing() {
+3 -1
View File
@@ -3,7 +3,9 @@
require '../config.php';
if (!empty($_SESSION['login_status'])) {
header('Location: ' . $server_url . 'dashboard/index.php');
require_once '../assets/utils/app_registry.php';
$login_app = app_default_for_access($app_registry, $_SESSION['login_app_access'] ?? 'wms');
header('Location: ' . $server_url . app_home_path($app_registry, $login_app));
exit;
}
+3 -3
View File
@@ -3,11 +3,11 @@
require '../config.php';
require_once '../assets/utils/otp_policy.php';
require '../include_header.php';
require_once '../assets/utils/app_registry.php';
// successful login — redirect based on app_access
if(!empty($_SESSION["login_status"])){
$redirect = ($_SESSION['login_app_access'] ?? 'wms') === 'accounting'
? $server_url . 'ac_dashboard/index.php'
: $server_url . 'dashboard/index.php';
$login_app = app_default_for_access($app_registry, $_SESSION['login_app_access'] ?? 'wms');
$redirect = $server_url . app_home_path($app_registry, $login_app);
header('Location: ' . $redirect);
exit;
}
+3 -1
View File
@@ -4,7 +4,9 @@
// Redirect if already logged in
if (!empty($_SESSION['login_status'])) {
header('Location: ' . $server_url . 'dashboard/index.php');
require_once '../assets/utils/app_registry.php';
$login_app = app_default_for_access($app_registry, $_SESSION['login_app_access'] ?? 'wms');
header('Location: ' . $server_url . app_home_path($app_registry, $login_app));
exit;
}
+3 -2
View File
@@ -45,8 +45,9 @@ if (!defined('OTP_REQUIRED')) {
// the user.app_access enum; assets/utils/app_registry.php supplies this default
// for configs that do not define it.
$app_registry = [
'wms' => ['label' => 'WMS', 'icon' => 'ti-box', 'color' => 'bg-label-primary'],
'accounting' => ['label' => 'Accounting', 'icon' => 'ti-calculator', 'color' => 'bg-label-success'],
'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'],
];
// ── Usage packages ───────────────────────────────────────────────────────────
+104 -1
View File
@@ -97,7 +97,7 @@ CREATE TABLE IF NOT EXISTS `user` (
`language` varchar(3) NOT NULL DEFAULT 'th',
`regis_date` timestamp NOT NULL DEFAULT current_timestamp(),
`license` enum('owner','user') NOT NULL DEFAULT 'owner',
`app_access` enum('all','wms','accounting') NOT NULL DEFAULT 'all',
`app_access` enum('all','wms','accounting','asset') NOT NULL DEFAULT 'all',
`active` varchar(255) NOT NULL DEFAULT 'yes',
`phone` varchar(50) NOT NULL DEFAULT '',
`verify_token` varchar(64) DEFAULT NULL,
@@ -114,6 +114,9 @@ CREATE TABLE IF NOT EXISTS `user` (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
", 'user');
// Existing databases were created before the Asset Management app existed.
run($pdo, "ALTER TABLE `user` MODIFY `app_access` enum('all','wms','accounting','asset') NOT NULL DEFAULT 'all'", 'user.app_access includes asset');
run($pdo, "
CREATE TABLE IF NOT EXISTS `company_list` (
`company_id` int(11) NOT NULL AUTO_INCREMENT,
@@ -1217,6 +1220,106 @@ CREATE TABLE IF NOT EXISTS `document_number_sequences` (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
", 'document_number_sequences');
// ── Asset management ──────────────────────────────────────────────────────────
// Categories carry the GL accounts; td_asset_depreciation holds one row per
// asset per month and td_asset_run groups those rows into one GL entry.
run($pdo, "
CREATE TABLE IF NOT EXISTS `md_asset_category` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`company_id` int(11) NOT NULL,
`category_code` varchar(20) NOT NULL,
`category_name` varchar(255) NOT NULL,
`asset_class` enum('tangible','intangible') NOT NULL DEFAULT 'tangible',
`useful_life_months` int(11) NOT NULL DEFAULT 60,
`cost_account_code` varchar(20) NOT NULL DEFAULT '',
`accum_account_code` varchar(20) NOT NULL DEFAULT '',
`expense_account_code` varchar(20) NOT NULL DEFAULT '',
`gain_account_code` varchar(20) NOT NULL DEFAULT '',
`loss_account_code` varchar(20) NOT NULL DEFAULT '',
`description` text DEFAULT NULL,
`status` tinyint(1) NOT NULL DEFAULT 1,
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
`updated_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_company_category` (`company_id`,`category_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
", 'md_asset_category');
run($pdo, "
CREATE TABLE IF NOT EXISTS `td_asset` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`company_id` int(11) NOT NULL DEFAULT 0,
`asset_number` varchar(50) NOT NULL DEFAULT '',
`asset_name` varchar(255) NOT NULL DEFAULT '',
`category_id` int(11) NOT NULL DEFAULT 0,
`asset_class` enum('tangible','intangible') NOT NULL DEFAULT 'tangible',
`description` text NOT NULL DEFAULT '',
`source` varchar(20) NOT NULL DEFAULT 'manual' COMMENT 'manual | purchase_invoice',
`invoice_id` int(11) unsigned NOT NULL DEFAULT 0,
`invoice_item_id` int(11) unsigned NOT NULL DEFAULT 0,
`contact_id` int(11) NOT NULL DEFAULT 0,
`department_id` int(11) NOT NULL DEFAULT 0,
`acquisition_date` date DEFAULT NULL,
`in_service_date` date DEFAULT NULL,
`cost` decimal(18,4) NOT NULL DEFAULT 0.0000,
`salvage_value` decimal(18,4) NOT NULL DEFAULT 0.0000,
`useful_life_months` int(11) NOT NULL DEFAULT 0,
`clearing_account_code` varchar(20) NOT NULL DEFAULT '' COMMENT 'credited by the capitalization entry; empty = no entry',
`status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '0 draft, 1 active, 2 fully depreciated, 3 disposed, 4 void',
`disposal_date` date DEFAULT NULL,
`sale_price` decimal(18,4) NOT NULL DEFAULT 0.0000,
`proceeds_account_code` varchar(20) NOT NULL DEFAULT '',
`disposal_notes` text NOT NULL DEFAULT '',
`created_by` int(11) NOT NULL DEFAULT 0,
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
`updated_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `company_status` (`company_id`,`status`),
KEY `company_invoice` (`company_id`,`invoice_id`),
KEY `category_id` (`category_id`),
KEY `asset_number` (`asset_number`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
", 'td_asset');
run($pdo, "
CREATE TABLE IF NOT EXISTS `td_asset_run` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`company_id` int(11) NOT NULL DEFAULT 0,
`run_number` varchar(50) NOT NULL DEFAULT '',
`asset_class` enum('tangible','intangible') NOT NULL DEFAULT 'tangible',
`period` varchar(7) NOT NULL DEFAULT '',
`run_date` date NOT NULL,
`asset_count` int(11) NOT NULL DEFAULT 0,
`total_amount` decimal(18,4) NOT NULL DEFAULT 0.0000,
`status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '1 active, 4 void',
`created_by` int(11) NOT NULL DEFAULT 0,
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
`voided_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `company_period` (`company_id`,`period`,`asset_class`),
KEY `status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
", 'td_asset_run');
run($pdo, "
CREATE TABLE IF NOT EXISTS `td_asset_depreciation` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`company_id` int(11) NOT NULL DEFAULT 0,
`asset_id` int(11) unsigned NOT NULL DEFAULT 0,
`run_id` int(11) unsigned NOT NULL DEFAULT 0,
`period` varchar(7) NOT NULL DEFAULT '',
`category_id` int(11) NOT NULL DEFAULT 0 COMMENT 'snapshot at run time; GL lines group by it',
`department_id` int(11) NOT NULL DEFAULT 0 COMMENT 'snapshot at run time; GL lines group by it',
`amount` decimal(18,4) NOT NULL DEFAULT 0.0000,
`accumulated_after` decimal(18,4) NOT NULL DEFAULT 0.0000,
`book_value_after` decimal(18,4) NOT NULL DEFAULT 0.0000,
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
UNIQUE KEY `uq_asset_period` (`company_id`,`asset_id`,`period`),
KEY `run_id` (`company_id`,`run_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
", 'td_asset_depreciation');
// ── Summary ───────────────────────────────────────────────────────────────────
$total = $ok_count + $skip_count + $err_count;
echo "\n\033[1m=== Done ===\033[0m\n";