Globinours/app/Services/GrantService.php

313 lines
14 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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');
}
}