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/).
|
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
|
## [1.0.0-rc.31] — 2026-10-01
|
||||||
|
|
||||||
### Navigation et synchronisation des dossiers animaux
|
### 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.
|
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
|
## `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.
|
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 ;
|
- placements, adoptions, retours, décès, archives et corbeille ;
|
||||||
- agenda partagé, rappels médicaux et abonnement iCalendar privé ;
|
- agenda partagé, rappels médicaux et abonnement iCalendar privé ;
|
||||||
- pharmacie, lots, péremptions et mouvements de stock ;
|
- 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 ;
|
- registre des entrées et sorties, exports CSV et dossiers de subvention ;
|
||||||
- recherche globale avec contrôle des résultats selon le rôle ;
|
- recherche globale avec contrôle des résultats selon le rôle ;
|
||||||
- site public facultatif pour les animaux proposés à l’adoption ;
|
- 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
|
## É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).
|
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',
|
'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]);
|
$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');
|
$periodRaw = (string) ($_GET['year'] ?? 'all');
|
||||||
$periodYear =
|
$periodYear =
|
||||||
ctype_digit($periodRaw) && (int) $periodRaw >= 2000 && (int) $periodRaw <= 2100 ? (int) $periodRaw : null;
|
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'] ?? ''));
|
$periodMunicipality = trim((string) ($_GET['municipality'] ?? ''));
|
||||||
$expenseWhere = ['ae.clinic_contact_id=:id', 'a.deleted_at IS NULL'];
|
$expenseWhere = ['ae.clinic_contact_id=:id', 'a.deleted_at IS NULL'];
|
||||||
$expenseParams = [':id' => $id];
|
$expenseParams = [':id' => $id];
|
||||||
if ($periodYear !== null) {
|
if ($periodStart !== null && $periodEnd !== null) {
|
||||||
$expenseWhere[] = 'date(ae.occurred_on) BETWEEN :expense_start AND :expense_end';
|
$expenseWhere[] = 'date(ae.occurred_on) BETWEEN :expense_start AND :expense_end';
|
||||||
$expenseParams[':expense_start'] = sprintf('%04d-01-01', $periodYear);
|
$expenseParams[':expense_start'] = $periodStart;
|
||||||
$expenseParams[':expense_end'] = sprintf('%04d-12-31', $periodYear);
|
$expenseParams[':expense_end'] = $periodEnd;
|
||||||
}
|
}
|
||||||
$expenses = $db->prepare(
|
$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 ' .
|
'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);
|
$expenseRows = array_slice($expenseRows, 0, 250);
|
||||||
$invoiceWhere = ['vendor_contact_id=:vendor', 'deleted_at IS NULL'];
|
$invoiceWhere = ['vendor_contact_id=:vendor', 'deleted_at IS NULL'];
|
||||||
$invoiceParams = [':vendor' => $id];
|
$invoiceParams = [':vendor' => $id];
|
||||||
if ($periodYear !== null) {
|
if ($periodStart !== null && $periodEnd !== null) {
|
||||||
$invoiceWhere[] = "strftime('%Y',invoice_date)=:invoice_year";
|
$invoiceWhere[] = 'date(invoice_date) BETWEEN :invoice_start AND :invoice_end';
|
||||||
$invoiceParams[':invoice_year'] = (string) $periodYear;
|
$invoiceParams[':invoice_start'] = $periodStart;
|
||||||
|
$invoiceParams[':invoice_end'] = $periodEnd;
|
||||||
}
|
}
|
||||||
$invoiceStmt = $db->prepare(
|
$invoiceStmt = $db->prepare(
|
||||||
'SELECT * FROM accounting_invoices WHERE ' .
|
'SELECT * FROM accounting_invoices WHERE ' .
|
||||||
|
|
@ -162,6 +185,8 @@ final class DirectoryController
|
||||||
'expenseCount' => $expenseCount,
|
'expenseCount' => $expenseCount,
|
||||||
'expensePeriodRequested' => $periodRequested,
|
'expensePeriodRequested' => $periodRequested,
|
||||||
'expensePeriodYear' => $periodYear,
|
'expensePeriodYear' => $periodYear,
|
||||||
|
'expensePeriodLabel' => $periodLabel,
|
||||||
|
'expensePeriodQuery' => $periodQuery,
|
||||||
'expensePeriodMunicipality' => $periodMunicipality,
|
'expensePeriodMunicipality' => $periodMunicipality,
|
||||||
'contactInvoices' => $contactInvoices,
|
'contactInvoices' => $contactInvoices,
|
||||||
'contactInvoiceTotal' => $contactInvoiceTotal,
|
'contactInvoiceTotal' => $contactInvoiceTotal,
|
||||||
|
|
|
||||||
|
|
@ -6,37 +6,95 @@ final class StatisticsController
|
||||||
{
|
{
|
||||||
public static function index(): void
|
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();
|
$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();
|
$municipalities = StatisticsService::municipalities();
|
||||||
$municipality = trim((string) ($_GET['municipality'] ?? ''));
|
$municipality = trim((string) ($_GET['municipality'] ?? ''));
|
||||||
if ($municipality !== '' && !isset($municipalities[$municipality])) {
|
if ($municipality !== '' && !isset($municipalities[$municipality])) {
|
||||||
$municipality = '';
|
$municipality = '';
|
||||||
}
|
}
|
||||||
$ids = $municipality !== '' ? StatisticsService::animalIdsForMunicipality($municipality) : null;
|
$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', [
|
render('statistics.php', [
|
||||||
'title' => t('statistics.title'),
|
'title' => t('statistics.title'),
|
||||||
'pageDescription' => t('statistics.description'),
|
'pageDescription' => t('statistics.description'),
|
||||||
'year' => $year,
|
'year' => $year,
|
||||||
'allYears' => $all,
|
'allYears' => $all,
|
||||||
|
'periodType' => $periodType,
|
||||||
|
'month' => $month,
|
||||||
|
'dateFrom' => $dateFrom,
|
||||||
|
'dateTo' => $dateTo,
|
||||||
|
'periodLabel' => $periodLabel,
|
||||||
|
'previousPeriodLabel' => $previousPeriodLabel,
|
||||||
|
'periodQuery' => $periodQuery,
|
||||||
'years' => $years,
|
'years' => $years,
|
||||||
'municipality' => $municipality,
|
'municipality' => $municipality,
|
||||||
'municipalities' => $municipalities,
|
'municipalities' => $municipalities,
|
||||||
'municipalityCoverage' => StatisticsService::municipalityCoverage(),
|
'municipalityCoverage' => StatisticsService::municipalityCoverage(),
|
||||||
'stats' => $all ? StatisticsService::all($ids) : StatisticsService::annual($year, $ids),
|
'stats' => $all ? StatisticsService::all($ids) : StatisticsService::period($start, $end, $ids),
|
||||||
'comparison' => $all ? [] : StatisticsService::comparison($year, $ids),
|
'comparison' => $comparison,
|
||||||
'timeline' => StatisticsService::timeline($years, $ids),
|
'timeline' => StatisticsService::timeline($years, $ids),
|
||||||
'undatedHandled' => StatisticsService::undatedHandled($ids),
|
'undatedHandled' => StatisticsService::undatedHandled($ids),
|
||||||
'undatedDeaths' => StatisticsService::undatedDeaths($ids),
|
'undatedDeaths' => StatisticsService::undatedDeaths($ids),
|
||||||
'undatedBirths' => StatisticsService::undatedBirths($ids),
|
'undatedBirths' => StatisticsService::undatedBirths($ids),
|
||||||
'undatedExits' => StatisticsService::undatedExits($ids),
|
'undatedExits' => StatisticsService::undatedExits($ids),
|
||||||
'scopeCounts' => StatisticsService::scopeCounts($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),
|
'health' => StatisticsService::currentHealth($ids),
|
||||||
'costs' => StatisticsService::costs($year, $ids),
|
'costs' => StatisticsService::costs($all ? null : $year, $ids, $start, $end),
|
||||||
'municipalityCosts' => StatisticsService::costsByMunicipality($year),
|
'municipalityCosts' => StatisticsService::costsByMunicipality($all ? null : $year, $start, $end),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,14 @@ declare(strict_types=1);
|
||||||
|
|
||||||
final class StatisticsService
|
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();
|
$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 = [
|
$empty = [
|
||||||
'total_cents' => 0,
|
'total_cents' => 0,
|
||||||
'animals' => 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)) . ')'];
|
$where = ['a.deleted_at IS NULL', 'ae.animal_id IN(' . implode(',', array_map('intval', $scopeIds)) . ')'];
|
||||||
$params = [];
|
$params = [];
|
||||||
if ($year !== null) {
|
if ($start !== null && $end !== null) {
|
||||||
$where[] = 'date(ae.occurred_on) BETWEEN :start AND :end';
|
$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 =
|
$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 ' .
|
' 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();
|
$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) {
|
if (!$scopeIds) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
@ -92,9 +100,9 @@ final class StatisticsService
|
||||||
}
|
}
|
||||||
$params = [];
|
$params = [];
|
||||||
$dateSql = '';
|
$dateSql = '';
|
||||||
if ($year !== null) {
|
if ($start !== null && $end !== null) {
|
||||||
$dateSql = ' AND date(occurred_on) BETWEEN :start AND :end';
|
$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(
|
$s = $db->prepare(
|
||||||
'SELECT animal_id,SUM(total_cents) total_cents FROM animal_expenses WHERE animal_id IN(' .
|
'SELECT animal_id,SUM(total_cents) total_cents FROM animal_expenses WHERE animal_id IN(' .
|
||||||
|
|
@ -133,22 +141,24 @@ final class StatisticsService
|
||||||
return $out;
|
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();
|
$db = DB::pdo();
|
||||||
$limit = $ids === null ? '' : self::idSql($ids, 'a.id');
|
$limit = $ids === null ? '' : self::idSql($ids, 'a.id');
|
||||||
if ($year === null) {
|
if ($start === null && $year !== null) {
|
||||||
$sql = 'SELECT a.id FROM animals a WHERE a.deleted_at IS NULL' . $limit;
|
|
||||||
} else {
|
|
||||||
$start = sprintf('%04d-01-01', $year);
|
$start = sprintf('%04d-01-01', $year);
|
||||||
$end = sprintf('%04d-12-31', $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 =
|
$sql =
|
||||||
'SELECT a.id FROM animals a WHERE a.deleted_at IS NULL' .
|
'SELECT a.id FROM animals a WHERE a.deleted_at IS NULL' .
|
||||||
$limit .
|
$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))";
|
" 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 = $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));
|
return array_map('intval', $s->fetchAll(PDO::FETCH_COLUMN));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -224,17 +234,19 @@ final class StatisticsService
|
||||||
|
|
||||||
public static function annual(int $year, ?array $ids = null): array
|
public static function annual(int $year, ?array $ids = null): array
|
||||||
{
|
{
|
||||||
if ($ids === null) {
|
return self::period(sprintf('%04d-01-01', $year), sprintf('%04d-12-31', $year), $ids);
|
||||||
$stats = GrantService::statistics($year);
|
}
|
||||||
} else {
|
|
||||||
|
public static function period(string $start, string $end, ?array $ids = null): array
|
||||||
|
{
|
||||||
$db = DB::pdo();
|
$db = DB::pdo();
|
||||||
$p = [':start' => sprintf('%04d-01-01', $year), ':end' => sprintf('%04d-12-31', $year)];
|
$range = [':start' => $start, ':end' => $end];
|
||||||
$in = self::idSql($ids);
|
$scalar = static function (string $sql) use ($db, $range): float {
|
||||||
$scalar = static function (string $sql) use ($db, $p): float {
|
|
||||||
$s = $db->prepare($sql);
|
$s = $db->prepare($sql);
|
||||||
$s->execute($p);
|
$s->execute($range);
|
||||||
return (float) ($s->fetchColumn() ?: 0);
|
return (float) ($s->fetchColumn() ?: 0);
|
||||||
};
|
};
|
||||||
|
$in = self::idSql($ids);
|
||||||
$stats = [];
|
$stats = [];
|
||||||
$stats['entries'] = $scalar(
|
$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" .
|
"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" .
|
||||||
|
|
@ -250,38 +262,21 @@ final class StatisticsService
|
||||||
self::idSql($ids, 'd.animal_id') .
|
self::idSql($ids, 'd.animal_id') .
|
||||||
')',
|
')',
|
||||||
);
|
);
|
||||||
$stats['adoptions'] = $scalar(
|
$stats['adoptions'] = $scalar('SELECT COUNT(*) FROM adoptions WHERE date(adoption_date) BETWEEN :start AND :end' . $in);
|
||||||
'SELECT COUNT(*) FROM adoptions WHERE date(adoption_date) BETWEEN :start AND :end' . $in,
|
|
||||||
);
|
|
||||||
$stats['deaths'] = $scalar(
|
$stats['deaths'] = $scalar(
|
||||||
"SELECT COUNT(*) FROM animal_deaths WHERE date(COALESCE(deceased_date,printf('%04d-01-01',death_year))) BETWEEN :start AND :end" .
|
"SELECT COUNT(*) FROM animal_deaths WHERE date(COALESCE(deceased_date,printf('%04d-01-01',death_year))) BETWEEN :start AND :end" . $in,
|
||||||
$in,
|
|
||||||
);
|
);
|
||||||
$stats['births'] = $scalar(
|
$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" .
|
"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'),
|
||||||
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['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(
|
$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)))" .
|
"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'),
|
||||||
self::idSql($ids, 'a.id'),
|
|
||||||
);
|
);
|
||||||
$stats['current'] =
|
$stats['current'] = (float) ($db->query(
|
||||||
(float) ($db
|
"SELECT COUNT(*) FROM animals WHERE deleted_at IS NULL AND archived_at IS NULL AND status NOT IN ('adopte','decede')" . self::idSql($ids, 'id'),
|
||||||
->query(
|
)->fetchColumn() ?: 0);
|
||||||
"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);
|
|
||||||
}
|
|
||||||
$db = DB::pdo();
|
|
||||||
$range = [':start' => sprintf('%04d-01-01', $year), ':end' => sprintf('%04d-12-31', $year)];
|
|
||||||
$filter = self::idSql($ids, 'a.id');
|
$filter = self::idSql($ids, 'a.id');
|
||||||
$s = $db->prepare(
|
$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" .
|
"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;
|
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
|
public static function comparison(int $year, ?array $ids = null): array
|
||||||
{
|
{
|
||||||
$current = self::annual($year, $ids);
|
$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();
|
$db = DB::pdo();
|
||||||
$p = [':start' => sprintf('%04d-01-01', $year), ':end' => sprintf('%04d-12-31', $year)];
|
$p = [':start' => $start, ':end' => $end];
|
||||||
$base =
|
$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" .
|
"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') .
|
self::idSql($ids, 'm.animal_id') .
|
||||||
|
|
@ -561,7 +574,7 @@ final class StatisticsService
|
||||||
);
|
);
|
||||||
$deathCauses = $map(
|
$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" .
|
"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',
|
' GROUP BY cause_code ORDER BY total DESC',
|
||||||
);
|
);
|
||||||
$stays = $map(
|
$stays = $map(
|
||||||
|
|
@ -572,10 +585,33 @@ final class StatisticsService
|
||||||
return compact('sex', 'ages', 'deathCauses', 'stays');
|
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();
|
$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 {
|
$rows = static function (string $sql, array $params = []) use ($db): array {
|
||||||
$s = $db->prepare($sql);
|
$s = $db->prepare($sql);
|
||||||
$s->execute($params);
|
$s->execute($params);
|
||||||
|
|
@ -585,9 +621,9 @@ final class StatisticsService
|
||||||
}
|
}
|
||||||
return $out;
|
return $out;
|
||||||
};
|
};
|
||||||
$movementDate = $year === null ? '' : ' AND date(m.created_at) BETWEEN :start AND :end';
|
$movementDate = $bounded ? ' AND date(m.created_at) BETWEEN :start AND :end' : '';
|
||||||
$animalDate = $year === null ? '' : ' AND date(intake_date) BETWEEN :start AND :end';
|
$animalDate = $bounded ? ' AND date(intake_date) BETWEEN :start AND :end' : '';
|
||||||
$datedAnimal = $year === null ? '' : " AND NULLIF(TRIM(intake_date),'') IS NOT NULL";
|
$datedAnimal = $bounded ? " AND NULLIF(TRIM(intake_date),'') IS NOT NULL" : '';
|
||||||
$entered =
|
$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'" .
|
"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 .
|
$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",
|
"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,
|
$p,
|
||||||
);
|
);
|
||||||
if ($year === null) {
|
if (!$bounded) {
|
||||||
$animalFilter = self::idSql($ids, 'a.id');
|
$animalFilter = self::idSql($ids, 'a.id');
|
||||||
$details = [];
|
$details = [];
|
||||||
$details['sex'] = $rows(
|
$details['sex'] = $rows(
|
||||||
|
|
@ -651,12 +687,11 @@ final class StatisticsService
|
||||||
' GROUP BY label ORDER BY total DESC',
|
' GROUP BY label ORDER BY total DESC',
|
||||||
);
|
);
|
||||||
$origins = $municipality !== null ? [$municipality => (int) array_sum($species)] : self::municipalities();
|
$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 {
|
} else {
|
||||||
$details = GrantService::detailedStatistics($year);
|
$details = self::detailsRange($start, $end, $ids);
|
||||||
$origins = $details['origins'];
|
$origins = $municipality !== null
|
||||||
|
? [$municipality => (int) array_sum($species)]
|
||||||
|
: self::originsForPeriod($start, $end, $ids);
|
||||||
}
|
}
|
||||||
foreach ($details as &$items) {
|
foreach ($details as &$items) {
|
||||||
arsort($items, SORT_NUMERIC);
|
arsort($items, SORT_NUMERIC);
|
||||||
|
|
@ -674,7 +709,7 @@ final class StatisticsService
|
||||||
' GROUP BY label ORDER BY total DESC,label',
|
' GROUP BY label ORDER BY total DESC,label',
|
||||||
);
|
);
|
||||||
$releaseSql =
|
$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)<>''" .
|
? "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 .
|
$filter .
|
||||||
' GROUP BY label ORDER BY label DESC'
|
' 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 justify-content-between align-items-center gap-2 mb-3">
|
||||||
<div class="d-flex flex-wrap gap-2"><a class="btn" href="/directory">←
|
<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"
|
<?= 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"
|
<?= 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>
|
href="/directory/edit?id=<?= (int)$contact['id'] ?>"><?= h(t('common.edit')) ?></a>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -78,7 +78,7 @@ if ($address): ?><div>📍 <?= h($address) ?></div>
|
||||||
<div class="row align-items-center">
|
<div class="row align-items-center">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<div class="subheader"><?= h(t('directory.total_invoiced')) ?><?php if ($expensePeriodRequested): ?> ·
|
<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>
|
· <?= h($expensePeriodMunicipality) ?><?php endif; ?><?php endif; ?></div>
|
||||||
<div class="h1 mb-0 text-orange"><?= h($money((int)$expenseTotal)) ?></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])) ?>
|
<div class="text-muted small"><?= h(t('directory.expense_count', ['count' => (int)$expenseCount])) ?>
|
||||||
|
|
|
||||||
|
|
@ -31,10 +31,10 @@
|
||||||
<div class="col-lg-6">
|
<div class="col-lg-6">
|
||||||
<section class="card h-100">
|
<section class="card h-100">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<span class="badge bg-blue-lt mb-2">1.0.0-rc.31</span>
|
<span class="badge bg-blue-lt mb-2">1.0.0-rc.32</span>
|
||||||
<h3>Parcours de correction plus fiables</h3>
|
<h3>Statistiques sur mesure</h3>
|
||||||
<p class="text-muted">Liens d’adoptants corrigés, modification des vaccins plus explicite et
|
<p class="text-muted">Bilans par année, par mois ou entre deux dates, avec coûts vétérinaires et
|
||||||
synchronisation i-CAD utilisable même sans JavaScript.</p>
|
comparaison de la période précédente.</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -54,12 +54,19 @@ $groups = [
|
||||||
<div class="text-muted"><?= $municipality !== '' ? t('statistics.city_help') : t('statistics.help') ?></div>
|
<div class="text-muted"><?= $municipality !== '' ? t('statistics.city_help') : t('statistics.help') ?></div>
|
||||||
</div>
|
</div>
|
||||||
<form method="get" action="/statistics" class="d-flex flex-wrap align-items-end gap-2">
|
<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"
|
<div><label class="form-label"><?= h(t('statistics.period')) ?></label><select class="form-select" name="period_type" id="statisticsPeriodType">
|
||||||
name="year">
|
<option value="all" <?= $periodType === 'all' ? 'selected' : '' ?>><?= h(t('statistics.all_years')) ?></option>
|
||||||
<option value="all" <?= $allYears ? 'selected' : '' ?>><?= h(t('statistics.all_years')) ?></option>
|
<option value="year" <?= $periodType === 'year' ? 'selected' : '' ?>><?= h(t('statistics.by_year')) ?></option>
|
||||||
<?php foreach (array_reverse($years) as $y): ?><option value="<?= $y ?>"
|
<option value="month" <?= $periodType === 'month' ? 'selected' : '' ?>><?= h(t('statistics.by_month')) ?></option>
|
||||||
<?= !$allYears && $y === $year ? 'selected' : '' ?>><?= $y ?></option><?php endforeach; ?>
|
<option value="custom" <?= $periodType === 'custom' ? 'selected' : '' ?>><?= h(t('statistics.custom_period')) ?></option>
|
||||||
</select></div>
|
</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"
|
<div><label class="form-label"><?= h(t('statistics.origin_city')) ?></label><select class="form-select"
|
||||||
name="municipality">
|
name="municipality">
|
||||||
<option value=""><?= h(t('statistics.all_cities')) ?></option>
|
<option value=""><?= h(t('statistics.all_cities')) ?></option>
|
||||||
|
|
@ -69,7 +76,7 @@ $groups = [
|
||||||
</select></div><button
|
</select></div><button
|
||||||
class="btn btn-cyan"><?= h(t('common.show')) ?></button><?php if ($municipality !== ''): ?><a
|
class="btn btn-cyan"><?= h(t('common.show')) ?></button><?php if ($municipality !== ''): ?><a
|
||||||
class="btn btn-outline-secondary"
|
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()">🖨
|
class="btn btn-outline-primary" type="button" onclick="window.print()">🖨
|
||||||
<?= h(t('common.print')) ?></button><?php endif; ?>
|
<?= h(t('common.print')) ?></button><?php endif; ?>
|
||||||
</form>
|
</form>
|
||||||
|
|
@ -78,7 +85,7 @@ $groups = [
|
||||||
|
|
||||||
<?php if ($municipality !== ''): ?><div class="alert alert-info d-flex flex-wrap align-items-center gap-2">
|
<?php if ($municipality !== ''): ?><div class="alert alert-info d-flex flex-wrap align-items-center gap-2">
|
||||||
<strong><?= h($municipality) ?> ·
|
<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; ?>
|
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">
|
<?php if ($municipality === '' && !empty($municipalityCoverage)): ?><div class="alert alert-info mb-4">
|
||||||
<div class="d-flex flex-wrap align-items-center gap-2">
|
<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="text-muted small text-uppercase"><?= h($label) ?></div>
|
||||||
<div class="display-6 fw-bold"><?= (int)$stats[$key] ?></div>
|
<div class="display-6 fw-bold"><?= (int)$stats[$key] ?></div>
|
||||||
<div class="small text-muted">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -138,7 +145,7 @@ $groups = [
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<h4 class="mb-1"><?= h(t('statistics.by_structure')) ?></h4>
|
<h4 class="mb-1"><?= h(t('statistics.by_structure')) ?></h4>
|
||||||
<div class="text-muted small mb-2"><?= h(t('statistics.structure_cost_help')) ?></div>
|
<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">
|
<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) ?>"
|
<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
|
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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<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>
|
<tr>
|
||||||
<td><a class="fw-semibold" href="<?= h($cityUrl) ?>"
|
<td><a class="fw-semibold" href="<?= h($cityUrl) ?>"
|
||||||
title="<?= h(t('statistics.cost_city_open', ['city' => $row['municipality']])) ?>"><?= h($row['municipality']) ?></a>
|
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><?php else: ?><div class="card h-100">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div>
|
<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 class="text-muted small"><?= h(t('statistics.no_previous_percent')) ?></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -254,8 +261,8 @@ if (empty($costs['by_clinic'])):?><span class="text-muted"><?= h(t('statistics.n
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th><?= h(t('grant.indicator')) ?></th>
|
<th><?= h(t('grant.indicator')) ?></th>
|
||||||
<th class="text-end"><?= $year - 1 ?></th>
|
<th class="text-end"><?= h($previousPeriodLabel) ?></th>
|
||||||
<th class="text-end"><?= $year ?></th>
|
<th class="text-end"><?= h($periodLabel) ?></th>
|
||||||
<th class="text-end"><?= h(t('grant.evolution')) ?></th>
|
<th class="text-end"><?= h(t('grant.evolution')) ?></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</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">
|
<div class="d-flex flex-wrap align-items-baseline gap-2 mb-3">
|
||||||
<h2 class="mb-0"><?= h(t('statistics.profiles')) ?></h2><span
|
<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>
|
||||||
<div class="row row-cards"><?php foreach ($groups as $group): [$title,$items,$labels,$color] = $group;
|
<div class="row row-cards"><?php foreach ($groups as $group): [$title,$items,$labels,$color] = $group;
|
||||||
$linkType = $group[4] ?? null;
|
$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;
|
<?php foreach ($items as $label => $total): $display = $labels[$label] ?? $distributionLabels[$label] ?? $label;
|
||||||
$width = $maximum > 0 ? max(4, round($total / $maximum * 100)) : 0;
|
$width = $maximum > 0 ? max(4, round($total / $maximum * 100)) : 0;
|
||||||
$pct = $sum > 0 ? round($total / $sum * 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) {
|
$filterValue = $linkType === 'sex' ? match ((string)$label) {
|
||||||
'Femelles' => 'F', 'Mâles' => 'M', default => ''
|
'Femelles' => 'F', 'Mâles' => 'M', default => ''
|
||||||
} : (string)$label;
|
} : (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><?php endforeach; ?></div><?php endif; ?></div>
|
||||||
</div>
|
</div>
|
||||||
</div><?php endforeach; ?></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>
|
<?php if ($allYears): ?><script>
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const canvas = document.getElementById('activityHistoryChart');
|
const canvas = document.getElementById('activityHistoryChart');
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
|
|
||||||
## Statistiques
|
## 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.
|
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
|
## 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.
|
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.code'=>'Code',
|
||||||
'catalog.active_ingredient'=>'Active ingredient',
|
'catalog.active_ingredient'=>'Active ingredient',
|
||||||
'catalog.delete_confirm'=>'Delete this label?',
|
'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.code'=>'Code',
|
||||||
'catalog.active_ingredient'=>'Principe actif',
|
'catalog.active_ingredient'=>'Principe actif',
|
||||||
'catalog.delete_confirm'=>'Supprimer cet intitulé ?',
|
'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($structure !== null && $structure['label'] === 'Structure test');
|
||||||
$assert((int) $structure['total_cents'] === 1234);
|
$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 {
|
$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');
|
$view = file_get_contents(__DIR__ . '/../app/Views/_animal_show_identity.php');
|
||||||
$routes = file_get_contents(__DIR__ . '/../app/Routes/web.php');
|
$routes = file_get_contents(__DIR__ . '/../app/Routes/web.php');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue