Ajouter les statistiques par période
This commit is contained in:
parent
9ff26ad9be
commit
86642095cd
14 changed files with 315 additions and 125 deletions
10
CHANGELOG.md
10
CHANGELOG.md
|
|
@ -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/).
|
||||
|
||||
## [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
|
||||
|
||||
### Navigation et synchronisation des dossiers animaux
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
## `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
|
||||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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 ;
|
||||
- agenda partagé, rappels médicaux et abonnement iCalendar privé ;
|
||||
- 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 ;
|
||||
- recherche globale avec contrôle des résultats selon le rôle ;
|
||||
- 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
|
||||
|
||||
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).
|
||||
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
1.0.0-rc.31
|
||||
1.0.0-rc.32
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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])) ?>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
|
||||
## 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.
|
||||
|
||||
|
|
@ -24,7 +24,7 @@ Les anciens dossiers sans commune fiable restent dans les totaux généraux, mai
|
|||
|
||||
## 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -251,4 +251,13 @@ return [
|
|||
'catalog.code'=>'Code',
|
||||
'catalog.active_ingredient'=>'Active ingredient',
|
||||
'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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -251,4 +251,13 @@ return [
|
|||
'catalog.code'=>'Code',
|
||||
'catalog.active_ingredient'=>'Principe actif',
|
||||
'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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -503,6 +503,27 @@ try {
|
|||
$assert($structure !== null && $structure['label'] === 'Structure test');
|
||||
$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 {
|
||||
$view = file_get_contents(__DIR__ . '/../app/Views/_animal_show_identity.php');
|
||||
$routes = file_get_contents(__DIR__ . '/../app/Routes/web.php');
|
||||
|
|
|
|||
Loading…
Reference in a new issue