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.
335 lines
15 KiB
PHP
335 lines
15 KiB
PHP
<?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];
|
|
}
|
|
}
|