Files
wms-app/app/assets/utils/classes_as/AssetPosting.php
T
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

268 lines
13 KiB
PHP

<?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}).");
}
}
}