Ajouter les statistiques par période

This commit is contained in:
Alexandre NOEL 2026-10-02 12:11:30 +02:00
commit 86642095cd
14 changed files with 315 additions and 125 deletions

View file

@ -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/). 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 ## [1.0.0-rc.31] — 2026-10-01
### Navigation et synchronisation des dossiers animaux ### Navigation et synchronisation des dossiers animaux

View file

@ -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. 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 ## `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. 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.

View file

@ -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 ; - placements, adoptions, retours, décès, archives et corbeille ;
- agenda partagé, rappels médicaux et abonnement iCalendar privé ; - agenda partagé, rappels médicaux et abonnement iCalendar privé ;
- pharmacie, lots, péremptions et mouvements de stock ; - 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 ; - registre des entrées et sorties, exports CSV et dossiers de subvention ;
- recherche globale avec contrôle des résultats selon le rôle ; - recherche globale avec contrôle des résultats selon le rôle ;
- site public facultatif pour les animaux proposés à l’adoption ; - 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 ## É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). Le contrôle de stabilisation est disponible dans [docs/AUDIT-1.0.md](docs/AUDIT-1.0.md).

View file

@ -1 +1 @@
1.0.0-rc.31 1.0.0-rc.32

View file

@ -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', '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]); $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'); $periodRaw = (string) ($_GET['year'] ?? 'all');
$periodYear = $periodYear =
ctype_digit($periodRaw) && (int) $periodRaw >= 2000 && (int) $periodRaw <= 2100 ? (int) $periodRaw : null; 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'] ?? '')); $periodMunicipality = trim((string) ($_GET['municipality'] ?? ''));
$expenseWhere = ['ae.clinic_contact_id=:id', 'a.deleted_at IS NULL']; $expenseWhere = ['ae.clinic_contact_id=:id', 'a.deleted_at IS NULL'];
$expenseParams = [':id' => $id]; $expenseParams = [':id' => $id];
if ($periodYear !== null) { if ($periodStart !== null && $periodEnd !== null) {
$expenseWhere[] = 'date(ae.occurred_on) BETWEEN :expense_start AND :expense_end'; $expenseWhere[] = 'date(ae.occurred_on) BETWEEN :expense_start AND :expense_end';
$expenseParams[':expense_start'] = sprintf('%04d-01-01', $periodYear); $expenseParams[':expense_start'] = $periodStart;
$expenseParams[':expense_end'] = sprintf('%04d-12-31', $periodYear); $expenseParams[':expense_end'] = $periodEnd;
} }
$expenses = $db->prepare( $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 ' . '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); $expenseRows = array_slice($expenseRows, 0, 250);
$invoiceWhere = ['vendor_contact_id=:vendor', 'deleted_at IS NULL']; $invoiceWhere = ['vendor_contact_id=:vendor', 'deleted_at IS NULL'];
$invoiceParams = [':vendor' => $id]; $invoiceParams = [':vendor' => $id];
if ($periodYear !== null) { if ($periodStart !== null && $periodEnd !== null) {
$invoiceWhere[] = "strftime('%Y',invoice_date)=:invoice_year"; $invoiceWhere[] = 'date(invoice_date) BETWEEN :invoice_start AND :invoice_end';
$invoiceParams[':invoice_year'] = (string) $periodYear; $invoiceParams[':invoice_start'] = $periodStart;
$invoiceParams[':invoice_end'] = $periodEnd;
} }
$invoiceStmt = $db->prepare( $invoiceStmt = $db->prepare(
'SELECT * FROM accounting_invoices WHERE ' . 'SELECT * FROM accounting_invoices WHERE ' .
@ -162,6 +185,8 @@ final class DirectoryController
'expenseCount' => $expenseCount, 'expenseCount' => $expenseCount,
'expensePeriodRequested' => $periodRequested, 'expensePeriodRequested' => $periodRequested,
'expensePeriodYear' => $periodYear, 'expensePeriodYear' => $periodYear,
'expensePeriodLabel' => $periodLabel,
'expensePeriodQuery' => $periodQuery,
'expensePeriodMunicipality' => $periodMunicipality, 'expensePeriodMunicipality' => $periodMunicipality,
'contactInvoices' => $contactInvoices, 'contactInvoices' => $contactInvoices,
'contactInvoiceTotal' => $contactInvoiceTotal, 'contactInvoiceTotal' => $contactInvoiceTotal,

View file

@ -6,37 +6,95 @@ final class StatisticsController
{ {
public static function index(): void 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(); $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(); $municipalities = StatisticsService::municipalities();
$municipality = trim((string) ($_GET['municipality'] ?? '')); $municipality = trim((string) ($_GET['municipality'] ?? ''));
if ($municipality !== '' && !isset($municipalities[$municipality])) { if ($municipality !== '' && !isset($municipalities[$municipality])) {
$municipality = ''; $municipality = '';
} }
$ids = $municipality !== '' ? StatisticsService::animalIdsForMunicipality($municipality) : null; $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', [ render('statistics.php', [
'title' => t('statistics.title'), 'title' => t('statistics.title'),
'pageDescription' => t('statistics.description'), 'pageDescription' => t('statistics.description'),
'year' => $year, 'year' => $year,
'allYears' => $all, 'allYears' => $all,
'periodType' => $periodType,
'month' => $month,
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
'periodLabel' => $periodLabel,
'previousPeriodLabel' => $previousPeriodLabel,
'periodQuery' => $periodQuery,
'years' => $years, 'years' => $years,
'municipality' => $municipality, 'municipality' => $municipality,
'municipalities' => $municipalities, 'municipalities' => $municipalities,
'municipalityCoverage' => StatisticsService::municipalityCoverage(), 'municipalityCoverage' => StatisticsService::municipalityCoverage(),
'stats' => $all ? StatisticsService::all($ids) : StatisticsService::annual($year, $ids), 'stats' => $all ? StatisticsService::all($ids) : StatisticsService::period($start, $end, $ids),
'comparison' => $all ? [] : StatisticsService::comparison($year, $ids), 'comparison' => $comparison,
'timeline' => StatisticsService::timeline($years, $ids), 'timeline' => StatisticsService::timeline($years, $ids),
'undatedHandled' => StatisticsService::undatedHandled($ids), 'undatedHandled' => StatisticsService::undatedHandled($ids),
'undatedDeaths' => StatisticsService::undatedDeaths($ids), 'undatedDeaths' => StatisticsService::undatedDeaths($ids),
'undatedBirths' => StatisticsService::undatedBirths($ids), 'undatedBirths' => StatisticsService::undatedBirths($ids),
'undatedExits' => StatisticsService::undatedExits($ids), 'undatedExits' => StatisticsService::undatedExits($ids),
'scopeCounts' => StatisticsService::scopeCounts($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), 'health' => StatisticsService::currentHealth($ids),
'costs' => StatisticsService::costs($year, $ids), 'costs' => StatisticsService::costs($all ? null : $year, $ids, $start, $end),
'municipalityCosts' => StatisticsService::costsByMunicipality($year), 'municipalityCosts' => StatisticsService::costsByMunicipality($all ? null : $year, $start, $end),
]); ]);
} }
} }

View file

@ -4,10 +4,14 @@ declare(strict_types=1);
final class StatisticsService 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(); $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 = [ $empty = [
'total_cents' => 0, 'total_cents' => 0,
'animals' => 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)) . ')']; $where = ['a.deleted_at IS NULL', 'ae.animal_id IN(' . implode(',', array_map('intval', $scopeIds)) . ')'];
$params = []; $params = [];
if ($year !== null) { if ($start !== null && $end !== null) {
$where[] = 'date(ae.occurred_on) BETWEEN :start AND :end'; $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 = $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 ' . ' 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(); $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) { if (!$scopeIds) {
return []; return [];
} }
@ -92,9 +100,9 @@ final class StatisticsService
} }
$params = []; $params = [];
$dateSql = ''; $dateSql = '';
if ($year !== null) { if ($start !== null && $end !== null) {
$dateSql = ' AND date(occurred_on) BETWEEN :start AND :end'; $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( $s = $db->prepare(
'SELECT animal_id,SUM(total_cents) total_cents FROM animal_expenses WHERE animal_id IN(' . 'SELECT animal_id,SUM(total_cents) total_cents FROM animal_expenses WHERE animal_id IN(' .
@ -133,22 +141,24 @@ final class StatisticsService
return $out; 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(); $db = DB::pdo();
$limit = $ids === null ? '' : self::idSql($ids, 'a.id'); $limit = $ids === null ? '' : self::idSql($ids, 'a.id');
if ($year === null) { if ($start === null && $year !== null) {
$sql = 'SELECT a.id FROM animals a WHERE a.deleted_at IS NULL' . $limit;
} else {
$start = sprintf('%04d-01-01', $year); $start = sprintf('%04d-01-01', $year);
$end = sprintf('%04d-12-31', $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 = $sql =
'SELECT a.id FROM animals a WHERE a.deleted_at IS NULL' . 'SELECT a.id FROM animals a WHERE a.deleted_at IS NULL' .
$limit . $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))"; " 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 = $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)); 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 public static function annual(int $year, ?array $ids = null): array
{ {
if ($ids === null) { return self::period(sprintf('%04d-01-01', $year), sprintf('%04d-12-31', $year), $ids);
$stats = GrantService::statistics($year); }
} else {
$db = DB::pdo(); public static function period(string $start, string $end, ?array $ids = null): array
$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);
}
$db = DB::pdo(); $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'); $filter = self::idSql($ids, 'a.id');
$s = $db->prepare( $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" . "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; 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 public static function comparison(int $year, ?array $ids = null): array
{ {
$current = self::annual($year, $ids); $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(); $db = DB::pdo();
$p = [':start' => sprintf('%04d-01-01', $year), ':end' => sprintf('%04d-12-31', $year)]; $p = [':start' => $start, ':end' => $end];
$base = $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" . "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') . self::idSql($ids, 'm.animal_id') .
@ -561,7 +574,7 @@ final class StatisticsService
); );
$deathCauses = $map( $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" . "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', ' GROUP BY cause_code ORDER BY total DESC',
); );
$stays = $map( $stays = $map(
@ -572,10 +585,33 @@ final class StatisticsService
return compact('sex', 'ages', 'deathCauses', 'stays'); 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(); $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 { $rows = static function (string $sql, array $params = []) use ($db): array {
$s = $db->prepare($sql); $s = $db->prepare($sql);
$s->execute($params); $s->execute($params);
@ -585,9 +621,9 @@ final class StatisticsService
} }
return $out; return $out;
}; };
$movementDate = $year === null ? '' : ' AND date(m.created_at) BETWEEN :start AND :end'; $movementDate = $bounded ? ' AND date(m.created_at) BETWEEN :start AND :end' : '';
$animalDate = $year === null ? '' : ' AND date(intake_date) BETWEEN :start AND :end'; $animalDate = $bounded ? ' AND date(intake_date) BETWEEN :start AND :end' : '';
$datedAnimal = $year === null ? '' : " AND NULLIF(TRIM(intake_date),'') IS NOT NULL"; $datedAnimal = $bounded ? " AND NULLIF(TRIM(intake_date),'') IS NOT NULL" : '';
$entered = $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'" . "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 . $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", "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, $p,
); );
if ($year === null) { if (!$bounded) {
$animalFilter = self::idSql($ids, 'a.id'); $animalFilter = self::idSql($ids, 'a.id');
$details = []; $details = [];
$details['sex'] = $rows( $details['sex'] = $rows(
@ -651,12 +687,11 @@ final class StatisticsService
' GROUP BY label ORDER BY total DESC', ' GROUP BY label ORDER BY total DESC',
); );
$origins = $municipality !== null ? [$municipality => (int) array_sum($species)] : self::municipalities(); $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 { } else {
$details = GrantService::detailedStatistics($year); $details = self::detailsRange($start, $end, $ids);
$origins = $details['origins']; $origins = $municipality !== null
? [$municipality => (int) array_sum($species)]
: self::originsForPeriod($start, $end, $ids);
} }
foreach ($details as &$items) { foreach ($details as &$items) {
arsort($items, SORT_NUMERIC); arsort($items, SORT_NUMERIC);
@ -674,7 +709,7 @@ final class StatisticsService
' GROUP BY label ORDER BY total DESC,label', ' GROUP BY label ORDER BY total DESC,label',
); );
$releaseSql = $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)<>''" . ? "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 . $filter .
' GROUP BY label ORDER BY label DESC' ' GROUP BY label ORDER BY label DESC'

View file

@ -30,7 +30,7 @@ $animalRows = static function (array$rows, string$title, string$color, string$ic
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3"> <div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<div class="d-flex flex-wrap gap-2"><a class="btn" href="/directory">← <div class="d-flex flex-wrap gap-2"><a class="btn" href="/directory">←
<?= h(t('directory.back')) ?></a><?php if ($expensePeriodRequested): ?><a class="btn btn-outline-orange" <?= h(t('directory.back')) ?></a><?php if ($expensePeriodRequested): ?><a class="btn btn-outline-orange"
href="/statistics?<?= h(http_build_query(['year' => $expensePeriodYear ?? 'all','municipality' => $expensePeriodMunicipality])) ?>">← href="/statistics?<?= h(http_build_query(($expensePeriodQuery ?? ['period_type' => 'all']) + ['municipality' => $expensePeriodMunicipality])) ?>">←
<?= h(t('directory.back_statistics')) ?></a><?php endif; ?></div><a class="btn btn-primary" <?= h(t('directory.back_statistics')) ?></a><?php endif; ?></div><a class="btn btn-primary"
href="/directory/edit?id=<?= (int)$contact['id'] ?>"><?= h(t('common.edit')) ?></a> href="/directory/edit?id=<?= (int)$contact['id'] ?>"><?= h(t('common.edit')) ?></a>
</div> </div>
@ -78,7 +78,7 @@ if ($address): ?><div>📍 <?= h($address) ?></div>
<div class="row align-items-center"> <div class="row align-items-center">
<div class="col"> <div class="col">
<div class="subheader"><?= h(t('directory.total_invoiced')) ?><?php if ($expensePeriodRequested): ?> · <div class="subheader"><?= h(t('directory.total_invoiced')) ?><?php if ($expensePeriodRequested): ?> ·
<?= h($expensePeriodYear ?? t('statistics.all_years')) ?><?php if ($expensePeriodMunicipality !== ''): ?> <?= h($expensePeriodLabel ?? t('statistics.all_years')) ?><?php if ($expensePeriodMunicipality !== ''): ?>
· <?= h($expensePeriodMunicipality) ?><?php endif; ?><?php endif; ?></div> · <?= h($expensePeriodMunicipality) ?><?php endif; ?><?php endif; ?></div>
<div class="h1 mb-0 text-orange"><?= h($money((int)$expenseTotal)) ?></div> <div class="h1 mb-0 text-orange"><?= h($money((int)$expenseTotal)) ?></div>
<div class="text-muted small"><?= h(t('directory.expense_count', ['count' => (int)$expenseCount])) ?> <div class="text-muted small"><?= h(t('directory.expense_count', ['count' => (int)$expenseCount])) ?>

View file

@ -31,10 +31,10 @@
<div class="col-lg-6"> <div class="col-lg-6">
<section class="card h-100"> <section class="card h-100">
<div class="card-body"> <div class="card-body">
<span class="badge bg-blue-lt mb-2">1.0.0-rc.31</span> <span class="badge bg-blue-lt mb-2">1.0.0-rc.32</span>
<h3>Parcours de correction plus fiables</h3> <h3>Statistiques sur mesure</h3>
<p class="text-muted">Liens d’adoptants corrigés, modification des vaccins plus explicite et <p class="text-muted">Bilans par année, par mois ou entre deux dates, avec coûts vétérinaires et
synchronisation i-CAD utilisable même sans JavaScript.</p> comparaison de la période précédente.</p>
</div> </div>
</section> </section>
</div> </div>

View file

@ -54,12 +54,19 @@ $groups = [
<div class="text-muted"><?= $municipality !== '' ? t('statistics.city_help') : t('statistics.help') ?></div> <div class="text-muted"><?= $municipality !== '' ? t('statistics.city_help') : t('statistics.help') ?></div>
</div> </div>
<form method="get" action="/statistics" class="d-flex flex-wrap align-items-end gap-2"> <form method="get" action="/statistics" class="d-flex flex-wrap align-items-end gap-2">
<div><label class="form-label"><?= h(t('statistics.period')) ?></label><select class="form-select" <div><label class="form-label"><?= h(t('statistics.period')) ?></label><select class="form-select" name="period_type" id="statisticsPeriodType">
name="year"> <option value="all" <?= $periodType === 'all' ? 'selected' : '' ?>><?= h(t('statistics.all_years')) ?></option>
<option value="all" <?= $allYears ? 'selected' : '' ?>><?= h(t('statistics.all_years')) ?></option> <option value="year" <?= $periodType === 'year' ? 'selected' : '' ?>><?= h(t('statistics.by_year')) ?></option>
<?php foreach (array_reverse($years) as $y): ?><option value="<?= $y ?>" <option value="month" <?= $periodType === 'month' ? 'selected' : '' ?>><?= h(t('statistics.by_month')) ?></option>
<?= !$allYears && $y === $year ? 'selected' : '' ?>><?= $y ?></option><?php endforeach; ?> <option value="custom" <?= $periodType === 'custom' ? 'selected' : '' ?>><?= h(t('statistics.custom_period')) ?></option>
</select></div> </select></div>
<div data-period-field="year"><label class="form-label"><?= h(t('statistics.year')) ?></label><select class="form-select" name="year">
<?php foreach (array_reverse($years) as $y): ?><option value="<?= $y ?>"
<?= $y === $year ? 'selected' : '' ?>><?= $y ?></option><?php endforeach; ?>
</select></div>
<div data-period-field="month"><label class="form-label"><?= h(t('statistics.month')) ?></label><input class="form-control" type="month" name="month" value="<?= h($month) ?>"></div>
<div data-period-field="custom"><label class="form-label"><?= h(t('statistics.from')) ?></label><input class="form-control" type="date" name="date_from" value="<?= h($dateFrom) ?>"></div>
<div data-period-field="custom"><label class="form-label"><?= h(t('statistics.to')) ?></label><input class="form-control" type="date" name="date_to" value="<?= h($dateTo) ?>"></div>
<div><label class="form-label"><?= h(t('statistics.origin_city')) ?></label><select class="form-select" <div><label class="form-label"><?= h(t('statistics.origin_city')) ?></label><select class="form-select"
name="municipality"> name="municipality">
<option value=""><?= h(t('statistics.all_cities')) ?></option> <option value=""><?= h(t('statistics.all_cities')) ?></option>
@ -69,7 +76,7 @@ $groups = [
</select></div><button </select></div><button
class="btn btn-cyan"><?= h(t('common.show')) ?></button><?php if ($municipality !== ''): ?><a class="btn btn-cyan"><?= h(t('common.show')) ?></button><?php if ($municipality !== ''): ?><a
class="btn btn-outline-secondary" class="btn btn-outline-secondary"
href="/statistics?year=<?= h($allYears ? 'all' : (string)$year) ?>"><?= h(t('common.clear')) ?></a><button href="/statistics?<?= h(http_build_query($periodQuery)) ?>"><?= h(t('common.clear')) ?></a><button
class="btn btn-outline-primary" type="button" onclick="window.print()">🖨 class="btn btn-outline-primary" type="button" onclick="window.print()">🖨
<?= h(t('common.print')) ?></button><?php endif; ?> <?= h(t('common.print')) ?></button><?php endif; ?>
</form> </form>
@ -78,7 +85,7 @@ $groups = [
<?php if ($municipality !== ''): ?><div class="alert alert-info d-flex flex-wrap align-items-center gap-2"> <?php if ($municipality !== ''): ?><div class="alert alert-info d-flex flex-wrap align-items-center gap-2">
<strong><?= h($municipality) ?> · <strong><?= h($municipality) ?> ·
<?= h($allYears ? t('statistics.all_years') : (string)$year) ?></strong><span><?= h(t('statistics.city_filter_summary', ['count' => (int)$stats['handled']])) ?></span><span <?= h($periodLabel) ?></strong><span><?= h(t('statistics.city_filter_summary', ['count' => (int)$stats['handled']])) ?></span><span
class="ms-auto small"><?= h(t('statistics.filter_applied')) ?></span></div><?php endif; ?> class="ms-auto small"><?= h(t('statistics.filter_applied')) ?></span></div><?php endif; ?>
<?php if ($municipality === '' && !empty($municipalityCoverage)): ?><div class="alert alert-info mb-4"> <?php if ($municipality === '' && !empty($municipalityCoverage)): ?><div class="alert alert-info mb-4">
<div class="d-flex flex-wrap align-items-center gap-2"> <div class="d-flex flex-wrap align-items-center gap-2">
@ -104,7 +111,7 @@ $groups = [
<div class="text-muted small text-uppercase"><?= h($label) ?></div> <div class="text-muted small text-uppercase"><?= h($label) ?></div>
<div class="display-6 fw-bold"><?= (int)$stats[$key] ?></div> <div class="display-6 fw-bold"><?= (int)$stats[$key] ?></div>
<div class="small text-muted"> <div class="small text-muted">
<?= $allYears ? t('statistics.since_start') : t('statistics.in_year', ['year' => (int)$year]) ?> <?= $allYears ? t('statistics.since_start') : h(t('statistics.in_period', ['period' => $periodLabel])) ?>
</div> </div>
</div> </div>
</div> </div>
@ -138,7 +145,7 @@ $groups = [
<div class="col-md-6"> <div class="col-md-6">
<h4 class="mb-1"><?= h(t('statistics.by_structure')) ?></h4> <h4 class="mb-1"><?= h(t('statistics.by_structure')) ?></h4>
<div class="text-muted small mb-2"><?= h(t('statistics.structure_cost_help')) ?></div> <div class="text-muted small mb-2"><?= h(t('statistics.structure_cost_help')) ?></div>
<?php foreach (($costs['by_clinic'] ?? []) as $r): $structureUrl = !empty($r['contact_id']) ? '/directory/view?' . http_build_query(['id' => (int)$r['contact_id'],'year' => $allYears ? 'all' : $year,'municipality' => $municipality]) : null; ?> <?php foreach (($costs['by_clinic'] ?? []) as $r): $structureUrl = !empty($r['contact_id']) ? '/directory/view?' . http_build_query(['id' => (int)$r['contact_id'],'municipality' => $municipality] + $periodQuery) : null; ?>
<div class="d-flex justify-content-between align-items-start gap-3 border-bottom py-2"> <div class="d-flex justify-content-between align-items-start gap-3 border-bottom py-2">
<span><?php if ($structureUrl): ?><a class="fw-semibold" href="<?= h($structureUrl) ?>" <span><?php if ($structureUrl): ?><a class="fw-semibold" href="<?= h($structureUrl) ?>"
title="<?= h(t('statistics.open_structure_cost', ['structure' => $r['label']])) ?>"><?= h($r['label']) ?></a><?php else: ?><?= h($r['label']) ?><?php endif; ?><span title="<?= h(t('statistics.open_structure_cost', ['structure' => $r['label']])) ?>"><?= h($r['label']) ?></a><?php else: ?><?= h($r['label']) ?><?php endif; ?><span
@ -186,7 +193,7 @@ if (empty($costs['by_clinic'])):?><span class="text-muted"><?= h(t('statistics.n
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<?php foreach (($municipalityCosts ?? []) as $row): $cityUrl = '/statistics?' . http_build_query(['year' => $allYears ? 'all' : $year,'municipality' => $row['municipality']]); ?> <?php foreach (($municipalityCosts ?? []) as $row): $cityUrl = '/statistics?' . http_build_query($periodQuery + ['municipality' => $row['municipality']]); ?>
<tr> <tr>
<td><a class="fw-semibold" href="<?= h($cityUrl) ?>" <td><a class="fw-semibold" href="<?= h($cityUrl) ?>"
title="<?= h(t('statistics.cost_city_open', ['city' => $row['municipality']])) ?>"><?= h($row['municipality']) ?></a> title="<?= h(t('statistics.cost_city_open', ['city' => $row['municipality']])) ?>"><?= h($row['municipality']) ?></a>
@ -245,7 +252,7 @@ if (empty($costs['by_clinic'])):?><span class="text-muted"><?= h(t('statistics.n
</div><?php else: ?><div class="card h-100"> </div><?php else: ?><div class="card h-100">
<div class="card-header"> <div class="card-header">
<div> <div>
<h3 class="card-title"><?= h(t('grant.comparison', ['year' => $year - 1])) ?></h3> <h3 class="card-title"><?= h(t('statistics.previous_period_comparison')) ?></h3>
<div class="text-muted small"><?= h(t('statistics.no_previous_percent')) ?></div> <div class="text-muted small"><?= h(t('statistics.no_previous_percent')) ?></div>
</div> </div>
</div> </div>
@ -254,8 +261,8 @@ if (empty($costs['by_clinic'])):?><span class="text-muted"><?= h(t('statistics.n
<thead> <thead>
<tr> <tr>
<th><?= h(t('grant.indicator')) ?></th> <th><?= h(t('grant.indicator')) ?></th>
<th class="text-end"><?= $year - 1 ?></th> <th class="text-end"><?= h($previousPeriodLabel) ?></th>
<th class="text-end"><?= $year ?></th> <th class="text-end"><?= h($periodLabel) ?></th>
<th class="text-end"><?= h(t('grant.evolution')) ?></th> <th class="text-end"><?= h(t('grant.evolution')) ?></th>
</tr> </tr>
</thead> </thead>
@ -297,7 +304,7 @@ if (empty($costs['by_clinic'])):?><span class="text-muted"><?= h(t('statistics.n
<div class="d-flex flex-wrap align-items-baseline gap-2 mb-3"> <div class="d-flex flex-wrap align-items-baseline gap-2 mb-3">
<h2 class="mb-0"><?= h(t('statistics.profiles')) ?></h2><span <h2 class="mb-0"><?= h(t('statistics.profiles')) ?></h2><span
class="text-muted"><?= h($allYears ? t('statistics.profiles_help_all') : t('statistics.profiles_help_year', ['year' => (int)$year])) ?></span> class="text-muted"><?= h($allYears ? t('statistics.profiles_help_all') : t('statistics.profiles_help_period', ['period' => $periodLabel])) ?></span>
</div> </div>
<div class="row row-cards"><?php foreach ($groups as $group): [$title,$items,$labels,$color] = $group; <div class="row row-cards"><?php foreach ($groups as $group): [$title,$items,$labels,$color] = $group;
$linkType = $group[4] ?? null; $linkType = $group[4] ?? null;
@ -317,7 +324,7 @@ if (empty($costs['by_clinic'])):?><span class="text-muted"><?= h(t('statistics.n
<?php foreach ($items as $label => $total): $display = $labels[$label] ?? $distributionLabels[$label] ?? $label; <?php foreach ($items as $label => $total): $display = $labels[$label] ?? $distributionLabels[$label] ?? $label;
$width = $maximum > 0 ? max(4, round($total / $maximum * 100)) : 0; $width = $maximum > 0 ? max(4, round($total / $maximum * 100)) : 0;
$pct = $sum > 0 ? round($total / $sum * 100) : 0; $pct = $sum > 0 ? round($total / $sum * 100) : 0;
$cityUrl = '/statistics?' . http_build_query(['year' => $allYears ? 'all' : $year,'municipality' => $label]); $cityUrl = '/statistics?' . http_build_query($periodQuery + ['municipality' => $label]);
$filterValue = $linkType === 'sex' ? match ((string)$label) { $filterValue = $linkType === 'sex' ? match ((string)$label) {
'Femelles' => 'F', 'Mâles' => 'M', default => '' 'Femelles' => 'F', 'Mâles' => 'M', default => ''
} : (string)$label; } : (string)$label;
@ -339,6 +346,18 @@ if (empty($costs['by_clinic'])):?><span class="text-muted"><?= h(t('statistics.n
</div><?php endforeach; ?></div><?php endif; ?></div> </div><?php endforeach; ?></div><?php endif; ?></div>
</div> </div>
</div><?php endforeach; ?></div> </div><?php endforeach; ?></div>
<script>
(() => {
const selector = document.getElementById('statisticsPeriodType');
if (!selector) return;
const refresh = () => document.querySelectorAll('[data-period-field]').forEach((field) => {
field.hidden = field.dataset.periodField !== selector.value;
field.querySelectorAll('input,select').forEach((input) => input.disabled = field.hidden);
});
selector.addEventListener('change', refresh);
refresh();
})();
</script>
<?php if ($allYears): ?><script> <?php if ($allYears): ?><script>
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const canvas = document.getElementById('activityHistoryChart'); const canvas = document.getElementById('activityHistoryChart');

View file

@ -10,7 +10,7 @@
## Statistiques ## Statistiques
Sélectionnez une année ou **Toutes les années**. Une année affiche sa comparaison avec l’année précédente. La vue globale produit une évolution sur toutes les années sans inventer de date pour les événements historiques imprécis. Choisissez **Toutes les années**, une **Année**, un **Mois** ou une **Période personnalisée** comprise entre deux dates. Une année est comparée à l’année précédente ; un mois ou une période libre est comparé à l’intervalle immédiatement précédent de même durée. La vue globale produit une évolution sur toutes les années sans inventer de date pour les événements historiques imprécis.
Les indicateurs couvrent notamment : animaux distincts pris en charge, entrées, sorties, adoptions, décès, naissances, activité médicale, vaccinations, traitements, sexes, âges, espèces, races, robes, origines et délai d’adoption. Les indicateurs couvrent notamment : animaux distincts pris en charge, entrées, sorties, adoptions, décès, naissances, activité médicale, vaccinations, traitements, sexes, âges, espèces, races, robes, origines et délai d’adoption.
@ -24,7 +24,7 @@ Les anciens dossiers sans commune fiable restent dans les totaux généraux, mai
## Analyser les coûts ## Analyser les coûts
Les dépenses peuvent être réparties par structure et par catégorie. Le tableau indique total, animaux concernés, moyenne et médiane. Cliquez sur une structure pour ouvrir sa fiche avec la même année et la même commune : le détail permet de vérifier les actes avant de présenter un montant à un partenaire. Les dépenses peuvent être réparties par structure et par catégorie. Le tableau indique total, animaux concernés, moyenne et médiane. Cliquez sur une structure pour ouvrir sa fiche avec la même période et la même commune : le détail permet de vérifier les actes avant de présenter un montant à un partenaire.
Le coût moyen sur tous les animaux du périmètre et celui sur les seuls animaux ayant généré des frais répondent à deux questions différentes ; conservez leurs libellés dans toute communication de subvention. Le coût moyen sur tous les animaux du périmètre et celui sur les seuls animaux ayant généré des frais répondent à deux questions différentes ; conservez leurs libellés dans toute communication de subvention.

View file

@ -251,4 +251,13 @@ return [
'catalog.code'=>'Code', 'catalog.code'=>'Code',
'catalog.active_ingredient'=>'Active ingredient', 'catalog.active_ingredient'=>'Active ingredient',
'catalog.delete_confirm'=>'Delete this label?', 'catalog.delete_confirm'=>'Delete this label?',
'statistics.by_year'=>'Year',
'statistics.by_month'=>'Month',
'statistics.custom_period'=>'Custom period',
'statistics.month'=>'Month',
'statistics.from'=>'From',
'statistics.to'=>'To',
'statistics.in_period'=>'during :period',
'statistics.previous_period_comparison'=>'Comparison with the previous period',
'statistics.profiles_help_period'=>'Profiles cover animals admitted during :period, unless stated otherwise.',
]; ];

View file

@ -251,4 +251,13 @@ return [
'catalog.code'=>'Code', 'catalog.code'=>'Code',
'catalog.active_ingredient'=>'Principe actif', 'catalog.active_ingredient'=>'Principe actif',
'catalog.delete_confirm'=>'Supprimer cet intitulé ?', 'catalog.delete_confirm'=>'Supprimer cet intitulé ?',
'statistics.by_year'=>'Année',
'statistics.by_month'=>'Mois',
'statistics.custom_period'=>'Période personnalisée',
'statistics.month'=>'Mois',
'statistics.from'=>'Du',
'statistics.to'=>'Au',
'statistics.in_period'=>'sur :period',
'statistics.previous_period_comparison'=>'Comparaison avec la période précédente',
'statistics.profiles_help_period'=>'Les profils concernent les animaux entrés sur la période :period, sauf mention contraire.',
]; ];

View file

@ -503,6 +503,27 @@ try {
$assert($structure !== null && $structure['label'] === 'Structure test'); $assert($structure !== null && $structure['label'] === 'Structure test');
$assert((int) $structure['total_cents'] === 1234); $assert((int) $structure['total_cents'] === 1234);
}); });
$test('Les statistiques acceptent une année, un mois et une période libre', function () use ($assert): void {
$year = (int) date('Y');
$annual = StatisticsService::annual($year);
$yearRange = StatisticsService::period("$year-01-01", "$year-12-31");
foreach (['handled','entries','exits','adoptions','deaths','births','vaccinations','treatments','medical_notes'] as $key) {
$assert((float) $annual[$key] === (float) $yearRange[$key], 'Écart annuel sur ' . $key);
}
$today = date('Y-m-d');
$daily = StatisticsService::period($today, $today);
$assert((int) $daily['adoptions'] >= 1, 'La période du jour ne retrouve pas l’adoption de test');
$comparison = StatisticsService::comparisonPeriod($today, $today);
$assert(isset($comparison['rows']['adoptions'], $comparison['previous_start'], $comparison['previous_end']));
$costs = StatisticsService::costs(null, null, $today, $today);
$assert((int) $costs['total_cents'] >= 1234, 'Les coûts ne respectent pas la période personnalisée');
$distributions = StatisticsService::distributions(null, null, null, date('Y-m-01'), date('Y-m-t'));
$assert(isset($distributions['species'], $distributions['origins']));
$view = file_get_contents(__DIR__ . '/../app/Views/statistics.php');
foreach (['period_type','type="month"','date_from','date_to'] as $field) {
$assert(str_contains($view, $field), 'Filtre statistique absent : ' . $field);
}
});
$test('Le nom de l’adoptant ouvre la bonne route annuaire', function () use ($assert): void { $test('Le nom de l’adoptant ouvre la bonne route annuaire', function () use ($assert): void {
$view = file_get_contents(__DIR__ . '/../app/Views/_animal_show_identity.php'); $view = file_get_contents(__DIR__ . '/../app/Views/_animal_show_identity.php');
$routes = file_get_contents(__DIR__ . '/../app/Routes/web.php'); $routes = file_get_contents(__DIR__ . '/../app/Routes/web.php');