513 lines
20 KiB
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();
|
|
}
|
|
}
|