Compare commits

...
3 Commits
Author SHA1 Message Date
Thanakorn 931d95bafc Merge asset-management 2026-09-15 12:53:56 +07:00
Thanakorn d199fa7f67 Fix transfer quantity, unify date format, align demo seeds
Stock Transfer list showed 0.00 (read in instead of out); stock-out/transfer forms show the location quantity; dates display as YYYY-MM-DD HH:mm:ss. Demo seeds map product accounts, use product names and supplier batches, and add demo_seed_assets.php.
2026-09-15 12:43:47 +07:00
Thanakorn c957280aa5 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.
2026-09-15 09:50:55 +07:00
68 changed files with 4921 additions and 343 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>
+18 -4
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,9 +521,9 @@
? '<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">Journal Date</div><div>' + escape_html(format_date(h.journal_date)) + '</div></div>' +
'<div class="col-sm-4"><div class="text-muted small">Description</div><div>' + escape_html(h.description || '—') + '</div></div>'
: '<div class="col-sm-4"><div class="text-muted small">Formula</div><div>' + escape_html(h.formula_name) + '</div></div>' +
'<div class="col-sm-4"><div class="text-muted small">Version</div><div>v' + h.current_version + (h.current_version > 1 ? ' <span class="text-muted small">(replaced)</span>' : '') + '</div></div>' +
+2
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_input(l.issued_date));
$('#cost').val(Math.max(parseFloat(l.remaining) || 0, 0).toFixed(2));
$('#clearing_account_code').html(account_options(accounts, [], l.clearing_account_code, '— None —'));
load_departments('department_id', l.department_id);
$('#source_alert').removeClass('d-none').html(
'<i class="ti ti-file-invoice me-1"></i>From purchase invoice <strong>' + escape_html(l.invoice_number) + '</strong>'
+ ' (' + escape_html(l.contact_name || '—') + '), line <strong>' + escape_html(l.product_sku) + '</strong>: '
+ 'amount ' + format_number(l.line_amount, 2) + ', already capitalized ' + format_number(l.capitalized, 2)
+ ', remaining <strong>' + format_number(l.remaining, 2) + '</strong>.'
+ (l.clearing_account_code ? '' : ' <span class="text-danger">Select the account the invoice line was posted to as the clearing account.</span>')
);
$('#page_subtitle').text('Capitalizing a purchase invoice line');
update_calc();
}
});
}
function render_detail() {
var a = detail.asset;
var status = parseInt(a.status, 10);
var recorded = detail.schedule.some(function(r) { return r.state === 'recorded'; });
var closed = status === 3 || status === 4;
$('#page_title').text(a.asset_number + ' · ' + a.asset_name);
$('#page_subtitle').text(a.category_code + ' ' + a.category_name);
$('#id').val(a.id);
$('#asset_name').val(a.asset_name);
fill_category_select(a.category_id);
update_class_hint();
$('#clearing_account_code').html(account_options(accounts, [], a.clearing_account_code, '— None —'));
$('#acquisition_date').val(format_date_input(a.acquisition_date));
$('#in_service_date').val(format_date_input(a.in_service_date));
$('#cost').val(parseFloat(a.cost).toFixed(2));
$('#salvage_value').val(parseFloat(a.salvage_value).toFixed(2));
$('#useful_life_months').val(a.useful_life_months);
$('#description').val(a.description || '');
load_departments('department_id', a.department_id);
life_touched = true;
update_calc();
// Status card
$('#status_card').removeClass('d-none');
$('#status_badges').html(asset_status_badge(a.status, a.asset_class) + asset_class_badge(a.asset_class));
$('#display_accumulated').text(format_number(a.accumulated, 2));
$('#display_book_value').text(format_number(a.book_value, 2));
$('#display_last_period').text(a.last_period || '—');
if (parseInt(a.invoice_id, 10) > 0) {
var ref = escape_html(a.invoice_number || ('PI #' + a.invoice_id));
$('#display_source').html(pi_link_ok
? '<a href="' + server_url + 'expense/manage_purchase_invoice.php?id=' + a.invoice_id + '">' + ref + '</a>'
: ref);
$('#source_row').removeClass('d-none');
}
// GL entries
var cap_html;
if (!a.clearing_account_code) {
cap_html = '<span class="text-muted">No entry — no clearing account.</span>';
} else if (status === 0) {
cap_html = '<span class="text-muted">Activate the asset, then Accounting posts it.</span>';
} else {
cap_html = gl_entry_html(detail.gl.capitalization);
}
$('#gl_capitalization').html(cap_html);
$('#gl_disposal_row').toggleClass('d-none', status !== 3);
$('#gl_disposal').html(gl_entry_html(detail.gl.disposal));
// Disposal
if (status === 3) {
var gain = round_dp(parseFloat(a.sale_price) - (parseFloat(a.cost) - parseFloat(a.accumulated)), 2);
$('#display_disposal_date').text(format_date(a.disposal_date));
$('#display_sale_price').text(format_number(a.sale_price, 2));
$('#display_gain_loss').text(gain < 0 ? '(' + format_number(-gain, 2) + ')' : format_number(gain, 2))
.toggleClass('text-danger', gain < 0).toggleClass('text-success', gain > 0);
$('#display_disposal_notes').text(a.disposal_notes || '');
$('#disposal_info').removeClass('d-none');
} else {
$('#disposal_info').addClass('d-none');
}
render_schedule();
// Editing rules
$(FINANCIAL_FIELDS).prop('disabled', closed || recorded || !can_edit);
$('#asset_name, #description, #department_id').prop('disabled', closed || !can_edit);
$('#locked_hint').toggleClass('d-none', !(recorded && !closed));
$('#btn_save').toggleClass('d-none', closed || !can_edit).find('span').text(status === 0 ? 'Save Draft' : 'Save');
$('#btn_activate').toggleClass('d-none', !(status === 0 && can_admin));
$('#btn_dispose').toggleClass('d-none', !((status === 1 || status === 2) && can_admin));
$('#btn_undo_disposal').toggleClass('d-none', !(status === 3 && can_admin));
$('#btn_void').toggleClass('d-none', !((status === 1 || status === 2) && can_admin && !recorded));
$('#btn_delete').toggleClass('d-none', !(status === 0 && can_admin));
}
function gl_entry_html(entry) {
if (entry) {
return '<span class="badge bg-success-subtle text-success">Posted</span>'
+ ' <span class="text-muted ms-1">' + escape_html(entry.reference) + ' · ' + format_date(entry.journal_date) + '</span>';
}
return '<span class="badge bg-danger-subtle text-danger">Not posted</span>'
+ ' <span class="text-muted ms-1">Accounting → Batch GL Entries</span>';
}
function render_schedule() {
var rows = detail.schedule || [];
if (!rows.length) { $('#schedule_card').addClass('d-none'); return; }
var state_badge = {
recorded: function(r) { return '<span class="badge bg-success-subtle text-success">Charged</span> <span class="small text-muted">' + escape_html(r.run_number || '') + '</span>'; },
projected: function() { return '<span class="badge bg-light text-dark">Projected</span>'; },
stopped: function() { return '<span class="badge bg-secondary-subtle text-secondary">Not charged</span>'; }
};
var html = '';
var charged = 0;
rows.forEach(function(r, i) {
if (r.state === 'recorded') charged++;
html += '<tr class="' + (r.state === 'stopped' ? 'text-muted' : '') + '">'
+ '<td>' + (i + 1) + '</td>'
+ '<td>' + escape_html(r.period) + '</td>'
+ '<td class="text-end">' + format_number(r.amount, 2) + '</td>'
+ '<td class="text-end">' + format_number(r.accumulated, 2) + '</td>'
+ '<td class="text-end">' + format_number(r.book_value, 2) + '</td>'
+ '<td>' + state_badge[r.state](r) + '</td>'
+ '</tr>';
});
$('#schedule_tbody').html(html);
$('#schedule_summary').text(charged + ' of ' + rows.length + ' months charged · ' + format_number(detail.monthly_amount, 2) + ' per month');
$('#schedule_card').removeClass('d-none');
}
// ── actions ────────────────────────────────────────────────────────────────
function save_asset() {
return ajax_request({
url: server_url + 'asset/api/engine/manage_asset.php',
autoPrepare: true,
checkRequired: 1,
action: 'manage',
data: { id: asset_id || '', invoice_id: invoice_id, invoice_item_id: item_id },
onSuccess: function(res) {
var saved_id = res.output && res.output.id;
if (!asset_id && saved_id) {
window.location.href = server_url + 'asset/manage_asset.php?id=' + saved_id;
return;
}
toastr.success(res.message || 'Saved.');
retrieve_asset();
}
});
}
var ACTION_CONFIRM = {
activate: ['Activate this asset? It will start appearing in depreciation runs from its in-service month.', 'Activate', 'btn-success'],
undo_disposal: ['Undo the disposal? If its GL entry was posted, a reversal entry is created.', 'Undo Disposal', 'btn-warning'],
void: ['<strong>Void this asset?</strong><br>If its capitalization was posted, a reversal entry is created. This cannot be undone.', 'Void', 'btn-danger'],
delete: ['Delete this draft asset?', 'Delete', 'btn-danger']
};
function asset_action(action) {
var c = ACTION_CONFIRM[action];
bootbox.confirm({
message: c[0],
buttons: { confirm: { label: c[1], className: c[2] }, cancel: { label: 'Back', className: 'btn-secondary' } },
callback: function(ok) {
if (!ok) return;
ajax_request({
url: server_url + 'asset/api/engine/manage_asset.php',
autoPrepare: false,
action: action,
data: { json: JSON.stringify({
otp: document.getElementById('session-context').dataset.otp,
company_id: company_id,
action: action,
id: asset_id
}) },
onSuccess: function(res) {
if (action === 'delete') { window.location.href = server_url + 'asset/index.php'; return; }
toastr.success(res.message || 'Done.');
retrieve_asset();
}
});
}
});
}
function open_disposal() {
var a = detail.asset;
$('#proceeds_account_code').html(account_options(accounts, ['asset'], '', '— None (write-off) —'));
$('#sale_price').val('0');
$('#disposal_notes').val('');
$('#dp_cost').text(format_number(a.cost, 2));
$('#dp_accumulated').text(format_number(a.accumulated, 2));
$('#dp_book').text(format_number(a.book_value, 2));
update_disposal_result();
$('#disposal_modal').modal('show');
}
function update_disposal_result() {
if (!detail) return;
var gain = round_dp((parseFloat($('#sale_price').val()) || 0) - parseFloat(detail.asset.book_value), 2);
$('#dp_result_label').text(gain < 0 ? 'Loss' : 'Gain');
$('#dp_result').text(format_number(Math.abs(gain), 2)).toggleClass('text-danger', gain < 0).toggleClass('text-success', gain > 0);
}
function submit_disposal() {
ajax_request({
url: server_url + 'asset/api/engine/manage_asset.php',
autoPrepare: false,
action: 'dispose',
data: { json: JSON.stringify({
otp: document.getElementById('session-context').dataset.otp,
company_id: company_id,
action: 'dispose',
id: asset_id,
disposal_date: $('#disposal_date').val(),
sale_price: $('#sale_price').val(),
proceeds_account_code: $('#proceeds_account_code').val(),
disposal_notes: $('#disposal_notes').val()
}) },
onSuccess: function(res) {
$('#disposal_modal').modal('hide');
toastr.success(res.message || 'Disposal recorded.');
retrieve_asset();
}
});
}
// ── events ─────────────────────────────────────────────────────────────────
$('#cost, #salvage_value, #useful_life_months').on('input', update_calc);
$('#useful_life_months').on('input', function() { life_touched = true; });
$('#category_id').on('change', function() {
var c = category_by_id($(this).val());
if (c && !life_touched) {
$('#useful_life_months').val(c.useful_life_months);
update_calc();
}
update_class_hint();
});
$('#sale_price').on('input', update_disposal_result);
$(async function() {
flatpickr('#acquisition_date, #in_service_date, #disposal_date', { dateFormat: 'd/m/Y', allowInput: true });
try {
await load_categories();
await load_posting_accounts(function(rows) { accounts = rows; });
fill_category_select('');
$('#clearing_account_code').html(account_options(accounts, [], '', '— None —'));
if (asset_id) {
await retrieve_asset();
} else {
load_departments('department_id', 0);
if (invoice_id && item_id) await load_invoice_line();
if (!can_edit) $('#btn_save').addClass('d-none');
}
} catch (e) { console.log(e); }
});
</script>
</body>
</html>
+179
View File
@@ -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);
}
+11 -2
View File
@@ -995,15 +995,24 @@ document.addEventListener('DOMContentLoaded', () => {
/**
* Helper function to format date strings (assuming input is in ISO format)
*/
// Display format used across the app: YYYY-MM-DD, or YYYY-MM-DD HH:mm:ss when the value has a time.
function format_date(iso_string) {
if (!iso_string) return '—';
var split = iso_string.split(" ");
var split = String(iso_string).split(" ");
var datePart = split[0].split("-");
if (datePart.length !== 3) return iso_string;
var formatted = `${datePart[2]}/${datePart[1]}/${datePart[0]}`;
var formatted = `${datePart[0]}-${datePart[1]}-${datePart[2]}`;
return split.length === 2 ? `${formatted} ${split[1]}` : formatted;
}
// DD/MM/YYYY for filling date inputs (flatpickr dateFormat 'd/m/Y'); to_iso_date() reverses it.
function format_date_input(iso_string) {
if (!iso_string) return '';
var datePart = String(iso_string).split(" ")[0].split("-");
if (datePart.length !== 3) return iso_string;
return `${datePart[2]}/${datePart[1]}/${datePart[0]}`;
}
function to_iso_date(dateStr) {
if (!dateStr) return null;
+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)
+8 -5
View File
@@ -79,7 +79,8 @@ class StockManager {
public function getStockList(int $warehouse_id, string $type): array
{
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
$column = $type === 'out' ? 'ROUND(a.out, 2)' : 'ROUND(a.in, 2)';
// The transfer list shows the outbound row, whose quantity is in `out` (its `in` is always 0).
$column = in_array($type, ['out', 'transfer'], true) ? 'ROUND(a.out, 2)' : 'ROUND(a.in, 2)';
$stock_ref = $this->stockReferenceSql();
// Transfer list: show only the outbound side (out > 0) to avoid duplicate display
@@ -439,8 +440,9 @@ class StockManager {
);
}
// Quantity and identifiers come from the existing stock_in row (immutable)
$quantity = (int)$source_stock['in'];
// Quantity and identifiers come from the existing stock_in row (immutable).
// `in` is decimal(18,4) — an int cast would drop fractional quantities.
$quantity = (float)$source_stock['in'];
$ref_id = (int)$source_stock['id'];
$lot_number = $source_stock['lot_number'] ?? null;
$serial_number = $source_stock['serial_number'] ?? null;
@@ -611,8 +613,9 @@ class StockManager {
);
}
// Quantity and identifiers come from the source stock_in row (immutable)
$quantity = (int)$source_stock['in'];
// Quantity and identifiers come from the source stock_in row (immutable).
// `in` is decimal(18,4) — an int cast would drop fractional quantities.
$quantity = (float)$source_stock['in'];
$lot_number = $source_stock['lot_number'] ?? null;
$serial_number = $source_stock['serial_number'] ?? null;
+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 {
@@ -697,7 +697,7 @@ class FinancialReports
COALESCE(d.dept_code, '') AS dept_code,
COALESCE(d.dept_name, '') AS dept_name,
COALESCE(g.journal_date, DATE(g.created_at)) AS entry_date,
DATE_FORMAT(g.created_at, '%d/%m/%Y %H:%i') AS posted_at,
DATE_FORMAT(g.created_at, '%Y-%m-%d %H:%i:%s') AS posted_at,
i.debit,
i.credit,
COALESCE(i.description, '') AS line_description
+25 -7
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') {
@@ -75,13 +84,17 @@ class GlQueryManager
g.current_version,
g.formula_id,
COALESCE(f.formula_name, '') AS formula_name,
DATE_FORMAT(g.created_at, '%d/%m/%Y %H:%i') AS posted_at,
DATE_FORMAT(g.updated_at, '%d/%m/%Y %H:%i') AS updated_at,
DATE_FORMAT(g.created_at, '%Y-%m-%d %H:%i:%s') AS posted_at,
DATE_FORMAT(g.updated_at, '%Y-%m-%d %H:%i:%s') AS updated_at,
CASE g.source_type
WHEN 'receipt' THEN r.receipt_number
WHEN 'payment' THEN p.payment_number
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
@@ -142,8 +160,8 @@ class GlQueryManager
$sth = $this->pdo->prepare(
"SELECT g.*,
DATE_FORMAT(g.created_at, '%d/%m/%Y %H:%i') AS posted_at,
DATE_FORMAT(g.updated_at, '%d/%m/%Y %H:%i') AS updated_at_fmt,
DATE_FORMAT(g.created_at, '%Y-%m-%d %H:%i:%s') AS posted_at,
DATE_FORMAT(g.updated_at, '%Y-%m-%d %H:%i:%s') AS updated_at_fmt,
DATE_FORMAT(g.journal_date, '%d/%m/%Y') AS journal_date_fmt,
COALESCE(f.formula_name, '') AS formula_name
FROM td_gl g
@@ -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, '%Y-%m-%d %H:%i:%s') AS posted_at
FROM td_gl WHERE company_id = :cid AND source_type = :type AND source_id = :id LIMIT 1"
);
$sth->execute([':cid' => $this->companyId, ':type' => $source_type, ':id' => $source_id]);
return $sth->fetch(PDO::FETCH_ASSOC) ?: null;
}
/**
* The part of the invoice's pre-tax total the purchase posting put on this
* line — PurchaseInvoicePosting splits (grand_total - tax) by line total.
*/
private function postedLineAmount(int $invoice_id, float $line_total, float $grand_total, float $tax): float
{
$sth = $this->pdo->prepare(
"SELECT COALESCE(SUM(ABS(total_price)), 0) FROM td_invoice_item WHERE company_id = :cid AND invoice_id = :id"
);
$sth->execute([':cid' => $this->companyId, ':id' => $invoice_id]);
$sum = (float)$sth->fetchColumn();
if ($sum <= 0) return 0.0;
return round((abs($grand_total) - abs($tax)) * abs($line_total) / $sum, 2);
}
private function formulaTotalAccount(int $formula_id): string
{
if ($formula_id <= 0) {
$sth = $this->pdo->prepare(
"SELECT id FROM md_account_formula
WHERE company_id = :cid AND document_type = 'purchase_invoice' AND is_default = 1 AND status = 1
LIMIT 1"
);
$sth->execute([':cid' => $this->companyId]);
$formula_id = (int)$sth->fetchColumn();
}
if ($formula_id <= 0) return '';
$sth = $this->pdo->prepare(
"SELECT account_code FROM md_account_formula_item
WHERE company_id = :cid AND formula_id = :fid AND amount_key = 'total' AND drcr = 'D'
ORDER BY sort_order ASC LIMIT 1"
);
$sth->execute([':cid' => $this->companyId, ':fid' => $formula_id]);
return (string)($sth->fetchColumn() ?: '');
}
private function parseDate(string $value): string
{
$value = trim($value);
if ($value === '') return '';
if (preg_match('#^(\d{2})/(\d{2})/(\d{4})$#', $value, $m)) return "{$m[3]}-{$m[2]}-{$m[1]}";
if (preg_match('#^\d{4}-\d{2}-\d{2}$#', $value)) return $value;
throw new Exception("Invalid date '{$value}'.");
}
}
@@ -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, '%Y-%m-%d %H:%i:%s') AS created_at_fmt,
IF(g.id IS NULL, 0, 1) AS gl_status,
COALESCE(g.id, 0) AS gl_id
FROM td_asset_run r
LEFT JOIN td_gl g
ON g.company_id = r.company_id
AND g.source_id = r.id
AND g.source_type = IF(r.asset_class = 'intangible', 'asset_amortization', 'asset_depreciation')
WHERE " . implode(' AND ', $where) . "
ORDER BY r.period DESC, r.id DESC"
);
$sth->execute($params);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
public function getDetail(int $run_id): array
{
$sth = $this->pdo->prepare("SELECT * FROM td_asset_run WHERE company_id = :cid AND id = :id LIMIT 1");
$sth->execute([':cid' => $this->companyId, ':id' => $run_id]);
$run = $sth->fetch(PDO::FETCH_ASSOC);
if (!$run) throw new Exception('Run not found.');
$sth = $this->pdo->prepare(
"SELECT d.asset_id, d.amount, d.accumulated_after, d.book_value_after, d.department_id,
a.asset_number, a.asset_name,
COALESCE(c.category_code, '') AS category_code,
COALESCE(c.category_name, '') AS category_name,
COALESCE(dp.dept_code, '') AS dept_code
FROM td_asset_depreciation d
JOIN td_asset a
ON a.id = d.asset_id AND ABS(a.company_id) = d.company_id
LEFT JOIN md_asset_category c
ON c.company_id = d.company_id AND c.id = d.category_id
LEFT JOIN md_department dp
ON dp.company_id = d.company_id AND dp.id = d.department_id
WHERE d.company_id = :cid AND d.run_id = :run_id
ORDER BY a.asset_number ASC"
);
$sth->execute([':cid' => $this->companyId, ':run_id' => $run_id]);
$items = $sth->fetchAll(PDO::FETCH_ASSOC);
// A void run's rows are gone; show what it held from the header only.
return ['run' => $run, 'items' => $items];
}
/**
* Void a run: reverse its GL entry if posted and remove its charges. Only
* the latest month of each asset can be removed, and not after a disposal.
*/
public function void(int $run_id, GlManager $gl): void
{
$sth = $this->pdo->prepare("SELECT * FROM td_asset_run WHERE company_id = :cid AND id = :id FOR UPDATE");
$sth->execute([':cid' => $this->companyId, ':id' => $run_id]);
$run = $sth->fetch(PDO::FETCH_ASSOC);
if (!$run) throw new Exception('Run not found.');
if ((int)$run['status'] !== 1) throw new Exception('This run is already void.');
$sth = $this->pdo->prepare(
"SELECT a.asset_number
FROM td_asset_depreciation d
JOIN td_asset a ON a.company_id = d.company_id AND a.id = d.asset_id
WHERE d.company_id = :cid AND d.run_id = :run_id AND a.status = 3
LIMIT 1"
);
$sth->execute([':cid' => $this->companyId, ':run_id' => $run_id]);
if ($number = $sth->fetchColumn()) {
throw new Exception("Asset {$number} in this run has been disposed of. Undo the disposal first.");
}
$sth = $this->pdo->prepare(
"SELECT later.period
FROM td_asset_depreciation d
JOIN td_asset_depreciation later
ON later.company_id = d.company_id AND later.asset_id = d.asset_id AND later.period > d.period
WHERE d.company_id = :cid AND d.run_id = :run_id
ORDER BY later.period DESC
LIMIT 1"
);
$sth->execute([':cid' => $this->companyId, ':run_id' => $run_id]);
if ($later = $sth->fetchColumn()) {
throw new Exception("Assets in this run were charged again in {$later}. Void the later runs first.");
}
$gl->delete(self::sourceTypeFor($run['asset_class']), $run_id);
$sth = $this->pdo->prepare("SELECT asset_id FROM td_asset_depreciation WHERE company_id = :cid AND run_id = :run_id");
$sth->execute([':cid' => $this->companyId, ':run_id' => $run_id]);
$asset_ids = $sth->fetchAll(PDO::FETCH_COLUMN);
$this->pdo->prepare("DELETE FROM td_asset_depreciation WHERE company_id = :cid AND run_id = :run_id")
->execute([':cid' => $this->companyId, ':run_id' => $run_id]);
$this->pdo->prepare("UPDATE td_asset_run SET status = 4, voided_at = NOW() WHERE company_id = :cid AND id = :id")
->execute([':cid' => $this->companyId, ':id' => $run_id]);
(new AssetManager($this->pdo, $this->companyId))->syncStatuses($asset_ids);
}
/** What is due through the current month, per class (for the dashboard). */
public function dueSummary(): array
{
$summary = [];
foreach (array_keys(self::CLASS_META) as $asset_class) {
$runs = $this->preview($asset_class, date('Y-m'));
$summary[$asset_class] = [
'label' => self::labelFor($asset_class),
'period_count' => count($runs),
'from' => $runs ? $runs[0]['period'] : '',
'to' => $runs ? $runs[count($runs) - 1]['period'] : '',
'total' => round(array_sum(array_column($runs, 'total')), 2),
];
}
return $summary;
}
private function activeAssets(string $asset_class): array
{
$sth = $this->pdo->prepare(
"SELECT a.id, a.asset_number, a.asset_name, a.category_id, a.department_id,
a.cost, a.salvage_value, a.useful_life_months, a.in_service_date,
COALESCE(c.category_name, '') AS category_name,
COALESCE(d.accumulated, 0) AS accumulated,
COALESCE(d.last_period, '') AS last_period
FROM td_asset a
LEFT JOIN md_asset_category c
ON c.company_id = a.company_id AND c.id = a.category_id
LEFT JOIN (SELECT asset_id, SUM(amount) AS accumulated, MAX(period) AS last_period
FROM td_asset_depreciation WHERE company_id = :cid_d GROUP BY asset_id) d
ON d.asset_id = a.id
WHERE a.company_id = :cid AND a.asset_class = :class AND a.status = 1
ORDER BY a.asset_number ASC"
);
$sth->execute([':cid' => $this->companyId, ':cid_d' => $this->companyId, ':class' => $asset_class]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
private function nextRunNumber(string $asset_class, string $period): string
{
$sth = $this->pdo->prepare(
"SELECT COUNT(*) FROM td_asset_run WHERE company_id = :cid AND asset_class = :class AND period = :period"
);
$sth->execute([':cid' => $this->companyId, ':class' => $asset_class, ':period' => $period]);
$existing = (int)$sth->fetchColumn();
$number = self::meta($asset_class)['prefix'] . '-' . $period;
return $existing > 0 ? $number . '-' . ($existing + 1) : $number;
}
private static function meta(string $asset_class): array
{
if (!isset(self::CLASS_META[$asset_class])) throw new Exception('Invalid asset class.');
return self::CLASS_META[$asset_class];
}
}
@@ -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 ───────────────────────────────────────────────────────────
+71 -2
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 = {
@@ -230,7 +294,7 @@
$('#badge_status').html(invoice_status_badge(inv.status, inv.due_date));
$('#display_contact').text(inv.contact_name || '—');
$('#display_issued').text(format_date(inv.issued_date) || '—');
$('#due_date').val(inv.due_date ? format_date(inv.due_date) : '');
$('#due_date').val(inv.due_date ? format_date_input(inv.due_date) : '');
$('#notes').val(inv.notes || '');
// Source link
@@ -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();
}
});
}
+2 -2
View File
@@ -274,8 +274,8 @@
$('#po_number_display').text(po_data.po_number || '');
$('#contact').val(po_data.contact_name || '');
$('#contact_id').val(po_data.contact_id || '');
$('#po_date').val(po_data.po_date ? format_date(po_data.po_date) : '');
$('#expected_date').val(po_data.expected_date ? format_date(po_data.expected_date) : '');
$('#po_date').val(po_data.po_date ? format_date_input(po_data.po_date) : '');
$('#expected_date').val(po_data.expected_date ? format_date_input(po_data.expected_date) : '');
$('#department_id').val(po_data.department_id || 0);
$('#notes').val(po_data.notes || '');
$('#discount').val(po_data.discount || 0);
+2 -2
View File
@@ -253,8 +253,8 @@
.html(request_data.po_id > 0 ? 'PO: <a href="<?php echo $server_url?>expense/manage_purchase_order.php?id=' + request_data.po_id + '">' + escape_html(request_data.po_number || ('PO #' + request_data.po_id)) + '</a>' : '');
$('#contact').val(request_data.contact_name || '');
$('#contact_id').val(request_data.contact_id || 0);
$('#request_date').val(request_data.request_date ? format_date(request_data.request_date) : '');
$('#required_date').val(request_data.required_date ? format_date(request_data.required_date) : '');
$('#request_date').val(request_data.request_date ? format_date_input(request_data.request_date) : '');
$('#required_date').val(request_data.required_date ? format_date_input(request_data.required_date) : '');
$('#department_id').val(request_data.department_id || 0);
$('#notes').val(request_data.notes || '');
$('#discount').val(request_data.discount || 0);
+38
View File
@@ -0,0 +1,38 @@
<?php
session_start();
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes/WarehouseManager.php';
// What one location holds — the quantity a stock-out or transfer from it moves
// (StockManager takes the whole approved stock-in row in the bin).
try {
$whMgmt = new WarehouseManager($pdo2, $company_id);
$row = $whMgmt->getBinStock(
(int)($data['warehouse'] ?? 0),
$data['zone'] ?? '',
$data['aisle'] ?? '',
$data['bin'] ?? ''
);
$output = null;
if ($row) {
$sth = $pdo2->prepare("SELECT uom FROM md_product WHERE company_id = :c AND sku = :sku LIMIT 1");
$sth->execute([':c' => $company_id, ':sku' => $row['product_sku']]);
$output = [
'product_sku' => $row['product_sku'],
'lot_number' => $row['lot_number'],
'serial_number' => $row['serial_number'],
'quantity' => (float)$row['in'],
'uom' => (string)($sth->fetchColumn() ?: ''),
];
}
$answer['output'] = $output;
$answer['success'] = 1;
} catch (Exception $e) {
$answer['message'] = $e->getMessage();
http_response_code(400);
}
exit(json_encode($answer));
?>
+47 -1
View File
@@ -93,6 +93,16 @@
</select>
</div>
<!-- Quantity: a stock-out always takes everything in the location -->
<div class="mb-3 col-lg-6">
<label for="location_quantity" class="form-label">Quantity</label>
<div class="input-group">
<input type="text" id="location_quantity" class="form-control text-end" placeholder="—" disabled>
<span class="input-group-text" id="location_uom" style="min-width:60px;">&nbsp;</span>
</div>
<div class="form-text">The whole quantity in the selected location is taken out.</div>
</div>
<!-- Contact -->
<div class="mb-3 col-lg-6">
<label for="contact" class="form-label">Contact</label>
@@ -159,6 +169,35 @@
}
set_select_value('#bin', data.bin, data.bin);
scan_location_ready = true;
show_location_quantity();
});
}
// Quantity held in the chosen location — the amount this stock-out moves.
function show_location_quantity() {
var bin_val = $('#bin').val();
$('#location_quantity').val('');
$('#location_uom').html('&nbsp;');
if (!$('#warehouse').val() || !bin_val) return;
return ajax_request({
url: '<?php echo $server_url?>ics/api/engine/retrieve_bin_stock.php',
autoPrepare: true,
checkRequired: 0,
noLoading: true,
queueLock: false,
action: 'read',
data: {
warehouse: $('#warehouse').val(),
zone: advanced ? $('#zone').val() : bin_val,
aisle: advanced ? $('#aisle').val() : bin_val,
bin: bin_val
},
onSuccess: function(res) {
if (!res.output) return;
$('#location_quantity').val(format_number(res.output.quantity, 2));
$('#location_uom').text(res.output.uom || '');
}
});
}
@@ -483,6 +522,8 @@
$('#zone').html('<option value="">Please select ' + label_zone.toLowerCase() + '</option>').prop('disabled', true);
$('#aisle').html('<option value="">Please select ' + label_aisle.toLowerCase() + '</option>').prop('disabled', true);
$('#bin').html('<option value="">Please select ' + label_bin.toLowerCase() + '</option>').prop('disabled', true);
$('#location_quantity').val('');
$('#location_uom').html('&nbsp;');
}
@@ -509,7 +550,7 @@
$('#serial_number').html(`<option value="${data.serial_number || ''}">${data.serial_number || '—'}</option>`)
.val(data.serial_number || '').prop('disabled', true);
$('#warehouse').val('<?php echo $_GET["wh"];?>').prop('disabled', true);
$('#warehouse').val('<?php echo (int)($_GET["wh"] ?? 0);?>').prop('disabled', true);
function populate_fields() {
$.each(data, function(key, item) {
@@ -522,6 +563,8 @@
.attr('data-id', data.contact_id);
$('#product_name').val(data.product_name);
$('#product_sku').attr('secondary', data.product_sku).prop('disabled', true);
$('#location_quantity').val(format_number(data.quantity, 2));
$('#location_uom').text(data.uom || '');
$('button[type=submit]').text('Update');
$('button[type=reset]').hide();
if (data.status == 0) {
@@ -619,6 +662,9 @@
if (advanced) retrieve_bin();
});
// Bin selected → show how much it holds
$('#bin').on('change', show_location_quantity);
<?php } ?>
</script>
+45
View File
@@ -92,6 +92,15 @@
<option value="">Please select bin</option>
</select>
</div>
<!-- Quantity: a transfer always moves everything in the from-location -->
<div class="mb-3 col-lg-3">
<label for="transfer_quantity" class="form-label">Quantity</label>
<div class="input-group">
<input type="text" id="transfer_quantity" class="form-control text-end" placeholder="—" disabled>
<span class="input-group-text" id="transfer_uom" style="min-width:52px;">&nbsp;</span>
</div>
<div class="form-text">Whole location is moved.</div>
</div>
<div class="col-12"><hr class="my-2"></div>
@@ -204,12 +213,41 @@
if (role === 'from') {
scan_from_ready = true;
show_from_quantity();
} else {
scan_to_ready = true;
}
});
}
// Quantity held in the from-location — the amount this transfer moves.
function show_from_quantity() {
var bin_val = $('#bin_from').val();
$('#transfer_quantity').val('');
$('#transfer_uom').html('&nbsp;');
if (!$('#warehouse_from').val() || !bin_val) return;
return ajax_request({
url: '<?php echo $server_url?>ics/api/engine/retrieve_bin_stock.php',
autoPrepare: true,
checkRequired: 0,
noLoading: true,
queueLock: false,
action: 'read',
data: {
warehouse: $('#warehouse_from').val(),
zone: advanced ? $('#zone_from').val() : bin_val,
aisle: advanced ? $('#aisle_from').val() : bin_val,
bin: bin_val
},
onSuccess: function(res) {
if (!res.output) return;
$('#transfer_quantity').val(format_number(res.output.quantity, 2));
$('#transfer_uom').text(res.output.uom || '');
}
});
}
function same_location_as_from(data) {
return String($('#warehouse_from').val()) === String(data.warehouse_id)
&& String($('#zone_from').val()) === String(data.zone)
@@ -574,6 +612,8 @@
$('#zone_from').html('<option value="">Please select ' + label_zone.toLowerCase() + '</option>').prop('disabled', true);
$('#aisle_from').html('<option value="">Please select ' + label_aisle.toLowerCase() + '</option>').prop('disabled', true);
$('#bin_from').html('<option value="">Please select ' + label_bin.toLowerCase() + '</option>').prop('disabled', true);
$('#transfer_quantity').val('');
$('#transfer_uom').html('&nbsp;');
}
@@ -640,6 +680,8 @@
$('#contact').val(data.contact_name)
.attr('secondary', data.contact_name)
.attr('data-id', data.contact_id);
$('#transfer_quantity').val(format_number(data.quantity, 2));
$('#transfer_uom').text(data.uom || '');
$('button[type=submit]').text('Update');
$('button[type=reset]').hide();
if (data.status == 0) {
@@ -732,6 +774,9 @@
if (advanced) retrieve_bin('from');
});
// From bin → show how much it holds
$('#bin_from').on('change', show_from_quantity);
// To warehouse → zone (advanced) or bin directly (simple)
$('select[name="warehouse"][role="to"]').on('change', function() {
if (advanced) { retrieve_zone('to'); } else { retrieve_bin('to'); }
+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; ?>
+13 -1
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() {
@@ -187,7 +199,7 @@
document.getElementById('gl_detail_body').innerHTML =
'<div class="row g-3 mb-4">' +
'<div class="col-6"><p class="text-muted small mb-0">Date</p><strong>' + escape_html(h.journal_date_fmt || h.journal_date || '—') + '</strong></div>' +
'<div class="col-6"><p class="text-muted small mb-0">Date</p><strong>' + escape_html(format_date(h.journal_date)) + '</strong></div>' +
'<div class="col-6"><p class="text-muted small mb-0">Period</p><strong>' + escape_html(h.period) + '</strong></div>' +
'<div class="col-6"><p class="text-muted small mb-0">Reference</p><strong>' + escape_html(h.reference || '—') + '</strong></div>' +
'<div class="col-6"><p class="text-muted small mb-0">Source</p><strong>' + escape_html(src_labels[h.source_type] || h.source_type) + '</strong></div>' +
+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;
}
+1 -1
View File
@@ -250,7 +250,7 @@
$('#display_contact').text(inv.contact_name || '—');
$('#display_department').text(get_dept_label(inv.department_id));
$('#display_issued').text(format_date(inv.issued_date) || '—');
$('#due_date').val(inv.due_date ? format_date(inv.due_date) : '');
$('#due_date').val(inv.due_date ? format_date_input(inv.due_date) : '');
$('#notes').val(inv.notes || '');
var gl_type = inv.doc_type === 'credit_note' ? 'sales_credit_note' : 'sales_invoice';
+1 -1
View File
@@ -493,7 +493,7 @@
// Fields
$('#contact').val(o.contact_name || '');
$('#contact_id').val(o.contact_id);
$('#order_date').val(o.order_date ? format_date(o.order_date) : '');
$('#order_date').val(o.order_date ? format_date_input(o.order_date) : '');
$('#department_id').val(o.department_id || 0);
$('#notes').val(o.notes || '');
$('#discount').val(o.discount || 0);
+1 -1
View File
@@ -795,7 +795,7 @@
$('#badge_status').html(return_status_badge(r.status));
$('#return_number_display').text(r.return_number);
$('#return_date').val(r.return_date ? format_date(r.return_date) : '');
$('#return_date').val(r.return_date ? format_date_input(r.return_date) : '');
$('#reason').val(r.reason || '');
$('#tax_adjustment').val(parseFloat(r.tax_adjustment || 0).toFixed(2));
$('#display_department').text(get_dept_label(r.department_id));
+2 -2
View File
@@ -682,8 +682,8 @@
$('#contact').val(o.contact_name || '');
$('#contact_id').val(o.contact_id || '');
$('#po_date').val(o.po_date ? format_date(o.po_date) : '');
$('#expected_date').val(o.expected_date ? format_date(o.expected_date) : '');
$('#po_date').val(o.po_date ? format_date_input(o.po_date) : '');
$('#expected_date').val(o.expected_date ? format_date_input(o.expected_date) : '');
$('#warehouse_id').val(o.warehouse_id || '');
$('#department_id').val(o.department_id || 0);
$('#notes').val(o.notes || '');
+1 -1
View File
@@ -226,7 +226,7 @@
$('#display_contact').text(inv.contact_name || '—');
$('#display_department').text(get_dept_label(inv.department_id));
$('#display_issued').text(format_date(inv.issued_date) || '—');
$('#due_date').val(inv.due_date ? format_date(inv.due_date) : '');
$('#due_date').val(inv.due_date ? format_date_input(inv.due_date) : '');
$('#notes').val(inv.notes || '');
// Source link
+1 -1
View File
@@ -432,7 +432,7 @@
$('#badge_status').html(return_status_badge(r.status));
$('#badge_fulfillment').html(fulfillment_status_badge(r.fulfillment_status));
$('#return_number_display').text(r.return_number);
$('#return_date').val(r.return_date ? format_date(r.return_date) : '');
$('#return_date').val(r.return_date ? format_date_input(r.return_date) : '');
$('#reason').val(r.reason || '');
$('#tax_adjustment').val(parseFloat(r.tax_adjustment || 0).toFixed(2));
$('#display_department').text(get_dept_label(r.department_id));
+1 -1
View File
@@ -207,7 +207,7 @@
$('#display_contact').html(display_text(inv.contact_name));
$('#display_issued').html(inv.issued_date ? format_date(inv.issued_date) : '<span class="text-muted">—</span>');
$('#display_due').html(inv.due_date ? format_date(inv.due_date) : '<span class="text-muted">—</span>');
$('#due_date').val(inv.due_date ? format_date(inv.due_date) : '');
$('#due_date').val(inv.due_date ? format_date_input(inv.due_date) : '');
$('#display_notes').html(inv.notes ? escape_html(inv.notes).replace(/\n/g, '<br>') : '<span class="text-muted">—</span>');
var rows = '';
+1 -1
View File
@@ -356,7 +356,7 @@
$('#order_number_display').text(o.order_number);
$('#contact').val(o.contact_name || '');
$('#contact_id').val(o.contact_id || 0);
$('#order_date').val(o.order_date ? format_date(o.order_date) : '');
$('#order_date').val(o.order_date ? format_date_input(o.order_date) : '');
$('#notes').val(o.notes || '');
$('#discount').val(o.discount || 0);
$('#tax_adjustment').val(o.tax_adjustment || 0);
+2 -2
View File
@@ -379,8 +379,8 @@
$('#contact').val(q.contact_name || '');
$('#contact_id').val(q.contact_id || 0);
$('#quotation_date').val(q.quotation_date ? format_date(q.quotation_date) : '');
$('#valid_until').val(q.valid_until ? format_date(q.valid_until) : '');
$('#quotation_date').val(q.quotation_date ? format_date_input(q.quotation_date) : '');
$('#valid_until').val(q.valid_until ? format_date_input(q.valid_until) : '');
$('#department_id').val(q.department_id || 0);
$('#notes').val(q.notes || '');
$('#discount').val(parseFloat(q.discount) || 0);
+1 -3
View File
@@ -257,9 +257,7 @@
const license_badge = license_html(u.license);
const access_badge = app_access_html(u.app_access);
const joined = u.created_at
? new Date(u.created_at).toLocaleDateString('en-GB', {day:'2-digit', month:'short', year:'numeric'})
: '—';
const joined = u.created_at ? format_date(String(u.created_at).split(' ')[0]) : '—';
const is_pending = u.status === 'pending' && u.is_pending_invite == 1;
+4 -2
View File
@@ -532,9 +532,11 @@ foreach ($stockout_defs as $so) {
$qty = $so['qty'];
$cost = $p['cost'];
$uom = $p['uom'];
// The batch is bought in from a supplier; only the stock-out goes to the customer.
$supplier_id = $supplier_ids[0];
dbTransaction($pdo2, function ($pdo2) use (
$stockMgmt, $whMgmt, $company_id, $main_wh_id, $sku, $qty, $cost, $customer_id,
$stockMgmt, $whMgmt, $company_id, $main_wh_id, $sku, $qty, $cost, $customer_id, $supplier_id,
$logging, $dispatch_in_marker, $dispatch_out_marker
) {
$bin = findFreeBin($pdo2, $company_id, $main_wh_id);
@@ -549,7 +551,7 @@ foreach ($stockout_defs as $so) {
'zone' => $bin,
'aisle' => $bin,
'bin' => $bin,
'contact_id' => $customer_id,
'contact_id' => $supplier_id,
'description' => $dispatch_in_marker,
], $logging, $in_uuid);
$stockMgmt->approveStock($stock_id, $main_wh_id, 'in', $whMgmt);
+374
View File
@@ -0,0 +1,374 @@
<?php
/**
* Demo Data Seeder — Asset Management
* Run after demo_seed.php and demo_seed_transactions.php: php demo_seed_assets.php
*
* Seeds the Asset Management app through the same classes its pages and the
* Batch GL Entries page use:
* 1. GL accounts for fixed assets, and asset categories (AssetCategoryManager)
* 2. A purchase of a laptop fleet + an ERP licence: PO -> confirm -> purchase
* invoice -> issue -> GL post (PurchaseOrderManager, InvoiceManager), the
* only way a purchase invoice exists in the app
* 3. Assets capitalized from those invoice lines, plus two vehicles bought
* before the system was used and already on the books (AssetManager)
* 4. Depreciation runs up to last month, the sale of the forklift today, then
* this month's depreciation and amortization runs (AssetRunManager)
* 5. GL posting of the purchase invoice, capitalizations, the disposal and every
* run before this month (AssetPosting + GlManager, as
* accounting/api/engine/post_gl_entry.php does) — this month's runs stay
* unposted so Batch GL Entries has Asset Management work to show.
*
* Dates are relative to today: the app stamps documents with the current date.
*
* Safe to re-run: every step checks what already exists.
*/
if (PHP_SAPI !== 'cli') {
http_response_code(403);
exit('Run via CLI only: php demo_seed_assets.php');
}
$_SESSION = [];
require_once __DIR__ . '/app/config.php';
require_once __DIR__ . '/app/dbconn.php';
require_once __DIR__ . '/app/assets/utils/db_helpers.php';
require_once __DIR__ . '/app/assets/utils/classes/WarehouseManager.php';
require_once __DIR__ . '/app/assets/utils/classes/StockManager.php';
require_once __DIR__ . '/app/assets/utils/classes/ProductManager.php';
require_once __DIR__ . '/app/assets/utils/classes/PurchaseOrderManager.php';
require_once __DIR__ . '/app/assets/utils/classes/InvoiceManager.php';
require_once __DIR__ . '/app/assets/utils/classes_ac/ChartOfAccounts.php';
require_once __DIR__ . '/app/assets/utils/classes_ac/PostingWindowGuard.php';
require_once __DIR__ . '/app/assets/utils/classes_ac/GlManager.php';
require_once __DIR__ . '/app/assets/utils/classes_ac/posting/BasePosting.php';
require_once __DIR__ . '/app/assets/utils/classes_ac/posting/PurchaseInvoicePosting.php';
require_once __DIR__ . '/app/assets/utils/classes_as/AssetManager.php';
require_once __DIR__ . '/app/assets/utils/classes_as/AssetRunManager.php';
require_once __DIR__ . '/app/assets/utils/classes_as/AssetPosting.php';
function info(string $msg): void { echo "\033[36m •\033[0m {$msg}\n"; }
function ok(string $msg): void { echo "\033[32m ✓\033[0m {$msg}\n"; }
function skip(string $msg): void { echo "\033[33m –\033[0m {$msg}\n"; }
echo "\n\033[1m=== brnwms Demo Asset Management Seeder ===\033[0m\n\n";
// ─────────────────────────────────────────────────────────────────────────────
// 0. Resolve what the earlier seeders created
// ─────────────────────────────────────────────────────────────────────────────
$sth = $pdo1->prepare("SELECT company_id FROM company_list WHERE channel_name = 'demo' LIMIT 1");
$sth->execute();
$company_id = (int)($sth->fetchColumn() ?: 0);
if (!$company_id) exit("ERROR: demo company not found — run demo_seed.php first.\n");
$sth = $pdo1->prepare("SELECT user_id FROM user WHERE username = 'admin' LIMIT 1");
$sth->execute();
$owner_user_id = (int)($sth->fetchColumn() ?: 0);
$lookup = function (string $sql, array $params) use ($pdo2): int {
$sth = $pdo2->prepare($sql);
$sth->execute($params);
return (int)($sth->fetchColumn() ?: 0);
};
$main_wh = $lookup("SELECT id FROM md_warehouse WHERE company_id = :c AND warehouse_name = 'Main Warehouse' LIMIT 1", [':c' => $company_id]);
$supplier_id = $lookup("SELECT id FROM md_contact WHERE company_id = :c AND contact_name = 'Asia Electronics Wholesale' LIMIT 1", [':c' => $company_id]);
$wh_dept_id = $lookup("SELECT id FROM md_department WHERE company_id = :c AND dept_code = 'WH' LIMIT 1", [':c' => $company_id]);
if (!$main_wh || !$supplier_id || !$wh_dept_id) {
exit("ERROR: warehouse, supplier or WH department missing — run demo_seed.php and demo_seed_transactions.php first.\n");
}
$coa = new ChartOfAccounts($pdo2, $company_id);
foreach (['1000', '4000', '5000'] as $code) {
if (!$coa->getByCode($code)) exit("ERROR: account {$code} missing — run demo_seed_transactions.php first.\n");
}
$logging = ['user_id' => $owner_user_id, 'dt' => date('Y-m-d H:i:s'), 'login' => null, 'action' => 'seed_assets'];
$today = date('Y-m-d');
$this_period = date('Y-m');
$last_period = DepreciationCalculator::addMonths($this_period, -1);
// ─────────────────────────────────────────────────────────────────────────────
// 1. Accounts + asset categories
// ─────────────────────────────────────────────────────────────────────────────
echo "--- Asset accounts ---\n";
$account_defs = [
['code' => '1500', 'name' => 'Equipment and Vehicles', 'type' => 'asset'],
['code' => '1590', 'name' => 'Accumulated Depreciation', 'type' => 'asset'],
['code' => '1600', 'name' => 'Software and Licences', 'type' => 'asset'],
['code' => '1690', 'name' => 'Accumulated Amortization', 'type' => 'asset'],
['code' => '4900', 'name' => 'Gain on Asset Disposal', 'type' => 'revenue'],
['code' => '6100', 'name' => 'Depreciation Expense', 'type' => 'expense'],
['code' => '6200', 'name' => 'Amortization Expense', 'type' => 'expense'],
['code' => '6900', 'name' => 'Loss on Asset Disposal', 'type' => 'expense'],
];
foreach ($account_defs as $a) {
if ($coa->getByCode($a['code'])) {
skip("Account {$a['code']} — {$a['name']} already exists");
continue;
}
$coa->create([
'account_code' => $a['code'], 'account_name' => $a['name'], 'account_type' => $a['type'],
'account_category' => '', 'parent_code' => null, 'is_posting' => 1, 'status' => 1,
]);
ok("Created account {$a['code']} — {$a['name']} ({$a['type']})");
}
echo "\n--- Asset categories ---\n";
$category_defs = [
['code' => 'IT', 'name' => 'Computer Equipment', 'class' => 'tangible', 'life' => 36, 'cost' => '1500', 'accum' => '1590', 'expense' => '6100'],
['code' => 'VEH', 'name' => 'Vehicles and Forklifts', 'class' => 'tangible', 'life' => 60, 'cost' => '1500', 'accum' => '1590', 'expense' => '6100'],
['code' => 'SW', 'name' => 'Software Licences', 'class' => 'intangible', 'life' => 24, 'cost' => '1600', 'accum' => '1690', 'expense' => '6200'],
];
$category_ids = [];
foreach ($category_defs as $c) {
$id = $lookup("SELECT id FROM md_asset_category WHERE company_id = :c AND category_code = :code LIMIT 1", [':c' => $company_id, ':code' => $c['code']]);
if ($id) {
skip("Category {$c['code']} already exists (id={$id})");
} else {
$id = (new AssetCategoryManager($pdo2, $company_id))->save([
'category_code' => $c['code'], 'category_name' => $c['name'], 'asset_class' => $c['class'],
'useful_life_months' => $c['life'], 'status' => 1, 'description' => '',
'cost_account_code' => $c['cost'], 'accum_account_code' => $c['accum'], 'expense_account_code' => $c['expense'],
'gain_account_code' => '4900', 'loss_account_code' => '6900',
]);
ok("Created category {$c['code']} — {$c['name']} ({$c['class']}, {$c['life']} months)");
}
$category_ids[$c['code']] = $id;
}
// ─────────────────────────────────────────────────────────────────────────────
// 2. Purchase: PO -> confirm -> purchase invoice -> issue -> GL
// ─────────────────────────────────────────────────────────────────────────────
echo "\n--- Asset purchase (PO -> purchase invoice) ---\n";
$prodMgmt = new ProductManager($pdo2, $company_id);
$cat_id = $lookup("SELECT id FROM md_product_category WHERE company_id = :c AND slug = 'fixed-assets' LIMIT 1", [':c' => $company_id]);
if (!$cat_id) {
dbTransaction($pdo2, fn($pdo) => (new ProductManager($pdo, $company_id))->saveCategory([
'id' => 0, 'category' => 'Fixed Assets', 'slug' => 'fixed-assets', 'description' => '', 'status' => 1,
], $logging));
$cat_id = $lookup("SELECT id FROM md_product_category WHERE company_id = :c AND slug = 'fixed-assets' LIMIT 1", [':c' => $company_id]);
ok("Created product category 'Fixed Assets' (id={$cat_id})");
}
$asset_product_defs = [
['sku' => 'FA-LAPTOP', 'name' => 'Laptop - Dell Latitude 5440', 'price' => 32000],
['sku' => 'FA-ERP', 'name' => 'ERP Software Licence (2 years)', 'price' => 48000],
];
$products = [];
foreach ($asset_product_defs as $p) {
$id = $lookup("SELECT id FROM md_product WHERE company_id = :c AND sku = :s LIMIT 1", [':c' => $company_id, ':s' => $p['sku']]);
if ($id) {
skip("Product {$p['sku']} already exists");
} else {
dbTransaction($pdo2, fn($pdo) => (new ProductManager($pdo, $company_id))->saveProduct([
'id' => 0, 'product_name' => $p['name'], 'sku' => $p['sku'], 'barcode' => '', 'uom' => 'pcs',
'price' => $p['price'], 'cost_price' => $p['price'], 'min_stock' => 0, 'reorder_point' => 0,
'category' => $cat_id, 'description' => 'Bought as a fixed asset (demo seed)', 'status' => 1,
], $logging, ''));
$id = $lookup("SELECT id FROM md_product WHERE company_id = :c AND sku = :s LIMIT 1", [':c' => $company_id, ':s' => $p['sku']]);
// Same accounts as the other demo products: the purchase posts to 5000, and the
// capitalization entry reclassifies it from there to the asset account.
dbTransaction($pdo2, fn($pdo) => (new ProductManager($pdo, $company_id))->updateAccountMapping($id, '4000', '5000', $logging));
ok("Created product {$p['sku']} — {$p['name']} (sales 4000, purchase 5000)");
}
$products[$p['sku']] = $p;
}
$po_marker = 'Demo seed asset purchase';
$po_id = $lookup("SELECT id FROM td_purchase_order WHERE company_id = :c AND notes = :n LIMIT 1", [':c' => $company_id, ':n' => $po_marker]);
if ($po_id) {
skip("Asset purchase PO already exists (id={$po_id})");
} else {
$po_items = [];
foreach ([['FA-LAPTOP', 3], ['FA-ERP', 1]] as $i => [$sku, $qty]) {
$total = round($qty * $products[$sku]['price'], 4);
$po_items[] = [
'item_id' => $i + 1, 'product_sku' => $sku, 'product_name' => $products[$sku]['name'],
'quantity' => $qty, 'unit_price' => $products[$sku]['price'], 'total_price' => $total,
'tax_rate' => 7.0, 'tax_amount' => round($total * 0.07, 4),
];
}
$po_id = dbTransaction($pdo2, function ($pdo) use ($company_id, $supplier_id, $wh_dept_id, $main_wh, $po_items, $today, $po_marker, $logging) {
$poMgmt = new PurchaseOrderManager($pdo, $company_id);
$pid = $poMgmt->savePo([
'id' => 0, 'contact_id' => $supplier_id, 'department_id' => $wh_dept_id,
'po_date' => $today, 'expected_date' => date('Y-m-d', strtotime('+7 days')),
'warehouse_id' => $main_wh, 'items' => $po_items, 'discount' => 0, 'shipping_fee' => 0,
'notes' => $po_marker,
], $logging);
$poMgmt->confirmPo($pid, $logging);
return $pid;
});
ok("Created + confirmed PO for 3 laptops and 1 ERP licence (id={$po_id})");
}
$pi_id = $lookup(
"SELECT id FROM td_invoice WHERE company_id = :c AND source = 'po' AND source_id = :p AND doc_type = 'purchase_invoice' AND status <> 4 LIMIT 1",
[':c' => $company_id, ':p' => $po_id]
);
if ($pi_id) {
skip("Purchase invoice for the asset PO already exists (id={$pi_id})");
} else {
$pi_id = dbTransaction($pdo2, function ($pdo) use ($company_id, $po_id, $logging) {
$invMgmt = new InvoiceManager($pdo, $company_id);
$iid = $invMgmt->createFromPo($po_id, $logging);
$invMgmt->issueInvoice($iid, $logging, date('Y-m-d', strtotime('+30 days')));
return $iid;
});
ok("Created + issued purchase invoice from the asset PO (id={$pi_id})");
}
$gl = new GlManager($pdo2, $company_id, new PostingWindowGuard($pdo1, $company_id));
if ($gl->getBySource('purchase_invoice', $pi_id)) {
skip("GL already posted for purchase invoice #{$pi_id}");
} else {
// Same as order/api/engine/issue_invoice.php's auto-post.
$built = (new PurchaseInvoicePosting($pdo2, $company_id))->build($pi_id, null);
$pdo2->beginTransaction();
$gl->post('purchase_invoice', $pi_id, $built['formula_id'], $built['period'], $built['lines'], ['journal_date' => $built['doc_date']]);
$pdo2->commit();
ok("GL posted for purchase invoice #{$pi_id} (" . count($built['lines']) . " lines)");
}
// ─────────────────────────────────────────────────────────────────────────────
// 3. Assets
// ─────────────────────────────────────────────────────────────────────────────
echo "\n--- Assets ---\n";
$sth = $pdo2->prepare("SELECT product_sku, id FROM td_invoice_item WHERE company_id = :c AND invoice_id = :i");
$sth->execute([':c' => $company_id, ':i' => $pi_id]);
$invoice_items = $sth->fetchAll(PDO::FETCH_KEY_PAIR); // sku => td_invoice_item.id
$asset_defs = [
['key' => 'laptops', 'name' => 'Laptop fleet - Dell Latitude 5440 (3 units)', 'category' => 'IT', 'sku' => 'FA-LAPTOP', 'salvage' => 6000, 'life' => 36],
['key' => 'erp', 'name' => 'ERP software licence', 'category' => 'SW', 'sku' => 'FA-ERP', 'salvage' => 0, 'life' => 24],
['key' => 'truck', 'name' => 'Delivery truck - Isuzu D-Max', 'category' => 'VEH', 'acquired' => date('Y-m-15', strtotime('first day of -18 months')), 'cost' => 850000, 'salvage' => 150000, 'life' => 60],
['key' => 'forklift','name' => 'Forklift - Toyota 8FD25', 'category' => 'VEH', 'acquired' => date('Y-m-01', strtotime('first day of -30 months')), 'cost' => 420000, 'salvage' => 20000, 'life' => 48],
];
$asset_ids = [];
foreach ($asset_defs as $d) {
$sth = $pdo2->prepare("SELECT id, status FROM td_asset WHERE company_id = :c AND asset_name = :n AND status <> 4 LIMIT 1");
$sth->execute([':c' => $company_id, ':n' => $d['name']]);
$row = $sth->fetch(PDO::FETCH_ASSOC);
if ($row) {
skip("Asset '{$d['name']}' already exists (id={$row['id']})");
$id = (int)$row['id'];
} else {
$data = [
'asset_name' => $d['name'], 'category_id' => $category_ids[$d['category']],
'salvage_value' => $d['salvage'], 'useful_life_months' => $d['life'],
'department_id' => $wh_dept_id, 'description' => 'Demo seed',
];
if (!empty($d['sku'])) {
// Capitalized from the purchase invoice line, as asset/manage_asset.php?invoice_id=&item_id= does.
$item_id = (int)($invoice_items[$d['sku']] ?? 0);
$line = (new AssetManager($pdo2, $company_id))->getInvoiceLine($pi_id, $item_id);
$data += [
'invoice_id' => $pi_id, 'invoice_item_id' => $item_id,
'cost' => $line['remaining'], 'clearing_account_code' => $line['clearing_account_code'],
'acquisition_date' => $line['issued_date'], 'in_service_date' => $line['issued_date'],
];
} else {
// Bought before the system was used: already on the books, so no clearing account.
$data += ['cost' => $d['cost'], 'clearing_account_code' => '', 'acquisition_date' => $d['acquired'], 'in_service_date' => $d['acquired']];
}
$id = dbTransaction($pdo2, fn($pdo) => (new AssetManager($pdo, $company_id))->save($data, $owner_user_id));
ok("Created asset '{$d['name']}' (id={$id}), cost " . number_format((float)$data['cost'], 2) . ", in service {$data['in_service_date']}");
}
if ((int)($row['status'] ?? 0) === 0) {
dbTransaction($pdo2, fn($pdo) => (new AssetManager($pdo, $company_id))->activate($id));
ok("Activated '{$d['name']}'");
}
$asset_ids[$d['key']] = $id;
}
// ─────────────────────────────────────────────────────────────────────────────
// 4. Runs and the forklift sale
// ─────────────────────────────────────────────────────────────────────────────
echo "\n--- Depreciation / amortization runs ---\n";
$run_through = function (string $asset_class, string $period) use ($pdo2, $company_id, $owner_user_id) {
$label = AssetRunManager::labelFor($asset_class);
if (!(new AssetRunManager($pdo2, $company_id))->preview($asset_class, $period)) {
skip("{$label} already charged through {$period}");
return;
}
$created = dbTransaction($pdo2, fn($pdo) => (new AssetRunManager($pdo, $company_id))->run($asset_class, $period, $owner_user_id));
$first = $created[0]['run_number'];
$last = $created[count($created) - 1]['run_number'];
ok("{$label}: " . count($created) . " run(s) {$first}" . ($first !== $last ? " .. {$last}" : '')
. ", total " . number_format(array_sum(array_column($created, 'total')), 2));
};
$run_through('tangible', $last_period);
$forklift = (new AssetManager($pdo2, $company_id))->getById($asset_ids['forklift']);
if (in_array((int)$forklift['status'], [AssetManager::STATUS_ACTIVE, AssetManager::STATUS_FULLY], true)) {
dbTransaction($pdo2, fn($pdo) => (new AssetManager($pdo, $company_id))->dispose($asset_ids['forklift'], [
'disposal_date' => $today, 'sale_price' => 180000, 'proceeds_account_code' => '1000',
'disposal_notes' => 'Sold to a used-equipment dealer (demo seed)',
]));
ok("Sold '{$forklift['asset_name']}' today for 180,000.00 (book value " . number_format((float)$forklift['book_value'], 2) . ")");
} else {
skip("'{$forklift['asset_name']}' already disposed of");
}
$run_through('tangible', $this_period);
$run_through('intangible', $this_period);
// ─────────────────────────────────────────────────────────────────────────────
// 5. GL posting (Batch GL Entries equivalent)
// ─────────────────────────────────────────────────────────────────────────────
echo "\n--- GL posting ---\n";
$post_asset_gl = function (string $source_type, int $id) use ($pdo1, $pdo2, $company_id) {
$gl = new GlManager($pdo2, $company_id, new PostingWindowGuard($pdo1, $company_id));
if ($gl->getBySource($source_type, $id)) return null;
$built = (new AssetPosting($pdo2, $company_id))->build($source_type, $id);
$pdo2->beginTransaction();
try {
$gl->post($source_type, $id, 0, $built['period'], $built['lines'], [
'journal_date' => $built['doc_date'], 'reference' => $built['reference'], 'description' => $built['description'],
]);
$pdo2->commit();
} catch (Throwable $e) {
if ($pdo2->inTransaction()) $pdo2->rollBack();
throw $e;
}
return $built;
};
$queue = [];
$sth = $pdo2->prepare("SELECT id FROM td_asset WHERE company_id = :c AND status IN (1, 2, 3) AND clearing_account_code <> '' ORDER BY id");
$sth->execute([':c' => $company_id]);
foreach ($sth->fetchAll(PDO::FETCH_COLUMN) as $id) $queue[] = ['asset_capitalization', (int)$id];
$sth = $pdo2->prepare("SELECT id, asset_class FROM td_asset_run WHERE company_id = :c AND status = 1 AND period < :p ORDER BY period, id");
$sth->execute([':c' => $company_id, ':p' => $this_period]);
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $r) $queue[] = [AssetRunManager::sourceTypeFor($r['asset_class']), (int)$r['id']];
$sth = $pdo2->prepare("SELECT id FROM td_asset WHERE company_id = :c AND status = 3 ORDER BY id");
$sth->execute([':c' => $company_id]);
foreach ($sth->fetchAll(PDO::FETCH_COLUMN) as $id) $queue[] = ['asset_disposal', (int)$id];
$posted = 0;
foreach ($queue as [$source_type, $id]) {
$built = $post_asset_gl($source_type, $id);
if ($built) {
$posted++;
ok("Posted {$built['reference']} — {$built['description']}");
}
}
if (!$posted) skip("Asset GL entries already posted");
info("This month's runs are left unposted — post them from Accounting > Batch GL Entries.");
echo "\n\033[1m=== Done ===\033[0m\n\n";
+8 -3
View File
@@ -39,7 +39,7 @@ function buildLineItem(array $products, string $sku, float $qty): array {
$tax_amount = round($total_price * $tax_rate / 100, 4);
return [
'product_sku' => $sku,
'product_name' => $sku,
'product_name' => $p['product_name'],
'quantity' => $qty,
'unit_price' => $unit_price,
'total_price' => $total_price,
@@ -69,11 +69,16 @@ $sth->execute([':c' => $company_id]);
$sales_dept_id = (int)$sth->fetchColumn();
if (!$sales_dept_id) { exit("ERROR: SALES department not found — run demo_seed_transactions.php first.\n"); }
$sth = $pdo2->prepare("SELECT sku, price FROM md_product WHERE company_id = :c");
$sth = $pdo2->prepare("SELECT sku, product_name, price FROM md_product WHERE company_id = :c");
$sth->execute([':c' => $company_id]);
$products = $sth->fetchAll(PDO::FETCH_ASSOC | PDO::FETCH_UNIQUE);
$sth = $pdo2->prepare("SELECT id, contact_name FROM md_contact WHERE company_id = :c AND contact_type = 1 ORDER BY id");
// contact_type holds an md_contact_type id — resolve 'Customer' by name, not by a fixed id.
$sth = $pdo2->prepare(
"SELECT c.id, c.contact_name FROM md_contact c
JOIN md_contact_type t ON t.company_id = c.company_id AND t.id = c.contact_type
WHERE c.company_id = :c AND t.contact_type = 'Customer' ORDER BY c.id"
);
$sth->execute([':c' => $company_id]);
$customers = $sth->fetchAll(PDO::FETCH_ASSOC);
$customer_ids = array_column($customers, 'id');
+46 -173
View File
@@ -5,18 +5,16 @@
*
* Extends the base demo data (demo_seed.php: company, warehouses, products,
* contacts, opening stock) with transactional data covering the rest of the
* app's features:
* app's features (restock / dispatch / transfer movements are seeded by demo_seed.php):
*
* 1. Additional stock-in replenishment (restock events)
* 2. Stock-out (direct dispatch) + stock transfer (Main -> Bangna)
* 3. Chart of accounts, departments, GL posting formulas
* 4. Sales cycle: quotation -> sales order -> invoice -> GL post
* 5. AR: receipt billing -> receipt -> GL post
* 6. Purchasing cycle: purchase request -> PO -> receive -> purchase invoice -> GL post
* 7. AP: payment billing -> payment -> GL post
* 8. Supplier return (confirmed, restocks reversed)
* 9. Customer return (draft only — see note below)
* 10. Barcode labels for a handful of products
* 4. Chart of accounts, departments, GL posting formulas, product account mapping
* 5. Sales cycle: quotation -> sales order -> invoice -> GL post
* 6. AR: receipt billing -> receipt -> GL post
* 7. Purchasing cycle: purchase request -> PO -> receive -> purchase invoice -> GL post
* 8. AP: payment billing -> payment -> GL post
* 9. Supplier return (confirmed, restocks reversed)
* 10. Customer return (draft only — see note below)
* 11. Barcode labels for a handful of products
*
* Every write goes through the same Manager classes + engine-file patterns
* the app itself uses (dbTransaction wrapping, GlManager posting exactly as
@@ -46,6 +44,7 @@ require_once __DIR__ . '/app/dbconn.php';
require_once __DIR__ . '/app/assets/utils/db_helpers.php';
require_once __DIR__ . '/app/assets/utils/classes/WarehouseManager.php';
require_once __DIR__ . '/app/assets/utils/classes/StockManager.php';
require_once __DIR__ . '/app/assets/utils/classes/ProductManager.php';
require_once __DIR__ . '/app/assets/utils/classes/QuotationManager.php';
require_once __DIR__ . '/app/assets/utils/classes/OrderManager.php';
require_once __DIR__ . '/app/assets/utils/classes/InvoiceManager.php';
@@ -89,24 +88,29 @@ $sth->execute();
$owner_user_id = (int)($sth->fetchColumn() ?: 0);
if (!$owner_user_id) exit("ERROR: demo owner user not found — run demo_seed.php first.\n");
// Resolve by name: ids depend on what else exists in the database.
$sth = $pdo2->prepare("SELECT id, warehouse_name FROM md_warehouse WHERE company_id = :c ORDER BY id");
$sth->execute([':c' => $company_id]);
$warehouses = $sth->fetchAll(PDO::FETCH_KEY_PAIR); // id => name
if (count($warehouses) < 2) exit("ERROR: expected 2 warehouses — run demo_seed.php first.\n");
$wh_ids = array_keys($warehouses);
$main_wh = $wh_ids[0]; // Main Warehouse
$bangna_wh = $wh_ids[1]; // Bangna Distribution Center
$main_wh = (int)(array_search('Main Warehouse', $warehouses, true) ?: 0);
$bangna_wh = (int)(array_search('Bangna Distribution Center', $warehouses, true) ?: 0);
if (!$main_wh || !$bangna_wh) exit("ERROR: expected Main Warehouse and Bangna Distribution Center — run demo_seed.php first.\n");
$sth = $pdo2->prepare("SELECT id, contact_name, contact_type FROM md_contact WHERE company_id = :c ORDER BY id");
// md_contact.contact_type is an md_contact_type id, so match on the type name.
$sth = $pdo2->prepare(
"SELECT c.id, c.contact_name, t.contact_type AS type_name
FROM md_contact c
JOIN md_contact_type t ON t.company_id = c.company_id AND t.id = c.contact_type
WHERE c.company_id = :c
ORDER BY c.id"
);
$sth->execute([':c' => $company_id]);
$contacts = $sth->fetchAll(PDO::FETCH_ASSOC);
if (count($contacts) < 7) exit("ERROR: expected 7 contacts — run demo_seed.php first.\n");
$customer_ids = array_column(array_filter($contacts, fn($c) => (int)$c['contact_type'] === 1), 'id');
$supplier_ids = array_column(array_filter($contacts, fn($c) => (int)$c['contact_type'] === 2), 'id');
$customer_ids = array_values($customer_ids);
$supplier_ids = array_values($supplier_ids);
$customer_ids = array_values(array_column(array_filter($contacts, fn($c) => $c['type_name'] === 'Customer'), 'id'));
$supplier_ids = array_values(array_column(array_filter($contacts, fn($c) => $c['type_name'] === 'Supplier'), 'id'));
if (count($customer_ids) < 4 || count($supplier_ids) < 3) exit("ERROR: expected 4 customers and 3 suppliers — run demo_seed.php first.\n");
$sth = $pdo2->prepare("SELECT sku, uom, cost_price, price FROM md_product WHERE company_id = :c ORDER BY id");
$sth = $pdo2->prepare("SELECT sku, product_name, uom, cost_price, price FROM md_product WHERE company_id = :c ORDER BY id");
$sth->execute([':c' => $company_id]);
$products = $sth->fetchAll(PDO::FETCH_ASSOC | PDO::FETCH_UNIQUE);
if (count($products) < 14) exit("ERROR: expected 14 products — run demo_seed.php first.\n");
@@ -114,156 +118,6 @@ if (count($products) < 14) exit("ERROR: expected 14 products — run demo_seed.p
$logging = ['user_id' => $owner_user_id, 'dt' => date('Y-m-d H:i:s'), 'login' => null, 'action' => 'seed_transactions'];
$whMgmt = new WarehouseManager($pdo2, $company_id);
$stockMgmt = new StockManager($pdo2, $company_id);
/** Find the next unused simple-location bin label "A-N" for a warehouse. */
function nextFreeBin(PDO $pdo2, int $company_id, int $warehouse_id): string {
$sth = $pdo2->prepare(
"SELECT bin FROM md_bin WHERE company_id = :c AND warehouse = :w AND product_sku IS NULL
ORDER BY CAST(SUBSTRING(bin, 3) AS UNSIGNED) ASC LIMIT 1"
);
$sth->execute([':c' => $company_id, ':w' => $warehouse_id]);
$bin = $sth->fetchColumn();
if (!$bin) throw new Exception("No free bin available in warehouse {$warehouse_id}.");
return $bin;
}
// ─────────────────────────────────────────────────────────────────────────────
// 1. Additional stock-in replenishment (restock events)
// ─────────────────────────────────────────────────────────────────────────────
echo "--- Restock (additional stock-in) ---\n";
$restock_defs = [
['sku' => 'EL-001', 'warehouse' => $main_wh, 'qty' => 40, 'supplier_idx' => 0],
['sku' => 'OF-001', 'warehouse' => $main_wh, 'qty' => 60, 'supplier_idx' => 1],
['sku' => 'BV-002', 'warehouse' => $bangna_wh, 'qty' => 35, 'supplier_idx' => 2],
];
foreach ($restock_defs as $r) {
$sth = $pdo2->prepare(
"SELECT COUNT(*) FROM `td_stock_{$r['warehouse']}`
WHERE company_id = :c AND product_sku = :sku AND type = 'in' AND description = 'Restock (demo seed)'"
);
$sth->execute([':c' => $company_id, ':sku' => $r['sku']]);
if ((int)$sth->fetchColumn() > 0) {
skip("Restock for {$r['sku']} in warehouse {$r['warehouse']} already exists");
continue;
}
$bin = nextFreeBin($pdo2, $company_id, $r['warehouse']);
$supplier_id = $supplier_ids[$r['supplier_idx']];
$uuid = bin2hex(random_bytes(16));
$p = $products[$r['sku']];
dbTransaction($pdo2, function ($pdo2) use ($stockMgmt, $whMgmt, $r, $bin, $supplier_id, $logging, $uuid, $p) {
$stock_id = $stockMgmt->saveStockIn([
'id' => 0, 'warehouse' => $r['warehouse'], 'product_sku' => $r['sku'],
'quantity' => $r['qty'], 'price' => $p['cost_price'],
'zone' => $bin, 'aisle' => $bin, 'bin' => $bin,
'contact_id' => $supplier_id, 'description' => 'Restock (demo seed)',
], $logging, $uuid);
$stockMgmt->approveStock($stock_id, $r['warehouse'], 'in', $whMgmt);
});
ok("Restocked {$r['qty']} {$p['uom']} of {$r['sku']} into {$warehouses[$r['warehouse']]} bin {$bin}");
}
// ─────────────────────────────────────────────────────────────────────────────
// 2. Stock-out (direct dispatch) — dedicated batch in + full-bin out
// ─────────────────────────────────────────────────────────────────────────────
echo "\n--- Stock-out (direct dispatch) ---\n";
$dispatch_defs = [
['sku' => 'PK-003', 'warehouse' => $main_wh, 'qty' => 25, 'customer_idx' => 0],
['sku' => 'OF-003', 'warehouse' => $main_wh, 'qty' => 15, 'customer_idx' => 1],
];
foreach ($dispatch_defs as $d) {
$sth = $pdo2->prepare(
"SELECT COUNT(*) FROM `td_stock_{$d['warehouse']}`
WHERE company_id = :c AND product_sku = :sku AND type = 'out' AND description = 'Direct dispatch (demo seed)'"
);
$sth->execute([':c' => $company_id, ':sku' => $d['sku']]);
if ((int)$sth->fetchColumn() > 0) {
skip("Dispatch for {$d['sku']} already exists");
continue;
}
$bin = nextFreeBin($pdo2, $company_id, $d['warehouse']);
$customer_id = $customer_ids[$d['customer_idx']];
$p = $products[$d['sku']];
dbTransaction($pdo2, function ($pdo2) use ($stockMgmt, $whMgmt, $d, $bin, $customer_id, $logging, $p) {
// Receive a dedicated batch first (so we don't touch demo_seed.php's opening-stock bins)
$in_uuid = bin2hex(random_bytes(16));
$in_id = $stockMgmt->saveStockIn([
'id' => 0, 'warehouse' => $d['warehouse'], 'product_sku' => $d['sku'],
'quantity' => $d['qty'], 'price' => $p['cost_price'],
'zone' => $bin, 'aisle' => $bin, 'bin' => $bin,
'contact_id' => $customer_id, 'description' => 'Batch for dispatch (demo seed)',
], $logging, $in_uuid);
$stockMgmt->approveStock($in_id, $d['warehouse'], 'in', $whMgmt);
// Dispatch it straight out (saveStockOut takes the whole bin)
$out_uuid = bin2hex(random_bytes(16));
$out_id = $stockMgmt->saveStockOut([
'id' => 0, 'warehouse' => $d['warehouse'], 'product_sku' => $d['sku'],
'zone' => $bin, 'aisle' => $bin, 'bin' => $bin,
'contact_id' => $customer_id, 'description' => 'Direct dispatch (demo seed)',
], $logging, $out_uuid);
$stockMgmt->approveStock($out_id, $d['warehouse'], 'out', $whMgmt);
});
ok("Dispatched {$d['qty']} {$p['uom']} of {$d['sku']} from {$warehouses[$d['warehouse']]}");
}
// ─────────────────────────────────────────────────────────────────────────────
// 3. Stock transfer — Main Warehouse -> Bangna Distribution Center
// ─────────────────────────────────────────────────────────────────────────────
echo "\n--- Stock transfer (Main -> Bangna) ---\n";
$transfer_defs = [
['sku' => 'BV-001', 'qty' => 30],
['sku' => 'PK-002', 'qty' => 12],
];
foreach ($transfer_defs as $t) {
$sth = $pdo2->prepare(
"SELECT COUNT(*) FROM `td_stock_{$main_wh}`
WHERE company_id = :c AND product_sku = :sku AND type = 'transfer' AND description = 'Transfer to Bangna (demo seed)'"
);
$sth->execute([':c' => $company_id, ':sku' => $t['sku']]);
if ((int)$sth->fetchColumn() > 0) {
skip("Transfer for {$t['sku']} already exists");
continue;
}
// Bin allocation is independent of the transaction below — resolve both up front.
$from_bin = nextFreeBin($pdo2, $company_id, $main_wh);
$to_bin = nextFreeBin($pdo2, $company_id, $bangna_wh);
$p = $products[$t['sku']];
dbTransaction($pdo2, function ($pdo2) use ($stockMgmt, $whMgmt, $t, $from_bin, $to_bin, $main_wh, $bangna_wh, $logging, $p) {
// Receive a dedicated batch first (so we don't touch demo_seed.php's opening-stock bins)
$in_uuid = bin2hex(random_bytes(16));
$in_id = $stockMgmt->saveStockIn([
'id' => 0, 'warehouse' => $main_wh, 'product_sku' => $t['sku'],
'quantity' => $t['qty'], 'price' => $p['cost_price'],
'zone' => $from_bin, 'aisle' => $from_bin, 'bin' => $from_bin,
'contact_id' => 0, 'description' => 'Batch for transfer (demo seed)',
], $logging, $in_uuid);
$stockMgmt->approveStock($in_id, $main_wh, 'in', $whMgmt);
$tr_uuid = bin2hex(random_bytes(16));
$out_id = $stockMgmt->saveStockTransfer([
'id' => 0, 'warehouse_from' => $main_wh, 'warehouse_to' => $bangna_wh,
'product_sku' => $t['sku'],
'zone_from' => $from_bin, 'aisle_from' => $from_bin, 'bin_from' => $from_bin,
'zone_to' => $to_bin, 'aisle_to' => $to_bin, 'bin_to' => $to_bin,
'contact_id' => 0, 'description' => 'Transfer to Bangna (demo seed)',
], $logging, $tr_uuid);
$stockMgmt->approveStock($out_id, $main_wh, 'transfer', $whMgmt);
});
ok("Transferred {$t['qty']} {$p['uom']} of {$t['sku']}: {$warehouses[$main_wh]} bin {$from_bin} -> {$warehouses[$bangna_wh]} bin {$to_bin}");
}
// ─────────────────────────────────────────────────────────────────────────────
// 4. Chart of accounts + departments + GL posting formulas
@@ -379,6 +233,25 @@ foreach ($formula_defs as $doc_type => $def) {
$formula_ids[$doc_type] = $id;
}
echo "\n--- Product account mapping ---\n";
// The sales and purchase formulas split 'total' per product, so Batch GL Entries
// refuses to post until every product has sales/purchase accounts
// (Account Formulas > Product Accounts, accounting/api/engine/product_account_mapping.php).
$sth = $pdo2->prepare("SELECT id, sku, sales_account_code, purchase_account_code FROM md_product WHERE company_id = :c ORDER BY id");
$sth->execute([':c' => $company_id]);
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $prod) {
if ($prod['sales_account_code'] && $prod['purchase_account_code']) {
skip("Product {$prod['sku']} already mapped");
continue;
}
$sales_code = $prod['sales_account_code'] ?: '4000';
$purchase_code = $prod['purchase_account_code'] ?: '5000';
dbTransaction($pdo2, fn($pdo) => (new ProductManager($pdo, $company_id))
->updateAccountMapping((int)$prod['id'], $sales_code, $purchase_code, $logging));
ok("Mapped {$prod['sku']}: sales {$sales_code}, purchase {$purchase_code}");
}
/** Post (or replace) GL for a document, mirroring order/api/engine/issue_invoice.php exactly. */
function postGl(PDO $pdo1, PDO $pdo2, int $company_id, string $doc_type, int $doc_id, string $posting_class): array {
require_once __DIR__ . "/app/assets/utils/classes_ac/posting/{$posting_class}.php";
@@ -409,7 +282,7 @@ function buildLineItem(array $products, string $sku, float $qty): array {
$tax_amount = round($total_price * $tax_rate / 100, 4);
return [
'product_sku' => $sku,
'product_name' => $sku, // demo_seed.php products keyed by SKU; name not needed for GL/report correctness
'product_name' => $p['product_name'], // documents show the product name, as the UI's product search fills it
'quantity' => $qty,
'unit_price' => $unit_price,
'total_price' => $total_price,
+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";