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

@ -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,

View file

@ -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),
]);
}
}

View file

@ -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'

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 gap-2"><a class="btn" href="/directory">←
<?= 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"
href="/directory/edit?id=<?= (int)$contact['id'] ?>"><?= h(t('common.edit')) ?></a>
</div>
@ -78,7 +78,7 @@ if ($address): ?><div>📍 <?= h($address) ?></div>
<div class="row align-items-center">
<div class="col">
<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>
<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])) ?>

View file

@ -31,10 +31,10 @@
<div class="col-lg-6">
<section class="card h-100">
<div class="card-body">
<span class="badge bg-blue-lt mb-2">1.0.0-rc.31</span>
<h3>Parcours de correction plus fiables</h3>
<p class="text-muted">Liens d’adoptants corrigés, modification des vaccins plus explicite et
synchronisation i-CAD utilisable même sans JavaScript.</p>
<span class="badge bg-blue-lt mb-2">1.0.0-rc.32</span>
<h3>Statistiques sur mesure</h3>
<p class="text-muted">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.</p>
</div>
</section>
</div>

View file

@ -54,12 +54,19 @@ $groups = [
<div class="text-muted"><?= $municipality !== '' ? t('statistics.city_help') : t('statistics.help') ?></div>
</div>
<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"
name="year">
<option value="all" <?= $allYears ? 'selected' : '' ?>><?= h(t('statistics.all_years')) ?></option>
<?php foreach (array_reverse($years) as $y): ?><option value="<?= $y ?>"
<?= !$allYears && $y === $year ? 'selected' : '' ?>><?= $y ?></option><?php endforeach; ?>
<div><label class="form-label"><?= h(t('statistics.period')) ?></label><select class="form-select" name="period_type" id="statisticsPeriodType">
<option value="all" <?= $periodType === 'all' ? 'selected' : '' ?>><?= h(t('statistics.all_years')) ?></option>
<option value="year" <?= $periodType === 'year' ? 'selected' : '' ?>><?= h(t('statistics.by_year')) ?></option>
<option value="month" <?= $periodType === 'month' ? 'selected' : '' ?>><?= h(t('statistics.by_month')) ?></option>
<option value="custom" <?= $periodType === 'custom' ? 'selected' : '' ?>><?= h(t('statistics.custom_period')) ?></option>
</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"
name="municipality">
<option value=""><?= h(t('statistics.all_cities')) ?></option>
@ -69,7 +76,7 @@ $groups = [
</select></div><button
class="btn btn-cyan"><?= h(t('common.show')) ?></button><?php if ($municipality !== ''): ?><a
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()">🖨
<?= h(t('common.print')) ?></button><?php endif; ?>
</form>
@ -78,7 +85,7 @@ $groups = [
<?php if ($municipality !== ''): ?><div class="alert alert-info d-flex flex-wrap align-items-center gap-2">
<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; ?>
<?php if ($municipality === '' && !empty($municipalityCoverage)): ?><div class="alert alert-info mb-4">
<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="display-6 fw-bold"><?= (int)$stats[$key] ?></div>
<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>
@ -138,7 +145,7 @@ $groups = [
<div class="col-md-6">
<h4 class="mb-1"><?= h(t('statistics.by_structure')) ?></h4>
<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">
<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
@ -186,7 +193,7 @@ if (empty($costs['by_clinic'])):?><span class="text-muted"><?= h(t('statistics.n
</tr>
</thead>
<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>
<td><a class="fw-semibold" href="<?= h($cityUrl) ?>"
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 class="card-header">
<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>
</div>
@ -254,8 +261,8 @@ if (empty($costs['by_clinic'])):?><span class="text-muted"><?= h(t('statistics.n
<thead>
<tr>
<th><?= h(t('grant.indicator')) ?></th>
<th class="text-end"><?= $year - 1 ?></th>
<th class="text-end"><?= $year ?></th>
<th class="text-end"><?= h($previousPeriodLabel) ?></th>
<th class="text-end"><?= h($periodLabel) ?></th>
<th class="text-end"><?= h(t('grant.evolution')) ?></th>
</tr>
</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">
<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 class="row row-cards"><?php foreach ($groups as $group): [$title,$items,$labels,$color] = $group;
$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;
$width = $maximum > 0 ? max(4, round($total / $maximum * 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) {
'Femelles' => 'F', 'Mâles' => 'M', default => ''
} : (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>
</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>
document.addEventListener('DOMContentLoaded', () => {
const canvas = document.getElementById('activityHistoryChart');