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:
@@ -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}).");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user