Globinours/app/Controllers/AccountingController.php

513 lines
20 KiB
PHP

<?php
declare(strict_types=1);
final class AccountingController
{
private static function dir(): string
{
$d = dirname(__DIR__, 2) . '/data/accounting-documents';
if (!is_dir($d)) {
mkdir($d, 0700, true);
}
return $d;
}
private static function vendors(PDO $db): array
{
return $db
->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();
}
}