Publier Globinours 1.0.0-rc.3

This commit is contained in:
Alexandre NOEL 2026-09-03 12:39:15 +02:00
commit 9a2b4068da
325 changed files with 38230 additions and 20 deletions

View file

@ -0,0 +1,313 @@
<?php
declare(strict_types=1);
final class GrantService
{
public const STATUSES = [
'draft' => '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 lorganisme',
'statutes' => 'Statuts',
'prefecture' => 'Récépissé de préfecture',
'jo' => 'Publication au Journal officiel',
'rib' => 'RIB',
'insurance' => 'Attestation dassurance',
'ag' => 'Procès-verbal dassemblée générale',
'activity' => 'Rapport dactivité',
'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 dun 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 dun 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 dun 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');
}
}