['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 */ public function preview(string $asset_class, string $through_period): array { self::meta($asset_class); DepreciationCalculator::addMonths($through_period, 0); // validates the format if (DepreciationCalculator::monthsBetween(date('Y-m'), $through_period) > 0) { throw new Exception('You cannot run ' . strtolower(self::labelFor($asset_class)) . ' for a future month.'); } $by_period = []; foreach ($this->activeAssets($asset_class) as $asset) { $cost = (float)$asset['cost']; $salvage = (float)$asset['salvage_value']; $life = (int)$asset['useful_life_months']; $accumulated = round((float)$asset['accumulated'], 2); $period = $asset['last_period'] !== '' ? DepreciationCalculator::addMonths($asset['last_period'], 1) : DepreciationCalculator::firstPeriod($asset['in_service_date']); while (DepreciationCalculator::monthsBetween($period, $through_period) >= 0) { $index = DepreciationCalculator::periodIndex($asset['in_service_date'], $period); $amount = DepreciationCalculator::amountFor($cost, $salvage, $life, $index, $accumulated); if ($amount <= 0) break; $accumulated = round($accumulated + $amount, 2); $by_period[$period][] = [ 'asset_id' => (int)$asset['id'], 'asset_number' => $asset['asset_number'], 'asset_name' => $asset['asset_name'], 'category_id' => (int)$asset['category_id'], 'category_name' => $asset['category_name'], 'department_id' => (int)$asset['department_id'], 'amount' => $amount, 'accumulated_after' => $accumulated, 'book_value_after' => round($cost - $accumulated, 2), ]; $period = DepreciationCalculator::addMonths($period, 1); } } ksort($by_period); $runs = []; foreach ($by_period as $period => $items) { $runs[] = [ 'period' => $period, 'run_date' => DepreciationCalculator::periodEndDate($period), 'asset_count' => count($items), 'total' => round(array_sum(array_column($items, 'amount')), 2), 'items' => $items, ]; } return $runs; } /** Create the runs shown by preview(). Call inside a transaction. */ public function run(string $asset_class, string $through_period, int $user_id): array { // Serialize concurrent runs for the same class. $this->pdo->prepare( "SELECT id FROM td_asset WHERE company_id = :cid AND asset_class = :class AND status = 1 FOR UPDATE" )->execute([':cid' => $this->companyId, ':class' => $asset_class]); $preview = $this->preview($asset_class, $through_period); if (!$preview) { throw new Exception('No ' . strtolower(self::labelFor($asset_class)) . " is due through {$through_period}."); } $insert_run = $this->pdo->prepare( "INSERT INTO td_asset_run (company_id, run_number, asset_class, period, run_date, asset_count, total_amount, status, created_by, created_at) VALUES (:cid, :number, :class, :period, :run_date, :count, :total, 1, :user_id, NOW())" ); $insert_row = $this->pdo->prepare( "INSERT INTO td_asset_depreciation (company_id, asset_id, run_id, period, category_id, department_id, amount, accumulated_after, book_value_after, created_at) VALUES (:cid, :asset_id, :run_id, :period, :category_id, :department_id, :amount, :accumulated, :book_value, NOW())" ); $created = []; $asset_ids = []; foreach ($preview as $run) { $number = $this->nextRunNumber($asset_class, $run['period']); $insert_run->execute([ ':cid' => $this->companyId, ':number' => $number, ':class' => $asset_class, ':period' => $run['period'], ':run_date' => $run['run_date'], ':count' => $run['asset_count'], ':total' => $run['total'], ':user_id' => $user_id, ]); $run_id = (int)$this->pdo->lastInsertId(); foreach ($run['items'] as $item) { $insert_row->execute([ ':cid' => $this->companyId, ':asset_id' => $item['asset_id'], ':run_id' => $run_id, ':period' => $run['period'], ':category_id' => $item['category_id'], ':department_id' => $item['department_id'], ':amount' => $item['amount'], ':accumulated' => $item['accumulated_after'], ':book_value' => $item['book_value_after'], ]); $asset_ids[] = $item['asset_id']; } $created[] = [ 'id' => $run_id, 'run_number' => $number, 'period' => $run['period'], 'asset_count' => $run['asset_count'], 'total' => $run['total'], ]; } (new AssetManager($this->pdo, $this->companyId))->syncStatuses($asset_ids); return $created; } public function getList(string $asset_class = ''): array { $where = ['r.company_id = :cid']; $params = [':cid' => $this->companyId]; if ($asset_class !== '') { self::meta($asset_class); $where[] = 'r.asset_class = :class'; $params[':class'] = $asset_class; } $sth = $this->pdo->prepare( "SELECT r.*, DATE_FORMAT(r.created_at, '%Y-%m-%d %H:%i:%s') AS created_at_fmt, IF(g.id IS NULL, 0, 1) AS gl_status, COALESCE(g.id, 0) AS gl_id FROM td_asset_run r LEFT JOIN td_gl g ON g.company_id = r.company_id AND g.source_id = r.id AND g.source_type = IF(r.asset_class = 'intangible', 'asset_amortization', 'asset_depreciation') WHERE " . implode(' AND ', $where) . " ORDER BY r.period DESC, r.id DESC" ); $sth->execute($params); return $sth->fetchAll(PDO::FETCH_ASSOC); } public function getDetail(int $run_id): array { $sth = $this->pdo->prepare("SELECT * FROM td_asset_run WHERE company_id = :cid AND id = :id LIMIT 1"); $sth->execute([':cid' => $this->companyId, ':id' => $run_id]); $run = $sth->fetch(PDO::FETCH_ASSOC); if (!$run) throw new Exception('Run not found.'); $sth = $this->pdo->prepare( "SELECT d.asset_id, d.amount, d.accumulated_after, d.book_value_after, d.department_id, a.asset_number, a.asset_name, COALESCE(c.category_code, '') AS category_code, COALESCE(c.category_name, '') AS category_name, COALESCE(dp.dept_code, '') AS dept_code FROM td_asset_depreciation d JOIN td_asset a ON a.id = d.asset_id AND ABS(a.company_id) = d.company_id LEFT JOIN md_asset_category c ON c.company_id = d.company_id AND c.id = d.category_id LEFT JOIN md_department dp ON dp.company_id = d.company_id AND dp.id = d.department_id WHERE d.company_id = :cid AND d.run_id = :run_id ORDER BY a.asset_number ASC" ); $sth->execute([':cid' => $this->companyId, ':run_id' => $run_id]); $items = $sth->fetchAll(PDO::FETCH_ASSOC); // A void run's rows are gone; show what it held from the header only. return ['run' => $run, 'items' => $items]; } /** * Void a run: reverse its GL entry if posted and remove its charges. Only * the latest month of each asset can be removed, and not after a disposal. */ public function void(int $run_id, GlManager $gl): void { $sth = $this->pdo->prepare("SELECT * FROM td_asset_run WHERE company_id = :cid AND id = :id FOR UPDATE"); $sth->execute([':cid' => $this->companyId, ':id' => $run_id]); $run = $sth->fetch(PDO::FETCH_ASSOC); if (!$run) throw new Exception('Run not found.'); if ((int)$run['status'] !== 1) throw new Exception('This run is already void.'); $sth = $this->pdo->prepare( "SELECT a.asset_number FROM td_asset_depreciation d JOIN td_asset a ON a.company_id = d.company_id AND a.id = d.asset_id WHERE d.company_id = :cid AND d.run_id = :run_id AND a.status = 3 LIMIT 1" ); $sth->execute([':cid' => $this->companyId, ':run_id' => $run_id]); if ($number = $sth->fetchColumn()) { throw new Exception("Asset {$number} in this run has been disposed of. Undo the disposal first."); } $sth = $this->pdo->prepare( "SELECT later.period FROM td_asset_depreciation d JOIN td_asset_depreciation later ON later.company_id = d.company_id AND later.asset_id = d.asset_id AND later.period > d.period WHERE d.company_id = :cid AND d.run_id = :run_id ORDER BY later.period DESC LIMIT 1" ); $sth->execute([':cid' => $this->companyId, ':run_id' => $run_id]); if ($later = $sth->fetchColumn()) { throw new Exception("Assets in this run were charged again in {$later}. Void the later runs first."); } $gl->delete(self::sourceTypeFor($run['asset_class']), $run_id); $sth = $this->pdo->prepare("SELECT asset_id FROM td_asset_depreciation WHERE company_id = :cid AND run_id = :run_id"); $sth->execute([':cid' => $this->companyId, ':run_id' => $run_id]); $asset_ids = $sth->fetchAll(PDO::FETCH_COLUMN); $this->pdo->prepare("DELETE FROM td_asset_depreciation WHERE company_id = :cid AND run_id = :run_id") ->execute([':cid' => $this->companyId, ':run_id' => $run_id]); $this->pdo->prepare("UPDATE td_asset_run SET status = 4, voided_at = NOW() WHERE company_id = :cid AND id = :id") ->execute([':cid' => $this->companyId, ':id' => $run_id]); (new AssetManager($this->pdo, $this->companyId))->syncStatuses($asset_ids); } /** What is due through the current month, per class (for the dashboard). */ public function dueSummary(): array { $summary = []; foreach (array_keys(self::CLASS_META) as $asset_class) { $runs = $this->preview($asset_class, date('Y-m')); $summary[$asset_class] = [ 'label' => self::labelFor($asset_class), 'period_count' => count($runs), 'from' => $runs ? $runs[0]['period'] : '', 'to' => $runs ? $runs[count($runs) - 1]['period'] : '', 'total' => round(array_sum(array_column($runs, 'total')), 2), ]; } return $summary; } private function activeAssets(string $asset_class): array { $sth = $this->pdo->prepare( "SELECT a.id, a.asset_number, a.asset_name, a.category_id, a.department_id, a.cost, a.salvage_value, a.useful_life_months, a.in_service_date, COALESCE(c.category_name, '') AS category_name, COALESCE(d.accumulated, 0) AS accumulated, COALESCE(d.last_period, '') AS last_period FROM td_asset a LEFT JOIN md_asset_category c ON c.company_id = a.company_id AND c.id = a.category_id LEFT JOIN (SELECT asset_id, SUM(amount) AS accumulated, MAX(period) AS last_period FROM td_asset_depreciation WHERE company_id = :cid_d GROUP BY asset_id) d ON d.asset_id = a.id WHERE a.company_id = :cid AND a.asset_class = :class AND a.status = 1 ORDER BY a.asset_number ASC" ); $sth->execute([':cid' => $this->companyId, ':cid_d' => $this->companyId, ':class' => $asset_class]); return $sth->fetchAll(PDO::FETCH_ASSOC); } private function nextRunNumber(string $asset_class, string $period): string { $sth = $this->pdo->prepare( "SELECT COUNT(*) FROM td_asset_run WHERE company_id = :cid AND asset_class = :class AND period = :period" ); $sth->execute([':cid' => $this->companyId, ':class' => $asset_class, ':period' => $period]); $existing = (int)$sth->fetchColumn(); $number = self::meta($asset_class)['prefix'] . '-' . $period; return $existing > 0 ? $number . '-' . ($existing + 1) : $number; } private static function meta(string $asset_class): array { if (!isset(self::CLASS_META[$asset_class])) throw new Exception('Invalid asset class.'); return self::CLASS_META[$asset_class]; } }