'Brouillon', 'ready' => 'Prête', 'submitted' => 'Déposée', 'awarded' => 'Accordée', 'refused' => 'Refusée', 'withdrawn' => 'Abandonnée', ]; public const CATEGORIES = [ 'official_form' => 'Formulaire officiel de l’organisme', 'statutes' => 'Statuts', 'prefecture' => 'Récépissé de préfecture', 'jo' => 'Publication au Journal officiel', 'rib' => 'RIB', 'insurance' => 'Attestation d’assurance', 'ag' => 'Procès-verbal d’assemblée générale', 'activity' => 'Rapport d’activité', 'accounts' => 'Comptes approuvés', 'budget' => 'Budget prévisionnel', 'other' => 'Autre', ]; public const REQUIRED = [ 'official_form', 'statutes', 'prefecture', 'jo', 'rib', 'insurance', 'ag', 'activity', 'accounts', 'budget', ]; public static function statusLabel(string $key): string { return t('grant.status.' . $key, [], self::STATUSES[$key] ?? $key); } public static function categoryLabel(string $key): string { return t('grant.category.' . $key, [], self::CATEGORIES[$key] ?? $key); } public static function ensureYear(int $year): array { $db = DB::pdo(); $s = $db->prepare('INSERT OR IGNORE INTO grant_years(year) VALUES(?)'); $s->execute([$year]); $s = $db->prepare('SELECT * FROM grant_years WHERE year=?'); $s->execute([$year]); return $s->fetch() ?: []; } public static function statistics(int $year): array { $db = DB::pdo(); $start = sprintf('%04d-01-01', $year); $end = sprintf('%04d-12-31', $year); $scalar = static function (string $sql, array $params = []) use ($db): float { $s = $db->prepare($sql); $s->execute($params); return (float) ($s->fetchColumn() ?: 0); }; $p = [':start' => $start, ':end' => $end]; $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 UNION SELECT a.id FROM animals a WHERE a.deleted_at IS NULL AND date(a.intake_date) BETWEEN :start AND :end)", $p, ); $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)+(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))", $p, ); $adoptions = $scalar('SELECT COUNT(*) FROM adoptions WHERE date(adoption_date) BETWEEN :start AND :end', $p); $deaths = $scalar( "SELECT COUNT(*) FROM animal_deaths WHERE date(COALESCE(deceased_date,printf('%04d-01-01',death_year))) BETWEEN :start AND :end", $p, ); $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", $p, ); $vaccinations = $scalar('SELECT COUNT(*) FROM vaccinations WHERE date(done_date) BETWEEN :start AND :end', $p); $treatments = $scalar('SELECT COUNT(*) FROM treatments WHERE date(start_date) BETWEEN :start AND :end', $p); $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)))", $p, ); $current = $scalar( "SELECT COUNT(*) FROM animals WHERE deleted_at IS NULL AND archived_at IS NULL AND status NOT IN ('adopte','decede')", ); return compact( 'entries', 'exits', 'adoptions', 'deaths', 'births', 'vaccinations', 'treatments', 'avg', 'current', ); } public static function detailedStatistics(int $year): array { $db = DB::pdo(); $start = sprintf('%04d-01-01', $year); $end = sprintf('%04d-12-31', $year); $p = [':start' => $start, ':end' => $end]; $rows = static function (string $sql, array $params) use ($db): array { $s = $db->prepare($sql); $s->execute($params); return $s->fetchAll(PDO::FETCH_ASSOC); }; $map = static function (array $items, string $key = 'label'): array { $out = []; foreach ($items as $item) { $out[(string) $item[$key]] = (int) $item['total']; } return $out; }; $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 UNION SELECT id animal_id,date(intake_date) entry_date FROM animals WHERE deleted_at IS NULL AND date(intake_date) BETWEEN :start AND :end), entered AS (SELECT animal_id,MIN(entry_date) entry_date FROM entry_sources GROUP BY animal_id) "; $sex = $map( $rows( $base . "SELECT CASE a.sex WHEN 'F' THEN 'Femelles' WHEN 'M' THEN 'Mâles' ELSE 'Inconnu' END label,COUNT(*) total FROM entered e JOIN animals a ON a.id=e.animal_id GROUP BY a.sex ORDER BY total DESC", $p, ), ); $ages = $map( $rows( $base . "SELECT CASE WHEN a.birth_date IS NULL THEN 'Non renseigné' WHEN (julianday(e.entry_date)-julianday(a.birth_date))/365.25<1 THEN 'Moins d’un an' WHEN (julianday(e.entry_date)-julianday(a.birth_date))/365.25<3 THEN '1 à 2 ans' WHEN (julianday(e.entry_date)-julianday(a.birth_date))/365.25<8 THEN '3 à 7 ans' ELSE '8 ans et plus' END label,COUNT(*) total FROM entered e JOIN animals a ON a.id=e.animal_id GROUP BY label ORDER BY total DESC", $p, ), ); $originRows = $rows( $base . 'SELECT a.rescue_location_name,a.rescue_address FROM entered e JOIN animals a ON a.id=e.animal_id', $p, ); $origins = []; foreach ($originRows as $originRow) { $label = self::municipality((string) ($originRow['rescue_location_name'] ?: $originRow['rescue_address'])); $origins[$label] = ($origins[$label] ?? 0) + 1; } arsort($origins, SORT_NUMERIC); $origins = array_slice($origins, 0, 12, true); $deathCauses = $map( $rows( "SELECT cause_code label,COUNT(*) total FROM animal_deaths WHERE date(COALESCE(deceased_date,printf('%04d-01-01',death_year))) BETWEEN :start AND :end GROUP BY cause_code ORDER BY total DESC", $p, ), ); $stays = $map( $rows( "SELECT CASE WHEN julianday(ad.adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))<30 THEN 'Moins d’un mois' WHEN julianday(ad.adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))<90 THEN '1 à 3 mois' WHEN julianday(ad.adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))<180 THEN '3 à 6 mois' WHEN julianday(ad.adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))<365 THEN '6 à 12 mois' ELSE 'Plus d’un an' END label,COUNT(*) total FROM adoptions ad JOIN animals a ON a.id=ad.animal_id WHERE date(ad.adoption_date) BETWEEN :start AND :end AND date(ad.adoption_date)>=date(COALESCE(NULLIF(a.intake_date,''),date(a.created_at))) GROUP BY label ORDER BY total DESC", $p, ), ); return compact('sex', 'ages', 'origins', 'deathCauses', 'stays'); } public static function comparison(int $year): array { $current = self::statistics($year); $previous = self::statistics($year - 1); $out = []; foreach (['entries', 'exits', 'adoptions', 'deaths', 'births', 'vaccinations', 'treatments', '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 $out; } public static function municipality(string $value): string { $value = trim(preg_replace('/\s+/u', ' ', $value) ?? ''); if ($value === '') { return 'Non renseignée'; } if (preg_match('/\b\d{5}\s+([^,;]+)(?:[,;]|$)/u', $value, $match)) { $value = trim($match[1]); } elseif (str_contains($value, ',')) { $parts = array_values(array_filter(array_map('trim', explode(',', $value)))); $value = (string) end($parts); } $value = preg_replace('/^(?:FRANCE|FR)\s*[-–—]?\s*/iu', '', $value) ?? $value; return $value !== '' ? mb_convert_case($value, MB_CASE_TITLE, 'UTF-8') : 'Non renseignée'; } public static function applications(int $year): array { $s = DB::pdo()->prepare( 'SELECT * FROM grant_applications WHERE year=? ORDER BY COALESCE(deadline,\'9999-12-31\'), organization_name', ); $s->execute([$year]); return $s->fetchAll(); } public static function documents(int $year): array { $s = DB::pdo()->prepare( 'SELECT d.*,u.display_name uploader,ga.organization_name application_name FROM grant_documents d LEFT JOIN users u ON u.id=d.uploaded_by LEFT JOIN grant_applications ga ON ga.id=d.application_id WHERE d.year IS NULL OR d.year=? ORDER BY d.category,d.created_at DESC', ); $s->execute([$year]); return $s->fetchAll(); } public static function checklist(int $year): array { $applications = self::applications($year); $documents = self::documents($year); $today = date('Y-m-d'); $soon = date('Y-m-d', strtotime('+30 days')); $result = []; foreach ($applications as $application) { $items = []; $valid = 0; $warnings = 0; foreach (self::REQUIRED as $category) { $matches = array_values( array_filter($documents, static function (array $document) use ($category, $application): bool { if ($document['category'] !== $category) { return false; } if ($category === 'official_form') { return (int) ($document['application_id'] ?? 0) === (int) $application['id']; } return empty($document['application_id']) || (int) $document['application_id'] === (int) $application['id']; }), ); usort( $matches, static fn(array $a, array $b): int => strcmp((string) $b['created_at'], (string) $a['created_at']), ); $document = $matches[0] ?? null; $state = 'missing'; if ($document) { $expiry = (string) ($document['valid_until'] ?? ''); if ($expiry !== '' && $expiry < $today) { $state = 'expired'; } elseif ($expiry !== '' && $expiry <= $soon) { $state = 'expiring'; } else { $state = 'valid'; } } if ($state === 'valid') { $valid++; } elseif ($state === 'expiring') { $valid++; $warnings++; } else { $warnings++; } $items[$category] = ['state' => $state, 'document' => $document]; } $total = count(self::REQUIRED); $result[(int) $application['id']] = [ 'items' => $items, 'valid' => $valid, 'total' => $total, 'warnings' => $warnings, 'complete' => $valid === $total, ]; } return $result; } public static function alerts(int $year): array { $applications = self::applications($year); $checklist = self::checklist($year); $today = date('Y-m-d'); $soon = date('Y-m-d', strtotime('+30 days')); $deadlines = 0; $overdue = 0; $incomplete = 0; $documents = 0; foreach ($applications as $application) { if (in_array($application['status'], ['awarded', 'refused', 'withdrawn'], true)) { continue; } $deadline = (string) ($application['deadline'] ?? ''); if ($deadline !== '' && $deadline < $today) { $overdue++; } elseif ($deadline !== '' && $deadline <= $soon) { $deadlines++; } if (!($checklist[(int) $application['id']]['complete'] ?? false)) { $incomplete++; } } foreach (self::documents($year) as $document) { $expiry = (string) ($document['valid_until'] ?? ''); if ($expiry !== '' && $expiry <= $soon) { $documents++; } } return compact('deadlines', 'overdue', 'incomplete', 'documents'); } }