Ajouter les statistiques par période
This commit is contained in:
parent
9ff26ad9be
commit
86642095cd
14 changed files with 315 additions and 125 deletions
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in a new issue