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