pdo = $pdo; $this->companyId = $company_id; $this->postingWindowGuard = $postingWindowGuard; } // ── public ─────────────────────────────────────────────────────────────── public function getBySource(string $source_type, int $source_id): ?array { $sth = $this->pdo->prepare( "SELECT * FROM td_gl WHERE company_id = :cid AND source_type = :source_type AND source_id = :source_id LIMIT 1" ); $sth->execute([ ':cid' => $this->companyId, ':source_type' => $source_type, ':source_id' => $source_id, ]); return $sth->fetch(PDO::FETCH_ASSOC) ?: null; } public function post(string $source_type, int $source_id, int $formula_id, string $period, array $lines, array $meta = []): void { $journal_date = $this->resolveJournalDate($meta['journal_date'] ?? null, $period); $this->assertPostingWindow($journal_date, 'GL posting'); $now = date('Y-m-d H:i:s'); $sth = $this->pdo->prepare( "INSERT INTO td_gl (company_id, source_type, source_id, reference, description, journal_date, formula_id, period, current_version, history, created_at, updated_at) VALUES (:cid, :source_type, :source_id, :reference, :description, :journal_date, :formula_id, :period, 1, '[]', :created_at, :updated_at)" ); $sth->execute([ ':cid' => $this->companyId, ':source_type' => $source_type, ':source_id' => $source_id, ':reference' => $meta['reference'] ?? '', ':description' => $meta['description'] ?? '', ':journal_date' => $journal_date, ':formula_id' => $formula_id, ':period' => $period, ':created_at' => $now, ':updated_at' => $now, ]); $this->insertLines((int)$this->pdo->lastInsertId(), $lines); $this->upsertEtl($lines, $period); } public function replaceManual(int $gl_id, string $reference, string $description, string $journal_date, string $period, array $lines): void { $this->assertPostingWindow($journal_date, 'Manual journal'); $now = date('Y-m-d H:i:s'); $sth = $this->pdo->prepare( "SELECT id, current_version, formula_id, history FROM td_gl WHERE company_id = :cid AND id = :gl_id AND source_type = 'manual' FOR UPDATE" ); $sth->execute([':cid' => $this->companyId, ':gl_id' => $gl_id]); $gl = $sth->fetch(PDO::FETCH_ASSOC); if (!$gl) throw new Exception("Manual GL entry #$gl_id not found."); $version = (int)$gl['current_version']; $history = json_decode($gl['history'], true) ?: []; $history[] = [ 'version' => $version, 'formula_id' => 0, 'replaced_at' => $now, 'lines' => $this->getLines($gl_id), ]; $old_lines = $history[count($history) - 1]['lines']; // captured just above $old_period = $gl['period']; $this->pdo->prepare( "DELETE FROM td_gl_item WHERE company_id = :cid AND gl_id = :gl_id" )->execute([':cid' => $this->companyId, ':gl_id' => $gl_id]); $this->pdo->prepare( "UPDATE td_gl SET reference = :reference, description = :description, journal_date = :journal_date, period = :period, current_version = :current_version, history = :history, updated_at = :updated_at WHERE id = :id" )->execute([ ':reference' => $reference, ':description' => $description, ':journal_date' => $journal_date, ':period' => $period, ':current_version' => $version + 1, ':history' => json_encode($history), ':updated_at' => $now, ':id' => $gl_id, ]); $this->insertLines($gl_id, $lines); $this->upsertEtl($old_lines, $old_period, -1); $this->upsertEtl($lines, $period); } public function postManual(string $reference, string $description, string $journal_date, string $period, array $lines): int { $this->assertPostingWindow($journal_date, 'Manual journal'); $now = date('Y-m-d H:i:s'); $sth = $this->pdo->prepare( "INSERT INTO td_gl (company_id, source_type, source_id, reference, description, journal_date, formula_id, period, current_version, history, created_at, updated_at) VALUES (:cid, 'manual', 0, :reference, :description, :journal_date, 0, :period, 1, '[]', :created_at, :updated_at)" ); $sth->execute([ ':cid' => $this->companyId, ':reference' => $reference, ':description' => $description, ':journal_date' => $journal_date, ':period' => $period, ':created_at' => $now, ':updated_at' => $now, ]); $gl_id = (int)$this->pdo->lastInsertId(); $this->insertLines($gl_id, $lines); $this->upsertEtl($lines, $period); return $gl_id; } public function replace(string $source_type, int $source_id, int $formula_id, string $period, array $lines, array $meta = []): void { $now = date('Y-m-d H:i:s'); $sth = $this->pdo->prepare( "SELECT id, current_version, formula_id, history, journal_date FROM td_gl WHERE company_id = :cid AND source_type = :source_type AND source_id = :source_id FOR UPDATE" ); $sth->execute([ ':cid' => $this->companyId, ':source_type' => $source_type, ':source_id' => $source_id, ]); $gl = $sth->fetch(PDO::FETCH_ASSOC); if (!$gl) throw new Exception("GL record not found for replace."); $journal_date = $this->resolveJournalDate($meta['journal_date'] ?? ($gl['journal_date'] ?? null), $period); $this->assertPostingWindow($journal_date, 'GL posting'); $gl_id = (int)$gl['id']; $version = (int)$gl['current_version']; $history = json_decode($gl['history'], true) ?: []; // Snapshot current lines into history $old_lines = $this->getLines($gl_id); $old_period = $gl['period']; $history[] = [ 'version' => $version, 'formula_id' => (int)$gl['formula_id'], 'replaced_at' => $now, 'lines' => $old_lines, ]; $this->pdo->prepare( "DELETE FROM td_gl_item WHERE company_id = :cid AND gl_id = :gl_id" )->execute([':cid' => $this->companyId, ':gl_id' => $gl_id]); $this->pdo->prepare( "UPDATE td_gl SET formula_id = :formula_id, journal_date = :journal_date, period = :period, current_version = :current_version, history = :history, updated_at = :updated_at WHERE id = :id" )->execute([ ':formula_id' => $formula_id, ':journal_date' => $journal_date, ':period' => $period, ':current_version' => $version + 1, ':history' => json_encode($history), ':updated_at' => $now, ':id' => $gl_id, ]); $this->insertLines($gl_id, $lines); $this->upsertEtl($old_lines, $old_period, -1); $this->upsertEtl($lines, $period); } public function delete(string $source_type, int $source_id): void { $sth = $this->pdo->prepare( "SELECT id, reference, description, journal_date, period, formula_id FROM td_gl WHERE company_id = :cid AND source_type = :source_type AND source_id = :source_id LIMIT 1" ); $sth->execute([ ':cid' => $this->companyId, ':source_type' => $source_type, ':source_id' => $source_id, ]); $gl = $sth->fetch(PDO::FETCH_ASSOC); $gl_id = (int)($gl['id'] ?? 0); if (!$gl_id) return; $journal_date = $this->resolveJournalDate($gl['journal_date'] ?? null, (string)($gl['period'] ?? '')); $this->assertPostingWindow($journal_date, 'GL void'); $original_lines = $this->getLines($gl_id); if (!empty($original_lines)) { $reversal_lines = array_map(fn($line) => [ 'account_code' => $line['account_code'], 'department_id' => (int)$line['department_id'], 'debit' => (float)$line['credit'], 'credit' => (float)$line['debit'], 'description' => 'VOID: ' . $line['description'], ], $original_lines); $now = date('Y-m-d H:i:s'); $this->pdo->prepare( "INSERT INTO td_gl (company_id, source_type, source_id, reference, description, journal_date, formula_id, period, current_version, history, created_at, updated_at) VALUES (:cid, 'reversal', :source_id, :reference, :description, :journal_date, :formula_id, :period, 1, '[]', :created_at, :updated_at)" )->execute([ ':cid' => $this->companyId, ':source_id' => $gl_id, ':reference' => 'VOID/' . ($gl['reference'] ?? ''), ':description' => 'Void: ' . ($gl['description'] ?? ''), ':journal_date' => $journal_date, ':formula_id' => (int)($gl['formula_id'] ?? 0), ':period' => $gl['period'] ?? '', ':created_at' => $now, ':updated_at' => $now, ]); $reversal_gl_id = (int)$this->pdo->lastInsertId(); $this->insertLines($reversal_gl_id, $reversal_lines); $this->upsertEtl($reversal_lines, $gl['period']); } // Mark original as voided — keeps audit trail; getBySource() won't match 'voided' source_type $this->pdo->prepare( "UPDATE td_gl SET source_type = 'voided', updated_at = NOW() WHERE id = :id AND company_id = :cid" )->execute([':id' => $gl_id, ':cid' => $this->companyId]); } // ── private ────────────────────────────────────────────────────────────── private function getLines(int $gl_id): array { $sth = $this->pdo->prepare( "SELECT account_code, department_id, debit, credit, description FROM td_gl_item WHERE company_id = :cid AND gl_id = :gl_id ORDER BY id ASC" ); $sth->execute([':cid' => $this->companyId, ':gl_id' => $gl_id]); return $sth->fetchAll(PDO::FETCH_ASSOC); } private function assertPostingWindow(?string $journal_date, string $context): void { if ($this->postingWindowGuard) { $this->postingWindowGuard->assertOpenDate($journal_date, $context); } } private function resolveJournalDate(?string $journal_date, string $period): string { $journal_date = trim((string)$journal_date); if ($journal_date !== '') { return $journal_date; } $period = trim($period); return preg_match('/^\d{4}-\d{2}$/', $period) ? "{$period}-01" : ''; } private function insertLines(int $gl_id, array $lines): void { $sth = $this->pdo->prepare( "INSERT INTO td_gl_item (company_id, gl_id, account_code, department_id, debit, credit, description) VALUES (:cid, :gl_id, :account_code, :dept_id, :debit, :credit, :description)" ); foreach ($lines as $line) { $sth->execute([ ':cid' => $this->companyId, ':gl_id' => $gl_id, ':account_code' => $line['account_code'], ':dept_id' => (int)($line['department_id'] ?? 0), ':debit' => (float)($line['debit'] ?? 0), ':credit' => (float)($line['credit'] ?? 0), ':description' => (string)($line['description'] ?? ''), ]); } } // sign=1 to add, sign=-1 to subtract (used when replacing old lines) private function upsertEtl(array $lines, string $period, int $sign = 1): void { if (empty($lines) || empty($period)) return; $sth = $this->pdo->prepare( "INSERT INTO etl_gl_summary (company_id, acc_code, period, debit, credit, count, updated_at) VALUES (:cid, :acc, :period, :debit, :credit, :cnt, NOW()) ON DUPLICATE KEY UPDATE debit = debit + VALUES(debit), credit = credit + VALUES(credit), count = count + VALUES(count), updated_at = NOW()" ); foreach ($lines as $line) { $sth->execute([ ':cid' => $this->companyId, ':acc' => (int)($line['account_code'] ?? 0), ':period' => $period, ':debit' => (float)($line['debit'] ?? 0) * $sign, ':credit' => (float)($line['credit'] ?? 0) * $sign, ':cnt' => $sign, ]); } } }