From 86642095cd6d9bb34b5b8d6a27e5f0cd967ce668 Mon Sep 17 00:00:00 2001 From: oversu <95a7002b-481b-4ef3-b578-f9c421550aa4@localhost> Date: Fri, 2 Oct 2026 12:11:30 +0200 Subject: [PATCH] =?UTF-8?q?Ajouter=20les=20statistiques=20par=20p=C3=A9rio?= =?UTF-8?q?de?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 10 ++ HISTORIQUE.md | 4 + README.md | 4 +- VERSION | 2 +- app/Controllers/DirectoryController.php | 39 ++++- app/Controllers/StatisticsController.php | 74 +++++++- app/Services/StatisticsService.php | 205 +++++++++++++---------- app/Views/directory_show.php | 4 +- app/Views/evolutions.php | 8 +- app/Views/statistics.php | 49 ++++-- docs/07-statistiques-et-administratif.md | 4 +- resources/lang/en.php | 9 + resources/lang/fr.php | 9 + tests/run.php | 21 +++ 14 files changed, 316 insertions(+), 126 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb39f70..22fb83d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ Toutes les modifications importantes du projet sont consignées ici. Le format s’inspire de [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le projet suit le [versionnage sémantique](https://semver.org/lang/fr/). +## [1.0.0-rc.32] — 2026-10-02 + +### Statistiques par mois et par période + +- ajout des modes « Année », « Mois » et « Période personnalisée » en complément de la vue globale ; +- application de la période aux entrées, sorties, adoptions, décès, naissances, vaccinations, traitements et notes médicales ; +- filtrage cohérent des coûts vétérinaires, factures, structures et communes sur les mêmes dates ; +- comparaison automatique avec la période précédente de même durée, tout en conservant la comparaison année contre année ; +- conservation de la période lors de la navigation vers une commune ou une structure vétérinaire. + ## [1.0.0-rc.31] — 2026-10-01 ### Navigation et synchronisation des dossiers animaux diff --git a/HISTORIQUE.md b/HISTORIQUE.md index 2b6f8d3..5b78c84 100644 --- a/HISTORIQUE.md +++ b/HISTORIQUE.md @@ -10,6 +10,10 @@ L’import depuis Animal Shelter Manager a été conçu comme un parcours pruden Avant la version 1.0, le projet a fait l’objet d’un nettoyage structurel et d’un découpage de ses fichiers les plus volumineux. La recherche universelle, les salles configurables, les traductions française et anglaise et une suite de contrôles isolés ont complété cette phase de stabilisation. +## `1.0.0-rc.32` — Statistiques à la période souhaitée + +Les bilans ne sont plus limités à une année complète. Une association peut désormais analyser un mois précis ou choisir librement deux dates ; les volumes d’activité, profils, coûts vétérinaires et comparaisons utilisent tous le même intervalle. + ## `1.0.0-rc.31` — Parcours de correction plus fiables Les liens entre animaux et adoptants conduisent désormais directement à la bonne fiche d’annuaire. La modification des vaccins devient explicite et la synchronisation i-CAD ne dépend plus entièrement du JavaScript : un formulaire serveur assure le rafraîchissement même lorsque l’interface dynamique rencontre un problème. diff --git a/README.md b/README.md index fffd27a..ef3f491 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ L’application est développée à partir de besoins rencontrés sur le terrain - placements, adoptions, retours, décès, archives et corbeille ; - agenda partagé, rappels médicaux et abonnement iCalendar privé ; - pharmacie, lots, péremptions et mouvements de stock ; -- factures vétérinaires, règlements, justificatifs et statistiques de coûts ; +- factures vétérinaires, règlements, justificatifs et statistiques de coûts par année, mois ou période libre ; - registre des entrées et sorties, exports CSV et dossiers de subvention ; - recherche globale avec contrôle des résultats selon le rôle ; - site public facultatif pour les animaux proposés à l’adoption ; @@ -200,7 +200,7 @@ L’option `--apply` ne doit être utilisée qu’après lecture des rapports. L ## État du projet -La candidate actuelle est `1.0.0-rc.31`. La branche 1.0 est gelée fonctionnellement : seuls les défauts bloquants découverts pendant les validations terrain sont corrigés avant `1.0.0`. +La candidate actuelle est `1.0.0-rc.32`. La branche 1.0 est gelée fonctionnellement : seuls les défauts bloquants découverts pendant les validations terrain sont corrigés avant `1.0.0`. Le contrôle de stabilisation est disponible dans [docs/AUDIT-1.0.md](docs/AUDIT-1.0.md). diff --git a/VERSION b/VERSION index 99cb14c..8768c04 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.0-rc.31 +1.0.0-rc.32 diff --git a/app/Controllers/DirectoryController.php b/app/Controllers/DirectoryController.php index 4f96200..1c101c7 100644 --- a/app/Controllers/DirectoryController.php +++ b/app/Controllers/DirectoryController.php @@ -95,17 +95,39 @@ final class DirectoryController 'SELECT id,name,internal_code,species,status,intake_date event_date FROM animals WHERE depositor_contact_id=:id ORDER BY date(intake_date) DESC,name COLLATE NOCASE', ); $deposited->execute([':id' => $id]); - $periodRequested = array_key_exists('year', $_GET); + $periodRequested = array_key_exists('year', $_GET) || array_key_exists('period_type', $_GET); $periodRaw = (string) ($_GET['year'] ?? 'all'); $periodYear = ctype_digit($periodRaw) && (int) $periodRaw >= 2000 && (int) $periodRaw <= 2100 ? (int) $periodRaw : null; + $periodType = (string) ($_GET['period_type'] ?? ($periodYear ? 'year' : 'all')); + $periodStart = $periodEnd = null; + $periodQuery = ['period_type' => $periodType]; + if ($periodType === 'year' && $periodYear !== null) { + $periodStart = sprintf('%04d-01-01', $periodYear); + $periodEnd = sprintf('%04d-12-31', $periodYear); + $periodQuery['year'] = $periodYear; + } elseif ($periodType === 'month' && preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', (string) ($_GET['month'] ?? ''))) { + $periodQuery['month'] = (string) $_GET['month']; + $periodStart = $periodQuery['month'] . '-01'; + $periodEnd = (new DateTimeImmutable($periodStart))->modify('last day of this month')->format('Y-m-d'); + } elseif ($periodType === 'custom') { + $from = (string) ($_GET['date_from'] ?? ''); + $to = (string) ($_GET['date_to'] ?? ''); + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $from) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $to)) { + [$periodStart, $periodEnd] = $from <= $to ? [$from, $to] : [$to, $from]; + $periodQuery += ['date_from' => $periodStart,'date_to' => $periodEnd]; + } + } + $periodLabel = $periodStart && $periodEnd + ? (new DateTimeImmutable($periodStart))->format('d/m/Y') . ' – ' . (new DateTimeImmutable($periodEnd))->format('d/m/Y') + : t('statistics.all_years'); $periodMunicipality = trim((string) ($_GET['municipality'] ?? '')); $expenseWhere = ['ae.clinic_contact_id=:id', 'a.deleted_at IS NULL']; $expenseParams = [':id' => $id]; - if ($periodYear !== null) { + if ($periodStart !== null && $periodEnd !== null) { $expenseWhere[] = 'date(ae.occurred_on) BETWEEN :expense_start AND :expense_end'; - $expenseParams[':expense_start'] = sprintf('%04d-01-01', $periodYear); - $expenseParams[':expense_end'] = sprintf('%04d-12-31', $periodYear); + $expenseParams[':expense_start'] = $periodStart; + $expenseParams[':expense_end'] = $periodEnd; } $expenses = $db->prepare( 'SELECT ae.*,a.name animal_name,a.internal_code,a.rescue_location_name,a.rescue_address FROM animal_expenses ae JOIN animals a ON a.id=ae.animal_id WHERE ' . @@ -129,9 +151,10 @@ final class DirectoryController $expenseRows = array_slice($expenseRows, 0, 250); $invoiceWhere = ['vendor_contact_id=:vendor', 'deleted_at IS NULL']; $invoiceParams = [':vendor' => $id]; - if ($periodYear !== null) { - $invoiceWhere[] = "strftime('%Y',invoice_date)=:invoice_year"; - $invoiceParams[':invoice_year'] = (string) $periodYear; + if ($periodStart !== null && $periodEnd !== null) { + $invoiceWhere[] = 'date(invoice_date) BETWEEN :invoice_start AND :invoice_end'; + $invoiceParams[':invoice_start'] = $periodStart; + $invoiceParams[':invoice_end'] = $periodEnd; } $invoiceStmt = $db->prepare( 'SELECT * FROM accounting_invoices WHERE ' . @@ -162,6 +185,8 @@ final class DirectoryController 'expenseCount' => $expenseCount, 'expensePeriodRequested' => $periodRequested, 'expensePeriodYear' => $periodYear, + 'expensePeriodLabel' => $periodLabel, + 'expensePeriodQuery' => $periodQuery, 'expensePeriodMunicipality' => $periodMunicipality, 'contactInvoices' => $contactInvoices, 'contactInvoiceTotal' => $contactInvoiceTotal, diff --git a/app/Controllers/StatisticsController.php b/app/Controllers/StatisticsController.php index 9d1e906..e1f067d 100644 --- a/app/Controllers/StatisticsController.php +++ b/app/Controllers/StatisticsController.php @@ -6,37 +6,95 @@ final class StatisticsController { public static function index(): void { - $requested = (string) ($_GET['year'] ?? date('Y')); - $all = $requested === 'all'; - $year = $all ? null : max(2000, min(2200, (int) $requested)); $years = StatisticsService::years(); + $requestedYear = (string) ($_GET['year'] ?? date('Y')); + $periodType = (string) ($_GET['period_type'] ?? ($requestedYear === 'all' ? 'all' : 'year')); + if (!in_array($periodType, ['all','year','month','custom'], true)) { + $periodType = 'year'; + } + $year = max(2000, min(2200, (int) ($requestedYear === 'all' ? date('Y') : $requestedYear))); + $month = preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', (string) ($_GET['month'] ?? '')) + ? (string) $_GET['month'] + : date('Y-m'); + $validDate = static function (string $value): bool { + $date = DateTimeImmutable::createFromFormat('!Y-m-d', $value); + return $date !== false && $date->format('Y-m-d') === $value; + }; + $dateFrom = $validDate((string) ($_GET['date_from'] ?? '')) ? (string) $_GET['date_from'] : date('Y-01-01'); + $dateTo = $validDate((string) ($_GET['date_to'] ?? '')) ? (string) $_GET['date_to'] : date('Y-m-d'); + if ($dateFrom > $dateTo) { + [$dateFrom, $dateTo] = [$dateTo, $dateFrom]; + } + $start = $end = null; + if ($periodType === 'year') { + $start = sprintf('%04d-01-01', $year); + $end = sprintf('%04d-12-31', $year); + } elseif ($periodType === 'month') { + $start = $month . '-01'; + $end = (new DateTimeImmutable($start))->modify('last day of this month')->format('Y-m-d'); + $year = (int) substr($month, 0, 4); + } elseif ($periodType === 'custom') { + $start = $dateFrom; + $end = $dateTo; + $year = (int) substr($dateFrom, 0, 4); + } + $all = $periodType === 'all'; + $formatDate = static fn(string $date): string => (new DateTimeImmutable($date))->format('d/m/Y'); + $periodLabel = match ($periodType) { + 'all' => t('statistics.all_years'), + 'month' => substr($month, 5, 2) . '/' . substr($month, 0, 4), + 'custom' => $formatDate($dateFrom) . ' – ' . $formatDate($dateTo), + default => (string) $year, + }; + $periodQuery = ['period_type' => $periodType]; + if ($periodType === 'year') $periodQuery['year'] = $year; + if ($periodType === 'month') $periodQuery['month'] = $month; + if ($periodType === 'custom') $periodQuery += ['date_from' => $dateFrom,'date_to' => $dateTo]; $municipalities = StatisticsService::municipalities(); $municipality = trim((string) ($_GET['municipality'] ?? '')); if ($municipality !== '' && !isset($municipalities[$municipality])) { $municipality = ''; } $ids = $municipality !== '' ? StatisticsService::animalIdsForMunicipality($municipality) : null; + if ($all) { + $comparison = []; + $previousPeriodLabel = ''; + } elseif ($periodType === 'year') { + $comparison = StatisticsService::comparison($year, $ids); + $previousPeriodLabel = (string) ($year - 1); + } else { + $comparisonResult = StatisticsService::comparisonPeriod($start, $end, $ids); + $comparison = $comparisonResult['rows']; + $previousPeriodLabel = $formatDate($comparisonResult['previous_start']) . ' – ' . $formatDate($comparisonResult['previous_end']); + } render('statistics.php', [ 'title' => t('statistics.title'), 'pageDescription' => t('statistics.description'), 'year' => $year, 'allYears' => $all, + 'periodType' => $periodType, + 'month' => $month, + 'dateFrom' => $dateFrom, + 'dateTo' => $dateTo, + 'periodLabel' => $periodLabel, + 'previousPeriodLabel' => $previousPeriodLabel, + 'periodQuery' => $periodQuery, 'years' => $years, 'municipality' => $municipality, 'municipalities' => $municipalities, 'municipalityCoverage' => StatisticsService::municipalityCoverage(), - 'stats' => $all ? StatisticsService::all($ids) : StatisticsService::annual($year, $ids), - 'comparison' => $all ? [] : StatisticsService::comparison($year, $ids), + 'stats' => $all ? StatisticsService::all($ids) : StatisticsService::period($start, $end, $ids), + 'comparison' => $comparison, 'timeline' => StatisticsService::timeline($years, $ids), 'undatedHandled' => StatisticsService::undatedHandled($ids), 'undatedDeaths' => StatisticsService::undatedDeaths($ids), 'undatedBirths' => StatisticsService::undatedBirths($ids), 'undatedExits' => StatisticsService::undatedExits($ids), 'scopeCounts' => StatisticsService::scopeCounts($ids), - 'distributions' => StatisticsService::distributions($year, $ids, $municipality ?: null), + 'distributions' => StatisticsService::distributions($all ? null : $year, $ids, $municipality ?: null, $start, $end), 'health' => StatisticsService::currentHealth($ids), - 'costs' => StatisticsService::costs($year, $ids), - 'municipalityCosts' => StatisticsService::costsByMunicipality($year), + 'costs' => StatisticsService::costs($all ? null : $year, $ids, $start, $end), + 'municipalityCosts' => StatisticsService::costsByMunicipality($all ? null : $year, $start, $end), ]); } } diff --git a/app/Services/StatisticsService.php b/app/Services/StatisticsService.php index 724081d..7975fe8 100644 --- a/app/Services/StatisticsService.php +++ b/app/Services/StatisticsService.php @@ -4,10 +4,14 @@ declare(strict_types=1); final class StatisticsService { - public static function costs(?int $year, ?array $ids = null): array + public static function costs(?int $year, ?array $ids = null, ?string $start = null, ?string $end = null): array { $db = DB::pdo(); - $scopeIds = self::costScopeIds($year, $ids); + if ($start === null && $year !== null) { + $start = sprintf('%04d-01-01', $year); + $end = sprintf('%04d-12-31', $year); + } + $scopeIds = self::costScopeIds($year, $ids, $start, $end); $empty = [ 'total_cents' => 0, 'animals' => 0, @@ -23,9 +27,9 @@ final class StatisticsService } $where = ['a.deleted_at IS NULL', 'ae.animal_id IN(' . implode(',', array_map('intval', $scopeIds)) . ')']; $params = []; - if ($year !== null) { + if ($start !== null && $end !== null) { $where[] = 'date(ae.occurred_on) BETWEEN :start AND :end'; - $params = [':start' => sprintf('%04d-01-01', $year), ':end' => sprintf('%04d-12-31', $year)]; + $params = [':start' => $start, ':end' => $end]; } $base = ' FROM animal_expenses ae JOIN animals a ON a.id=ae.animal_id LEFT JOIN directory_contacts dc ON dc.id=ae.clinic_contact_id LEFT JOIN clinic_tariffs ct ON ct.id=ae.tariff_id WHERE ' . @@ -63,10 +67,14 @@ final class StatisticsService ]; } - public static function costsByMunicipality(?int $year): array + public static function costsByMunicipality(?int $year, ?string $start = null, ?string $end = null): array { $db = DB::pdo(); - $scopeIds = self::costScopeIds($year, null); + if ($start === null && $year !== null) { + $start = sprintf('%04d-01-01', $year); + $end = sprintf('%04d-12-31', $year); + } + $scopeIds = self::costScopeIds($year, null, $start, $end); if (!$scopeIds) { return []; } @@ -92,9 +100,9 @@ final class StatisticsService } $params = []; $dateSql = ''; - if ($year !== null) { + if ($start !== null && $end !== null) { $dateSql = ' AND date(occurred_on) BETWEEN :start AND :end'; - $params = [':start' => sprintf('%04d-01-01', $year), ':end' => sprintf('%04d-12-31', $year)]; + $params = [':start' => $start, ':end' => $end]; } $s = $db->prepare( 'SELECT animal_id,SUM(total_cents) total_cents FROM animal_expenses WHERE animal_id IN(' . @@ -133,22 +141,24 @@ final class StatisticsService return $out; } - private static function costScopeIds(?int $year, ?array $ids): array + private static function costScopeIds(?int $year, ?array $ids, ?string $start = null, ?string $end = null): array { $db = DB::pdo(); $limit = $ids === null ? '' : self::idSql($ids, 'a.id'); - if ($year === null) { - $sql = 'SELECT a.id FROM animals a WHERE a.deleted_at IS NULL' . $limit; - } else { + if ($start === null && $year !== null) { $start = sprintf('%04d-01-01', $year); $end = sprintf('%04d-12-31', $year); + } + if ($start === null || $end === null) { + $sql = 'SELECT a.id FROM animals a WHERE a.deleted_at IS NULL' . $limit; + } else { $sql = 'SELECT a.id FROM animals a WHERE a.deleted_at IS NULL' . $limit . " AND (date(a.intake_date) BETWEEN :start AND :end OR EXISTS(SELECT 1 FROM animal_movements m WHERE m.animal_id=a.id AND m.kind='entry' AND date(m.created_at) BETWEEN :start AND :end) OR EXISTS(SELECT 1 FROM animal_expenses ae WHERE ae.animal_id=a.id AND date(ae.occurred_on) BETWEEN :start AND :end))"; } $s = $db->prepare($sql); - $s->execute($year === null ? [] : [':start' => $start, ':end' => $end]); + $s->execute($start === null || $end === null ? [] : [':start' => $start, ':end' => $end]); return array_map('intval', $s->fetchAll(PDO::FETCH_COLUMN)); } @@ -224,64 +234,49 @@ final class StatisticsService public static function annual(int $year, ?array $ids = null): array { - if ($ids === null) { - $stats = GrantService::statistics($year); - } else { - $db = DB::pdo(); - $p = [':start' => sprintf('%04d-01-01', $year), ':end' => sprintf('%04d-12-31', $year)]; - $in = self::idSql($ids); - $scalar = static function (string $sql) use ($db, $p): float { - $s = $db->prepare($sql); - $s->execute($p); - return (float) ($s->fetchColumn() ?: 0); - }; - $stats = []; - $stats['entries'] = $scalar( - "SELECT COUNT(*) FROM (SELECT m.animal_id FROM animal_movements m JOIN animals a ON a.id=m.animal_id WHERE a.deleted_at IS NULL AND m.kind='entry' AND date(m.created_at) BETWEEN :start AND :end" . - self::idSql($ids, 'm.animal_id') . - ' UNION SELECT a.id FROM animals a WHERE a.deleted_at IS NULL AND date(a.intake_date) BETWEEN :start AND :end' . - self::idSql($ids, 'a.id') . - ')', - ); - $stats['exits'] = $scalar( - 'SELECT (SELECT COUNT(*) FROM adoptions ad JOIN animals a ON a.id=ad.animal_id WHERE a.deleted_at IS NULL AND date(ad.adoption_date) BETWEEN :start AND :end' . - self::idSql($ids, 'ad.animal_id') . - ")+(SELECT COUNT(*) FROM animal_deaths d JOIN animals a ON a.id=d.animal_id WHERE a.deleted_at IS NULL AND date(COALESCE(d.deceased_date,printf('%04d-01-01',d.death_year))) BETWEEN :start AND :end AND NOT EXISTS(SELECT 1 FROM adoptions ad WHERE ad.animal_id=d.animal_id)" . - self::idSql($ids, 'd.animal_id') . - ')', - ); - $stats['adoptions'] = $scalar( - 'SELECT COUNT(*) FROM adoptions WHERE date(adoption_date) BETWEEN :start AND :end' . $in, - ); - $stats['deaths'] = $scalar( - "SELECT COUNT(*) FROM animal_deaths WHERE date(COALESCE(deceased_date,printf('%04d-01-01',death_year))) BETWEEN :start AND :end" . - $in, - ); - $stats['births'] = $scalar( - "SELECT COUNT(*) FROM litter_kittens lk JOIN litters l ON l.id=lk.litter_id JOIN animals a ON a.id=lk.animal_id WHERE a.deleted_at IS NULL AND date(COALESCE(NULLIF(l.birth_date,''),NULLIF(a.birth_date,''))) BETWEEN :start AND :end" . - self::idSql($ids, 'lk.animal_id'), - ); - $stats['vaccinations'] = $scalar( - 'SELECT COUNT(*) FROM vaccinations WHERE date(done_date) BETWEEN :start AND :end' . $in, - ); - $stats['treatments'] = $scalar( - 'SELECT COUNT(*) FROM treatments WHERE date(start_date) BETWEEN :start AND :end' . $in, - ); - $stats['avg'] = $scalar( - "SELECT AVG(julianday(adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))) FROM adoptions ad JOIN animals a ON a.id=ad.animal_id WHERE date(adoption_date) BETWEEN :start AND :end AND date(adoption_date)>=date(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))" . - self::idSql($ids, 'a.id'), - ); - $stats['current'] = - (float) ($db - ->query( - "SELECT COUNT(*) FROM animals WHERE deleted_at IS NULL AND archived_at IS NULL AND status NOT IN ('adopte','decede')" . - self::idSql($ids, 'id'), - ) - ->fetchColumn() ?: - 0); - } + return self::period(sprintf('%04d-01-01', $year), sprintf('%04d-12-31', $year), $ids); + } + + public static function period(string $start, string $end, ?array $ids = null): array + { $db = DB::pdo(); - $range = [':start' => sprintf('%04d-01-01', $year), ':end' => sprintf('%04d-12-31', $year)]; + $range = [':start' => $start, ':end' => $end]; + $scalar = static function (string $sql) use ($db, $range): float { + $s = $db->prepare($sql); + $s->execute($range); + return (float) ($s->fetchColumn() ?: 0); + }; + $in = self::idSql($ids); + $stats = []; + $stats['entries'] = $scalar( + "SELECT COUNT(*) FROM (SELECT m.animal_id FROM animal_movements m JOIN animals a ON a.id=m.animal_id WHERE a.deleted_at IS NULL AND m.kind='entry' AND date(m.created_at) BETWEEN :start AND :end" . + self::idSql($ids, 'm.animal_id') . + ' UNION SELECT a.id FROM animals a WHERE a.deleted_at IS NULL AND date(a.intake_date) BETWEEN :start AND :end' . + self::idSql($ids, 'a.id') . + ')', + ); + $stats['exits'] = $scalar( + 'SELECT (SELECT COUNT(*) FROM adoptions ad JOIN animals a ON a.id=ad.animal_id WHERE a.deleted_at IS NULL AND date(ad.adoption_date) BETWEEN :start AND :end' . + self::idSql($ids, 'ad.animal_id') . + ")+(SELECT COUNT(*) FROM animal_deaths d JOIN animals a ON a.id=d.animal_id WHERE a.deleted_at IS NULL AND date(COALESCE(d.deceased_date,printf('%04d-01-01',d.death_year))) BETWEEN :start AND :end AND NOT EXISTS(SELECT 1 FROM adoptions ad WHERE ad.animal_id=d.animal_id)" . + self::idSql($ids, 'd.animal_id') . + ')', + ); + $stats['adoptions'] = $scalar('SELECT COUNT(*) FROM adoptions WHERE date(adoption_date) BETWEEN :start AND :end' . $in); + $stats['deaths'] = $scalar( + "SELECT COUNT(*) FROM animal_deaths WHERE date(COALESCE(deceased_date,printf('%04d-01-01',death_year))) BETWEEN :start AND :end" . $in, + ); + $stats['births'] = $scalar( + "SELECT COUNT(*) FROM litter_kittens lk JOIN litters l ON l.id=lk.litter_id JOIN animals a ON a.id=lk.animal_id WHERE a.deleted_at IS NULL AND date(COALESCE(NULLIF(l.birth_date,''),NULLIF(a.birth_date,''))) BETWEEN :start AND :end" . self::idSql($ids, 'lk.animal_id'), + ); + $stats['vaccinations'] = $scalar('SELECT COUNT(*) FROM vaccinations WHERE date(done_date) BETWEEN :start AND :end' . $in); + $stats['treatments'] = $scalar('SELECT COUNT(*) FROM treatments WHERE date(start_date) BETWEEN :start AND :end' . $in); + $stats['avg'] = $scalar( + "SELECT AVG(julianday(adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))) FROM adoptions ad JOIN animals a ON a.id=ad.animal_id WHERE date(adoption_date) BETWEEN :start AND :end AND date(adoption_date)>=date(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))" . self::idSql($ids, 'a.id'), + ); + $stats['current'] = (float) ($db->query( + "SELECT COUNT(*) FROM animals WHERE deleted_at IS NULL AND archived_at IS NULL AND status NOT IN ('adopte','decede')" . self::idSql($ids, 'id'), + )->fetchColumn() ?: 0); $filter = self::idSql($ids, 'a.id'); $s = $db->prepare( "SELECT COUNT(*) FROM (SELECT m.animal_id FROM animal_movements m JOIN animals a ON a.id=m.animal_id WHERE a.deleted_at IS NULL AND m.kind='entry' AND date(m.created_at) BETWEEN :start AND :end" . @@ -303,6 +298,24 @@ final class StatisticsService return $stats; } + public static function comparisonPeriod(string $start, string $end, ?array $ids = null): array + { + $from = new DateTimeImmutable($start); + $to = new DateTimeImmutable($end); + $days = (int) $from->diff($to)->format('%a') + 1; + $previousEnd = $from->modify('-1 day'); + $previousStart = $previousEnd->modify('-' . ($days - 1) . ' days'); + $current = self::period($start, $end, $ids); + $previous = self::period($previousStart->format('Y-m-d'), $previousEnd->format('Y-m-d'), $ids); + $out = []; + foreach (['handled','entries','exits','adoptions','deaths','births','vaccinations','treatments','medical_notes','avg'] as $key) { + $a = (float) $current[$key]; + $b = (float) $previous[$key]; + $out[$key] = ['current' => $a,'previous' => $b,'difference' => $a - $b,'percent' => $b != 0 ? (($a - $b) / $b) * 100 : null]; + } + return ['rows' => $out,'previous_start' => $previousStart->format('Y-m-d'),'previous_end' => $previousEnd->format('Y-m-d')]; + } + public static function comparison(int $year, ?array $ids = null): array { $current = self::annual($year, $ids); @@ -532,10 +545,10 @@ final class StatisticsService ]; } - private static function details(int $year, array $ids): array + private static function detailsRange(string $start, string $end, ?array $ids): array { $db = DB::pdo(); - $p = [':start' => sprintf('%04d-01-01', $year), ':end' => sprintf('%04d-12-31', $year)]; + $p = [':start' => $start, ':end' => $end]; $base = "WITH entry_sources AS (SELECT m.animal_id,date(m.created_at) entry_date FROM animal_movements m JOIN animals source_animal ON source_animal.id=m.animal_id WHERE source_animal.deleted_at IS NULL AND m.kind='entry' AND date(m.created_at) BETWEEN :start AND :end" . self::idSql($ids, 'm.animal_id') . @@ -561,7 +574,7 @@ final class StatisticsService ); $deathCauses = $map( "SELECT cause_code label,COUNT(*) total FROM animal_deaths WHERE date(COALESCE(deceased_date,printf('%04d-01-01',death_year))) BETWEEN :start AND :end" . - $in . + self::idSql($ids) . ' GROUP BY cause_code ORDER BY total DESC', ); $stays = $map( @@ -572,10 +585,33 @@ final class StatisticsService return compact('sex', 'ages', 'deathCauses', 'stays'); } - public static function distributions(?int $year, ?array $ids = null, ?string $municipality = null): array + private static function originsForPeriod(string $start, string $end, ?array $ids): array { $db = DB::pdo(); - $p = $year === null ? [] : [':start' => sprintf('%04d-01-01', $year), ':end' => sprintf('%04d-12-31', $year)]; + $sql = + "SELECT DISTINCT a.id,a.rescue_location_name,a.rescue_address FROM animals a WHERE a.deleted_at IS NULL" . + self::idSql($ids, 'a.id') . + " AND (date(a.intake_date) BETWEEN :start AND :end OR EXISTS(SELECT 1 FROM animal_movements m WHERE m.animal_id=a.id AND m.kind='entry' AND date(m.created_at) BETWEEN :start AND :end))"; + $stmt = $db->prepare($sql); + $stmt->execute([':start' => $start, ':end' => $end]); + $origins = []; + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $city = GrantService::municipality((string) ($row['rescue_location_name'] ?: $row['rescue_address'])); + $origins[$city] = ($origins[$city] ?? 0) + 1; + } + arsort($origins, SORT_NUMERIC); + return $origins; + } + + public static function distributions(?int $year, ?array $ids = null, ?string $municipality = null, ?string $start = null, ?string $end = null): array + { + $db = DB::pdo(); + if ($start === null && $year !== null) { + $start = sprintf('%04d-01-01', $year); + $end = sprintf('%04d-12-31', $year); + } + $bounded = $start !== null && $end !== null; + $p = $bounded ? [':start' => $start, ':end' => $end] : []; $rows = static function (string $sql, array $params = []) use ($db): array { $s = $db->prepare($sql); $s->execute($params); @@ -585,9 +621,9 @@ final class StatisticsService } return $out; }; - $movementDate = $year === null ? '' : ' AND date(m.created_at) BETWEEN :start AND :end'; - $animalDate = $year === null ? '' : ' AND date(intake_date) BETWEEN :start AND :end'; - $datedAnimal = $year === null ? '' : " AND NULLIF(TRIM(intake_date),'') IS NOT NULL"; + $movementDate = $bounded ? ' AND date(m.created_at) BETWEEN :start AND :end' : ''; + $animalDate = $bounded ? ' AND date(intake_date) BETWEEN :start AND :end' : ''; + $datedAnimal = $bounded ? " AND NULLIF(TRIM(intake_date),'') IS NOT NULL" : ''; $entered = "WITH entry_sources AS (SELECT m.animal_id,date(m.created_at) entry_date FROM animal_movements m JOIN animals source_animal ON source_animal.id=m.animal_id WHERE source_animal.deleted_at IS NULL AND m.kind='entry'" . $movementDate . @@ -627,7 +663,7 @@ final class StatisticsService "SELECT CASE WHEN a.intake_owner_care_home=1 THEN 'owner_care_home' ELSE COALESCE(NULLIF(a.intake_reason,''),'unknown') END label,COUNT(*) total FROM entered e JOIN animals a ON a.id=e.animal_id GROUP BY label ORDER BY total DESC", $p, ); - if ($year === null) { + if (!$bounded) { $animalFilter = self::idSql($ids, 'a.id'); $details = []; $details['sex'] = $rows( @@ -651,12 +687,11 @@ final class StatisticsService ' GROUP BY label ORDER BY total DESC', ); $origins = $municipality !== null ? [$municipality => (int) array_sum($species)] : self::municipalities(); - } elseif ($ids !== null) { - $details = self::details($year, $ids); - $origins = $municipality !== null ? [$municipality => (int) array_sum($species)] : []; } else { - $details = GrantService::detailedStatistics($year); - $origins = $details['origins']; + $details = self::detailsRange($start, $end, $ids); + $origins = $municipality !== null + ? [$municipality => (int) array_sum($species)] + : self::originsForPeriod($start, $end, $ids); } foreach ($details as &$items) { arsort($items, SORT_NUMERIC); @@ -674,7 +709,7 @@ final class StatisticsService ' GROUP BY label ORDER BY total DESC,label', ); $releaseSql = - $year === null + !$bounded ? "SELECT substr(free_cat_released_at,1,4) label,COUNT(*) total FROM animals WHERE deleted_at IS NULL AND free_cat_released_at IS NOT NULL AND trim(free_cat_released_at)<>''" . $filter . ' GROUP BY label ORDER BY label DESC' diff --git a/app/Views/directory_show.php b/app/Views/directory_show.php index 50bb429..1886f02 100644 --- a/app/Views/directory_show.php +++ b/app/Views/directory_show.php @@ -30,7 +30,7 @@ $animalRows = static function (array$rows, string$title, string$color, string$ic
Liens d’adoptants corrigés, modification des vaccins plus explicite et - synchronisation i-CAD utilisable même sans JavaScript.
+ 1.0.0-rc.32 +Bilans par année, par mois ou entre deux dates, avec coûts vétérinaires et + comparaison de la période précédente.