query( "SELECT DISTINCT dc.id,dc.name FROM directory_contacts dc JOIN directory_contact_roles r ON r.contact_id=dc.id WHERE dc.deleted_at IS NULL AND r.role IN ('cabinet','crematorium','fourriere','structure_veterinaire') ORDER BY dc.name COLLATE NOCASE", ) ->fetchAll(PDO::FETCH_ASSOC); } public static function index(): void { $db = DB::pdo(); $year = (int) ($_GET['year'] ?? 0); $where = ['ai.deleted_at IS NULL']; $params = []; if ($year) { $where[] = "strftime('%Y',ai.invoice_date)=:year"; $params[':year'] = (string) $year; } $s = $db->prepare( 'SELECT ai.*,dc.name vendor_name FROM accounting_invoices ai LEFT JOIN directory_contacts dc ON dc.id=ai.vendor_contact_id WHERE ' . implode(' AND ', $where) . ' ORDER BY date(ai.invoice_date) DESC,ai.id DESC', ); $s->execute($params); $rows = $s->fetchAll(PDO::FETCH_ASSOC); $years = $db ->query( "SELECT DISTINCT strftime('%Y',invoice_date) y FROM accounting_invoices WHERE deleted_at IS NULL ORDER BY y DESC", ) ->fetchAll(PDO::FETCH_COLUMN); render('accounting.php', [ 'title' => 'Comptabilité', 'invoices' => $rows, 'vendors' => self::vendors($db), 'years' => $years, 'year' => $year, ]); } public static function edit(): void { $id = (int) ($_GET['id'] ?? 0); $db = DB::pdo(); $s = $db->prepare('SELECT * FROM accounting_invoices WHERE id=? AND deleted_at IS NULL'); $s->execute([$id]); $invoice = $s->fetch(PDO::FETCH_ASSOC); if (!$invoice) { http_response_code(404); return; } render('accounting_edit.php', [ 'title' => 'Modifier la facture ' . $invoice['reference'], 'invoice' => $invoice, 'vendors' => self::vendors($db), ]); } public static function save(): void { $values = self::values(); $file = self::upload(); $db = DB::pdo(); $s = $db->prepare( 'INSERT INTO accounting_invoices(vendor_contact_id,reference,invoice_date,due_date,document_type,status,payment_method,paid_on,total_ht_cents,total_ttc_cents,amount_due_cents,notes,source,original_filename,stored_filename,mime_type,file_size,created_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)', ); $s->execute([ ...$values, 'Saisie manuelle', $file['original'] ?? null, $file['stored'] ?? null, $file['mime'] ?? null, $file['size'] ?? null, Auth::id(), ]); AuditService::log( 'accounting_invoice_created', '/accounting/save', 'Facture ajoutée', 'accounting_invoice', (int) $db->lastInsertId(), ['reference' => $values[1]], ); header('Location: /accounting?year=0'); } public static function update(): void { $id = (int) ($_POST['id'] ?? 0); $db = DB::pdo(); $q = $db->prepare('SELECT * FROM accounting_invoices WHERE id=? AND deleted_at IS NULL'); $q->execute([$id]); $old = $q->fetch(PDO::FETCH_ASSOC); if (!$old) { http_response_code(404); return; } $values = self::values(); $file = self::upload(); $sql = "UPDATE accounting_invoices SET vendor_contact_id=?,reference=?,invoice_date=?,due_date=?,document_type=?,status=?,payment_method=?,paid_on=?,total_ht_cents=?,total_ttc_cents=?,amount_due_cents=?,notes=?,updated_at=datetime('now')"; if ($file) { $sql .= ',original_filename=?,stored_filename=?,mime_type=?,file_size=?'; array_push($values, $file['original'], $file['stored'], $file['mime'], $file['size']); } $sql .= ' WHERE id=? AND deleted_at IS NULL'; $values[] = $id; $db->prepare($sql)->execute($values); if ($file && !empty($old['stored_filename'])) { @unlink(self::dir() . '/' . basename((string) $old['stored_filename'])); } AuditService::log( 'accounting_invoice_updated', '/accounting/update', 'Facture modifiée', 'accounting_invoice', $id, ); header('Location: /accounting?year=0'); } public static function delete(): void { $id = (int) ($_POST['id'] ?? 0); $db = DB::pdo(); $s = $db->prepare( "UPDATE accounting_invoices SET deleted_at=datetime('now'),deleted_by=?,updated_at=datetime('now') WHERE id=? AND deleted_at IS NULL", ); $s->execute([Auth::id(), $id]); if ($s->rowCount()) { AuditService::log( 'accounting_invoice_deleted', '/accounting/delete', 'Facture mise à la corbeille', 'accounting_invoice', $id, ); } header('Location: /accounting?year=0'); } public static function document(): void { $id = (int) ($_GET['id'] ?? 0); $s = DB::pdo()->prepare('SELECT * FROM accounting_invoices WHERE id=? AND deleted_at IS NULL'); $s->execute([$id]); $r = $s->fetch(PDO::FETCH_ASSOC); if (!$r || !$r['stored_filename']) { http_response_code(404); return; } $p = self::dir() . '/' . basename((string) $r['stored_filename']); if (!is_file($p)) { http_response_code(404); return; } header('Content-Type: ' . $r['mime_type']); header( 'Content-Disposition: inline; filename="' . str_replace('"', '', (string) $r['original_filename']) . '"', ); header('Content-Length: ' . filesize($p)); readfile($p); } private static function values(): array { $ref = trim((string) ($_POST['reference'] ?? '')); $date = (string) ($_POST['invoice_date'] ?? ''); $ttc = self::money($_POST['total_ttc'] ?? ''); if ($ref === '' || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) || $ttc === null) { http_response_code(400); exit(t('accounting.required_fields')); } $status = (string) ($_POST['status'] ?? 'to_pay'); $due = self::money($_POST['amount_due'] ?? ''); if ($due === null) { $due = $status === 'paid' ? 0 : $ttc; } return [ (int) ($_POST['vendor_contact_id'] ?? 0) ?: null, $ref, $date, $_POST['due_date'] ?? '' ?: null, (string) ($_POST['document_type'] ?? 'invoice'), $status, $_POST['payment_method'] ?? '' ?: null, $_POST['paid_on'] ?? '' ?: null, self::money($_POST['total_ht'] ?? ''), $ttc, $due, trim((string) ($_POST['notes'] ?? '')) ?: null, ]; } private static function money(mixed $v): ?int { $v = trim(str_replace([' ', '€', "\xc2\xa0"], ['', '', ''], (string) $v)); if ($v === '') { return null; } $v = str_replace(',', '.', $v); return is_numeric($v) ? (int) round((float) $v * 100) : null; } private static function upload(): array { if (empty($_FILES['document']['tmp_name'])) { return []; } $f = $_FILES['document']; if (($f['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK || ($f['size'] ?? 0) > 20 * 1024 * 1024) { throw new RuntimeException(t('accounting.document_invalid')); } $mime = (new finfo(FILEINFO_MIME_TYPE))->file($f['tmp_name']); $ext = ['application/pdf' => 'pdf', 'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp'][$mime] ?? null; if (!$ext) { throw new RuntimeException(t('accounting.document_format')); } $stored = bin2hex(random_bytes(20)) . '.' . $ext; if (!move_uploaded_file($f['tmp_name'], self::dir() . '/' . $stored)) { throw new RuntimeException(t('accounting.document_save_failed')); } return [ 'original' => basename((string) $f['name']), 'stored' => $stored, 'mime' => $mime, 'size' => (int) $f['size'], ]; } public static function dashboard(): void { $db = DB::pdo(); $year = (int) ($_GET['year'] ?? 0); $where = ['ai.deleted_at IS NULL']; $params = []; if ($year) { $where[] = "strftime('%Y',ai.invoice_date)=:year"; $params[':year'] = (string) $year; } $sql = 'SELECT ai.*,dc.name vendor_name FROM accounting_invoices ai LEFT JOIN directory_contacts dc ON dc.id=ai.vendor_contact_id WHERE ' . implode(' AND ', $where) . ' ORDER BY date(ai.invoice_date) DESC,ai.id DESC'; $s = $db->prepare($sql); $s->execute($params); $rows = $s->fetchAll(PDO::FETCH_ASSOC); [$rows, $filters] = self::filterRows($rows); $vendors = []; $annual = []; $statuses = []; $methods = []; $totals = ['billed' => 0, 'due' => 0, 'documents' => count($rows), 'overdue' => 0, 'to_pay' => 0]; foreach ($rows as $r) { $ttc = (int) $r['total_ttc_cents']; $due = (int) ($r['amount_due_cents'] ?? 0); $totals['billed'] += $ttc; $totals['due'] += $due; if ($due > 0) { $totals['to_pay']++; } if ($due > 0 && !empty($r['due_date']) && $r['due_date'] < date('Y-m-d')) { $totals['overdue']++; } $vendorId = (int) ($r['vendor_contact_id'] ?? 0); $vendorLabel = $r['vendor_name'] ?: 'Structure non renseignée'; $vendors[$vendorId] ??= [ 'id' => $vendorId, 'label' => $vendorLabel, 'billed' => 0, 'due' => 0, 'documents' => 0, 'paid_documents' => 0, 'animals' => 0, 'animal_cost' => 0, ]; $vendors[$vendorId]['billed'] += $ttc; $vendors[$vendorId]['due'] += $due; $vendors[$vendorId]['documents']++; if ($r['status'] === 'paid') { $vendors[$vendorId]['paid_documents']++; } $y = substr((string) $r['invoice_date'], 0, 4); $annual[$y] ??= ['year' => $y, 'billed' => 0, 'due' => 0, 'documents' => 0]; $annual[$y]['billed'] += $ttc; $annual[$y]['due'] += $due; $annual[$y]['documents']++; $statuses[$r['status']] = ($statuses[$r['status']] ?? 0) + 1; $method = $r['payment_method'] ?: 'unknown'; $methods[$method] = ($methods[$method] ?? 0) + 1; } $expenseWhere = $year ? " AND strftime('%Y',ae.occurred_on)=:expense_year" : ''; $expense = $db->prepare( "SELECT ae.clinic_contact_id,COUNT(DISTINCT ae.animal_id) animals,SUM(ae.total_cents) total FROM animal_expenses ae WHERE ae.clinic_contact_id IS NOT NULL $expenseWhere GROUP BY ae.clinic_contact_id", ); $expense->execute($year ? [':expense_year' => (string) $year] : []); foreach ($expense->fetchAll(PDO::FETCH_ASSOC) as $e) { if (isset($vendors[(int) $e['clinic_contact_id']])) { $vendors[(int) $e['clinic_contact_id']]['animals'] = (int) $e['animals']; $vendors[(int) $e['clinic_contact_id']]['animal_cost'] = (int) $e['total']; } } usort($vendors, fn($a, $b) => $b['billed'] <=> $a['billed']); ksort($annual); $years = $db ->query( "SELECT DISTINCT strftime('%Y',invoice_date) y FROM accounting_invoices WHERE deleted_at IS NULL ORDER BY y DESC", ) ->fetchAll(PDO::FETCH_COLUMN); render('accounting.php', [ 'title' => 'Comptabilité', 'invoices' => $rows, 'vendors' => self::vendors($db), 'years' => $years, 'year' => $year, 'accountingFilters' => $filters, 'accountingTotals' => $totals, 'vendorStats' => $vendors, 'annualStats' => array_values($annual), 'statusStats' => $statuses, 'methodStats' => $methods, ]); } public static function exportCsv(): void { self::export(false); } private static function export(bool $unused): void { $db = DB::pdo(); $year = (int) ($_GET['year'] ?? 0); $where = ['ai.deleted_at IS NULL']; $params = []; if ($year) { $where[] = "strftime('%Y',ai.invoice_date)=:year"; $params[':year'] = (string) $year; } $s = $db->prepare( 'SELECT ai.*,dc.name vendor_name FROM accounting_invoices ai LEFT JOIN directory_contacts dc ON dc.id=ai.vendor_contact_id WHERE ' . implode(' AND ', $where) . ' ORDER BY date(ai.invoice_date),ai.id', ); $s->execute($params); header('Content-Type: text/csv; charset=UTF-8'); header( 'Content-Disposition: attachment; filename="globinours-comptabilite-' . ($year ?: 'toutes-annees') . '.csv"', ); echo "\xEF\xBB\xBF"; $out = fopen('php://output', 'wb'); fputcsv( $out, [ 'Date', 'Référence', 'Type', 'Structure', 'HT €', 'TTC €', 'Statut', 'Reste dû €', 'Règlement', 'Échéance', 'Notes', ], ';', ); $safe = static function ($v) { $v = (string) $v; return preg_match('/^[=+\-@]/', $v) ? "'" . $v : $v; }; foreach ($s->fetchAll(PDO::FETCH_ASSOC) as $r) { fputcsv( $out, [ $r['invoice_date'], $safe($r['reference']), $r['document_type'], $safe($r['vendor_name'] ?? ''), $r['total_ht_cents'] === null ? '' : number_format($r['total_ht_cents'] / 100, 2, '.', ''), number_format($r['total_ttc_cents'] / 100, 2, '.', ''), $r['status'], number_format(($r['amount_due_cents'] ?? 0) / 100, 2, '.', ''), $r['payment_method'], $r['due_date'], $safe($r['notes'] ?? ''), ], ';', ); } fclose($out); } private static function filterRows(array $rows): array { $filters = [ 'q' => trim((string) ($_GET['q'] ?? '')), 'vendor_id' => (int) ($_GET['vendor_id'] ?? 0), 'status' => (string) ($_GET['status'] ?? ''), 'type' => (string) ($_GET['type'] ?? ''), 'payment_method' => (string) ($_GET['payment_method'] ?? ''), ]; $rows = array_values( array_filter($rows, static function ($r) use ($filters) { if ( $filters['q'] !== '' && !str_contains( mb_strtolower( implode(' ', [$r['reference'] ?? '', $r['vendor_name'] ?? '', $r['notes'] ?? '']), ), mb_strtolower($filters['q']), ) ) { return false; } if ($filters['vendor_id'] > 0 && (int) $r['vendor_contact_id'] !== $filters['vendor_id']) { return false; } if ($filters['status'] !== '' && $r['status'] !== $filters['status']) { return false; } if ($filters['type'] !== '' && $r['document_type'] !== $filters['type']) { return false; } if ($filters['payment_method'] === 'unknown' && !empty($r['payment_method'])) { return false; } if ( $filters['payment_method'] !== '' && $filters['payment_method'] !== 'unknown' && $r['payment_method'] !== $filters['payment_method'] ) { return false; } return true; }), ); return [$rows, $filters]; } public static function exportFilteredCsv(): void { $db = DB::pdo(); $year = (int) ($_GET['year'] ?? 0); $s = $db->prepare( 'SELECT ai.*,dc.name vendor_name FROM accounting_invoices ai LEFT JOIN directory_contacts dc ON dc.id=ai.vendor_contact_id WHERE ai.deleted_at IS NULL' . ($year ? " AND strftime('%Y',ai.invoice_date)=:year" : '') . ' ORDER BY date(ai.invoice_date),ai.id', ); $s->execute($year ? [':year' => (string) $year] : []); [$rows] = self::filterRows($s->fetchAll(PDO::FETCH_ASSOC)); header('Content-Type: text/csv; charset=UTF-8'); header( 'Content-Disposition: attachment; filename="globinours-comptabilite-' . ($year ?: 'toutes-annees') . '.csv"', ); echo "\xEF\xBB\xBF"; $out = fopen('php://output', 'wb'); fputcsv( $out, [ 'Date', 'Référence', 'Type', 'Structure', 'HT €', 'TTC €', 'Statut', 'Reste dû €', 'Règlement', 'Échéance', 'Notes', ], ';', ); $safe = static fn($v) => preg_match('/^[=+\-@]/', (string) $v) ? "'" . $v : (string) $v; foreach ($rows as $r) { fputcsv( $out, [ $r['invoice_date'], $safe($r['reference']), $r['document_type'], $safe($r['vendor_name'] ?? ''), $r['total_ht_cents'] === null ? '' : number_format($r['total_ht_cents'] / 100, 2, '.', ''), number_format($r['total_ttc_cents'] / 100, 2, '.', ''), $r['status'], number_format(($r['amount_due_cents'] ?? 0) / 100, 2, '.', ''), $r['payment_method'], $r['due_date'], $safe($r['notes'] ?? ''), ], ';', ); } fclose($out); } public static function editByReference(): void { $ref = trim((string) ($_GET['reference'] ?? '')); $s = DB::pdo()->prepare( 'SELECT id FROM accounting_invoices WHERE reference=? AND deleted_at IS NULL ORDER BY id DESC LIMIT 1', ); $s->execute([$ref]); $_GET['id'] = (int) $s->fetchColumn(); self::edit(); } }