Add Asset Management app

Assets from purchase invoice lines, straight-line depreciation/amortization with salvage value, disposals, and [Asset Mgmt] sources in Batch GL Entries. Green third workspace with an app access guard.
This commit is contained in:
Thanakorn
2026-09-15 09:50:55 +07:00
parent 9512d440dd
commit c957280aa5
46 changed files with 4316 additions and 131 deletions
+7
View File
@@ -1,6 +1,13 @@
html, body { overflow-x: hidden; }
/* Soft badges (app access badges: WMS orange, Accounting blue, Asset Management green) */
.bg-label-primary { background-color: var(--bs-primary-bg-subtle) !important; color: var(--bs-primary-text-emphasis) !important; }
.bg-label-info { background-color: #e0f2fe !important; color: #0369a1 !important; }
.bg-label-success { background-color: #dcfce7 !important; color: #15803d !important; }
.bg-label-secondary,
.bg-label-dark { background-color: #e9ecef !important; color: #343a40 !important; }
.flatpickr-day.selected {
background: var(--bs-primary) !important;
border-color: var(--bs-primary) !important;
+74
View File
@@ -0,0 +1,74 @@
// Shared helpers for the Asset Management pages.
var ASSET_STATUS = {
0: ['Draft', 'bg-secondary'],
1: ['Active', 'bg-success'],
2: ['Fully depreciated', 'bg-info text-white'],
3: ['Disposed', 'bg-dark'],
4: ['Void', 'bg-light text-dark']
};
function asset_status_badge(status, asset_class) {
var code = parseInt(status, 10);
var entry = ASSET_STATUS[code] || ['—', 'bg-light text-dark'];
var label = (code === 2 && asset_class === 'intangible') ? 'Fully amortized' : entry[0];
return '<span class="badge ' + entry[1] + '">' + label + '</span>';
}
function asset_class_badge(asset_class) {
return asset_class === 'intangible'
? '<span class="badge bg-warning-subtle text-warning">Intangible · Amortization</span>'
: '<span class="badge bg-success-subtle text-success">Tangible · Depreciation</span>';
}
function asset_gl_badge(posted) {
return String(posted) === '1'
? '<span class="badge bg-success-subtle text-success">Posted</span>'
: '<span class="badge bg-danger-subtle text-danger">Unposted</span>';
}
// Active posting accounts from the chart of accounts.
function load_posting_accounts(onLoaded) {
return ajax_request({
url: server_url + 'accounting/api/engine/account.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
queueLock: false,
noLoading: true,
onSuccess: function(res) {
var rows = (res.output || []).filter(function(a) {
return String(a.is_posting) === '1' && String(a.status) === '1';
});
onLoaded(rows);
}
});
}
// <option> list for accounts, narrowed to account_types when any match.
function account_options(accounts, account_types, selected, empty_label) {
var list = accounts;
if (account_types && account_types.length) {
var narrowed = accounts.filter(function(a) { return account_types.indexOf(a.account_type) !== -1; });
if (narrowed.length) list = narrowed;
}
var html = '<option value="">' + escape_html(empty_label || '— Select account —') + '</option>';
var found = false;
list.forEach(function(a) {
var is_selected = String(a.account_code) === String(selected || '');
if (is_selected) found = true;
html += '<option value="' + escape_html(a.account_code) + '"' + (is_selected ? ' selected' : '') + '>'
+ escape_html(a.account_code + ' — ' + a.account_name) + '</option>';
});
// Keep a saved code visible even if it is no longer an active posting account.
if (selected && !found) {
html += '<option value="' + escape_html(selected) + '" selected>' + escape_html(selected) + ' (not active)</option>';
}
return html;
}
function current_period() {
var d = new Date();
return d.getFullYear() + '-' + ('0' + (d.getMonth() + 1)).slice(-2);
}
+40 -10
View File
@@ -2,21 +2,51 @@
// app/assets/utils/app_registry.php
//
// The apps a user can be given access to (user.app_access and
// company_map_user.app_access), with the label, icon and badge colour the
// Users Access page shows for each.
// company_map_user.app_access), with the label, icon, badge colour and home
// page of each. Used by the app switcher, the login redirect and the Users
// Access page.
//
// config.php may define its own $app_registry; this file only fills it in when
// it is missing or empty — which is every Docker-generated config.php written
// before the setting was documented. Without it the Add User dialog breaks
// config.php may define its own $app_registry. Configs written before Asset
// Management existed (every Docker-generated config.php among them) list fewer
// apps and have no 'home', so missing apps and keys are filled in from the
// defaults below. Without a registry the Add User dialog breaks
// (Object.entries(null) in setting/users.php) and inviting a user fails
// (array_keys(null) in setting/api/engine/manage_users.php).
//
// Keys must stay within the user.app_access enum: 'wms' and 'accounting'
// Keys must stay within the user.app_access enum: 'wms', 'accounting', 'asset'
// ('all' is implied and never listed here).
$_default_app_registry = [
'wms' => ['label' => 'WMS', 'icon' => 'ti-box', 'color' => 'bg-label-primary', 'home' => 'dashboard/index.php'],
'accounting' => ['label' => 'Accounting', 'icon' => 'ti-calculator', 'color' => 'bg-label-info', 'home' => 'ac_dashboard/index.php'],
'asset' => ['label' => 'Asset Management', 'icon' => 'ti-building-warehouse', 'color' => 'bg-label-success', 'home' => 'asset_dashboard/index.php'],
];
if (!isset($app_registry) || !is_array($app_registry) || !$app_registry) {
$app_registry = [
'wms' => ['label' => 'WMS', 'icon' => 'ti-box', 'color' => 'bg-label-primary'],
'accounting' => ['label' => 'Accounting', 'icon' => 'ti-calculator', 'color' => 'bg-label-success'],
];
$app_registry = $_default_app_registry;
} else {
foreach ($_default_app_registry as $_registry_key => $_registry_defaults) {
$app_registry[$_registry_key] = array_merge($_registry_defaults, $app_registry[$_registry_key] ?? []);
}
}
unset($_default_app_registry, $_registry_key, $_registry_defaults);
if (!function_exists('app_can_access')) {
/** 'all' opens every app. */
function app_can_access(string $access, string $app): bool
{
return $access === 'all' || $access === $app;
}
/** The app a user lands in: their single app, or WMS for 'all'. */
function app_default_for_access(array $registry, string $access): string
{
return ($access !== 'all' && isset($registry[$access])) ? $access : 'wms';
}
/** An app's home page, relative to $server_url. */
function app_home_path(array $registry, string $app): string
{
return $registry[$app]['home'] ?? 'dashboard/index.php';
}
}
@@ -33,6 +33,7 @@ class DocumentNumberManager
'manual' => ['name' => 'Manual Journal', 'prefix' => 'JV', 'format_type' => 'YYMM', 'sequence_digits' => 5, 'allow_manual' => 1],
'return' => ['name' => 'Customer Return', 'prefix' => 'RET', 'format_type' => 'YYMM', 'sequence_digits' => 5, 'allow_manual' => 1],
'supplier_return' => ['name' => 'Supplier Return', 'prefix' => 'SRN', 'format_type' => 'YYMM', 'sequence_digits' => 5, 'allow_manual' => 1],
'asset' => ['name' => 'Fixed Asset', 'prefix' => 'FA', 'format_type' => 'YYMM', 'sequence_digits' => 5, 'allow_manual' => 0],
];
public function __construct(PDO $pdo, int $company_id)
+1 -1
View File
@@ -426,7 +426,7 @@ class UserManager {
* within the owner's license.
*
* @param int $map_id The company_map_user.map_id to update.
* @param string $app_access New value: 'wms', 'accounting', or 'all'.
* @param string $app_access New value: 'wms', 'accounting', 'asset', or 'all'.
* @throws Exception If the member is not found or is the owner.
*/
public function updateAppAccess(int $map_id, string $app_access): void {
+21 -3
View File
@@ -1,4 +1,6 @@
<?php
require_once __DIR__ . '/../classes_as/AssetPosting.php';
class GlQueryManager
{
private PDO $pdo;
@@ -6,8 +8,10 @@ class GlQueryManager
private array $invoiceTypes = ['invoice', 'credit_note', 'purchase_invoice', 'supplier_credit_note'];
private array $mappingTypes = ['invoice', 'credit_note', 'purchase_invoice', 'supplier_credit_note', 'purchase_order'];
private array $postingTypes = ['invoice', 'credit_note', 'purchase_invoice', 'supplier_credit_note', 'receipt', 'payment', 'purchase_order'];
private array $journalTypes = ['invoice', 'credit_note', 'purchase_invoice', 'supplier_credit_note', 'receipt', 'payment', 'manual', 'purchase_order', 'reversal'];
private array $postingTypes = ['invoice', 'credit_note', 'purchase_invoice', 'supplier_credit_note', 'receipt', 'payment', 'purchase_order',
'asset_capitalization', 'asset_depreciation', 'asset_amortization', 'asset_disposal'];
private array $journalTypes = ['invoice', 'credit_note', 'purchase_invoice', 'supplier_credit_note', 'receipt', 'payment', 'manual', 'purchase_order', 'reversal',
'asset_capitalization', 'asset_depreciation', 'asset_amortization', 'asset_disposal'];
public function __construct(PDO $pdo, int $company_id)
{
@@ -25,6 +29,11 @@ class GlQueryManager
throw new Exception('Invalid doc_type.');
}
// Asset Management sources have no formula; accounts come from the asset category.
if (AssetPosting::isAssetSource($doc_type)) {
return (new AssetPosting($this->pdo, $this->companyId))->getPostableDocuments($doc_type, $date_from, $date_to);
}
if (in_array($doc_type, $this->invoiceTypes, true)) {
$rows = $this->getInvoicePostableDocuments($doc_type, $date_from, $date_to, $formula_id);
} elseif ($doc_type === 'receipt') {
@@ -82,6 +91,10 @@ class GlQueryManager
WHEN 'payment' THEN p.payment_number
WHEN 'manual' THEN IF(g.reference != '', g.reference, CONCAT('MJE-', g.id))
WHEN 'purchase_order' THEN po.po_number
WHEN 'asset_capitalization' THEN g.reference
WHEN 'asset_depreciation' THEN g.reference
WHEN 'asset_amortization' THEN g.reference
WHEN 'asset_disposal' THEN g.reference
ELSE i.invoice_number
END AS doc_number,
COALESCE(
@@ -90,6 +103,10 @@ class GlQueryManager
WHEN 'payment' THEN cp.contact_name
WHEN 'manual' THEN g.description
WHEN 'purchase_order' THEN cpo.contact_name
WHEN 'asset_capitalization' THEN g.description
WHEN 'asset_depreciation' THEN g.description
WHEN 'asset_amortization' THEN g.description
WHEN 'asset_disposal' THEN g.description
ELSE ci.contact_name
END, ''
) AS contact_name,
@@ -101,7 +118,8 @@ class GlQueryManager
LEFT JOIN td_gl_item gi
ON gi.company_id = g.company_id AND gi.gl_id = g.id
LEFT JOIN td_invoice i
ON g.source_type NOT IN ('receipt','payment','manual','purchase_order')
ON g.source_type NOT IN ('receipt','payment','manual','purchase_order',
'asset_capitalization','asset_depreciation','asset_amortization','asset_disposal')
AND i.company_id = g.company_id AND i.id = g.source_id
LEFT JOIN md_contact ci
ON ci.company_id = g.company_id AND ci.id = i.contact_id
@@ -0,0 +1,173 @@
<?php
/**
* AssetCategoryManager
*
* Asset categories decide the asset class (tangible = depreciation,
* intangible = amortization), the default useful life, and every GL account
* the Asset Management postings use.
*/
class AssetCategoryManager
{
public const CLASSES = ['tangible', 'intangible'];
public const ACCOUNT_FIELDS = [
'cost_account_code' => 'Asset cost account',
'accum_account_code' => 'Accumulated depreciation / amortization account',
'expense_account_code' => 'Depreciation / amortization expense account',
'gain_account_code' => 'Gain on disposal account',
'loss_account_code' => 'Loss on disposal account',
];
private PDO $pdo;
private int $companyId;
public function __construct(PDO $pdo, int $company_id)
{
$this->pdo = $pdo;
$this->companyId = $company_id;
}
public static function isPostingAccount(PDO $pdo, int $company_id, string $account_code): bool
{
if ($account_code === '') return false;
$sth = $pdo->prepare(
"SELECT COUNT(*) FROM md_account
WHERE company_id = :cid AND account_code = :code AND is_posting = 1 AND status = 1"
);
$sth->execute([':cid' => $company_id, ':code' => $account_code]);
return (int)$sth->fetchColumn() > 0;
}
public function getAll(): array
{
$sth = $this->pdo->prepare(
"SELECT c.*, COUNT(a.id) AS asset_count
FROM md_asset_category c
LEFT JOIN td_asset a
ON a.company_id = c.company_id
AND a.category_id = c.id
AND a.status <> 4
WHERE c.company_id = :cid
GROUP BY c.id
ORDER BY c.category_code ASC"
);
$sth->execute([':cid' => $this->companyId]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
public function getById(int $id): ?array
{
$sth = $this->pdo->prepare(
"SELECT * FROM md_asset_category WHERE company_id = :cid AND id = :id LIMIT 1"
);
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
return $sth->fetch(PDO::FETCH_ASSOC) ?: null;
}
public function save(array $data): int
{
$id = (int)($data['id'] ?? 0);
$code = strtoupper(trim((string)($data['category_code'] ?? '')));
$name = trim((string)($data['category_name'] ?? ''));
$class = trim((string)($data['asset_class'] ?? ''));
$life = (int)($data['useful_life_months'] ?? 0);
if ($code === '' || $name === '') throw new Exception('Category code and name are required.');
if (!in_array($class, self::CLASSES, true)) throw new Exception('Select tangible or intangible.');
if ($life < 1 || $life > 1200) throw new Exception('Default useful life must be between 1 and 1200 months.');
$accounts = [];
foreach (self::ACCOUNT_FIELDS as $field => $label) {
$account_code = trim((string)($data[$field] ?? ''));
if ($account_code === '') throw new Exception("{$label} is required.");
if (!self::isPostingAccount($this->pdo, $this->companyId, $account_code)) {
throw new Exception("{$label} {$account_code} is not an active posting account.");
}
$accounts[$field] = $account_code;
}
$sth = $this->pdo->prepare(
"SELECT COUNT(*) FROM md_asset_category
WHERE company_id = :cid AND category_code = :code AND id <> :id"
);
$sth->execute([':cid' => $this->companyId, ':code' => $code, ':id' => $id]);
if ((int)$sth->fetchColumn() > 0) throw new Exception("Category code {$code} already exists.");
$params = array_merge([
':cid' => $this->companyId,
':code' => $code,
':name' => $name,
':class' => $class,
':life' => $life,
':desc' => trim((string)($data['description'] ?? '')),
':status' => (int)($data['status'] ?? 1) === 1 ? 1 : 0,
], [
':cost_acc' => $accounts['cost_account_code'],
':accum_acc' => $accounts['accum_account_code'],
':expense_acc' => $accounts['expense_account_code'],
':gain_acc' => $accounts['gain_account_code'],
':loss_acc' => $accounts['loss_account_code'],
]);
if ($id) {
$existing = $this->getById($id);
if (!$existing) throw new Exception('Category not found.');
if ($existing['asset_class'] !== $class && $this->countAssets($id) > 0) {
throw new Exception('The asset class cannot change once assets use this category.');
}
$params[':id'] = $id;
$this->pdo->prepare(
"UPDATE md_asset_category SET
category_code = :code, category_name = :name, asset_class = :class,
useful_life_months = :life, description = :desc, status = :status,
cost_account_code = :cost_acc, accum_account_code = :accum_acc,
expense_account_code = :expense_acc, gain_account_code = :gain_acc,
loss_account_code = :loss_acc, updated_at = NOW()
WHERE company_id = :cid AND id = :id"
)->execute($params);
return $id;
}
$this->pdo->prepare(
"INSERT INTO md_asset_category
(company_id, category_code, category_name, asset_class, useful_life_months, description, status,
cost_account_code, accum_account_code, expense_account_code, gain_account_code, loss_account_code,
created_at)
VALUES
(:cid, :code, :name, :class, :life, :desc, :status,
:cost_acc, :accum_acc, :expense_acc, :gain_acc, :loss_acc,
NOW())"
)->execute($params);
return (int)$this->pdo->lastInsertId();
}
/** Categories are only deactivated: existing assets keep their accounts. */
public function deactivate(int $id): void
{
$this->pdo->prepare(
"UPDATE md_asset_category SET status = 0, updated_at = NOW() WHERE company_id = :cid AND id = :id"
)->execute([':cid' => $this->companyId, ':id' => $id]);
}
public function getStats(): array
{
$sth = $this->pdo->prepare(
"SELECT COUNT(*) AS total,
COALESCE(SUM(status = 1), 0) AS active,
COALESCE(SUM(status = 0), 0) AS inactive
FROM md_asset_category WHERE company_id = :cid"
);
$sth->execute([':cid' => $this->companyId]);
return $sth->fetch(PDO::FETCH_ASSOC) ?: [];
}
private function countAssets(int $category_id): int
{
$sth = $this->pdo->prepare(
"SELECT COUNT(*) FROM td_asset WHERE company_id = :cid AND category_id = :id AND status <> 4"
);
$sth->execute([':cid' => $this->companyId, ':id' => $category_id]);
return (int)$sth->fetchColumn();
}
}
@@ -0,0 +1,596 @@
<?php
require_once __DIR__ . '/DepreciationCalculator.php';
require_once __DIR__ . '/AssetCategoryManager.php';
require_once __DIR__ . '/../classes/DocumentNumberManager.php';
/**
* AssetManager
*
* The asset register: create (manually or from a purchase invoice line),
* activate, void, dispose of, and undo a disposal.
*
* Status: 0 draft, 1 active, 2 fully depreciated, 3 disposed, 4 void.
* GL entries are not posted here — Accounting posts them from Batch GL Entries.
* Voiding reverses entries that were already posted.
*/
class AssetManager
{
public const STATUS_DRAFT = 0;
public const STATUS_ACTIVE = 1;
public const STATUS_FULLY = 2;
public const STATUS_DISPOSED = 3;
public const STATUS_VOID = 4;
private PDO $pdo;
private int $companyId;
public function __construct(PDO $pdo, int $company_id)
{
$this->pdo = $pdo;
$this->companyId = $company_id;
}
// ── read ─────────────────────────────────────────────────────────────────
public function getList(int $invoice_id = 0): array
{
$where = ['a.company_id = :cid'];
$params = [':cid' => $this->companyId, ':cid_d' => $this->companyId];
if ($invoice_id > 0) {
$where[] = 'a.invoice_id = :invoice_id';
$params[':invoice_id'] = $invoice_id;
}
$sth = $this->pdo->prepare($this->baseSelect() . ' WHERE ' . implode(' AND ', $where) . ' ORDER BY a.id DESC');
$sth->execute($params);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
public function getById(int $id): ?array
{
$sth = $this->pdo->prepare($this->baseSelect() . ' WHERE a.company_id = :cid AND a.id = :id LIMIT 1');
$sth->execute([':cid' => $this->companyId, ':cid_d' => $this->companyId, ':id' => $id]);
return $sth->fetch(PDO::FETCH_ASSOC) ?: null;
}
/** Asset, month-by-month schedule (recorded + projected) and its GL entries. */
public function getDetail(int $id): array
{
$asset = $this->getById($id);
if (!$asset) throw new Exception('Asset not found.');
$sth = $this->pdo->prepare(
"SELECT d.period, d.amount, d.accumulated_after, d.book_value_after, d.run_id,
COALESCE(r.run_number, '') AS run_number
FROM td_asset_depreciation d
LEFT JOIN td_asset_run r ON r.company_id = d.company_id AND r.id = d.run_id
WHERE d.company_id = :cid AND d.asset_id = :id
ORDER BY d.period ASC"
);
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
$recorded = [];
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
$recorded[$row['period']] = $row;
}
$cost = (float)$asset['cost'];
$salvage = (float)$asset['salvage_value'];
$life = (int)$asset['useful_life_months'];
$disposal_period = $asset['disposal_date'] ? DepreciationCalculator::periodOf($asset['disposal_date']) : '';
$schedule = [];
if ($asset['in_service_date'] && $life > 0 && $cost > $salvage) {
foreach (DepreciationCalculator::schedule($cost, $salvage, $life, $asset['in_service_date']) as $row) {
if (isset($recorded[$row['period']])) {
$r = $recorded[$row['period']];
$row = [
'period' => $row['period'],
'amount' => (float)$r['amount'],
'accumulated' => (float)$r['accumulated_after'],
'book_value' => (float)$r['book_value_after'],
'state' => 'recorded',
'run_id' => (int)$r['run_id'],
'run_number' => $r['run_number'],
];
} else {
$stopped = $disposal_period !== '' && $row['period'] >= $disposal_period;
$row['state'] = $stopped || (int)$asset['status'] === self::STATUS_VOID ? 'stopped' : 'projected';
}
$schedule[] = $row;
}
}
return [
'asset' => $asset,
'schedule' => $schedule,
'monthly_amount' => $life > 0 ? DepreciationCalculator::monthlyAmount($cost, $salvage, $life) : 0,
'gl' => [
'capitalization' => $this->glEntry('asset_capitalization', $id),
'disposal' => $this->glEntry('asset_disposal', $id),
],
];
}
/**
* One purchase invoice line, the part of it already capitalized, and the
* account its purchase posting debited (the default clearing account).
*/
public function getInvoiceLine(int $invoice_id, int $item_id, int $exclude_asset_id = 0): array
{
$sth = $this->pdo->prepare(
"SELECT i.id AS invoice_id, i.invoice_number, i.issued_date, i.contact_id, i.department_id,
i.status AS invoice_status, i.formula_id, i.grand_total, i.tax,
COALESCE(c.contact_name, '') AS contact_name,
it.id AS item_id, it.product_sku, it.product_name, it.quantity, it.unit_price, it.total_price,
COALESCE(NULLIF(p.purchase_account_code, ''), '') AS product_account_code
FROM td_invoice i
JOIN td_invoice_item it
ON it.company_id = i.company_id AND it.invoice_id = i.id
LEFT JOIN md_contact c
ON c.company_id = i.company_id AND c.id = i.contact_id
LEFT JOIN md_product p
ON p.company_id = it.company_id AND p.sku = it.product_sku
WHERE i.company_id = :cid AND i.id = :invoice_id AND it.id = :item_id
AND i.doc_type = 'purchase_invoice'
LIMIT 1"
);
$sth->execute([':cid' => $this->companyId, ':invoice_id' => $invoice_id, ':item_id' => $item_id]);
$line = $sth->fetch(PDO::FETCH_ASSOC);
if (!$line) throw new Exception('Purchase invoice line not found.');
if (!in_array((int)$line['invoice_status'], [1, 2], true)) {
throw new Exception('Only issued or paid purchase invoices can be turned into assets.');
}
$line['line_amount'] = $this->postedLineAmount($invoice_id, (float)$line['total_price'], (float)$line['grand_total'], (float)$line['tax']);
$sth = $this->pdo->prepare(
"SELECT COALESCE(SUM(cost), 0) FROM td_asset
WHERE company_id = :cid AND invoice_item_id = :item_id AND status <> 4 AND id <> :exclude"
);
$sth->execute([':cid' => $this->companyId, ':item_id' => $item_id, ':exclude' => $exclude_asset_id]);
$line['capitalized'] = round((float)$sth->fetchColumn(), 2);
$line['remaining'] = round($line['line_amount'] - $line['capitalized'], 2);
$line['clearing_account_code'] = $line['product_account_code'] !== ''
? $line['product_account_code']
: $this->formulaTotalAccount((int)$line['formula_id']);
return $line;
}
/** Issued purchase invoice lines with how much of each has been capitalized. */
public function getCapitalizableLines(string $date_from = '', string $date_to = ''): array
{
$where = ['i.company_id = :cid', "i.doc_type = 'purchase_invoice'", 'i.status IN (1, 2)'];
$params = [':cid' => $this->companyId, ':cid_s' => $this->companyId, ':cid_x' => $this->companyId];
if ($date_from = $this->parseDate($date_from)) { $where[] = 'i.issued_date >= :date_from'; $params[':date_from'] = $date_from; }
if ($date_to = $this->parseDate($date_to)) { $where[] = 'i.issued_date <= :date_to'; $params[':date_to'] = $date_to; }
$sth = $this->pdo->prepare(
"SELECT i.id AS invoice_id, i.invoice_number, i.issued_date, i.status AS invoice_status,
i.grand_total, i.tax, s.sum_total,
COALESCE(c.contact_name, '') AS contact_name,
it.id AS item_id, it.product_sku, it.product_name, it.quantity, it.total_price,
COALESCE(x.capitalized, 0) AS capitalized,
COALESCE(x.asset_count, 0) AS asset_count
FROM td_invoice i
JOIN td_invoice_item it
ON it.company_id = i.company_id AND it.invoice_id = i.id
JOIN (SELECT invoice_id, SUM(ABS(total_price)) AS sum_total
FROM td_invoice_item WHERE company_id = :cid_s GROUP BY invoice_id) s
ON s.invoice_id = i.id
LEFT JOIN md_contact c
ON c.company_id = i.company_id AND c.id = i.contact_id
LEFT JOIN (SELECT invoice_item_id, SUM(cost) AS capitalized, COUNT(*) AS asset_count
FROM td_asset WHERE company_id = :cid_x AND status <> 4 GROUP BY invoice_item_id) x
ON x.invoice_item_id = it.id
WHERE " . implode(' AND ', $where) . "
ORDER BY i.issued_date DESC, i.id DESC, it.item_id ASC"
);
$sth->execute($params);
$rows = [];
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
$sum = (float)$row['sum_total'];
$row['line_amount'] = $sum > 0
? round(((float)$row['grand_total'] - (float)$row['tax']) * abs((float)$row['total_price']) / $sum, 2)
: 0.0;
$row['capitalized'] = round((float)$row['capitalized'], 2);
$row['remaining'] = round($row['line_amount'] - $row['capitalized'], 2);
unset($row['grand_total'], $row['tax'], $row['sum_total']);
$rows[] = $row;
}
return $rows;
}
public function getStats(): array
{
$sth = $this->pdo->prepare(
"SELECT COUNT(*) AS total,
COALESCE(SUM(a.status = 0), 0) AS draft,
COALESCE(SUM(a.status = 1), 0) AS active,
COALESCE(SUM(a.status = 2), 0) AS fully_depreciated,
COALESCE(SUM(a.status = 3), 0) AS disposed,
COALESCE(SUM(IF(a.status IN (1, 2), a.cost, 0)), 0) AS cost_in_use,
COALESCE(SUM(IF(a.status IN (1, 2), COALESCE(d.accumulated, 0), 0)), 0) AS accumulated_in_use
FROM td_asset a
LEFT JOIN (SELECT asset_id, SUM(amount) AS accumulated
FROM td_asset_depreciation WHERE company_id = :cid_d GROUP BY asset_id) d
ON d.asset_id = a.id
WHERE a.company_id = :cid AND a.status <> 4"
);
$sth->execute([':cid' => $this->companyId, ':cid_d' => $this->companyId]);
$stats = $sth->fetch(PDO::FETCH_ASSOC) ?: [];
$stats['book_value_in_use'] = round((float)($stats['cost_in_use'] ?? 0) - (float)($stats['accumulated_in_use'] ?? 0), 2);
$sth = $this->pdo->prepare(
"SELECT c.id, c.category_code, c.category_name, c.asset_class,
COUNT(a.id) AS asset_count,
COALESCE(SUM(a.cost), 0) AS cost,
COALESCE(SUM(COALESCE(d.accumulated, 0)), 0) AS accumulated
FROM td_asset a
JOIN md_asset_category c
ON c.company_id = a.company_id AND c.id = a.category_id
LEFT JOIN (SELECT asset_id, SUM(amount) AS accumulated
FROM td_asset_depreciation WHERE company_id = :cid_d GROUP BY asset_id) d
ON d.asset_id = a.id
WHERE a.company_id = :cid AND a.status IN (1, 2)
GROUP BY c.id, c.category_code, c.category_name, c.asset_class
ORDER BY c.category_code ASC"
);
$sth->execute([':cid' => $this->companyId, ':cid_d' => $this->companyId]);
$stats['by_category'] = $sth->fetchAll(PDO::FETCH_ASSOC);
return $stats;
}
// ── write ────────────────────────────────────────────────────────────────
public function save(array $data, int $user_id): int
{
$id = (int)($data['id'] ?? 0);
$existing = $id ? $this->getById($id) : null;
if ($id && !$existing) throw new Exception('Asset not found.');
if ($existing && in_array((int)$existing['status'], [self::STATUS_DISPOSED, self::STATUS_VOID], true)) {
throw new Exception('Disposed or void assets cannot be edited.');
}
$name = trim((string)($data['asset_name'] ?? ''));
if ($name === '') throw new Exception('Asset name is required.');
$description = trim((string)($data['description'] ?? ''));
$department_id = (int)($data['department_id'] ?? 0);
// Once a month has been charged, the numbers behind the schedule are fixed.
if ($existing && $this->hasDepreciation($id)) {
$this->pdo->prepare(
"UPDATE td_asset SET asset_name = :name, description = :desc, department_id = :dept, updated_at = NOW()
WHERE company_id = :cid AND id = :id"
)->execute([':name' => $name, ':desc' => $description, ':dept' => $department_id, ':cid' => $this->companyId, ':id' => $id]);
return $id;
}
$category = (new AssetCategoryManager($this->pdo, $this->companyId))->getById((int)($data['category_id'] ?? 0));
if (!$category) throw new Exception('Select an asset category.');
$same_category = $existing && (int)$existing['category_id'] === (int)$category['id'];
if ((int)$category['status'] !== 1 && !$same_category) throw new Exception('The selected category is inactive.');
$cost = round((float)($data['cost'] ?? 0), 2);
$salvage = round((float)($data['salvage_value'] ?? 0), 2);
$life = (int)($data['useful_life_months'] ?? 0);
DepreciationCalculator::assertInputs($cost, $salvage, $life);
$acquisition = $this->parseDate((string)($data['acquisition_date'] ?? ''));
$in_service = $this->parseDate((string)($data['in_service_date'] ?? '')) ?: $acquisition;
if ($acquisition === '') throw new Exception('Acquisition date is required.');
if ($in_service < $acquisition) throw new Exception('The in-service date cannot be before the acquisition date.');
$clearing = trim((string)($data['clearing_account_code'] ?? ''));
if ($clearing !== '' && !AssetCategoryManager::isPostingAccount($this->pdo, $this->companyId, $clearing)) {
throw new Exception("Clearing account {$clearing} is not an active posting account.");
}
$invoice_id = $existing ? (int)$existing['invoice_id'] : (int)($data['invoice_id'] ?? 0);
$item_id = $existing ? (int)$existing['invoice_item_id'] : (int)($data['invoice_item_id'] ?? 0);
$contact_id = $existing ? (int)$existing['contact_id'] : 0;
$source = 'manual';
if ($item_id > 0) {
$line = $this->getInvoiceLine($invoice_id, $item_id, $id);
if ($cost > $line['remaining'] + 0.004) {
throw new Exception('Cost is more than the part of this invoice line not yet capitalized (' . number_format($line['remaining'], 2) . ').');
}
if ($clearing === '') {
throw new Exception('Assets from a purchase invoice need the clearing account the invoice was posted to.');
}
$contact_id = (int)$line['contact_id'];
$source = 'purchase_invoice';
}
$params = [
':cid' => $this->companyId,
':name' => $name,
':category_id' => (int)$category['id'],
':class' => $category['asset_class'],
':desc' => $description,
':dept' => $department_id,
':acquisition' => $acquisition,
':in_service' => $in_service,
':cost' => $cost,
':salvage' => $salvage,
':life' => $life,
':clearing' => $clearing,
];
if ($existing) {
$params[':id'] = $id;
$this->pdo->prepare(
"UPDATE td_asset SET
asset_name = :name, category_id = :category_id, asset_class = :class, description = :desc,
department_id = :dept, acquisition_date = :acquisition, in_service_date = :in_service,
cost = :cost, salvage_value = :salvage, useful_life_months = :life,
clearing_account_code = :clearing, updated_at = NOW()
WHERE company_id = :cid AND id = :id"
)->execute($params);
return $id;
}
$params += [
':number' => (new DocumentNumberManager($this->pdo, $this->companyId))->generate('asset'),
':source' => $source,
':invoice_id' => $invoice_id,
':item_id' => $item_id,
':contact_id' => $contact_id,
':user_id' => $user_id,
];
$this->pdo->prepare(
"INSERT INTO td_asset
(company_id, asset_number, asset_name, category_id, asset_class, description, source,
invoice_id, invoice_item_id, contact_id, department_id, acquisition_date, in_service_date,
cost, salvage_value, useful_life_months, clearing_account_code, status, created_by, created_at)
VALUES
(:cid, :number, :name, :category_id, :class, :desc, :source,
:invoice_id, :item_id, :contact_id, :dept, :acquisition, :in_service,
:cost, :salvage, :life, :clearing, 0, :user_id, NOW())"
)->execute($params);
return (int)$this->pdo->lastInsertId();
}
public function activate(int $id): void
{
$asset = $this->requireAsset($id);
if ((int)$asset['status'] !== self::STATUS_DRAFT) throw new Exception('Only draft assets can be activated.');
$category = (new AssetCategoryManager($this->pdo, $this->companyId))->getById((int)$asset['category_id']);
if (!$category || (int)$category['status'] !== 1) throw new Exception('The asset category is missing or inactive.');
DepreciationCalculator::assertInputs((float)$asset['cost'], (float)$asset['salvage_value'], (int)$asset['useful_life_months']);
$this->setStatus($id, self::STATUS_ACTIVE);
}
/** Drafts are removed outright (soft delete, like other documents). */
public function delete(int $id): void
{
$asset = $this->requireAsset($id);
if ((int)$asset['status'] !== self::STATUS_DRAFT) throw new Exception('Only draft assets can be deleted. Void an active asset instead.');
$this->pdo->prepare(
"UPDATE td_asset SET company_id = company_id * -1, updated_at = NOW() WHERE company_id = :cid AND id = :id"
)->execute([':cid' => $this->companyId, ':id' => $id]);
}
/** Void an active asset recorded in error. Reverses its capitalization entry if posted. */
public function void(int $id, GlManager $gl): void
{
$asset = $this->requireAsset($id);
if (!in_array((int)$asset['status'], [self::STATUS_ACTIVE, self::STATUS_FULLY], true)) {
throw new Exception('Only active assets can be voided.');
}
if ($this->hasDepreciation($id)) {
throw new Exception('This asset has depreciation recorded. Void its depreciation runs first.');
}
$gl->delete('asset_capitalization', $id);
$this->setStatus($id, self::STATUS_VOID);
}
/**
* Record a sale or write-off. Every month before the disposal month must
* already be charged; the disposal month itself gets no charge.
*/
public function dispose(int $id, array $data): void
{
$asset = $this->requireAsset($id);
if (!in_array((int)$asset['status'], [self::STATUS_ACTIVE, self::STATUS_FULLY], true)) {
throw new Exception('Only active assets can be disposed of.');
}
$date = $this->parseDate((string)($data['disposal_date'] ?? ''));
if ($date === '') throw new Exception('Disposal date is required.');
if ($date < $asset['in_service_date']) throw new Exception('The disposal date cannot be before the in-service date.');
$sale_price = round((float)($data['sale_price'] ?? 0), 2);
if ($sale_price < 0) throw new Exception('Sale price cannot be negative.');
$proceeds = trim((string)($data['proceeds_account_code'] ?? ''));
if ($sale_price > 0 && $proceeds === '') throw new Exception('Select the account that received the sale proceeds.');
if ($proceeds !== '' && !AssetCategoryManager::isPostingAccount($this->pdo, $this->companyId, $proceeds)) {
throw new Exception("Proceeds account {$proceeds} is not an active posting account.");
}
$disposal_period = DepreciationCalculator::periodOf($date);
$sth = $this->pdo->prepare(
"SELECT COUNT(*) FROM td_asset_depreciation WHERE company_id = :cid AND asset_id = :id AND period >= :period"
);
$sth->execute([':cid' => $this->companyId, ':id' => $id, ':period' => $disposal_period]);
if ((int)$sth->fetchColumn() > 0) {
throw new Exception("Depreciation is already recorded for {$disposal_period} or later. Void those runs before disposing of the asset on this date.");
}
$sth = $this->pdo->prepare("SELECT period FROM td_asset_depreciation WHERE company_id = :cid AND asset_id = :id");
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
$recorded = array_flip($sth->fetchAll(PDO::FETCH_COLUMN));
$schedule = DepreciationCalculator::schedule(
(float)$asset['cost'], (float)$asset['salvage_value'], (int)$asset['useful_life_months'], $asset['in_service_date']
);
foreach ($schedule as $row) {
if ($row['period'] >= $disposal_period) break;
if ($row['amount'] > 0 && !isset($recorded[$row['period']])) {
$label = $asset['asset_class'] === 'intangible' ? 'amortization' : 'depreciation';
throw new Exception("Run {$label} through " . DepreciationCalculator::addMonths($disposal_period, -1)
. " before disposing of this asset (first month not charged: {$row['period']}).");
}
}
$this->pdo->prepare(
"UPDATE td_asset SET status = :status, disposal_date = :date, sale_price = :price,
proceeds_account_code = :proceeds, disposal_notes = :notes, updated_at = NOW()
WHERE company_id = :cid AND id = :id"
)->execute([
':status' => self::STATUS_DISPOSED,
':date' => $date,
':price' => $sale_price,
':proceeds' => $proceeds,
':notes' => trim((string)($data['disposal_notes'] ?? '')),
':cid' => $this->companyId,
':id' => $id,
]);
}
/** Reverse a disposal (and its GL entry if posted). */
public function undoDisposal(int $id, GlManager $gl): void
{
$asset = $this->requireAsset($id);
if ((int)$asset['status'] !== self::STATUS_DISPOSED) throw new Exception('This asset has not been disposed of.');
$gl->delete('asset_disposal', $id);
$this->pdo->prepare(
"UPDATE td_asset SET status = :status, disposal_date = NULL, sale_price = 0,
proceeds_account_code = '', disposal_notes = '', updated_at = NOW()
WHERE company_id = :cid AND id = :id"
)->execute([':status' => self::STATUS_ACTIVE, ':cid' => $this->companyId, ':id' => $id]);
$this->syncStatuses([$id]);
}
/** Set active / fully depreciated from what has been charged so far. */
public function syncStatuses(array $asset_ids): void
{
$ids = array_values(array_unique(array_filter(array_map('intval', $asset_ids))));
if (!$ids) return;
$this->pdo->prepare(
"UPDATE td_asset a
LEFT JOIN (SELECT asset_id, SUM(amount) AS accumulated
FROM td_asset_depreciation WHERE company_id = :cid_d GROUP BY asset_id) d
ON d.asset_id = a.id
SET a.status = IF(COALESCE(d.accumulated, 0) >= ROUND(a.cost - a.salvage_value, 2) - 0.004, 2, 1),
a.updated_at = NOW()
WHERE a.company_id = :cid AND a.status IN (1, 2) AND a.id IN (" . implode(',', $ids) . ")"
)->execute([':cid' => $this->companyId, ':cid_d' => $this->companyId]);
}
// ── private ──────────────────────────────────────────────────────────────
private function baseSelect(): string
{
return "SELECT a.*,
COALESCE(c.category_code, '') AS category_code,
COALESCE(c.category_name, '') AS category_name,
COALESCE(ct.contact_name, '') AS contact_name,
COALESCE(i.invoice_number, '') AS invoice_number,
COALESCE(d.accumulated, 0) AS accumulated,
COALESCE(d.last_period, '') AS last_period,
ROUND(a.cost - COALESCE(d.accumulated, 0), 4) AS book_value
FROM td_asset a
LEFT JOIN md_asset_category c
ON c.company_id = a.company_id AND c.id = a.category_id
LEFT JOIN md_contact ct
ON ct.company_id = a.company_id AND ct.id = a.contact_id
LEFT JOIN td_invoice i
ON i.company_id = a.company_id AND i.id = a.invoice_id
LEFT JOIN (SELECT asset_id, SUM(amount) AS accumulated, MAX(period) AS last_period
FROM td_asset_depreciation WHERE company_id = :cid_d GROUP BY asset_id) d
ON d.asset_id = a.id";
}
private function requireAsset(int $id): array
{
$sth = $this->pdo->prepare("SELECT * FROM td_asset WHERE company_id = :cid AND id = :id FOR UPDATE");
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
$asset = $sth->fetch(PDO::FETCH_ASSOC);
if (!$asset) throw new Exception('Asset not found.');
return $asset;
}
private function setStatus(int $id, int $status): void
{
$this->pdo->prepare("UPDATE td_asset SET status = :status, updated_at = NOW() WHERE company_id = :cid AND id = :id")
->execute([':status' => $status, ':cid' => $this->companyId, ':id' => $id]);
}
private function hasDepreciation(int $id): bool
{
$sth = $this->pdo->prepare("SELECT COUNT(*) FROM td_asset_depreciation WHERE company_id = :cid AND asset_id = :id");
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
return (int)$sth->fetchColumn() > 0;
}
private function glEntry(string $source_type, int $source_id): ?array
{
$sth = $this->pdo->prepare(
"SELECT id, reference, journal_date, period, current_version,
DATE_FORMAT(created_at, '%d/%m/%Y %H:%i') AS posted_at
FROM td_gl WHERE company_id = :cid AND source_type = :type AND source_id = :id LIMIT 1"
);
$sth->execute([':cid' => $this->companyId, ':type' => $source_type, ':id' => $source_id]);
return $sth->fetch(PDO::FETCH_ASSOC) ?: null;
}
/**
* The part of the invoice's pre-tax total the purchase posting put on this
* line — PurchaseInvoicePosting splits (grand_total - tax) by line total.
*/
private function postedLineAmount(int $invoice_id, float $line_total, float $grand_total, float $tax): float
{
$sth = $this->pdo->prepare(
"SELECT COALESCE(SUM(ABS(total_price)), 0) FROM td_invoice_item WHERE company_id = :cid AND invoice_id = :id"
);
$sth->execute([':cid' => $this->companyId, ':id' => $invoice_id]);
$sum = (float)$sth->fetchColumn();
if ($sum <= 0) return 0.0;
return round((abs($grand_total) - abs($tax)) * abs($line_total) / $sum, 2);
}
private function formulaTotalAccount(int $formula_id): string
{
if ($formula_id <= 0) {
$sth = $this->pdo->prepare(
"SELECT id FROM md_account_formula
WHERE company_id = :cid AND document_type = 'purchase_invoice' AND is_default = 1 AND status = 1
LIMIT 1"
);
$sth->execute([':cid' => $this->companyId]);
$formula_id = (int)$sth->fetchColumn();
}
if ($formula_id <= 0) return '';
$sth = $this->pdo->prepare(
"SELECT account_code FROM md_account_formula_item
WHERE company_id = :cid AND formula_id = :fid AND amount_key = 'total' AND drcr = 'D'
ORDER BY sort_order ASC LIMIT 1"
);
$sth->execute([':cid' => $this->companyId, ':fid' => $formula_id]);
return (string)($sth->fetchColumn() ?: '');
}
private function parseDate(string $value): string
{
$value = trim($value);
if ($value === '') return '';
if (preg_match('#^(\d{2})/(\d{2})/(\d{4})$#', $value, $m)) return "{$m[3]}-{$m[2]}-{$m[1]}";
if (preg_match('#^\d{4}-\d{2}-\d{2}$#', $value)) return $value;
throw new Exception("Invalid date '{$value}'.");
}
}
@@ -0,0 +1,267 @@
<?php
/**
* AssetPosting
*
* Builds the GL lines for the four Asset Management sources. Unlike the
* formula-based BasePosting classes, the accounts come from the asset category.
* Every entry's reference is the asset or run number and every description
* starts with "[Asset Mgmt]" so it is recognisable in Accounting.
*
* asset_capitalization DR asset cost / CR clearing account (the account the purchase invoice hit)
* asset_depreciation DR depreciation expense / CR accumulated depreciation (one run, grouped by category + department)
* asset_amortization DR amortization expense / CR accumulated amortization
* asset_disposal DR proceeds + accumulated (+ loss) / CR asset cost (+ gain)
*/
class AssetPosting
{
public const SOURCE_TYPES = ['asset_capitalization', 'asset_depreciation', 'asset_amortization', 'asset_disposal'];
public const LABEL = '[Asset Mgmt]';
private PDO $pdo;
private int $companyId;
public function __construct(PDO $pdo, int $company_id)
{
$this->pdo = $pdo;
$this->companyId = $company_id;
}
public static function isAssetSource(string $source_type): bool
{
return in_array($source_type, self::SOURCE_TYPES, true);
}
/**
* @return array{formula_id: int, period: string, doc_date: string, lines: array, reference: string, description: string}
*/
public function build(string $source_type, int $id): array
{
switch ($source_type) {
case 'asset_capitalization': $built = $this->buildCapitalization($id); break;
case 'asset_depreciation': $built = $this->buildRun($id, 'tangible'); break;
case 'asset_amortization': $built = $this->buildRun($id, 'intangible'); break;
case 'asset_disposal': $built = $this->buildDisposal($id); break;
default: throw new Exception("Unknown asset source '{$source_type}'.");
}
$this->assertBalanced($built['lines'], $built['reference']);
return [
'formula_id' => 0,
'period' => substr($built['doc_date'], 0, 7),
'doc_date' => $built['doc_date'],
'lines' => $built['lines'],
'reference' => mb_substr($built['reference'], 0, 100),
'description' => mb_substr($built['description'], 0, 255),
];
}
/** Rows for the Batch GL Entries tabs, in the same shape GlQueryManager returns. */
public function getPostableDocuments(string $source_type, string $date_from, string $date_to): array
{
$params = [':cid' => $this->companyId, ':source_type' => $source_type];
if ($source_type === 'asset_depreciation' || $source_type === 'asset_amortization') {
$where = ['r.company_id = :cid', 'r.status = 1', 'r.asset_class = :class'];
$params[':class'] = $source_type === 'asset_amortization' ? 'intangible' : 'tangible';
if ($date_from) { $where[] = 'r.run_date >= :date_from'; $params[':date_from'] = $date_from; }
if ($date_to) { $where[] = 'r.run_date <= :date_to'; $params[':date_to'] = $date_to; }
$sql = "SELECT r.id,
r.run_number AS doc_number,
CONCAT(r.asset_count, IF(r.asset_count = 1, ' asset', ' assets'), ' · ', r.period) AS contact_name,
r.total_amount AS grand_total,
r.run_date AS doc_date,
IF(g.id IS NULL, 0, 1) AS gl_status,
0 AS gl_formula_id, 0 AS product_mapping_checked, 0 AS product_mapping_missing
FROM td_asset_run r
LEFT JOIN td_gl g
ON g.company_id = r.company_id AND g.source_type = :source_type AND g.source_id = r.id
WHERE " . implode(' AND ', $where) . "
ORDER BY r.run_date DESC, r.id DESC";
} else {
$is_disposal = $source_type === 'asset_disposal';
$date_col = $is_disposal ? 'a.disposal_date' : 'a.acquisition_date';
$where = ['a.company_id = :cid'];
$where[] = $is_disposal ? 'a.status = 3' : "a.status IN (1, 2, 3) AND a.clearing_account_code <> ''";
if ($date_from) { $where[] = "{$date_col} >= :date_from"; $params[':date_from'] = $date_from; }
if ($date_to) { $where[] = "{$date_col} <= :date_to"; $params[':date_to'] = $date_to; }
$amount = $is_disposal ? 'a.sale_price' : 'a.cost';
$sql = "SELECT a.id,
a.asset_number AS doc_number,
a.asset_name AS contact_name,
{$amount} AS grand_total,
{$date_col} AS doc_date,
IF(g.id IS NULL, 0, 1) AS gl_status,
0 AS gl_formula_id, 0 AS product_mapping_checked, 0 AS product_mapping_missing
FROM td_asset a
LEFT JOIN td_gl g
ON g.company_id = a.company_id AND g.source_type = :source_type AND g.source_id = a.id
WHERE " . implode(' AND ', $where) . "
ORDER BY {$date_col} DESC, a.id DESC";
}
$sth = $this->pdo->prepare($sql);
$sth->execute($params);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
// ── builders ─────────────────────────────────────────────────────────────
private function buildCapitalization(int $id): array
{
$asset = $this->fetchAsset($id);
if (!in_array((int)$asset['status'], [1, 2, 3], true)) {
throw new Exception("Asset {$asset['asset_number']} is not active.");
}
if ($asset['clearing_account_code'] === '') {
throw new Exception("Asset {$asset['asset_number']} has no clearing account, so it has no capitalization entry.");
}
$cost = round((float)$asset['cost'], 2);
$text = self::LABEL . " Capitalize {$asset['asset_number']} {$asset['asset_name']}";
$dept = (int)$asset['department_id'];
return [
'doc_date' => $asset['acquisition_date'],
'reference' => $asset['asset_number'],
'description' => $text,
'lines' => [
$this->line($this->account($asset, 'cost_account_code'), $dept, $cost, 0, $text),
$this->line($asset['clearing_account_code'], $dept, 0, $cost, $text),
],
];
}
private function buildRun(int $run_id, string $asset_class): array
{
$sth = $this->pdo->prepare("SELECT * FROM td_asset_run WHERE company_id = :cid AND id = :id LIMIT 1");
$sth->execute([':cid' => $this->companyId, ':id' => $run_id]);
$run = $sth->fetch(PDO::FETCH_ASSOC);
if (!$run || (int)$run['status'] !== 1) throw new Exception("Asset run #{$run_id} not found or void.");
if ($run['asset_class'] !== $asset_class) throw new Exception("Asset run {$run['run_number']} is not a {$asset_class} run.");
$sth = $this->pdo->prepare(
"SELECT d.category_id, d.department_id, SUM(d.amount) AS amount,
c.category_code, c.category_name, c.expense_account_code, c.accum_account_code
FROM td_asset_depreciation d
LEFT JOIN md_asset_category c
ON c.company_id = d.company_id AND c.id = d.category_id
WHERE d.company_id = :cid AND d.run_id = :run_id
GROUP BY d.category_id, d.department_id, c.category_code, c.category_name,
c.expense_account_code, c.accum_account_code
ORDER BY c.category_code ASC, d.department_id ASC"
);
$sth->execute([':cid' => $this->companyId, ':run_id' => $run_id]);
$groups = $sth->fetchAll(PDO::FETCH_ASSOC);
if (!$groups) throw new Exception("Asset run {$run['run_number']} has no lines.");
$label = $asset_class === 'intangible' ? 'Amortization' : 'Depreciation';
$lines = [];
foreach ($groups as $g) {
$amount = round((float)$g['amount'], 2);
$text = self::LABEL . " {$label} {$run['period']} · {$g['category_code']} {$g['category_name']}";
$dept = (int)$g['department_id'];
$lines[] = $this->line($this->account($g, 'expense_account_code'), $dept, $amount, 0, $text);
$lines[] = $this->line($this->account($g, 'accum_account_code'), $dept, 0, $amount, $text);
}
return [
'doc_date' => $run['run_date'],
'reference' => $run['run_number'],
'description' => self::LABEL . " {$label} {$run['period']} ({$run['asset_count']} assets)",
'lines' => $lines,
];
}
private function buildDisposal(int $id): array
{
$asset = $this->fetchAsset($id);
if ((int)$asset['status'] !== 3) throw new Exception("Asset {$asset['asset_number']} has not been disposed of.");
$sth = $this->pdo->prepare(
"SELECT COALESCE(SUM(amount), 0) FROM td_asset_depreciation WHERE company_id = :cid AND asset_id = :id"
);
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
$accumulated = round((float)$sth->fetchColumn(), 2);
$cost = round((float)$asset['cost'], 2);
$sale_price = round((float)$asset['sale_price'], 2);
$gain = round($sale_price - ($cost - $accumulated), 2);
$dept = (int)$asset['department_id'];
$text = self::LABEL . " Disposal {$asset['asset_number']} {$asset['asset_name']}";
$lines = [];
if ($sale_price > 0) {
if ($asset['proceeds_account_code'] === '') throw new Exception("Asset {$asset['asset_number']} has a sale price but no proceeds account.");
$lines[] = $this->line($asset['proceeds_account_code'], $dept, $sale_price, 0, $text . ' · proceeds');
}
if ($accumulated > 0) {
$lines[] = $this->line($this->account($asset, 'accum_account_code'), $dept, $accumulated, 0, $text . ' · accumulated');
}
if ($gain < 0) {
$lines[] = $this->line($this->account($asset, 'loss_account_code'), $dept, -$gain, 0, $text . ' · loss');
}
$lines[] = $this->line($this->account($asset, 'cost_account_code'), $dept, 0, $cost, $text . ' · cost');
if ($gain > 0) {
$lines[] = $this->line($this->account($asset, 'gain_account_code'), $dept, 0, $gain, $text . ' · gain');
}
return [
'doc_date' => $asset['disposal_date'],
'reference' => $asset['asset_number'],
'description' => $text,
'lines' => $lines,
];
}
// ── helpers ──────────────────────────────────────────────────────────────
private function fetchAsset(int $id): array
{
$sth = $this->pdo->prepare(
"SELECT a.*, c.category_code, c.category_name, c.cost_account_code, c.accum_account_code,
c.expense_account_code, c.gain_account_code, c.loss_account_code
FROM td_asset a
LEFT JOIN md_asset_category c
ON c.company_id = a.company_id AND c.id = a.category_id
WHERE a.company_id = :cid AND a.id = :id
LIMIT 1"
);
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
$asset = $sth->fetch(PDO::FETCH_ASSOC);
if (!$asset) throw new Exception("Asset #{$id} not found.");
return $asset;
}
private function account(array $row, string $field): string
{
$code = trim((string)($row[$field] ?? ''));
if ($code === '') {
$category = trim(($row['category_code'] ?? '') . ' ' . ($row['category_name'] ?? ''));
throw new Exception("Asset category {$category} has no " . str_replace('_', ' ', $field) . '.');
}
return $code;
}
private function line(string $account_code, int $department_id, float $debit, float $credit, string $description): array
{
return [
'account_code' => $account_code,
'department_id' => $department_id,
'debit' => round($debit, 2),
'credit' => round($credit, 2),
'description' => mb_substr($description, 0, 255),
];
}
private function assertBalanced(array $lines, string $reference): void
{
$debit = round(array_sum(array_column($lines, 'debit')), 2);
$credit = round(array_sum(array_column($lines, 'credit')), 2);
if (!$lines || abs($debit - $credit) > 0.004) {
throw new Exception("GL entry for {$reference} does not balance (debit {$debit}, credit {$credit}).");
}
}
}
@@ -0,0 +1,334 @@
<?php
require_once __DIR__ . '/DepreciationCalculator.php';
require_once __DIR__ . '/AssetManager.php';
/**
* AssetRunManager
*
* Monthly depreciation (tangible) and amortization (intangible) runs. A run
* covers one class and one month; each becomes one GL entry that Accounting
* posts from Batch GL Entries. Running "through" a month creates one run per
* month that still has charges due, oldest first.
*/
class AssetRunManager
{
private const CLASS_META = [
'tangible' => ['source_type' => 'asset_depreciation', 'prefix' => 'AM-DEP', 'label' => 'Depreciation'],
'intangible' => ['source_type' => 'asset_amortization', 'prefix' => 'AM-AMT', 'label' => 'Amortization'],
];
private PDO $pdo;
private int $companyId;
public function __construct(PDO $pdo, int $company_id)
{
$this->pdo = $pdo;
$this->companyId = $company_id;
}
public static function sourceTypeFor(string $asset_class): string
{
return self::meta($asset_class)['source_type'];
}
public static function labelFor(string $asset_class): string
{
return self::meta($asset_class)['label'];
}
/**
* Charges due for every active asset of the class, from each asset's next
* uncharged month up to $through_period.
*
* @return array<int, array{period: string, run_date: string, asset_count: int, total: float, items: array}>
*/
public function preview(string $asset_class, string $through_period): array
{
self::meta($asset_class);
DepreciationCalculator::addMonths($through_period, 0); // validates the format
if (DepreciationCalculator::monthsBetween(date('Y-m'), $through_period) > 0) {
throw new Exception('You cannot run ' . strtolower(self::labelFor($asset_class)) . ' for a future month.');
}
$by_period = [];
foreach ($this->activeAssets($asset_class) as $asset) {
$cost = (float)$asset['cost'];
$salvage = (float)$asset['salvage_value'];
$life = (int)$asset['useful_life_months'];
$accumulated = round((float)$asset['accumulated'], 2);
$period = $asset['last_period'] !== ''
? DepreciationCalculator::addMonths($asset['last_period'], 1)
: DepreciationCalculator::firstPeriod($asset['in_service_date']);
while (DepreciationCalculator::monthsBetween($period, $through_period) >= 0) {
$index = DepreciationCalculator::periodIndex($asset['in_service_date'], $period);
$amount = DepreciationCalculator::amountFor($cost, $salvage, $life, $index, $accumulated);
if ($amount <= 0) break;
$accumulated = round($accumulated + $amount, 2);
$by_period[$period][] = [
'asset_id' => (int)$asset['id'],
'asset_number' => $asset['asset_number'],
'asset_name' => $asset['asset_name'],
'category_id' => (int)$asset['category_id'],
'category_name' => $asset['category_name'],
'department_id' => (int)$asset['department_id'],
'amount' => $amount,
'accumulated_after' => $accumulated,
'book_value_after' => round($cost - $accumulated, 2),
];
$period = DepreciationCalculator::addMonths($period, 1);
}
}
ksort($by_period);
$runs = [];
foreach ($by_period as $period => $items) {
$runs[] = [
'period' => $period,
'run_date' => DepreciationCalculator::periodEndDate($period),
'asset_count' => count($items),
'total' => round(array_sum(array_column($items, 'amount')), 2),
'items' => $items,
];
}
return $runs;
}
/** Create the runs shown by preview(). Call inside a transaction. */
public function run(string $asset_class, string $through_period, int $user_id): array
{
// Serialize concurrent runs for the same class.
$this->pdo->prepare(
"SELECT id FROM td_asset WHERE company_id = :cid AND asset_class = :class AND status = 1 FOR UPDATE"
)->execute([':cid' => $this->companyId, ':class' => $asset_class]);
$preview = $this->preview($asset_class, $through_period);
if (!$preview) {
throw new Exception('No ' . strtolower(self::labelFor($asset_class)) . " is due through {$through_period}.");
}
$insert_run = $this->pdo->prepare(
"INSERT INTO td_asset_run
(company_id, run_number, asset_class, period, run_date, asset_count, total_amount, status, created_by, created_at)
VALUES
(:cid, :number, :class, :period, :run_date, :count, :total, 1, :user_id, NOW())"
);
$insert_row = $this->pdo->prepare(
"INSERT INTO td_asset_depreciation
(company_id, asset_id, run_id, period, category_id, department_id,
amount, accumulated_after, book_value_after, created_at)
VALUES
(:cid, :asset_id, :run_id, :period, :category_id, :department_id,
:amount, :accumulated, :book_value, NOW())"
);
$created = [];
$asset_ids = [];
foreach ($preview as $run) {
$number = $this->nextRunNumber($asset_class, $run['period']);
$insert_run->execute([
':cid' => $this->companyId,
':number' => $number,
':class' => $asset_class,
':period' => $run['period'],
':run_date' => $run['run_date'],
':count' => $run['asset_count'],
':total' => $run['total'],
':user_id' => $user_id,
]);
$run_id = (int)$this->pdo->lastInsertId();
foreach ($run['items'] as $item) {
$insert_row->execute([
':cid' => $this->companyId,
':asset_id' => $item['asset_id'],
':run_id' => $run_id,
':period' => $run['period'],
':category_id' => $item['category_id'],
':department_id' => $item['department_id'],
':amount' => $item['amount'],
':accumulated' => $item['accumulated_after'],
':book_value' => $item['book_value_after'],
]);
$asset_ids[] = $item['asset_id'];
}
$created[] = [
'id' => $run_id,
'run_number' => $number,
'period' => $run['period'],
'asset_count' => $run['asset_count'],
'total' => $run['total'],
];
}
(new AssetManager($this->pdo, $this->companyId))->syncStatuses($asset_ids);
return $created;
}
public function getList(string $asset_class = ''): array
{
$where = ['r.company_id = :cid'];
$params = [':cid' => $this->companyId];
if ($asset_class !== '') {
self::meta($asset_class);
$where[] = 'r.asset_class = :class';
$params[':class'] = $asset_class;
}
$sth = $this->pdo->prepare(
"SELECT r.*,
DATE_FORMAT(r.created_at, '%d/%m/%Y %H:%i') AS created_at_fmt,
IF(g.id IS NULL, 0, 1) AS gl_status,
COALESCE(g.id, 0) AS gl_id
FROM td_asset_run r
LEFT JOIN td_gl g
ON g.company_id = r.company_id
AND g.source_id = r.id
AND g.source_type = IF(r.asset_class = 'intangible', 'asset_amortization', 'asset_depreciation')
WHERE " . implode(' AND ', $where) . "
ORDER BY r.period DESC, r.id DESC"
);
$sth->execute($params);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
public function getDetail(int $run_id): array
{
$sth = $this->pdo->prepare("SELECT * FROM td_asset_run WHERE company_id = :cid AND id = :id LIMIT 1");
$sth->execute([':cid' => $this->companyId, ':id' => $run_id]);
$run = $sth->fetch(PDO::FETCH_ASSOC);
if (!$run) throw new Exception('Run not found.');
$sth = $this->pdo->prepare(
"SELECT d.asset_id, d.amount, d.accumulated_after, d.book_value_after, d.department_id,
a.asset_number, a.asset_name,
COALESCE(c.category_code, '') AS category_code,
COALESCE(c.category_name, '') AS category_name,
COALESCE(dp.dept_code, '') AS dept_code
FROM td_asset_depreciation d
JOIN td_asset a
ON a.id = d.asset_id AND ABS(a.company_id) = d.company_id
LEFT JOIN md_asset_category c
ON c.company_id = d.company_id AND c.id = d.category_id
LEFT JOIN md_department dp
ON dp.company_id = d.company_id AND dp.id = d.department_id
WHERE d.company_id = :cid AND d.run_id = :run_id
ORDER BY a.asset_number ASC"
);
$sth->execute([':cid' => $this->companyId, ':run_id' => $run_id]);
$items = $sth->fetchAll(PDO::FETCH_ASSOC);
// A void run's rows are gone; show what it held from the header only.
return ['run' => $run, 'items' => $items];
}
/**
* Void a run: reverse its GL entry if posted and remove its charges. Only
* the latest month of each asset can be removed, and not after a disposal.
*/
public function void(int $run_id, GlManager $gl): void
{
$sth = $this->pdo->prepare("SELECT * FROM td_asset_run WHERE company_id = :cid AND id = :id FOR UPDATE");
$sth->execute([':cid' => $this->companyId, ':id' => $run_id]);
$run = $sth->fetch(PDO::FETCH_ASSOC);
if (!$run) throw new Exception('Run not found.');
if ((int)$run['status'] !== 1) throw new Exception('This run is already void.');
$sth = $this->pdo->prepare(
"SELECT a.asset_number
FROM td_asset_depreciation d
JOIN td_asset a ON a.company_id = d.company_id AND a.id = d.asset_id
WHERE d.company_id = :cid AND d.run_id = :run_id AND a.status = 3
LIMIT 1"
);
$sth->execute([':cid' => $this->companyId, ':run_id' => $run_id]);
if ($number = $sth->fetchColumn()) {
throw new Exception("Asset {$number} in this run has been disposed of. Undo the disposal first.");
}
$sth = $this->pdo->prepare(
"SELECT later.period
FROM td_asset_depreciation d
JOIN td_asset_depreciation later
ON later.company_id = d.company_id AND later.asset_id = d.asset_id AND later.period > d.period
WHERE d.company_id = :cid AND d.run_id = :run_id
ORDER BY later.period DESC
LIMIT 1"
);
$sth->execute([':cid' => $this->companyId, ':run_id' => $run_id]);
if ($later = $sth->fetchColumn()) {
throw new Exception("Assets in this run were charged again in {$later}. Void the later runs first.");
}
$gl->delete(self::sourceTypeFor($run['asset_class']), $run_id);
$sth = $this->pdo->prepare("SELECT asset_id FROM td_asset_depreciation WHERE company_id = :cid AND run_id = :run_id");
$sth->execute([':cid' => $this->companyId, ':run_id' => $run_id]);
$asset_ids = $sth->fetchAll(PDO::FETCH_COLUMN);
$this->pdo->prepare("DELETE FROM td_asset_depreciation WHERE company_id = :cid AND run_id = :run_id")
->execute([':cid' => $this->companyId, ':run_id' => $run_id]);
$this->pdo->prepare("UPDATE td_asset_run SET status = 4, voided_at = NOW() WHERE company_id = :cid AND id = :id")
->execute([':cid' => $this->companyId, ':id' => $run_id]);
(new AssetManager($this->pdo, $this->companyId))->syncStatuses($asset_ids);
}
/** What is due through the current month, per class (for the dashboard). */
public function dueSummary(): array
{
$summary = [];
foreach (array_keys(self::CLASS_META) as $asset_class) {
$runs = $this->preview($asset_class, date('Y-m'));
$summary[$asset_class] = [
'label' => self::labelFor($asset_class),
'period_count' => count($runs),
'from' => $runs ? $runs[0]['period'] : '',
'to' => $runs ? $runs[count($runs) - 1]['period'] : '',
'total' => round(array_sum(array_column($runs, 'total')), 2),
];
}
return $summary;
}
private function activeAssets(string $asset_class): array
{
$sth = $this->pdo->prepare(
"SELECT a.id, a.asset_number, a.asset_name, a.category_id, a.department_id,
a.cost, a.salvage_value, a.useful_life_months, a.in_service_date,
COALESCE(c.category_name, '') AS category_name,
COALESCE(d.accumulated, 0) AS accumulated,
COALESCE(d.last_period, '') AS last_period
FROM td_asset a
LEFT JOIN md_asset_category c
ON c.company_id = a.company_id AND c.id = a.category_id
LEFT JOIN (SELECT asset_id, SUM(amount) AS accumulated, MAX(period) AS last_period
FROM td_asset_depreciation WHERE company_id = :cid_d GROUP BY asset_id) d
ON d.asset_id = a.id
WHERE a.company_id = :cid AND a.asset_class = :class AND a.status = 1
ORDER BY a.asset_number ASC"
);
$sth->execute([':cid' => $this->companyId, ':cid_d' => $this->companyId, ':class' => $asset_class]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
private function nextRunNumber(string $asset_class, string $period): string
{
$sth = $this->pdo->prepare(
"SELECT COUNT(*) FROM td_asset_run WHERE company_id = :cid AND asset_class = :class AND period = :period"
);
$sth->execute([':cid' => $this->companyId, ':class' => $asset_class, ':period' => $period]);
$existing = (int)$sth->fetchColumn();
$number = self::meta($asset_class)['prefix'] . '-' . $period;
return $existing > 0 ? $number . '-' . ($existing + 1) : $number;
}
private static function meta(string $asset_class): array
{
if (!isset(self::CLASS_META[$asset_class])) throw new Exception('Invalid asset class.');
return self::CLASS_META[$asset_class];
}
}
@@ -0,0 +1,135 @@
<?php
/**
* DepreciationCalculator
*
* Straight-line depreciation (tangible) and amortization (intangible) maths.
* No database access — AssetManager and AssetRunManager call it.
*
* Conventions:
* - Amounts are rounded to 2 decimals per month; the last month of the life
* takes whatever is left so the book value ends exactly at salvage value.
* - The month the asset is put in service counts as a full month.
* - The month an asset is disposed of gets no charge.
* - Periods are 'YYYY-MM' strings.
*/
final class DepreciationCalculator
{
public static function periodOf(string $date): string
{
if (!preg_match('/^(\d{4})-(\d{2})/', $date, $m)) {
throw new Exception("Invalid date '{$date}'.");
}
return "{$m[1]}-{$m[2]}";
}
public static function addMonths(string $period, int $months): string
{
[$y, $m] = self::splitPeriod($period);
$index = $y * 12 + ($m - 1) + $months;
return sprintf('%04d-%02d', intdiv($index, 12), $index % 12 + 1);
}
/** Number of months from $from to $to (0 when equal, negative when $to is earlier). */
public static function monthsBetween(string $from, string $to): int
{
[$fy, $fm] = self::splitPeriod($from);
[$ty, $tm] = self::splitPeriod($to);
return ($ty * 12 + $tm) - ($fy * 12 + $fm);
}
public static function periodEndDate(string $period): string
{
[$y, $m] = self::splitPeriod($period);
return date('Y-m-t', mktime(0, 0, 0, $m, 1, $y));
}
public static function depreciableBase(float $cost, float $salvage): float
{
return round($cost - $salvage, 2);
}
public static function monthlyAmount(float $cost, float $salvage, int $life_months): float
{
if ($life_months <= 0) return 0.0;
return round(self::depreciableBase($cost, $salvage) / $life_months, 2);
}
public static function firstPeriod(string $in_service_date): string
{
return self::periodOf($in_service_date);
}
public static function lastPeriod(string $in_service_date, int $life_months): string
{
return self::addMonths(self::firstPeriod($in_service_date), max(1, $life_months) - 1);
}
/**
* Charge for one period.
*
* @param int $period_index 1 for the in-service month, up to $life_months.
* @param float $accumulated Total already charged before this period.
*/
public static function amountFor(float $cost, float $salvage, int $life_months, int $period_index, float $accumulated): float
{
if ($life_months <= 0 || $period_index < 1 || $period_index > $life_months) return 0.0;
$remaining = round(self::depreciableBase($cost, $salvage) - $accumulated, 2);
if ($remaining <= 0) return 0.0;
if ($period_index === $life_months) return $remaining;
return min(self::monthlyAmount($cost, $salvage, $life_months), $remaining);
}
/**
* Full projected schedule.
*
* @return array<int, array{period: string, amount: float, accumulated: float, book_value: float}>
*/
public static function schedule(float $cost, float $salvage, int $life_months, string $in_service_date): array
{
$rows = [];
$accumulated = 0.0;
$period = self::firstPeriod($in_service_date);
for ($i = 1; $i <= $life_months; $i++) {
$amount = self::amountFor($cost, $salvage, $life_months, $i, $accumulated);
$accumulated = round($accumulated + $amount, 2);
$rows[] = [
'period' => $period,
'amount' => $amount,
'accumulated' => $accumulated,
'book_value' => round($cost - $accumulated, 2),
];
$period = self::addMonths($period, 1);
}
return $rows;
}
/** 1-based position of $period in the asset's life (0 or less = before in service). */
public static function periodIndex(string $in_service_date, string $period): int
{
return self::monthsBetween(self::firstPeriod($in_service_date), $period) + 1;
}
public static function assertInputs(float $cost, float $salvage, int $life_months): void
{
if ($cost <= 0) throw new Exception('Cost must be greater than zero.');
if ($salvage < 0) throw new Exception('Salvage value cannot be negative.');
if ($salvage >= $cost) throw new Exception('Salvage value must be less than cost.');
if ($life_months < 1) throw new Exception('Useful life must be at least 1 month.');
if ($life_months > 1200) throw new Exception('Useful life cannot exceed 1200 months.');
if (self::monthlyAmount($cost, $salvage, $life_months) <= 0) {
throw new Exception('The amount to depreciate is too small for this useful life.');
}
}
private static function splitPeriod(string $period): array
{
if (!preg_match('/^(\d{4})-(\d{2})$/', $period, $m) || (int)$m[2] < 1 || (int)$m[2] > 12) {
throw new Exception("Invalid period '{$period}'.");
}
return [(int)$m[1], (int)$m[2]];
}
}
+11
View File
@@ -20,6 +20,17 @@ if (!function_exists('require_role')) {
}
}
// Blocks users whose app_access does not include $app ('all' includes every app).
if (!function_exists('require_app_access')) {
function require_app_access(string $app): void {
$access = $_SESSION['login_app_access'] ?? 'wms';
if ($access !== 'all' && $access !== $app) {
http_response_code(403);
exit(json_encode(['success' => 0, 'message' => 'You do not have access to this app.']));
}
}
}
if(!empty($_SESSION["login_company_id"])){
// CSRF Validation — add right at the top of the logged-in block