Publier Globinours 1.0.0-rc.3

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

View file

@ -0,0 +1,192 @@
<?php
require_once __DIR__ . '/DB.php';
require_once __DIR__ . '/GeocodeService.php';
class AdoptionService
{
public static function adopt(array $data): void
{
$db = DB::pdo();
$db->beginTransaction();
try {
$animalId = (int) $data['animal_id'];
$date = $data['adoption_date'] ?? date('Y-m-d');
$beforeStmt = $db->prepare(
'SELECT status,refuge_room,care_box_key,current_address FROM animals WHERE id=:id',
);
$beforeStmt->execute([':id' => $animalId]);
$beforeLocation = $beforeStmt->fetch(PDO::FETCH_ASSOC) ?: [];
// 1. Enregistrement de ladoption
$stmt = $db->prepare('
INSERT INTO adoptions (
animal_id,
adopter_name,
adopter_phone,
adopter_email,
adopter_address,
adopter_postal_code,
adopter_city,
adoption_date,
notes,
adopter_contact_id,
created_at
) VALUES (
:animal_id,
:name,
:phone,
:email,
:address,
:postal,
:city,
:date,
:notes,
:contact_id,
CURRENT_TIMESTAMP
)
');
$stmt->execute([
':animal_id' => $animalId,
':name' => $data['adopter_name'],
':phone' => $data['adopter_phone'] ?: null,
':email' => $data['adopter_email'] ?: null,
':address' => $data['adopter_address'] ?: null,
':postal' => $data['adopter_postal_code'] ?: null,
':city' => $data['adopter_city'] ?: null,
':date' => $date,
':notes' => $data['notes'] ?: null,
':contact_id' => $data['adopter_contact_id'] ?? null,
]);
$adoptionId = (int) $db->lastInsertId();
$db->prepare(
"INSERT INTO animal_placements(animal_id,event_type,event_date,contact_id,reason,notes,adoption_id,created_by) VALUES(:animal,'adoption',:date,:contact,'Adoption définitive',:notes,:adoption,:user)",
)->execute([
':animal' => $animalId,
':date' => $date,
':contact' => $data['adopter_contact_id'] ?? null,
':notes' => $data['notes'] ?: null,
':adoption' => $adoptionId,
':user' => Auth::id(),
]);
// 2. Mise à jour du statut de lanimal
$currentAddress = implode(
' ',
array_filter([
$data['adopter_address'] ?? '',
$data['adopter_postal_code'] ?? '',
$data['adopter_city'] ?? '',
]),
);
$lat = null;
$lng = null;
if (!empty($currentAddress)) {
$coords = GeocodeService::geocode($currentAddress);
if ($coords) {
$lat = $coords['lat'] ?? null;
$lng = $coords['lng'] ?? null;
}
}
$db->prepare(
"
UPDATE animals
SET
status = 'adopte',
refuge_room = NULL,
care_box_key = NULL,
updated_at = CURRENT_TIMESTAMP,
current_address = :currentAddress,
current_lat = :lat,
current_lng = :lng
WHERE id = :id
",
)->execute([':id' => $animalId, ':currentAddress' => $currentAddress, ':lat' => $lat, ':lng' => $lng]);
LocationHistoryService::record(
$db,
$animalId,
$beforeLocation,
[
'status' => 'adopte',
'refuge_room' => null,
'care_box_key' => null,
'current_address' => $currentAddress,
],
'adoption',
'Adoption finalisée',
$date . ' 12:00:00',
);
// 3. Ajout dans les mouvements administratifs (sortie)
$stmt = $db->prepare("
INSERT INTO animal_movements (
animal_id,
kind,
place,
lieu,
contact_name,
contact_phone,
contact_email,
note,
created_at
) VALUES (
:animal_id,
'exit',
:place,
'Adoption',
:contact_name,
:contact_phone,
:contact_email,
:note,
CURRENT_TIMESTAMP
)
");
$stmt->execute([
':animal_id' => $animalId,
':place' => $data['adopter_address'] ?: null,
':contact_name' => $data['adopter_name'] ?: null,
':contact_phone' => $data['adopter_phone'] ?: null,
':contact_email' => $data['adopter_email'] ?: null,
':note' => 'Adoption finalisée',
]);
// 4. Ajout dans lhistorique
$stmt = $db->prepare("
INSERT INTO animal_history (
animal_id,
type,
label,
details,
user_id,
created_at
) VALUES (
:animal_id,
'adoption',
'Adoption finalisée',
:details,
:user_id,
CURRENT_TIMESTAMP
)
");
$stmt->execute([
':animal_id' => $animalId,
':details' => 'Par ' . $data['adopter_name'] . ' - Note(s) : ' . $data['notes'],
':user_id' => Auth::id(),
]);
$db->commit();
} catch (Exception $e) {
$db->rollBack();
throw $e;
}
}
}

View file

@ -0,0 +1,351 @@
<?php
declare(strict_types=1);
final class AgendaService
{
public const TYPES = ['appointment', 'veterinary', 'transport', 'pre_adoption', 'task', 'other'];
public static function save(array $data, ?int $id = null): int
{
$type = (string) ($data['item_type'] ?? 'task');
$title = trim((string) ($data['title'] ?? ''));
$date = trim((string) ($data['date'] ?? ''));
$time = trim((string) ($data['time'] ?? ''));
$allDay = isset($data['all_day']);
if (!in_array($type, self::TYPES, true) || $title === '' || !self::date($date)) {
throw new RuntimeException(t('agenda.invalid'));
}
if (!$allDay && !preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $time)) {
throw new RuntimeException(t('agenda.invalid_time'));
}
$start = $date . ' ' . ($allDay ? '00:00:00' : $time . ':00');
$endDate = trim((string) ($data['end_date'] ?? ''));
$endTime = trim((string) ($data['end_time'] ?? ''));
$end = null;
if ($endDate !== '' || $endTime !== '') {
if (!self::date($endDate) || !preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $endTime)) {
throw new RuntimeException(t('agenda.invalid_end'));
}
$end = $endDate . ' ' . $endTime . ':00';
if ($end < $start) {
throw new RuntimeException(t('agenda.end_before_start'));
}
}
$repeat = (string) ($data['repeat_rule'] ?? 'none');
if (!in_array($repeat, ['none', 'daily', 'weekly', 'monthly'], true)) {
$repeat = 'none';
}
$until = trim((string) ($data['repeat_until'] ?? ''));
if ($until !== '' && !self::date($until)) {
throw new RuntimeException(t('agenda.invalid_repeat'));
}
$values = [
':type' => $type,
':title' => mb_substr($title, 0, 180),
':description' => trim((string) ($data['description'] ?? '')) ?: null,
':start' => $start,
':end' => $end,
':all_day' => $allDay ? 1 : 0,
':location' => trim((string) ($data['location'] ?? '')) ?: null,
':priority' => in_array(
$p = (string) ($data['priority'] ?? 'normal'),
['low', 'normal', 'high', 'urgent'],
true,
)
? $p
: 'normal',
':assigned' => (int) ($data['assigned_user_id'] ?? 0) ?: null,
':animal' => (int) ($data['animal_id'] ?? 0) ?: null,
':contact' => (int) ($data['contact_id'] ?? 0) ?: null,
':reminder' => ($data['reminder_minutes'] ?? '') === '' ? null : max(0, (int) $data['reminder_minutes']),
':repeat' => $repeat,
':until' => $until ?: null,
':user' => Auth::id(),
];
$db = DB::pdo();
$db->beginTransaction();
try {
if ($id) {
$values[':id'] = $id;
$db->prepare(
"UPDATE agenda_items SET item_type=:type,title=:title,description=:description,starts_at=:start,ends_at=:end,all_day=:all_day,location=:location,priority=:priority,assigned_user_id=:assigned,animal_id=:animal,contact_id=:contact,reminder_minutes=:reminder,repeat_rule=:repeat,repeat_until=:until,updated_at=datetime('now') WHERE id=:id",
)->execute($values);
} else {
$db->prepare(
'INSERT INTO agenda_items(item_type,title,description,starts_at,ends_at,all_day,location,priority,assigned_user_id,animal_id,contact_id,reminder_minutes,repeat_rule,repeat_until,created_by) VALUES(:type,:title,:description,:start,:end,:all_day,:location,:priority,:assigned,:animal,:contact,:reminder,:repeat,:until,:user)',
)->execute($values);
$id = (int) $db->lastInsertId();
}
self::saveVolunteers($id, $data['volunteer_contact_ids'] ?? []);
$db->commit();
return $id;
} catch (Throwable $e) {
if ($db->inTransaction()) {
$db->rollBack();
}
throw $e;
}
}
public static function find(int $id): ?array
{
$s = DB::pdo()->prepare(
"SELECT ai.*,(SELECT group_concat(contact_id,',') FROM agenda_volunteers WHERE agenda_item_id=ai.id) volunteer_ids FROM agenda_items ai WHERE ai.id=?",
);
$s->execute([$id]);
return $s->fetch(PDO::FETCH_ASSOC) ?: null;
}
public static function setStatus(int $id, string $status): void
{
if (!in_array($status, ['planned', 'completed', 'cancelled'], true)) {
throw new RuntimeException(t('agenda.invalid'));
}
DB::pdo()
->prepare(
"UPDATE agenda_items SET status=?,completed_at=CASE WHEN ?='completed' THEN datetime('now') ELSE NULL END,completed_by=CASE WHEN ?='completed' THEN ? ELSE NULL END,updated_at=datetime('now') WHERE id=?",
)
->execute([$status, $status, $status, Auth::id(), $id]);
}
public static function conflicts(int $id): array
{
$db = DB::pdo();
$s = $db->prepare(
"SELECT DISTINCT other.id,other.title,other.starts_at FROM agenda_items current JOIN agenda_items other ON other.id<>current.id AND other.status='planned' AND datetime(other.starts_at)<datetime(COALESCE(current.ends_at,CASE WHEN current.all_day=1 THEN datetime(current.starts_at,'+1 day') ELSE datetime(current.starts_at,'+1 hour') END)) AND datetime(COALESCE(other.ends_at,CASE WHEN other.all_day=1 THEN datetime(other.starts_at,'+1 day') ELSE datetime(other.starts_at,'+1 hour') END))>datetime(current.starts_at) WHERE current.id=:id AND current.status='planned' AND ((current.assigned_user_id IS NOT NULL AND other.assigned_user_id=current.assigned_user_id) OR EXISTS(SELECT 1 FROM agenda_volunteers mine JOIN agenda_volunteers theirs ON theirs.contact_id=mine.contact_id AND theirs.agenda_item_id=other.id WHERE mine.agenda_item_id=current.id)) ORDER BY datetime(other.starts_at)",
);
$s->execute([':id' => $id]);
return $s->fetchAll(PDO::FETCH_ASSOC);
}
public static function month(DateTimeImmutable $month): array
{
return self::between(
$month->modify('first day of this month')->setTime(0, 0),
$month->modify('last day of this month')->setTime(23, 59, 59),
);
}
public static function week(DateTimeImmutable $date): array
{
$start = $date->modify('monday this week')->setTime(0, 0);
return self::between($start, $start->modify('+6 days')->setTime(23, 59, 59));
}
public static function range(DateTimeImmutable $from, DateTimeImmutable $to): array
{
return self::between($from, $to);
}
public static function upcoming(int $days = 30): array
{
return self::between(new DateTimeImmutable('today'), new DateTimeImmutable('+' . $days . ' days 23:59:59'));
}
public static function due(): array
{
$db = DB::pdo();
$overdue = $db
->query(
"SELECT ai.*,ai.starts_at instance_starts_at,a.name animal_name,COALESCE(u.display_name,u.username) assigned_name FROM agenda_items ai LEFT JOIN animals a ON a.id=ai.animal_id LEFT JOIN users u ON u.id=ai.assigned_user_id WHERE ai.status='planned' AND ai.repeat_rule='none' AND datetime(ai.starts_at)<datetime('now') ORDER BY datetime(ai.starts_at) LIMIT 25",
)
->fetchAll(PDO::FETCH_ASSOC);
$soon = array_values(
array_filter(
self::upcoming(2),
static fn($row) => $row['status'] === 'planned' &&
strtotime($row['instance_starts_at']) <= strtotime('+1 day'),
),
);
$seen = [];
$out = [];
foreach ([...$overdue, ...$soon] as $row) {
$key = (int) $row['id'] . '|' . ($row['instance_starts_at'] ?? $row['starts_at']);
if (isset($seen[$key])) {
continue;
}
$seen[$key] = true;
$out[] = $row;
}
usort(
$out,
static fn($a, $b) => strcmp(
(string) ($a['instance_starts_at'] ?? $a['starts_at']),
(string) ($b['instance_starts_at'] ?? $b['starts_at']),
),
);
return array_slice($out, 0, 25);
}
private static function between(DateTimeImmutable $from, DateTimeImmutable $to): array
{
$db = DB::pdo();
$s = $db->prepare(
"SELECT ai.*,a.name animal_name,dc.name contact_name,COALESCE(u.display_name,u.username) assigned_name,(SELECT group_concat(vdc.name,', ') FROM agenda_volunteers av JOIN directory_contacts vdc ON vdc.id=av.contact_id WHERE av.agenda_item_id=ai.id) volunteer_names,(SELECT group_concat(av.contact_id,',') FROM agenda_volunteers av WHERE av.agenda_item_id=ai.id) volunteer_ids FROM agenda_items ai LEFT JOIN animals a ON a.id=ai.animal_id LEFT JOIN directory_contacts dc ON dc.id=ai.contact_id LEFT JOIN users u ON u.id=ai.assigned_user_id WHERE date(ai.starts_at)<=:to AND (ai.repeat_rule<>'none' OR datetime(ai.starts_at)>=:from) AND ai.status<>'cancelled' ORDER BY datetime(ai.starts_at),ai.priority DESC",
);
$s->execute([':from' => $from->format('Y-m-d H:i:s'), ':to' => $to->format('Y-m-d')]);
$out = [];
foreach ($s->fetchAll(PDO::FETCH_ASSOC) as $row) {
$start = new DateTimeImmutable($row['starts_at']);
$end = $row['ends_at'] ? new DateTimeImmutable($row['ends_at']) : null;
$step = match ($row['repeat_rule']) {
'daily' => '+1 day',
'weekly' => '+1 week',
'monthly' => '+1 month',
default => null,
};
while ($start < $from && $step) {
$start = $start->modify($step);
}
do {
if ($start > $to) {
break;
}
if (!$row['repeat_until'] || $start->format('Y-m-d') <= $row['repeat_until']) {
$instance = $row;
$instance['instance_starts_at'] = $start->format('Y-m-d H:i:s');
$instance['instance_ends_at'] = $end
? $end
->modify(
$start->getTimestamp() -
new DateTimeImmutable($row['starts_at'])->getTimestamp() .
' seconds',
)
->format('Y-m-d H:i:s')
: null;
$instance['source_type'] = 'agenda';
if (!empty($instance['volunteer_names'])) {
$instance['title'] .= ' · 👥 ' . $instance['volunteer_names'];
}
$out[] = $instance;
}
if (!$step) {
break;
}
$start = $start->modify($step);
} while (!$row['repeat_until'] || $start->format('Y-m-d') <= $row['repeat_until']);
}
$vaccines = $db->prepare(
"SELECT v.id,v.due_date,rv.name vaccine_name,a.id animal_id,a.name animal_name FROM vaccinations v JOIN ref_vaccines rv ON rv.id=v.vaccine_id JOIN animals a ON a.id=v.animal_id WHERE v.due_date IS NOT NULL AND date(v.due_date) BETWEEN :from AND :to AND a.deleted_at IS NULL AND a.archived_at IS NULL AND lower(a.status) NOT IN ('adopte','adopté','decede','décédé') AND NOT EXISTS(SELECT 1 FROM vaccinations newer WHERE newer.animal_id=v.animal_id AND newer.vaccine_id=v.vaccine_id AND date(newer.done_date)>date(v.done_date)) ORDER BY date(v.due_date),a.name COLLATE NOCASE",
);
$vaccines->execute([':from' => $from->format('Y-m-d'), ':to' => $to->format('Y-m-d')]);
foreach ($vaccines->fetchAll(PDO::FETCH_ASSOC) as $v) {
$out[] = self::reminder(
-(100000000 + (int) $v['id']),
(int) $v['id'],
'vaccine',
'vaccine',
t('agenda.vaccine_reminder', ['vaccine' => $v['vaccine_name']]),
$v['due_date'],
(int) $v['animal_id'],
$v['animal_name'],
'high',
);
}
$dewormings = $db->prepare(
"SELECT d.id,d.next_due_date,COALESCE(rd.name,'') dewormer_name,a.id animal_id,a.name animal_name FROM dewormings d LEFT JOIN ref_dewormers rd ON rd.id=d.dewormer_id JOIN animals a ON a.id=d.animal_id WHERE d.next_due_date IS NOT NULL AND date(d.next_due_date) BETWEEN :from AND :to AND a.deleted_at IS NULL AND a.archived_at IS NULL AND lower(a.status) NOT IN ('adopte','adopté','decede','décédé') AND NOT EXISTS(SELECT 1 FROM dewormings newer WHERE newer.animal_id=d.animal_id AND (date(newer.administered_on)>date(d.administered_on) OR (date(newer.administered_on)=date(d.administered_on) AND newer.id>d.id))) ORDER BY date(d.next_due_date),a.name COLLATE NOCASE",
);
$dewormings->execute([':from' => $from->format('Y-m-d'), ':to' => $to->format('Y-m-d')]);
foreach ($dewormings->fetchAll(PDO::FETCH_ASSOC) as $d) {
$out[] = self::reminder(
-(200000000 + (int) $d['id']),
(int) $d['id'],
'deworming',
'deworming',
t('agenda.deworming_reminder', ['product' => $d['dewormer_name'] ?: t('agenda.deworming')]),
$d['next_due_date'],
(int) $d['animal_id'],
$d['animal_name'],
'high',
);
}
$treatments = $db->prepare(
"SELECT t.id,t.start_date,t.end_date,m.name medication_name,a.id animal_id,a.name animal_name FROM treatments t JOIN ref_medications m ON m.id=t.medication_id JOIN animals a ON a.id=t.animal_id WHERE t.ongoing=1 AND (date(t.start_date) BETWEEN :from AND :to OR (t.end_date IS NOT NULL AND date(t.end_date) BETWEEN :from AND :to)) AND a.deleted_at IS NULL AND a.archived_at IS NULL AND lower(a.status) NOT IN ('adopte','adopté','decede','décédé') ORDER BY date(t.start_date),a.name COLLATE NOCASE",
);
$treatments->execute([':from' => $from->format('Y-m-d'), ':to' => $to->format('Y-m-d')]);
foreach ($treatments->fetchAll(PDO::FETCH_ASSOC) as $t) {
if ($t['start_date'] >= $from->format('Y-m-d') && $t['start_date'] <= $to->format('Y-m-d')) {
$out[] = self::reminder(
-(300000000 + (int) $t['id']),
(int) $t['id'],
'treatment_start',
'treatment_start',
t('agenda.treatment_start', ['medication' => $t['medication_name']]),
$t['start_date'],
(int) $t['animal_id'],
$t['animal_name'],
'high',
);
}
if ($t['end_date'] && $t['end_date'] >= $from->format('Y-m-d') && $t['end_date'] <= $to->format('Y-m-d')) {
$out[] = self::reminder(
-(400000000 + (int) $t['id']),
(int) $t['id'],
'treatment_end',
'treatment_end',
t('agenda.treatment_end', ['medication' => $t['medication_name']]),
$t['end_date'],
(int) $t['animal_id'],
$t['animal_name'],
'high',
);
}
}
usort($out, static fn($a, $b) => strcmp($a['instance_starts_at'], $b['instance_starts_at']));
return $out;
}
private static function reminder(
int $id,
int $sourceId,
string $sourceType,
string $itemType,
string $title,
string $date,
int $animalId,
string $animalName,
string $priority,
): array {
return [
'id' => $id,
'source_id' => $sourceId,
'source_type' => $sourceType,
'item_type' => $itemType,
'title' => $title,
'description' => null,
'starts_at' => $date . ' 00:00:00',
'instance_starts_at' => $date . ' 00:00:00',
'instance_ends_at' => null,
'all_day' => 1,
'location' => null,
'status' => 'planned',
'priority' => $priority,
'assigned_user_id' => null,
'assigned_name' => null,
'volunteer_names' => null,
'volunteer_ids' => null,
'animal_id' => $animalId,
'animal_name' => $animalName,
'contact_id' => null,
'contact_name' => null,
'reminder_minutes' => null,
'repeat_rule' => 'none',
'repeat_until' => null,
];
}
private static function saveVolunteers(int $itemId, mixed $input): void
{
$ids = is_array($input) ? array_values(array_unique(array_filter(array_map('intval', $input)))) : [];
$db = DB::pdo();
$db->prepare('DELETE FROM agenda_volunteers WHERE agenda_item_id=?')->execute([$itemId]);
if (!$ids) {
return;
}
$check = $db->prepare(
"SELECT 1 FROM directory_contacts dc JOIN directory_contact_roles r ON r.contact_id=dc.id AND r.role='benevole' WHERE dc.id=? AND dc.deleted_at IS NULL",
);
$insert = $db->prepare('INSERT INTO agenda_volunteers(agenda_item_id,contact_id) VALUES(?,?)');
foreach ($ids as $id) {
$check->execute([$id]);
if ($check->fetchColumn()) {
$insert->execute([$itemId, $id]);
}
}
}
private static function date(string $v): bool
{
$d = DateTimeImmutable::createFromFormat('!Y-m-d', $v);
return $d && $d->format('Y-m-d') === $v;
}
}

View file

@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
final class AppSettings
{
private const DEFAULTS = [
'association_name' => 'Association',
'association_address' => '',
'association_postal_code' => '',
'association_city' => '',
'association_phone' => '',
'association_email' => '',
'association_siret' => '',
'association_rna' => '',
'association_latitude' => '',
'association_longitude' => '',
'adoption_fee_sterilized' => '220',
'adoption_fee_unsterilized' => '200',
'quarantine_days' => '15',
'backup_retention' => '10',
'app_language' => 'fr',
'public_site_enabled' => '0',
'public_site_theme' => 'warm',
'public_site_routing' => 'integrated',
'public_site_home_title' => 'Ils attendent une famille',
'public_site_home_text' => 'Découvrez les animaux actuellement proposés à ladoption par notre association.',
'public_site_about_enabled' => '1',
'public_site_about_title' => 'Notre association',
'public_site_about_text' => 'Présentez ici votre association, son histoire et ses missions.',
'public_site_donation_title' => 'Nous soutenir',
'public_site_donation_text' =>
'Chaque don contribue directement aux soins, à lalimentation et à la protection des animaux.',
'public_site_donation_url' => '',
'public_site_contact_text' => 'Une question sur une adoption ou sur lassociation ? Contactez-nous.',
'public_site_facebook_url' => '',
'public_site_instagram_url' => '',
'notification_email_enabled' => '0',
'notification_email_frequency' => 'weekly',
'notification_email_recipient' => '',
'notification_last_sent_at' => '',
'privacy_retention_years' => '5',
'privacy_retention_adopter_years' => '5',
'privacy_retention_foster_years' => '5',
'privacy_retention_volunteer_years' => '5',
'privacy_access_log_months' => '24',
'backup_schedule_enabled' => '0',
'backup_schedule_frequency' => 'daily',
'backup_last_run_at' => '',
'backup_last_verified_at' => '',
'public_site_legal_text' => '',
'public_site_privacy_text' => '',
];
private static ?array $cache = null;
public static function all(): array
{
if (self::$cache !== null) {
return self::$cache;
}
$values = self::DEFAULTS;
try {
foreach (
DB::pdo()->query('SELECT setting_key,setting_value FROM app_settings')->fetchAll(PDO::FETCH_ASSOC)
as $row
) {
if (array_key_exists($row['setting_key'], $values)) {
$values[$row['setting_key']] = (string) $row['setting_value'];
}
}
} catch (Throwable) {
}
return self::$cache = $values;
}
public static function get(string $key): string
{
return (string) (self::all()[$key] ?? '');
}
public static function int(string $key, int $min = 0, int $max = PHP_INT_MAX): int
{
return max($min, min($max, (int) self::get($key)));
}
public static function save(array $values, ?int $userId): void
{
$allowed = array_keys(self::DEFAULTS);
$stmt = DB::pdo()->prepare(
"INSERT INTO app_settings(setting_key,setting_value,updated_by,updated_at) VALUES(:key,:value,:user,datetime('now')) ON CONFLICT(setting_key) DO UPDATE SET setting_value=excluded.setting_value,updated_by=excluded.updated_by,updated_at=datetime('now')",
);
foreach ($allowed as $key) {
if (array_key_exists($key, $values)) {
$stmt->execute([':key' => $key, ':value' => (string) $values[$key], ':user' => $userId]);
}
}
self::$cache = null;
}
}

View file

@ -0,0 +1,490 @@
<?php
declare(strict_types=1);
final class Asm3Analyzer
{
private const DOMAINS = [
'animal' => ['Animaux', 'animals', 'ready'],
'owner' => ['Contacts et structures', 'directory_contacts', 'planned'],
'adoption' => ['Mouvements, FA et adoptions', 'animal_placements / adoptions', 'planned'],
'animalvaccination' => ['Vaccinations', 'vaccinations', 'planned'],
'medical' => ['Traitements et actes médicaux', 'medical_notes / treatments', 'planned'],
'animalmedical' => ['Traitements et actes médicaux', 'medical_notes / treatments', 'planned'],
'media' => ['Médias', 'animal_photos / medical_photos', 'planned'],
'animallitter' => ['Portées', 'litters', 'planned'],
'animalcontrol' => ['Incidents et contrôles', 'non pris en charge', 'review'],
'animaltest' => ['Tests médicaux', 'medical_notes', 'planned'],
];
public static function analyze(string $path): array
{
if (!is_file($path) || !is_readable($path)) {
throw new RuntimeException(t('asm3.unreadable'));
}
if (self::isCopyDump($path)) {
return self::analyzeCopy($path);
}
$handle = fopen($path, 'rb');
if (!$handle) {
throw new RuntimeException(t('asm3.open_failed'));
}
$counts = [];
$columns = [];
$animalStats = [
'active' => 0,
'archived' => 0,
'adoptable' => 0,
'not_adoptable' => 0,
'deceased' => 0,
'microchip' => 0,
'tattoo' => 0,
'neutered' => 0,
'missing_name' => 0,
'missing_birth_date' => 0,
'species' => [],
];
$statement = '';
$quoted = false;
$bytes = 0;
try {
while (($chunk = fgets($handle)) !== false) {
$bytes += strlen($chunk);
$statement .= $chunk;
$length = strlen($statement);
$quoted = false;
for ($i = 0; $i < $length; $i++) {
if ($statement[$i] !== "'") {
continue;
}
if ($quoted && $i + 1 < $length && $statement[$i + 1] === "'") {
$i++;
continue;
}
$quoted = !$quoted;
}
if ($quoted || !preg_match('/;\s*$/s', $statement)) {
continue;
}
self::consume($statement, $counts, $columns, $animalStats);
$statement = '';
}
if (trim($statement) !== '') {
self::consume($statement, $counts, $columns, $animalStats);
}
} finally {
fclose($handle);
}
arsort($counts);
$domains = [];
foreach (self::DOMAINS as $table => [$label, $target, $status]) {
$domains[] = [
'table' => $table,
'label' => $label,
'target' => $target,
'status' => $status,
'rows' => $counts[$table] ?? 0,
'present' => isset($counts[$table]),
];
}
$warnings = [];
if (!isset($counts['animal'])) {
$warnings[] = t('asm3.animal_table_missing');
}
if (($counts['animal'] ?? 0) > 0 && $animalStats['missing_name'] > 0) {
$warnings[] = t('asm3.animals_without_name', ['count' => $animalStats['missing_name']]);
}
if (($counts['animal'] ?? 0) > 0 && $animalStats['missing_birth_date'] > 0) {
$warnings[] = t('asm3.animals_without_birth', ['count' => $animalStats['missing_birth_date']]);
}
$unsupported = [];
foreach ($counts as $table => $count) {
if (!isset(self::DOMAINS[$table]) && $count > 0) {
$unsupported[$table] = $count;
}
}
return [
'format' => 'ASM3 SQL INSERT',
'source' => basename($path),
'size_bytes' => filesize($path),
'read_bytes' => $bytes,
'generated_at' => date(DATE_ATOM),
'tables' => $counts,
'columns' => $columns,
'domains' => $domains,
'animals' => $animalStats,
'warnings' => $warnings,
'unmapped_tables' => $unsupported,
'read_only' => true,
];
}
public static function rows(string $path, array $wantedTables): array
{
if (self::isCopyDump($path)) {
return self::copyRows($path, $wantedTables);
}
$wanted = array_fill_keys(array_map('strtolower', $wantedTables), true);
$rows = [];
$columns = [];
$handle = fopen($path, 'rb');
if (!$handle) {
throw new RuntimeException(t('asm3.open_failed'));
}
$statement = '';
try {
while (($chunk = fgets($handle)) !== false) {
$statement .= $chunk;
$quoted = false;
$length = strlen($statement);
for ($i = 0; $i < $length; $i++) {
if ($statement[$i] !== "'") {
continue;
}
if ($quoted && $i + 1 < $length && $statement[$i + 1] === "'") {
$i++;
continue;
}
$quoted = !$quoted;
}
if ($quoted || !preg_match('/;\s*$/s', $statement)) {
continue;
}
if (
preg_match(
'/^\s*INSERT\s+INTO\s+[`"]?([a-zA-Z0-9_]+)[`"]?\s*\((.*?)\)\s*VALUES\s*\((.*)\)\s*;\s*$/is',
$statement,
$m,
)
) {
$table = strtolower($m[1]);
if (isset($wanted[$table])) {
$cols = $columns[$table] ??= array_map(
static fn($v) => strtoupper(trim($v, " \t\r\n`\"")),
explode(',', $m[2]),
);
$values = self::values($m[3]);
$row = [];
foreach ($cols as $i => $column) {
$row[$column] = $values[$i] ?? null;
}
$rows[$table][] = $row;
}
}
$statement = '';
}
} finally {
fclose($handle);
}
return $rows;
}
private static function consume(string $sql, array &$counts, array &$columns, array &$animalStats): void
{
if (
!preg_match(
'/^\s*INSERT\s+INTO\s+[`"]?([a-zA-Z0-9_]+)[`"]?\s*\((.*?)\)\s*VALUES\s*\((.*)\)\s*;\s*$/is',
$sql,
$m,
)
) {
return;
}
$table = strtolower($m[1]);
$counts[$table] = ($counts[$table] ?? 0) + 1;
if (!isset($columns[$table])) {
$columns[$table] = array_map(static fn($v) => strtoupper(trim($v, " \t\r\n`\"")), explode(',', $m[2]));
}
if ($table !== 'animal') {
return;
}
$values = self::values($m[3]);
$row = [];
foreach ($columns[$table] as $i => $column) {
$row[$column] = $values[$i] ?? null;
}
$truthy = static fn($v): bool => (string) $v === '1';
$blank = static fn($v): bool => $v === null || trim((string) $v) === '';
$animalStats[$truthy($row['ARCHIVED'] ?? 0) ? 'archived' : 'active']++;
$animalStats[$truthy($row['ADOPTABLE'] ?? 0) ? 'adoptable' : 'not_adoptable']++;
if (!$blank($row['DECEASEDDATE'] ?? null) || $truthy($row['PUTTOSLEEP'] ?? 0)) {
$animalStats['deceased']++;
}
if (!$blank($row['IDENTICHIPNUMBER'] ?? null) || $truthy($row['IDENTICHIPPED'] ?? 0)) {
$animalStats['microchip']++;
}
if (!$blank($row['TATTOONUMBER'] ?? null) || $truthy($row['TATTOO'] ?? 0)) {
$animalStats['tattoo']++;
}
if ($truthy($row['NEUTERED'] ?? 0)) {
$animalStats['neutered']++;
}
if ($blank($row['ANIMALNAME'] ?? null)) {
$animalStats['missing_name']++;
}
if ($blank($row['DATEOFBIRTH'] ?? null)) {
$animalStats['missing_birth_date']++;
}
$species = (string) ($row['SPECIESID'] ?? 'unknown');
$animalStats['species'][$species] = ($animalStats['species'][$species] ?? 0) + 1;
}
private static function isCopyDump(string $path): bool
{
$handle = @fopen($path, 'rb');
if (!$handle) {
return false;
}
$sample = (string) fread($handle, 262144);
fclose($handle);
return preg_match('/^COPY\s+(?:public\.)?[a-zA-Z0-9_]+\s*\(/mi', $sample) === 1;
}
private static function analyzeCopy(string $path): array
{
$counts = [];
$columns = [];
$animalStats = [
'active' => 0,
'archived' => 0,
'adoptable' => 0,
'not_adoptable' => 0,
'deceased' => 0,
'microchip' => 0,
'tattoo' => 0,
'neutered' => 0,
'missing_name' => 0,
'missing_birth_date' => 0,
'species' => [],
];
$handle = fopen($path, 'rb');
if (!$handle) {
throw new RuntimeException(t('asm3.open_failed'));
}
$table = null;
$tableColumns = [];
$bytes = 0;
try {
while (($line = fgets($handle)) !== false) {
$bytes += strlen($line);
$line = rtrim($line, "\r\n");
if ($table === null) {
if (
preg_match(
'/^COPY\s+(?:public\.)?[`"]?([a-zA-Z0-9_]+)[`"]?\s*\((.*?)\)\s+FROM\s+stdin;$/i',
$line,
$m,
)
) {
$table = strtolower($m[1]);
$tableColumns = array_map(static fn($v) => strtoupper(trim($v, " \t`\"")), explode(',', $m[2]));
$columns[$table] = $tableColumns;
$counts[$table] ??= 0;
}
continue;
}
if ($line === '\\.') {
$table = null;
$tableColumns = [];
continue;
}
$counts[$table]++;
if ($table === 'animal') {
self::accumulateAnimalStats(self::copyRow($line, $tableColumns), $animalStats);
}
}
} finally {
fclose($handle);
}
arsort($counts);
$domains = [];
foreach (self::DOMAINS as $name => [$label, $target, $status]) {
$domains[] = [
'table' => $name,
'label' => $label,
'target' => $target,
'status' => $status,
'rows' => $counts[$name] ?? 0,
'present' => isset($counts[$name]),
];
}
$warnings = [];
if (!isset($counts['animal'])) {
$warnings[] = t('asm3.animal_table_missing');
}
if (($counts['animal'] ?? 0) > 0 && $animalStats['missing_name'] > 0) {
$warnings[] = t('asm3.animals_without_name', ['count' => $animalStats['missing_name']]);
}
if (($counts['animal'] ?? 0) > 0 && $animalStats['missing_birth_date'] > 0) {
$warnings[] = t('asm3.animals_without_birth', ['count' => $animalStats['missing_birth_date']]);
}
$unsupported = [];
foreach ($counts as $name => $count) {
if (!isset(self::DOMAINS[$name]) && $count > 0) {
$unsupported[$name] = $count;
}
}
return [
'format' => 'ASM3 PostgreSQL COPY',
'source' => basename($path),
'size_bytes' => filesize($path),
'read_bytes' => $bytes,
'generated_at' => date(DATE_ATOM),
'tables' => $counts,
'columns' => $columns,
'domains' => $domains,
'animals' => $animalStats,
'warnings' => $warnings,
'unmapped_tables' => $unsupported,
'read_only' => true,
];
}
private static function copyRows(string $path, array $wantedTables): array
{
$wanted = array_fill_keys(array_map('strtolower', $wantedTables), true);
$rows = [];
$handle = fopen($path, 'rb');
if (!$handle) {
throw new RuntimeException(t('asm3.open_failed'));
}
$table = null;
$columns = [];
$capture = false;
try {
while (($line = fgets($handle)) !== false) {
$line = rtrim($line, "\r\n");
if ($table === null) {
if (
preg_match(
'/^COPY\s+(?:public\.)?[`"]?([a-zA-Z0-9_]+)[`"]?\s*\((.*?)\)\s+FROM\s+stdin;$/i',
$line,
$m,
)
) {
$table = strtolower($m[1]);
$capture = isset($wanted[$table]);
$columns = $capture
? array_map(static fn($v) => strtoupper(trim($v, " \t`\"")), explode(',', $m[2]))
: [];
}
continue;
}
if ($line === '\\.') {
$table = null;
$columns = [];
$capture = false;
continue;
}
if ($capture) {
$rows[$table][] = self::copyRow($line, $columns);
}
}
} finally {
fclose($handle);
}
return $rows;
}
private static function copyRow(string $line, array $columns): array
{
$values = explode("\t", $line);
$row = [];
foreach ($columns as $i => $column) {
$row[$column] = self::copyScalar($values[$i] ?? '\\N');
}
return $row;
}
private static function copyScalar(string $value): ?string
{
if ($value === '\\N') {
return null;
}
return preg_replace_callback(
'/\\\\([0-7]{1,3}|.)/s',
static function (array $m): string {
$escape = $m[1];
if (ctype_digit($escape)) {
return chr(octdec($escape));
}
return match ($escape) {
'b' => "\x08",
'f' => "\x0c",
'n' => "\n",
'r' => "\r",
't' => "\t",
'v' => "\x0b",
'\\' => '\\',
default => $escape,
};
},
$value,
) ?? $value;
}
private static function accumulateAnimalStats(array $row, array &$animalStats): void
{
$truthy = static fn($v): bool => (string) $v === '1';
$blank = static fn($v): bool => $v === null || trim((string) $v) === '';
$animalStats[$truthy($row['ARCHIVED'] ?? 0) ? 'archived' : 'active']++;
$animalStats[$truthy($row['ADOPTABLE'] ?? 0) ? 'adoptable' : 'not_adoptable']++;
if (!$blank($row['DECEASEDDATE'] ?? null) || $truthy($row['PUTTOSLEEP'] ?? 0)) {
$animalStats['deceased']++;
}
if (!$blank($row['IDENTICHIPNUMBER'] ?? null) || $truthy($row['IDENTICHIPPED'] ?? 0)) {
$animalStats['microchip']++;
}
if (!$blank($row['TATTOONUMBER'] ?? null) || $truthy($row['TATTOO'] ?? 0)) {
$animalStats['tattoo']++;
}
if ($truthy($row['NEUTERED'] ?? 0)) {
$animalStats['neutered']++;
}
if ($blank($row['ANIMALNAME'] ?? null)) {
$animalStats['missing_name']++;
}
if ($blank($row['DATEOFBIRTH'] ?? null)) {
$animalStats['missing_birth_date']++;
}
$species = (string) ($row['SPECIESID'] ?? 'unknown');
$animalStats['species'][$species] = ($animalStats['species'][$species] ?? 0) + 1;
}
private static function values(string $input): array
{
$values = [];
$value = '';
$quoted = false;
$length = strlen($input);
for ($i = 0; $i < $length; $i++) {
$char = $input[$i];
if ($char === "'") {
if ($quoted && $i + 1 < $length && $input[$i + 1] === "'") {
$value .= "'";
$i++;
continue;
}
$quoted = !$quoted;
continue;
}
if ($char === ',' && !$quoted) {
$values[] = self::scalar($value);
$value = '';
continue;
}
$value .= $char;
}
$values[] = self::scalar($value);
return $values;
}
private static function scalar(string $value): mixed
{
$value = trim($value);
if (strcasecmp($value, 'null') === 0) {
return null;
}
return $value;
}
}

View file

@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
final class Asm3AnimalImporter
{
public static function run(string $path, PDO $db, bool $apply = false, bool $createBackup = true): array
{
$plan = Asm3MigrationPlanner::plan($path, $db);
$hash = hash_file('sha256', $path);
if ($hash === false) {
throw new RuntimeException(t('asm3.hash_failed'));
}
$result = [
'mode' => $apply ? 'apply' : 'dry-run',
'source' => basename($path),
'source_sha256' => $hash,
'planned' => $plan['summary'],
'created' => 0,
'linked_matches' => 0,
'skipped_existing_import' => 0,
'skipped_match' => 0,
'skipped_review' => $plan['summary']['review'],
'backup' => null,
'read_only' => !$apply,
];
if (!$apply) {
$result['would_create'] = $plan['summary']['create'];
return $result;
}
if ($createBackup) {
$backup = BackupService::create('pre-asm3-import');
$result['backup'] = $backup['name'] ?? null;
}
$ownsTransaction = !$db->inTransaction();
if ($ownsTransaction) {
$db->beginTransaction();
}
try {
$run = $db->prepare(
"INSERT INTO asm3_import_runs(source_name,source_sha256,status,summary_json,backup_name) VALUES(:name,:hash,'completed','{}',:backup)",
);
$run->execute([':name' => basename($path), ':hash' => $hash, ':backup' => $result['backup']]);
$runId = (int) $db->lastInsertId();
foreach ($plan['items'] as $item) {
if ($item['action'] === 'review') {
continue;
}
$check = $db->prepare(
"SELECT target_id FROM asm3_import_links WHERE source_sha256=:hash AND entity_type='animal' AND source_id=:source",
);
$check->execute([':hash' => $hash, ':source' => $item['asm3_id']]);
if ($check->fetchColumn()) {
$result['skipped_existing_import']++;
continue;
}
if ($item['action'] === 'match') {
$target = (int) ($item['globinours_candidates'][0]['id'] ?? 0);
if ($target <= 0) {
$result['skipped_match']++;
continue;
}
$result['linked_matches']++;
} else {
$target = self::insert($db, $item['mapped'], $item['asm3_id']);
$result['created']++;
}
$db->prepare(
"INSERT INTO asm3_import_links(source_sha256,entity_type,source_id,target_id,import_run_id) VALUES(:hash,'animal',:source,:target,:run)",
)->execute([':hash' => $hash, ':source' => $item['asm3_id'], ':target' => $target, ':run' => $runId]);
}
$db->prepare('UPDATE asm3_import_runs SET summary_json=:summary WHERE id=:id')->execute([
':summary' => json_encode($result, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
':id' => $runId,
]);
if ($ownsTransaction) {
$db->commit();
}
} catch (Throwable $e) {
if ($ownsTransaction && $db->inTransaction()) {
$db->rollBack();
}
throw $e;
}
return $result;
}
private static function insert(PDO $db, array $a, int $sourceId): int
{
$code = trim((string) $a['internal_code']);
if ($code === '') {
$code = 'ASM3-' . $sourceId;
}
$exists = $db->prepare('SELECT 1 FROM animals WHERE internal_code=:code');
$exists->execute([':code' => $code]);
if ($exists->fetchColumn()) {
$code .= '-ASM3-' . $sourceId;
}
$stmt = $db->prepare(
"INSERT INTO animals(internal_code,name,species,sex,birth_date,birth_is_estimated,chip_id,status,sterilized,sterilization_status,sterilization_date,color,breed,notes,intake_date,archived_at,identification_type,identification_date,identification_registration_status,adoption_availability,adoption_available_from,adoption_unavailability_reason,compatibility_dogs,compatibility_cats,compatibility_children,house_trained,created_at,updated_at) VALUES(:code,:name,:species,:sex,:birth,:estimated,:chip,:status,:sterilized,:sterilization_status,:sterilization_date,:color,:breed,:notes,:intake,:archived,:identification_type,:identification_date,:registration,:availability,:available_from,:unavailable_reason,:dogs,:cats,:children,:trained,datetime('now'),datetime('now'))",
);
$stmt->execute([
':code' => $code,
':name' => $a['name'] ?: 'Animal ASM3 ' . $sourceId,
':species' => $a['species'] ?: 'inconnu',
':sex' => $a['sex'],
':birth' => $a['birth_date'],
':estimated' => $a['birth_is_estimated'],
':chip' => $a['chip_id'] ?: null,
':status' => $a['status'],
':sterilized' => $a['sterilization_status'] === 'yes' ? 1 : 0,
':sterilization_status' => $a['sterilization_status'],
':sterilization_date' => $a['sterilization_date'],
':color' => $a['color'],
':breed' => $a['breed'] ?: null,
':notes' => $a['notes'] ?: null,
':intake' => $a['intake_date'],
':archived' => $a['archived'] ? date('Y-m-d H:i:s') : null,
':identification_type' => $a['identification_type'],
':identification_date' => $a['identification_date'],
':registration' => $a['identification_registration_status'],
':availability' => $a['adoption_availability'],
':available_from' => $a['adoption_available_from'],
':unavailable_reason' => $a['adoption_unavailability_reason'] ?: null,
':dogs' => $a['compatibility_dogs'],
':cats' => $a['compatibility_cats'],
':children' => $a['compatibility_children'],
':trained' => $a['house_trained'],
]);
$id = (int) $db->lastInsertId();
if ($a['deceased_date']) {
$db->prepare(
"INSERT INTO animal_deaths(animal_id,deceased_date,cause_code,cause_details,place_type,body_disposition,notes) VALUES(:animal,:date,'unknown',NULL,'inconnu','pending',:notes)",
)->execute([':animal' => $id, ':date' => $a['deceased_date'], ':notes' => 'Importé depuis ASM3']);
}
return $id;
}
}

View file

@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
final class Asm3ClinicalImporter
{
public static function run(string $path, PDO $db, bool $apply = false, bool $createBackup = true): array
{
$hash = hash_file('sha256', $path);
$src = Asm3Analyzer::rows($path, ['animalvaccination', 'vaccinationtype', 'log', 'logtype']);
$links = self::animalLinks($db, $hash);
$types = [];
foreach ($src['vaccinationtype'] ?? [] as $r) {
$types[(int) $r['ID']] = trim((string) $r['VACCINATIONTYPE']);
}
$logTypes = [];
foreach ($src['logtype'] ?? [] as $r) {
$logTypes[(int) $r['ID']] = trim((string) $r['LOGTYPENAME']);
}
$result = [
'mode' => $apply ? 'apply' : 'dry-run',
'read_only' => !$apply,
'vaccinations' => 0,
'medical_notes' => 0,
'weights' => 0,
'unresolved' => 0,
'already_imported' => 0,
'backup' => null,
];
foreach ($src['animalvaccination'] ?? [] as $r) {
$result['vaccinations']++;
if (!isset($links[(int) $r['ANIMALID']])) {
$result['unresolved']++;
}
}
foreach ($src['log'] ?? [] as $r) {
if ((int) ($r['LINKTYPE'] ?? -1) === 0) {
if (!isset($links[(int) $r['LINKID']])) {
$result['unresolved']++;
}
$name = mb_strtolower($logTypes[(int) $r['LOGTYPEID']] ?? '');
if ($name === 'poids') {
$result['weights']++;
} else {
$result['medical_notes']++;
}
}
}
if (!$apply) {
return $result;
}
if ($createBackup) {
$b = BackupService::create('pre-asm3-import');
$result['backup'] = $b['name'] ?? null;
}
$ownsTransaction = !$db->inTransaction();
if ($ownsTransaction) {
$db->beginTransaction();
}
try {
$runId = self::runId($db, $path, $hash, $result['backup'], 'médical');
foreach ($src['animalvaccination'] ?? [] as $r) {
$animal = $links[(int) $r['ANIMALID']] ?? 0;
if (!$animal) {
continue;
}
$source = (int) $r['ID'];
if (self::seen($db, $hash, 'vaccination', $source)) {
$result['already_imported']++;
continue;
}
$name = $types[(int) $r['VACCINATIONID']] ?? 'Vaccin ASM3 ' . (int) $r['VACCINATIONID'];
$db->prepare('INSERT OR IGNORE INTO ref_vaccines(name) VALUES(:name)')->execute([':name' => $name]);
$q = $db->prepare('SELECT id FROM ref_vaccines WHERE name=:name');
$q->execute([':name' => $name]);
$vaccine = (int) $q->fetchColumn();
$db->prepare(
'INSERT INTO vaccinations(animal_id,vaccine_id,done_date,due_date,lot,notes,manufacturer,batch_expires_on,administered_by_name) VALUES(:animal,:vaccine,:done,:due,:lot,:notes,:manufacturer,:expires,:by)',
)->execute([
':animal' => $animal,
':vaccine' => $vaccine,
':done' =>
self::date($r['DATEOFVACCINATION'] ?? null) ?:
self::date($r['DATEREQUIRED'] ?? null) ?:
date('Y-m-d'),
':due' => self::date($r['DATEREQUIRED'] ?? null),
':lot' => trim((string) $r['BATCHNUMBER']) ?: null,
':notes' => trim((string) $r['COMMENTS']) ?: null,
':manufacturer' => trim((string) $r['MANUFACTURER']) ?: null,
':expires' => self::date($r['BATCHEXPIRYDATE'] ?? null),
':by' => trim((string) $r['GIVENBY']) ?: null,
]);
self::link($db, $hash, 'vaccination', $source, (int) $db->lastInsertId(), $runId);
}
foreach ($src['log'] ?? [] as $r) {
if ((int) ($r['LINKTYPE'] ?? -1) !== 0) {
continue;
}
$animal = $links[(int) $r['LINKID']] ?? 0;
if (!$animal) {
continue;
}
$source = (int) $r['ID'];
$name = $logTypes[(int) $r['LOGTYPEID']] ?? 'Historique';
$entity = mb_strtolower($name) === 'poids' ? 'measurement' : 'medical-log';
if (self::seen($db, $hash, $entity, $source)) {
continue;
}
$text = trim((string) $r['COMMENTS']);
$date = self::date($r['DATE'] ?? null) ?: date('Y-m-d');
if ($entity === 'measurement' && preg_match('/([0-9]+(?:[.,][0-9]+)?)/', $text, $m)) {
$value = (float) str_replace(',', '.', $m[1]);
$db->prepare(
"INSERT INTO measurements(animal_id,measured_at,type,value,unit,notes) VALUES(:animal,:date,'weight',:value,'kg',:notes)",
)->execute([':animal' => $animal, ':date' => $date, ':value' => $value, ':notes' => $text]);
} else {
$db->prepare(
"INSERT INTO medical_notes(animal_id,noted_at,kind,reason,plan) VALUES(:animal,:date,'autre',:reason,:plan)",
)->execute([':animal' => $animal, ':date' => $date, ':reason' => $name, ':plan' => $text ?: null]);
}
self::link($db, $hash, $entity, $source, (int) $db->lastInsertId(), $runId);
}
if ($ownsTransaction) {
$db->commit();
}
} catch (Throwable $e) {
if ($ownsTransaction && $db->inTransaction()) {
$db->rollBack();
}
throw $e;
}
return $result;
}
private static function animalLinks(PDO $db, string $hash): array
{
$s = $db->prepare(
"SELECT source_id,target_id FROM asm3_import_links WHERE source_sha256=:h AND entity_type='animal'",
);
$s->execute([':h' => $hash]);
$o = [];
foreach ($s as $r) {
$o[(int) $r['source_id']] = (int) $r['target_id'];
}
return $o;
}
private static function runId(PDO $db, string $p, string $h, ?string $b, string $label): int
{
$db->prepare(
"INSERT INTO asm3_import_runs(source_name,source_sha256,status,summary_json,backup_name) VALUES(:n,:h,'completed','{}',:b)",
)->execute([':n' => basename($p) . ' · ' . $label, ':h' => $h, ':b' => $b]);
return (int) $db->lastInsertId();
}
private static function seen(PDO $db, string $h, string $e, int $s): bool
{
$q = $db->prepare('SELECT 1 FROM asm3_import_links WHERE source_sha256=:h AND entity_type=:e AND source_id=:s');
$q->execute([':h' => $h, ':e' => $e, ':s' => $s]);
return (bool) $q->fetchColumn();
}
private static function link(PDO $db, string $h, string $e, int $s, int $t, int $r): void
{
$db->prepare(
'INSERT INTO asm3_import_links(source_sha256,entity_type,source_id,target_id,import_run_id) VALUES(:h,:e,:s,:t,:r)',
)->execute([':h' => $h, ':e' => $e, ':s' => $s, ':t' => $t, ':r' => $r]);
}
private static function date(mixed $v): ?string
{
return $v && preg_match('/^\d{4}-\d{2}-\d{2}/', (string) $v, $m) ? $m[0] : null;
}
}

View file

@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
final class Asm3ContactImporter
{
public static function run(string $path, PDO $db, bool $apply = false, bool $createBackup = true): array
{
$plan = Asm3ContactPlanner::plan($path, $db);
$hash = hash_file('sha256', $path);
if ($hash === false) {
throw new RuntimeException(t('asm3.hash_unavailable'));
}
$result = [
'mode' => $apply ? 'apply' : 'dry-run',
'entity' => 'contacts',
'planned' => $plan['summary'],
'created' => 0,
'linked_matches' => 0,
'skipped_match' => 0,
'skipped_review' => $plan['summary']['review'],
'backup' => null,
'read_only' => !$apply,
'would_create' => $plan['summary']['create'],
];
if (!$apply) {
return $result;
}
if ($createBackup) {
$backup = BackupService::create('pre-asm3-import');
$result['backup'] = $backup['name'] ?? null;
}
$ownsTransaction = !$db->inTransaction();
if ($ownsTransaction) {
$db->beginTransaction();
}
try {
$db->prepare(
"INSERT INTO asm3_import_runs(source_name,source_sha256,status,summary_json,backup_name) VALUES(:name,:hash,'completed','{}',:backup)",
)->execute([':name' => basename($path) . ' · contacts', ':hash' => $hash, ':backup' => $result['backup']]);
$runId = (int) $db->lastInsertId();
foreach ($plan['items'] as $item) {
if ($item['action'] === 'review') {
continue;
}
$check = $db->prepare(
"SELECT target_id FROM asm3_import_links WHERE source_sha256=:hash AND entity_type='contact' AND source_id=:source",
);
$check->execute([':hash' => $hash, ':source' => $item['asm3_id']]);
if ($check->fetchColumn()) {
continue;
}
$c = $item['mapped'];
if ($item['action'] === 'match') {
$target = (int) ($item['globinours_candidates'][0]['id'] ?? 0);
if ($target <= 0) {
$result['skipped_match']++;
continue;
}
$result['linked_matches']++;
} else {
$db->prepare(
'INSERT INTO directory_contacts(kind,name,phone,email,address,postal_code,city,country,notes) VALUES(:kind,:name,:phone,:email,:address,:postal,:city,:country,:notes)',
)->execute([
':kind' => $c['kind'],
':name' => $c['name'],
':phone' => $c['phone'],
':email' => $c['email'],
':address' => $c['address'],
':postal' => $c['postal_code'],
':city' => $c['city'],
':country' => $c['country'],
':notes' => $c['notes'],
]);
$target = (int) $db->lastInsertId();
$result['created']++;
}
$role = $db->prepare(
'INSERT OR IGNORE INTO directory_contact_roles(contact_id,role) VALUES(:contact,:role)',
);
foreach ($c['roles'] as $value) {
$role->execute([':contact' => $target, ':role' => $value]);
}
$db->prepare(
"INSERT INTO asm3_import_links(source_sha256,entity_type,source_id,target_id,import_run_id) VALUES(:hash,'contact',:source,:target,:run)",
)->execute([':hash' => $hash, ':source' => $item['asm3_id'], ':target' => $target, ':run' => $runId]);
}
$db->prepare('UPDATE asm3_import_runs SET summary_json=:summary WHERE id=:id')->execute([
':summary' => json_encode($result, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
':id' => $runId,
]);
if ($ownsTransaction) {
$db->commit();
}
} catch (Throwable $e) {
if ($ownsTransaction && $db->inTransaction()) {
$db->rollBack();
}
throw $e;
}
return $result;
}
}

View file

@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
final class Asm3ContactPlanner
{
public static function plan(string $path, PDO $db): array
{
$source = Asm3Analyzer::rows($path, ['owner', 'animal', 'adoption', 'animalvaccination']);
$adopters = [];
$depositors = [];
$vets = [];
foreach ($source['adoption'] ?? [] as $r) {
if ((int) ($r['OWNERID'] ?? 0) > 0) {
$adopters[(int) $r['OWNERID']] = true;
}
}
foreach ($source['animal'] ?? [] as $r) {
foreach (['BROUGHTINBYOWNERID', 'ORIGINALOWNERID'] as $field) {
if ((int) ($r[$field] ?? 0) > 0) {
$depositors[(int) $r[$field]] = true;
}
}
foreach (['CURRENTVETID', 'OWNERSVETID'] as $field) {
if ((int) ($r[$field] ?? 0) > 0) {
$vets[(int) $r[$field]] = true;
}
}
}
foreach ($source['animalvaccination'] ?? [] as $r) {
if ((int) ($r['ADMINISTERINGVETID'] ?? 0) > 0) {
$vets[(int) $r['ADMINISTERINGVETID']] = true;
}
}
$existing = $db
->query('SELECT id,kind,name,phone,email,city FROM directory_contacts WHERE deleted_at IS NULL')
->fetchAll(PDO::FETCH_ASSOC);
$items = [];
$summary = ['create' => 0, 'match' => 0, 'review' => 0];
foreach ($source['owner'] ?? [] as $row) {
$mapped = self::map(
$row,
isset($adopters[(int) $row['ID']]),
isset($depositors[(int) $row['ID']]),
isset($vets[(int) $row['ID']]),
);
$strong = [];
$weak = [];
$reasons = [];
foreach ($existing as $contact) {
$email = self::text($mapped['email']);
$phone = self::phone($mapped['phone']);
$sameEmail = $email !== '' && $email === self::text($contact['email']);
$samePhone = $phone !== '' && $phone === self::phone($contact['phone']);
$sameIdentity =
self::text($mapped['name']) === self::text($contact['name']) &&
self::text($mapped['city']) === self::text($contact['city']);
if ($sameEmail || $samePhone || $sameIdentity) {
$strong[(int) $contact['id']] = $contact;
if ($sameEmail) {
$reasons[] = 'même adresse e-mail';
}
if ($samePhone) {
$reasons[] = 'même téléphone';
}
if ($sameIdentity) {
$reasons[] = 'même nom et ville';
}
} elseif (
self::text($mapped['name']) !== '' &&
self::text($mapped['name']) === self::text($contact['name'])
) {
$weak[(int) $contact['id']] = $contact;
}
}
if (count($strong) === 1) {
$action = 'match';
$candidates = $strong;
} elseif (count($strong) > 1) {
$action = 'review';
$candidates = $strong;
} elseif ($weak) {
$action = 'review';
$candidates = $weak;
$reasons[] = 'même nom uniquement';
} else {
$action = 'create';
$candidates = [];
}
$summary[$action]++;
$items[] = [
'asm3_id' => (int) $row['ID'],
'action' => $action,
'match_reasons' => array_values(array_unique($reasons)),
'globinours_candidates' => array_values(
array_map(
static fn($c) => ['id' => (int) $c['id'], 'name' => $c['name'], 'city' => $c['city']],
$candidates,
),
),
'mapped' => $mapped,
];
}
return ['source' => basename($path), 'read_only' => true, 'summary' => $summary, 'items' => $items];
}
private static function map(array $r, bool $adopter, bool $depositor, bool $vetReferenced): array
{
$yes = static fn($v) => (string) $v === '1';
$name = trim(
(string) ($r['OWNERNAME'] ?? '' ?:
trim((string) ($r['OWNERFORENAMES'] ?? '') . ' ' . (string) ($r['OWNERSURNAME'] ?? ''))),
);
$organization =
(bool) preg_match('/\b(cabinet|clinique|cr[eé]matorium|association|refuge|fourri[eè]re)\b/iu', $name) ||
$yes($r['ISSHELTER'] ?? 0) ||
$yes($r['ISSUPPLIER'] ?? 0);
$roles = [];
if ($adopter || $yes($r['ISADOPTER'] ?? 0)) {
$roles[] = 'adoptant';
}
if ($yes($r['ISFOSTERER'] ?? 0)) {
$roles[] = 'fa';
}
if ($yes($r['ISVOLUNTEER'] ?? 0)) {
$roles[] = 'benevole';
}
if ($depositor) {
$roles[] = 'deposant';
}
if ($vetReferenced || $yes($r['ISVET'] ?? 0)) {
$roles[] = $organization ? 'cabinet' : 'veterinaire';
}
if (preg_match('/cr[eé]matorium/iu', $name)) {
$roles[] = 'crematorium';
}
$phones = array_values(
array_filter([
trim((string) ($r['MOBILETELEPHONE'] ?? '')),
trim((string) ($r['HOMETELEPHONE'] ?? '')),
trim((string) ($r['WORKTELEPHONE'] ?? '')),
]),
);
return [
'kind' => $organization ? 'organization' : 'person',
'name' => $name ?: 'Contact ASM3 ' . (int) $r['ID'],
'phone' => $phones[0] ?? null,
'email' => trim((string) ($r['EMAILADDRESS'] ?? '')) ?: null,
'address' => trim((string) ($r['OWNERADDRESS'] ?? '')) ?: null,
'postal_code' => trim((string) ($r['OWNERPOSTCODE'] ?? '')) ?: null,
'city' => trim((string) ($r['OWNERTOWN'] ?? '')) ?: null,
'country' => trim((string) ($r['OWNERCOUNTRY'] ?? '')) ?: 'France',
'notes' => trim((string) ($r['COMMENTS'] ?? '')) ?: null,
'roles' => array_values(array_unique($roles)),
];
}
private static function text(mixed $v): string
{
return mb_strtolower(trim((string) $v));
}
private static function phone(mixed $v): string
{
return (string) preg_replace('/\D+/', '', (string) $v);
}
}

View file

@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
final class Asm3IntakeRecovery
{
private const CITIES = [
'Betton' => ['betton', 'beton'],
'Brignogan-Plages' => ['brignogan'],
'Fougères' => ['fougère', 'fougeres'],
'Gouesnou' => ['gouesnou'],
'Goulven' => ['goulven'],
'Guissény' => ['guissény', 'guisseny'],
'Kernouës' => ['kernouës', 'kernoues'],
'Kerlouan' => ['kerlouan'],
'Landerneau' => ['landerneau'],
'Lanhouarneau' => ['lanhouarneau'],
'Landivisiau' => ['landivisiau'],
'Le Drennec' => ['le drennec'],
'Le Folgoët' => ['le folgoët', 'le folgoet', 'du folgoët', 'du folgoet'],
'Lesneven' => ['lesneven', 'lesnevenet'],
'Perros-Guirec' => ['perros guirec', 'perros guierrec'],
'Ploudaniel' => ['ploudaniel'],
'Plouescat' => ['plouescat'],
'Plouguerneau' => ['plouguerneau'],
'Plouider' => ['plouider'],
'Plounéour-Brignogan-Plages' => [
'plounéour trez',
'plouneour trez',
'plounéour brignogan plage',
'plouneour brignogan plage',
],
'Plounévez-Lochrist' => ['plounevez lochrist', 'plounévez lochrist'],
'Plouzané' => ['plouzané', 'plouzane'],
'Saint-Frégant' => ['saint frégant', 'saint fregant'],
'Saint-Méen' => ['saint méen', 'saint meen'],
'Saint-Pol-de-Léon' => ['saint pol de leon', 'saint-pol-de-léon'],
];
public static function reason(array $row): string
{
return trim((string) ($row['REASONFORENTRY'] ?? ''));
}
public static function municipality(string $reason): ?string
{
$text = self::plain($reason);
if ($text === '') {
return null;
}
$found = [];
foreach (self::CITIES as $city => $aliases) {
foreach ($aliases as $alias) {
if (str_contains($text, self::plain($alias))) {
$found[$city] = true;
break;
}
}
}
if (count($found) !== 1) {
return null;
}
$city = (string) array_key_first($found);
$words =
'trouv|errant|recueill|recup|ramass|vient|arriv|fourriere|mairie|police municipale|jardin|terrain|ferme|hangar|grange|ne dans|abandonn|signal';
if (preg_match('/^\s*' . self::cityPattern($city) . '\b/u', $text)) {
return $city;
}
if (preg_match('/(?:' . $words . ').{0,100}' . self::cityPattern($city) . '/u', $text)) {
return $city;
}
if (preg_match('/' . self::cityPattern($city) . '.{0,55}(?:' . $words . ')/u', $text)) {
return $city;
}
return null;
}
private static function cityPattern(string $city): string
{
$parts = [];
foreach (self::CITIES[$city] as $alias) {
$parts[] = preg_quote(self::plain($alias), '/');
}
return '(?:' . implode('|', $parts) . ')';
}
private static function plain(string $value): string
{
$value = mb_strtolower(trim($value));
$v = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value);
return preg_replace('/\s+/u', ' ', is_string($v) ? $v : $value) ?? $value;
}
}

View file

@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
final class Asm3LitterImporter
{
public static function run(string $path, PDO $db, bool $apply = false, bool $createBackup = true): array
{
$hash = hash_file('sha256', $path);
$src = Asm3Analyzer::rows($path, ['animallitter', 'animal']);
$links = [];
$q = $db->prepare(
"SELECT source_id,target_id FROM asm3_import_links WHERE source_sha256=:h AND entity_type='animal'",
);
$q->execute([':h' => $hash]);
foreach ($q as $r) {
$links[(int) $r['source_id']] = (int) $r['target_id'];
}
$animals = [];
foreach ($src['animal'] ?? [] as $a) {
$animals[(string) $a['ACCEPTANCENUMBER']][] = (int) $a['ID'];
}
$result = [
'mode' => $apply ? 'apply' : 'dry-run',
'read_only' => !$apply,
'litters' => count($src['animallitter'] ?? []),
'would_import' => 0,
'unresolved' => 0,
'created' => 0,
'kittens_linked' => 0,
'backup' => null,
];
foreach ($src['animallitter'] ?? [] as $l) {
$parent = (int) $l['PARENTANIMALID'];
$kids = array_filter($animals[(string) $l['ACCEPTANCENUMBER']] ?? [], fn($id) => $id !== $parent);
if (!isset($links[$parent]) || array_filter($kids, fn($id) => !isset($links[$id]))) {
$result['unresolved']++;
continue;
}
$result['would_import']++;
}
if (!$apply) {
return $result;
}
if ($createBackup) {
$b = BackupService::create('pre-asm3-import');
$result['backup'] = $b['name'] ?? null;
}
$owns = !$db->inTransaction();
if ($owns) {
$db->beginTransaction();
}
try {
$db->prepare(
"INSERT INTO asm3_import_runs(source_name,source_sha256,status,summary_json,backup_name) VALUES(:n,:h,'completed','{}',:b)",
)->execute([':n' => basename($path) . ' · portées', ':h' => $hash, ':b' => $result['backup']]);
$run = (int) $db->lastInsertId();
foreach ($src['animallitter'] ?? [] as $l) {
$source = (int) $l['ID'];
$seen = $db->prepare(
"SELECT 1 FROM asm3_import_links WHERE source_sha256=:h AND entity_type='litter' AND source_id=:s",
);
$seen->execute([':h' => $hash, ':s' => $source]);
if ($seen->fetchColumn()) {
continue;
}
$parent = (int) $l['PARENTANIMALID'];
$kids = array_values(
array_filter($animals[(string) $l['ACCEPTANCENUMBER']] ?? [], fn($id) => $id !== $parent),
);
if (!isset($links[$parent]) || array_filter($kids, fn($id) => !isset($links[$id]))) {
continue;
}
$db->prepare('INSERT INTO litters(mother_id,birth_date,notes) VALUES(:parent,:date,:notes)')->execute([
':parent' => $links[$parent],
':date' => substr((string) $l['DATE'], 0, 10),
':notes' => trim((string) $l['COMMENTS']) ?: null,
]);
$target = (int) $db->lastInsertId();
$ins = $db->prepare(
'INSERT OR IGNORE INTO litter_kittens(litter_id,animal_id,position) VALUES(:litter,:animal,:position)',
);
foreach ($kids as $i => $kid) {
$ins->execute([':litter' => $target, ':animal' => $links[$kid], ':position' => $i + 1]);
$result['kittens_linked']++;
}
$db->prepare(
"INSERT INTO asm3_import_links(source_sha256,entity_type,source_id,target_id,import_run_id) VALUES(:h,'litter',:s,:t,:r)",
)->execute([':h' => $hash, ':s' => $source, ':t' => $target, ':r' => $run]);
$result['created']++;
}
if ($owns) {
$db->commit();
}
} catch (Throwable $e) {
if ($owns && $db->inTransaction()) {
$db->rollBack();
}
throw $e;
}
return $result;
}
}

View file

@ -0,0 +1,199 @@
<?php
declare(strict_types=1);
final class Asm3MigrationPlanner
{
public static function plan(string $path, PDO $db): array
{
$source = Asm3Analyzer::rows($path, ['animal', 'species', 'basecolour', 'lksex']);
$species = self::lookup($source['species'] ?? [], 'ID', 'SPECIESNAME');
$colors = self::lookup($source['basecolour'] ?? [], 'ID', 'BASECOLOUR');
$sexes = self::lookup($source['lksex'] ?? [], 'ID', 'SEX');
$existing = $db
->query('SELECT id,name,internal_code,chip_id,birth_date FROM animals')
->fetchAll(PDO::FETCH_ASSOC);
$byChip = [];
$byCode = [];
$byIdentity = [];
$byName = [];
foreach ($existing as $animal) {
$chip = self::identifier($animal['chip_id'] ?? '');
if ($chip !== '') {
$byChip[$chip][] = $animal;
}
$code = self::text($animal['internal_code'] ?? '');
if ($code !== '') {
$byCode[$code][] = $animal;
}
$identity = self::identity($animal['name'] ?? '', $animal['birth_date'] ?? '');
if ($identity !== '') {
$byIdentity[$identity][] = $animal;
}
$name = self::text($animal['name'] ?? '');
if ($name !== '') {
$byName[$name][] = $animal;
}
}
$items = [];
$summary = ['create' => 0, 'match' => 0, 'review' => 0];
foreach ($source['animal'] ?? [] as $row) {
$mapped = self::mapAnimal($row, $species, $colors, $sexes);
$candidates = [];
$reasons = [];
$chip = self::identifier($mapped['chip_id']);
if ($chip !== '' && !empty($byChip[$chip])) {
foreach ($byChip[$chip] as $a) {
$candidates[(int) $a['id']] = $a;
}
$reasons[] = 'même identification';
}
// Les codes refuge peuvent être réutilisés après une remise à zéro dASM3.
// Un code seul nest donc jamais une preuve suffisante entre deux sauvegardes historiques.
$code = self::text($mapped['internal_code']);
$identity = self::identity($mapped['name'], $mapped['birth_date']);
if ($identity !== '' && !empty($byIdentity[$identity])) {
foreach ($byIdentity[$identity] as $a) {
$candidates[(int) $a['id']] = $a;
}
$reasons[] = 'même nom et naissance';
}
$strongMatch = count($candidates) > 0;
if (!$strongMatch) {
$name = self::text($mapped['name']);
if ($name !== '' && !empty($byName[$name])) {
$weakByName = $byName[$name];
$knownDifferentBirths = $mapped['birth_date'] !== '' && $mapped['birth_date'] !== null;
foreach ($weakByName as $a) {
$knownDifferentBirths =
$knownDifferentBirths &&
!empty($a['birth_date']) &&
$a['birth_date'] !== $mapped['birth_date'];
}
if (!$knownDifferentBirths) {
foreach ($weakByName as $a) {
$candidates[(int) $a['id']] = $a;
}
$reasons[] = 'même nom uniquement';
}
}
}
// Un code isolé et réutilisé ne doit ni fusionner, ni bloquer la création.
// Asm3AnimalImporter suffixera le code technique si nécessaire.
if (!$strongMatch && count($candidates) === 0) {
$action = 'create';
} elseif ($strongMatch && count($candidates) === 1) {
$action = 'match';
} else {
$action = 'review';
}
$summary[$action]++;
$items[] = [
'asm3_id' => (int) ($row['ID'] ?? 0),
'action' => $action,
'match_reasons' => array_values(array_unique($reasons)),
'globinours_candidates' => array_values(
array_map(
static fn($a) => ['id' => (int) $a['id'], 'name' => $a['name'], 'code' => $a['internal_code']],
$candidates,
),
),
'mapped' => $mapped,
];
}
return [
'source' => basename($path),
'generated_at' => date(DATE_ATOM),
'read_only' => true,
'summary' => $summary,
'items' => $items,
'lookups' => ['species' => $species, 'colors' => $colors, 'sexes' => $sexes],
];
}
private static function mapAnimal(array $r, array $species, array $colors, array $sexes): array
{
$date = static fn($v) => $v && preg_match('/^\d{4}-\d{2}-\d{2}/', (string) $v, $m) ? $m[0] : null;
$yes = static fn($v) => (string) $v === '1';
$tri = static fn($v) => match ((string) $v) {
'0' => 'no',
'1' => 'yes',
default => 'unknown',
};
$chip = trim((string) ($r['IDENTICHIPNUMBER'] ?? ''));
$tattoo = trim((string) ($r['TATTOONUMBER'] ?? ''));
$identifier = $chip !== '' ? $chip : $tattoo;
$deceased = $date($r['DECEASEDDATE'] ?? null) !== null || $yes($r['PUTTOSLEEP'] ?? 0);
$availability = $yes($r['ISNOTAVAILABLEFORADOPTION'] ?? 0)
? 'not_available'
: ($yes($r['ADOPTABLE'] ?? 0)
? 'available'
: 'unknown');
return [
'name' => trim((string) ($r['ANIMALNAME'] ?? '')),
'internal_code' => trim((string) ($r['SHELTERCODE'] ?? '' ?: $r['SHORTCODE'] ?? '')),
'species' => self::species($species[(string) ($r['SPECIESID'] ?? '')] ?? ''),
'sex' => match (mb_strtolower((string) ($sexes[(string) ($r['SEX'] ?? '')] ?? ''))) {
'mâle' => 'M',
'femelle' => 'F',
default => 'U',
},
'birth_date' => $date($r['DATEOFBIRTH'] ?? null),
'birth_is_estimated' => $yes($r['ESTIMATEDDOB'] ?? 0) ? 1 : 0,
'breed' => trim((string) ($r['BREEDNAME'] ?? '')),
'color' => $colors[(string) ($r['BASECOLOURID'] ?? '')] ?? null,
'notes' => trim((string) ($r['ANIMALCOMMENTS'] ?? '')),
'intake_date' => $date($r['MOSTRECENTENTRYDATE'] ?? null ?: $r['DATEBROUGHTIN'] ?? null),
'status' => $deceased ? 'decede' : ($yes($r['ISQUARANTINE'] ?? 0) ? 'quarantaine' : 'refuge'),
'archived' => $yes($r['ARCHIVED'] ?? 0),
'deceased_date' => $date($r['DECEASEDDATE'] ?? null),
'identification_type' => $chip !== '' ? 'microchip' : ($tattoo !== '' ? 'tattoo' : 'unknown'),
'chip_id' => $identifier,
'identification_date' => $date($chip !== '' ? $r['IDENTICHIPDATE'] ?? null : $r['TATTOODATE'] ?? null),
'identification_registration_status' => match ((string) ($r['IDENTICHIPSTATUS'] ?? '')) {
'1' => 'registered',
'0' => 'pending',
default => 'unknown',
},
'sterilization_status' => $yes($r['NEUTERED'] ?? 0) ? 'yes' : 'unknown',
'sterilization_date' => $date($r['NEUTEREDDATE'] ?? null),
'adoption_availability' => $availability,
'adoption_available_from' => $date($r['HOLDUNTILDATE'] ?? null),
'adoption_unavailability_reason' => trim((string) ($r['REASONNO'] ?? '')),
'compatibility_dogs' => $tri($r['ISGOODWITHDOGS'] ?? null),
'compatibility_cats' => $tri($r['ISGOODWITHCATS'] ?? null),
'compatibility_children' => $tri($r['ISGOODWITHCHILDREN'] ?? null),
'house_trained' => $tri($r['ISHOUSETRAINED'] ?? null),
];
}
private static function lookup(array $rows, string $key, string $value): array
{
$out = [];
foreach ($rows as $row) {
$out[(string) $row[$key]] = (string) $row[$value];
}
return $out;
}
private static function species(string $value): string
{
return match (mb_strtolower($value)) {
'chat' => 'chat',
'chien' => 'chien',
default => mb_strtolower($value) ?: 'inconnu',
};
}
private static function identifier(mixed $value): string
{
return strtoupper((string) preg_replace('/[^a-zA-Z0-9]/', '', (string) $value));
}
private static function text(mixed $value): string
{
return mb_strtolower(trim((string) $value));
}
private static function identity(mixed $name, mixed $birth): string
{
$name = self::text($name);
$birth = trim((string) $birth);
return $name !== '' && $birth !== '' ? $name . '|' . $birth : '';
}
}

View file

@ -0,0 +1,271 @@
<?php
declare(strict_types=1);
final class Asm3MovementImporter
{
public static function run(string $path, PDO $db, bool $apply = false, bool $createBackup = true): array
{
$hash = hash_file('sha256', $path);
if ($hash === false) {
throw new RuntimeException(t('asm3.hash_unavailable'));
}
$rows = Asm3Analyzer::rows($path, ['adoption'])['adoption'] ?? [];
$events = [];
$unsupported = 0;
foreach ($rows as $row) {
$source = (int) ($row['ID'] ?? 0);
$type = (int) ($row['MOVEMENTTYPE'] ?? 0);
$movementDate = self::date($row['MOVEMENTDATE'] ?? null);
$returnDate = self::date($row['RETURNDATE'] ?? null);
$reservationDate = self::date($row['RESERVATIONDATE'] ?? null);
$cancelDate = self::date($row['RESERVATIONCANCELLEDDATE'] ?? null);
if ($reservationDate) {
$events[] = self::event($row, $source, 'reservation', $reservationDate, 'Réservation ASM3');
}
if ($cancelDate) {
$events[] = self::event($row, $source, 'cancellation', $cancelDate, 'Annulation de réservation ASM3');
}
if ($movementDate && $type === 1) {
$events[] = self::event($row, $source, 'adoption', $movementDate, 'Adoption définitive ASM3');
} elseif ($movementDate && in_array($type, [2, 12], true)) {
$events[] = self::event(
$row,
$source,
'foster',
$movementDate,
$type === 12 || (string) ($row['ISPERMANENTFOSTER'] ?? 0) === '1'
? 'FA permanente ASM3'
: 'Famille daccueil ASM3',
);
} elseif ($movementDate && !in_array($type, [0, 9, 10], true)) {
$unsupported++;
}
if ($returnDate && in_array($type, [1, 2, 12], true)) {
$events[] = self::event(
$row,
$source,
'return',
$returnDate,
trim((string) ($row['REASONFORRETURN'] ?? '')) ?: 'Retour enregistré dans ASM3',
);
}
}
$links = self::links($db, $hash);
$summary = [
'events' => count($events),
'adoptions' => 0,
'fosters' => 0,
'returns' => 0,
'reservations' => 0,
'cancellations' => 0,
'unresolved_animals' => 0,
'unresolved_contacts' => 0,
'unsupported_movements' => $unsupported,
'would_import' => 0,
'created' => 0,
'already_imported' => 0,
];
foreach ($events as $event) {
$summary[
$event['type'] === 'adoption'
? 'adoptions'
: ($event['type'] === 'foster'
? 'fosters'
: ($event['type'] === 'return'
? 'returns'
: ($event['type'] === 'reservation'
? 'reservations'
: 'cancellations')))
]++;
if (!isset($links['animal'][$event['animal_source_id']])) {
$summary['unresolved_animals']++;
continue;
}
if ($event['contact_source_id'] > 0 && !isset($links['contact'][$event['contact_source_id']])) {
$summary['unresolved_contacts']++;
}
$summary['would_import']++;
}
if (!$apply) {
return ['mode' => 'dry-run', 'read_only' => true, 'summary' => $summary];
}
if ($createBackup) {
$backup = BackupService::create('pre-asm3-import');
$backupName = $backup['name'] ?? null;
} else {
$backupName = null;
}
$ownsTransaction = !$db->inTransaction();
if ($ownsTransaction) {
$db->beginTransaction();
}
try {
$db->prepare(
"INSERT INTO asm3_import_runs(source_name,source_sha256,status,summary_json,backup_name) VALUES(:name,:hash,'completed','{}',:backup)",
)->execute([':name' => basename($path) . ' · mouvements', ':hash' => $hash, ':backup' => $backupName]);
$runId = (int) $db->lastInsertId();
$affected = [];
foreach ($events as $event) {
$animalId = $links['animal'][$event['animal_source_id']] ?? 0;
if (!$animalId) {
continue;
}
$affected[$animalId] = true;
$entity = 'placement-' . $event['type'];
$exists = $db->prepare(
'SELECT target_id FROM asm3_import_links WHERE source_sha256=:hash AND entity_type=:entity AND source_id=:source',
);
$exists->execute([':hash' => $hash, ':entity' => $entity, ':source' => $event['source_id']]);
if ($exists->fetchColumn()) {
$summary['already_imported']++;
continue;
}
$contactId = $links['contact'][$event['contact_source_id']] ?? null;
$adoptionId = null;
if ($event['type'] === 'adoption') {
$contact = self::contact($db, $contactId);
$db->prepare(
'INSERT INTO adoptions(animal_id,adopter_name,adopter_phone,adopter_email,adopter_address,adopter_postal_code,adopter_city,adopter_country,adoption_date,notes,adopter_contact_id) VALUES(:animal,:name,:phone,:email,:address,:postal,:city,:country,:date,:notes,:contact)',
)->execute([
':animal' => $animalId,
':name' => $contact['name'] ?? 'Adoptant ASM3 non retrouvé',
':phone' => $contact['phone'] ?? null,
':email' => $contact['email'] ?? null,
':address' => $contact['address'] ?? null,
':postal' => $contact['postal_code'] ?? null,
':city' => $contact['city'] ?? null,
':country' => $contact['country'] ?? 'France',
':date' => $event['date'],
':notes' => $event['notes'] ?: null,
':contact' => $contactId,
]);
$adoptionId = (int) $db->lastInsertId();
}
$db->prepare(
'INSERT INTO animal_placements(animal_id,event_type,event_date,contact_id,reason,notes,adoption_id) VALUES(:animal,:type,:date,:contact,:reason,:notes,:adoption)',
)->execute([
':animal' => $animalId,
':type' => $event['type'],
':date' => $event['date'],
':contact' => $contactId,
':reason' => $event['reason'],
':notes' => $event['notes'] ?: null,
':adoption' => $adoptionId,
]);
$placementId = (int) $db->lastInsertId();
$db->prepare(
'INSERT INTO asm3_import_links(source_sha256,entity_type,source_id,target_id,import_run_id) VALUES(:hash,:entity,:source,:target,:run)',
)->execute([
':hash' => $hash,
':entity' => $entity,
':source' => $event['source_id'],
':target' => $placementId,
':run' => $runId,
]);
$summary['created']++;
}
self::syncAnimalStates($db, array_keys($affected));
$db->prepare('UPDATE asm3_import_runs SET summary_json=:summary WHERE id=:id')->execute([
':summary' => json_encode($summary, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
':id' => $runId,
]);
if ($ownsTransaction) {
$db->commit();
}
} catch (Throwable $e) {
if ($ownsTransaction && $db->inTransaction()) {
$db->rollBack();
}
throw $e;
}
return ['mode' => 'apply', 'read_only' => false, 'backup' => $backupName, 'summary' => $summary];
}
private static function event(array $row, int $source, string $type, string $date, string $reason): array
{
return [
'source_id' => $source,
'type' => $type,
'date' => $date,
'animal_source_id' => (int) ($row['ANIMALID'] ?? 0),
'contact_source_id' => (int) ($row['OWNERID'] ?? 0),
'reason' => $reason,
'notes' => trim((string) ($row['COMMENTS'] ?? '')),
];
}
private static function date(mixed $value): ?string
{
return $value && preg_match('/^\d{4}-\d{2}-\d{2}/', (string) $value, $m) ? $m[0] : null;
}
private static function links(PDO $db, string $hash): array
{
$out = ['animal' => [], 'contact' => []];
$s = $db->prepare(
"SELECT entity_type,source_id,target_id FROM asm3_import_links WHERE source_sha256=:hash AND entity_type IN ('animal','contact')",
);
$s->execute([':hash' => $hash]);
foreach ($s as $r) {
$out[$r['entity_type']][(int) $r['source_id']] = (int) $r['target_id'];
}
return $out;
}
private static function contact(PDO $db, ?int $id): ?array
{
if (!$id) {
return null;
}
$s = $db->prepare('SELECT * FROM directory_contacts WHERE id=:id');
$s->execute([':id' => $id]);
return $s->fetch(PDO::FETCH_ASSOC) ?: null;
}
private static function syncAnimalStates(PDO $db, array $animalIds): void
{
$latest = $db->prepare(
'SELECT ap.*,dc.name contact_name,dc.address,dc.postal_code,dc.city FROM animal_placements ap LEFT JOIN directory_contacts dc ON dc.id=ap.contact_id WHERE ap.animal_id=:animal ORDER BY date(ap.event_date) DESC,ap.id DESC LIMIT 1',
);
foreach ($animalIds as $animalId) {
$death = $db->prepare('SELECT 1 FROM animal_deaths WHERE animal_id=?');
$death->execute([$animalId]);
if ($death->fetchColumn()) {
continue;
}
$latest->execute([':animal' => $animalId]);
$event = $latest->fetch(PDO::FETCH_ASSOC);
if (!$event) {
continue;
}
$address = implode(
' ',
array_filter([$event['address'] ?? null, $event['postal_code'] ?? null, $event['city'] ?? null]),
);
if ($event['event_type'] === 'adoption') {
$db->prepare(
"UPDATE animals SET status='adopte',is_archived=1,archived_at=:archived,current_address=:address,refuge_room=NULL,care_box_key=NULL,quarantine_until=NULL,updated_at=datetime('now') WHERE id=:id",
)->execute([
':archived' => $event['event_date'] . ' 12:00:00',
':address' =>
$address !== ''
? $address
: 'Adopté par ' . ($event['contact_name'] ?: 'un adoptant non renseigné'),
':id' => $animalId,
]);
} elseif ($event['event_type'] === 'foster') {
$db->prepare(
"UPDATE animals SET status=CASE WHEN lower(COALESCE(reason,'')) LIKE '%permanente%' THEN 'fa_permanente' ELSE 'fa' END,is_archived=0,archived_at=NULL,current_address=:address,refuge_room=NULL,care_box_key=NULL,updated_at=datetime('now') WHERE id=:id",
)->execute([
':address' =>
$address !== '' ? $address : 'Chez ' . ($event['contact_name'] ?: 'une famille daccueil'),
':id' => $animalId,
]);
} elseif (in_array($event['event_type'], ['return', 'cancellation'], true)) {
$db->prepare(
"UPDATE animals SET status='refuge',is_archived=0,archived_at=NULL,current_address=NULL,updated_at=datetime('now') WHERE id=?",
)->execute([$animalId]);
} elseif ($event['event_type'] === 'reservation') {
$db->prepare(
"UPDATE animals SET status='reserve',is_archived=0,archived_at=NULL,updated_at=datetime('now') WHERE id=?",
)->execute([$animalId]);
}
}
}
}

View file

@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
final class Asm3ResetService
{
public static function clear(PDO $db): array
{
$before = [
'animals' => (int) $db->query('SELECT COUNT(*) FROM animals')->fetchColumn(),
'contacts' => (int) $db->query('SELECT COUNT(*) FROM directory_contacts')->fetchColumn(),
];
$tables = [
'agenda_volunteers',
'agenda_items',
'prescription_treatments',
'lab_results',
'medical_prescriptions',
'lab_reports',
'treatment_administrations',
'care_round_observations',
'animal_photos',
'medical_photos',
'animal_health_conditions',
'vaccinations',
'treatments',
'medical_notes',
'measurements',
'animal_placements',
'adoptions',
'animal_movements',
'animal_deaths',
'animal_location_history',
'animal_history',
'litter_kittens',
'litters',
'bonded_group_members',
'bonded_groups',
'care_rounds',
'icad_cache',
'asm3_import_links',
'asm3_import_runs',
'directory_contact_roles',
'animals',
'directory_contacts',
];
foreach ($tables as $table) {
$db->exec('DELETE FROM ' . $table);
}
return $before;
}
public static function clearMedia(): void
{
foreach (
[dirname(__DIR__, 2) . '/public/media/animals', dirname(__DIR__, 2) . '/data/medical-documents']
as $root
) {
if (!is_dir($root)) {
continue;
}
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST,
);
foreach ($iterator as $file) {
$path = $file->getPathname();
if ($file->isLink()) {
unlink($path);
} elseif ($file->isDir()) {
rmdir($path);
} else {
unlink($path);
}
}
}
}
}

View file

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
final class AssociationBrand
{
private const MAX_BYTES = 5_000_000;
private const MIME_EXTENSIONS = ['image/png' => 'png', 'image/jpeg' => 'jpg', 'image/webp' => 'webp'];
public static function path(): ?string
{
$files = glob(self::directory() . '/logo.*') ?: [];
foreach ($files as $file) {
if (is_file($file)) {
return $file;
}
}
return null;
}
public static function store(array $upload): void
{
$error = (int) ($upload['error'] ?? UPLOAD_ERR_NO_FILE);
if ($error === UPLOAD_ERR_NO_FILE) {
return;
}
if ($error !== UPLOAD_ERR_OK || !is_uploaded_file((string) ($upload['tmp_name'] ?? ''))) {
throw new RuntimeException(t('service.logo.upload_failed'));
}
if ((int) ($upload['size'] ?? 0) > self::MAX_BYTES) {
throw new RuntimeException(t('service.logo.too_large'));
}
$tmp = (string) $upload['tmp_name'];
$mime = new finfo(FILEINFO_MIME_TYPE)->file($tmp);
$extension = self::MIME_EXTENSIONS[$mime] ?? null;
if (!$extension) {
throw new RuntimeException(t('service.logo.format'));
}
$dimensions = @getimagesize($tmp);
if (!$dimensions || $dimensions[0] * $dimensions[1] > 40_000_000) {
throw new RuntimeException(t('service.logo.dimensions'));
}
$dir = self::directory();
$stored = ImageService::storeUploaded($tmp, $dir, 'association', 'logo');
$target = $dir . '/logo.' . $stored['extension'];
foreach (glob($dir . '/logo.*') ?: [] as $old) {
if ($old !== $stored['path']) {
@unlink($old);
}
}
if (!rename($stored['path'], $target)) {
@unlink($stored['path']);
throw new RuntimeException(t('service.logo.finalize_failed'));
}
}
public static function delete(): void
{
foreach (glob(self::directory() . '/logo.*') ?: [] as $file) {
if (is_file($file)) {
@unlink($file);
}
}
}
private static function directory(): string
{
return dirname(__DIR__, 2) . '/data/association';
}
}

View file

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
final class AuditService
{
public static function log(
string $action,
string $path,
?string $summary = null,
?string $entityType = null,
?int $entityId = null,
array $metadata = [],
): void {
$safe = [];
foreach ($metadata as $key => $value) {
if (in_array(strtolower((string) $key), ['password', 'password_confirm', 'csrf'], true)) {
continue;
}
if (is_scalar($value) || $value === null) {
$safe[$key] = $value;
}
}
DB::pdo()
->prepare(
'INSERT INTO audit_log(user_id,action,method,path,entity_type,entity_id,summary,metadata,ip_address) VALUES(:user,:action,:method,:path,:type,:entity,:summary,:meta,:ip)',
)
->execute([
':user' => Auth::id(),
':action' => $action,
':method' => $_SERVER['REQUEST_METHOD'] ?? 'CLI',
':path' => $path,
':type' => $entityType,
':entity' => $entityId,
':summary' => $summary,
':meta' => $safe ? json_encode($safe, JSON_UNESCAPED_UNICODE) : null,
':ip' => $_SERVER['REMOTE_ADDR'] ?? null,
]);
}
}

201
app/Services/Auth.php Normal file
View file

@ -0,0 +1,201 @@
<?php
declare(strict_types=1);
final class Auth
{
private static ?array $user = null;
public static function start(): void
{
if (session_status() === PHP_SESSION_ACTIVE) {
return;
}
ini_set('session.use_strict_mode', '1');
ini_set('session.use_only_cookies', '1');
ini_set('session.cookie_httponly', '1');
session_name('globinours_session');
session_set_cookie_params([
'httponly' => true,
'secure' => self::isSecureRequest(),
'samesite' => 'Strict',
'path' => '/',
]);
session_start();
$now = time();
$idle = max(300, (int) (getenv('GLOBINOURS_SESSION_IDLE') ?: 1800));
$absolute = max($idle, (int) (getenv('GLOBINOURS_SESSION_ABSOLUTE') ?: 43200));
if (
!empty($_SESSION['user_id']) &&
((isset($_SESSION['last_activity']) && $now - (int) $_SESSION['last_activity'] > $idle) ||
(isset($_SESSION['authenticated_at']) && $now - (int) $_SESSION['authenticated_at'] > $absolute))
) {
self::logout(false);
session_regenerate_id(true);
}
if (
empty($_SESSION['user_id']) &&
class_exists(TrustedDeviceService::class) &&
($id = TrustedDeviceService::authenticate())
) {
session_regenerate_id(true);
$_SESSION['user_id'] = $id;
$_SESSION['authenticated_at'] = $now;
}
if (!empty($_SESSION['user_id'])) {
$_SESSION['last_activity'] = $now;
}
}
public static function countUsers(): int
{
return (int) DB::pdo()->query('SELECT COUNT(*) FROM users')->fetchColumn();
}
public static function user(): ?array
{
if (self::$user !== null) {
return self::$user;
}
$id = (int) ($_SESSION['user_id'] ?? 0);
if (!$id) {
return null;
}
$s = DB::pdo()->prepare(
'SELECT id,username,display_name,role,active,last_login_at,animal_list_view,agenda_view FROM users WHERE id=:id AND active=1',
);
$s->execute([':id' => $id]);
$u = $s->fetch(PDO::FETCH_ASSOC);
if (!$u) {
self::logout(false);
return null;
}
return self::$user = $u;
}
public static function id(): ?int
{
return ($u = self::user()) ? (int) $u['id'] : null;
}
public static function login(
string $username,
string $password,
bool $remember = false,
string $deviceName = '',
): bool {
$db = DB::pdo();
$username = trim($username);
$ip = mb_substr((string) ($_SERVER['REMOTE_ADDR'] ?? ''), 0, 64);
$db->exec("DELETE FROM login_attempts WHERE attempted_at<datetime('now','-7 days')");
$rate = $db->prepare(
"SELECT COUNT(*) FROM login_attempts WHERE succeeded=0 AND ip_address=? AND attempted_at>=datetime('now','-15 minutes')",
);
$rate->execute([$ip ?: null]);
if ((int) $rate->fetchColumn() >= 20) {
self::recordAttempt($username, $ip, false);
return false;
}
$s = $db->prepare('SELECT * FROM users WHERE lower(username)=lower(:u) LIMIT 1');
$s->execute([':u' => $username]);
$u = $s->fetch(PDO::FETCH_ASSOC);
$valid =
$u &&
(int) $u['active'] === 1 &&
(empty($u['locked_until']) || strtotime($u['locked_until']) <= time()) &&
password_verify($password, (string) $u['password_hash']);
if (!$u) {
password_verify($password, '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2uheWG/igi.');
}
if (!$valid) {
if ($u) {
$failed = (int) $u['failed_attempts'] + 1;
$lock = $failed >= 5 ? date('Y-m-d H:i:s', time() + 900) : null;
$db->prepare('UPDATE users SET failed_attempts=:f,locked_until=:l WHERE id=:id')->execute([
':f' => $failed,
':l' => $lock,
':id' => $u['id'],
]);
}
self::recordAttempt($username, $ip, false);
return false;
}
session_regenerate_id(true);
$_SESSION['user_id'] = (int) $u['id'];
$_SESSION['authenticated_at'] = time();
$_SESSION['last_activity'] = time();
self::$user = null;
$db->prepare(
"UPDATE users SET failed_attempts=0,locked_until=NULL,last_login_at=datetime('now') WHERE id=:id",
)->execute([':id' => $u['id']]);
self::recordAttempt($username, $ip, true);
if ($remember && class_exists(TrustedDeviceService::class)) {
TrustedDeviceService::create((int) $u['id'], $deviceName);
}
return true;
}
public static function logout(bool $destroy = true): void
{
if ($destroy && class_exists(TrustedDeviceService::class)) {
TrustedDeviceService::revokeCurrent();
}
self::$user = null;
unset($_SESSION['user_id']);
if ($destroy) {
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$p = session_get_cookie_params();
setcookie(
session_name(),
'',
time() - 42000,
$p['path'],
$p['domain'] ?? '',
(bool) $p['secure'],
(bool) $p['httponly'],
);
}
session_destroy();
}
}
public static function csrf(): string
{
if (empty($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(32));
}
return (string) $_SESSION['csrf'];
}
public static function validCsrf(?string $token): bool
{
return is_string($token) && hash_equals(self::csrf(), $token);
}
public static function is(string $role): bool
{
return (self::user()['role'] ?? '') === $role;
}
public static function canWrite(string $path): bool
{
$module = PermissionService::moduleForPath($path);
return $module === null || PermissionService::can($module, 'edit');
}
public static function isSecureRequest(): bool
{
if (!empty($_SERVER['HTTPS']) && strtolower((string) $_SERVER['HTTPS']) !== 'off') {
return true;
}
if (str_starts_with(strtolower((string) getenv('APP_URL')), 'https://')) {
return true;
}
$remote = (string) ($_SERVER['REMOTE_ADDR'] ?? '');
$trusted = array_filter(array_map('trim', explode(',', (string) getenv('GLOBINOURS_TRUSTED_PROXIES'))));
if (in_array($remote, ['127.0.0.1', '::1'], true) || in_array($remote, $trusted, true)) {
return strtolower(trim(explode(',', (string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? ''))[0] ?? '')) ===
'https';
}
return false;
}
private static function recordAttempt(string $username, string $ip, bool $success): void
{
try {
DB::pdo()
->prepare('INSERT INTO login_attempts(username_hash,ip_address,succeeded) VALUES(?,?,?)')
->execute([hash('sha256', mb_strtolower($username)), $ip ?: null, $success ? 1 : 0]);
} catch (Throwable) {
}
}
}

View file

@ -0,0 +1,467 @@
<?php
declare(strict_types=1);
final class BackupService
{
private const FORMAT_VERSION = 1;
public static function create(string $reason = 'manual'): array
{
if (!class_exists(ZipArchive::class)) {
throw new RuntimeException(t('service.backup.zip_missing'));
}
$root = dirname(__DIR__, 2);
$backupDir = $root . '/storage/backups';
if (!is_dir($backupDir) && !mkdir($backupDir, 0775, true) && !is_dir($backupDir)) {
throw new RuntimeException(t('service.backup.directory_failed'));
}
$stamp = new DateTimeImmutable('now', new DateTimeZone('Europe/Paris'))->format('Ymd-His');
$suffix = $reason === 'pre-restore' ? '-avant-restauration' : '';
$target = $backupDir . '/globinours-' . $stamp . $suffix . '.zip';
// Certains volumes synchronisés acceptent les ZIP mais SQLite ne peut pas
// y créer de base avec VACUUM INTO. Le snapshot transitoire appartient au
// répertoire temporaire système ; seule l'archive finale va dans backups/.
$snapshot = tempnam(sys_get_temp_dir(), 'globinours-snapshot-');
if ($snapshot === false) {
throw new RuntimeException(t('service.backup.prepare_failed'));
}
@unlink($snapshot); // VACUUM INTO exige que la destination n'existe pas.
$pdo = DB::pdo();
$pdo->exec('PRAGMA wal_checkpoint(FULL)');
$pdo->exec('VACUUM INTO ' . $pdo->quote($snapshot));
$zip = new ZipArchive();
if ($zip->open($target, ZipArchive::CREATE | ZipArchive::EXCL) !== true) {
@unlink($snapshot);
throw new RuntimeException(t('service.backup.create_failed'));
}
$files = [];
try {
self::addFile($zip, $snapshot, 'database/refuge.sqlite', $files);
if (getenv('GLOBINOURS_BACKUP_DATA_ONLY') !== '1') {
self::addTree($zip, $root . '/public/media', 'public/media', $files);
self::addTree($zip, $root . '/data/association', 'data/association', $files);
self::addTree($zip, $root . '/data/grants', 'data/grants', $files);
self::addTree($zip, $root . '/data/medical-documents', 'data/medical-documents', $files);
self::addTree($zip, $root . '/data/private-media', 'data/private-media', $files);
}
$manifest = [
'application' => 'Globinours',
'format_version' => self::FORMAT_VERSION,
'created_at' => new DateTimeImmutable('now', new DateTimeZone('Europe/Paris'))->format(
DateTimeInterface::ATOM,
),
'reason' => $reason,
'php_version' => PHP_VERSION,
'files' => $files,
];
$zip->addFromString(
'manifest.json',
json_encode(
$manifest,
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR,
),
);
} finally {
$zip->close();
@unlink($snapshot);
}
@chmod($target, 0600);
self::prune($backupDir);
return self::describe($target);
}
public static function list(): array
{
$dir = dirname(__DIR__, 2) . '/storage/backups';
$files = glob($dir . '/globinours-*.zip') ?: [];
usort($files, static fn(string $a, string $b): int => filemtime($b) <=> filemtime($a));
return array_map([self::class, 'describe'], $files);
}
public static function path(string $name): ?string
{
if (
$name !== basename($name) ||
!preg_match('/^globinours-[0-9]{8}-[0-9]{6}(?:-avant-restauration)?\.zip$/', $name)
) {
return null;
}
$path = dirname(__DIR__, 2) . '/storage/backups/' . $name;
return is_file($path) ? $path : null;
}
public static function delete(string $name): void
{
$path = self::path($name);
if (!$path) {
throw new RuntimeException(t('service.backup.not_found'));
}
if (!unlink($path)) {
throw new RuntimeException(t('service.backup.delete_failed'));
}
}
public static function verify(string $name): array
{
$path = self::path($name);
if (!$path) {
throw new RuntimeException(t('service.backup.not_found'));
}
$zip = new ZipArchive();
if ($zip->open($path) !== true) {
throw new RuntimeException(t('service.backup.unreadable'));
}
try {
$raw = $zip->getFromName('manifest.json');
if ($raw === false) {
throw new RuntimeException(t('service.backup.manifest_missing'));
}
$manifest = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
if (
($manifest['application'] ?? '') !== 'Globinours' ||
(int) ($manifest['format_version'] ?? 0) !== self::FORMAT_VERSION
) {
throw new RuntimeException(t('service.backup.incompatible'));
}
foreach ($manifest['files'] ?? [] as $file => $meta) {
$stream = $zip->getStream((string) $file);
if (!$stream) {
throw new RuntimeException(t('service.backup.file_altered', ['name' => $file]));
}
$context = hash_init('sha256');
hash_update_stream($context, $stream);
fclose($stream);
if (!hash_equals((string) ($meta['sha256'] ?? ''), hash_final($context))) {
throw new RuntimeException(t('service.backup.file_altered', ['name' => $file]));
}
}
$tmp = tempnam(sys_get_temp_dir(), 'globinours-verify-');
if ($tmp === false) {
throw new RuntimeException(t('service.backup.prepare_failed'));
}
$dbBytes = $zip->getFromName('database/refuge.sqlite');
if ($dbBytes === false) {
@unlink($tmp);
throw new RuntimeException(t('service.backup.database_missing'));
}
file_put_contents($tmp, $dbBytes);
self::validateDatabase($tmp);
@unlink($tmp);
return [
'valid' => true,
'name' => $name,
'created_at' => (string) ($manifest['created_at'] ?? ''),
'files' => count($manifest['files'] ?? []),
];
} finally {
$zip->close();
}
}
public static function runScheduled(bool $force = false): array
{
if (AppSettings::get('backup_schedule_enabled') !== '1' && !$force) {
return ['created' => false, 'reason' => 'disabled'];
}
$frequency = AppSettings::get('backup_schedule_frequency');
$last = AppSettings::get('backup_last_run_at');
$minimum = $frequency === 'weekly' ? 6 * 86400 : 20 * 3600;
if (!$force && $last !== '' && time() - (strtotime($last) ?: 0) < $minimum) {
return ['created' => false, 'reason' => 'not_due'];
}
$backup = self::create('scheduled');
$verification = self::verify($backup['name']);
AppSettings::save(
['backup_last_run_at' => date('Y-m-d H:i:s'), 'backup_last_verified_at' => date('Y-m-d H:i:s')],
null,
);
return ['created' => true, 'backup' => $backup, 'verification' => $verification];
}
public static function restore(string $uploadedPath): array
{
if (!is_file($uploadedPath)) {
throw new RuntimeException(t('service.backup.restore_missing'));
}
$root = dirname(__DIR__, 2);
$stage = $root . '/storage/restore-stage-' . bin2hex(random_bytes(6));
if (!mkdir($stage, 0700, true)) {
throw new RuntimeException(t('service.backup.prepare_failed'));
}
$zip = new ZipArchive();
if ($zip->open($uploadedPath) !== true) {
self::removeTree($stage);
throw new RuntimeException(t('service.backup.unreadable'));
}
try {
if ($zip->numFiles > 10000) {
throw new RuntimeException(t('service.backup.too_many_files'));
}
$total = 0;
$seen = [];
for ($i = 0; $i < $zip->numFiles; $i++) {
$stat = $zip->statIndex($i);
$name = (string) ($stat['name'] ?? '');
if (isset($seen[$name])) {
throw new RuntimeException(t('service.backup.unsafe_path'));
}
$seen[$name] = true;
$total += (int) ($stat['size'] ?? 0);
if ($total > 2_000_000_000) {
throw new RuntimeException(t('service.backup.too_large'));
}
if (
$name === '' ||
str_contains($name, "\0") ||
str_starts_with($name, '/') ||
preg_match('#(^|/)\.\.(/|$)#', $name)
) {
throw new RuntimeException(t('service.backup.unsafe_path'));
}
}
$manifestRaw = $zip->getFromName('manifest.json');
if ($manifestRaw === false) {
throw new RuntimeException(t('service.backup.manifest_missing'));
}
$manifest = json_decode($manifestRaw, true, 512, JSON_THROW_ON_ERROR);
if (
($manifest['application'] ?? '') !== 'Globinours' ||
(int) ($manifest['format_version'] ?? 0) !== self::FORMAT_VERSION
) {
throw new RuntimeException(t('service.backup.incompatible'));
}
$manifestFiles = array_keys(is_array($manifest['files'] ?? null) ? $manifest['files'] : []);
$allowed = array_fill_keys(array_merge(['manifest.json'], $manifestFiles), true);
for ($i = 0; $i < $zip->numFiles; $i++) {
$stat = $zip->statIndex($i);
$name = (string) ($stat['name'] ?? '');
if (str_ends_with($name, '/')) {
continue;
}
if (!isset($allowed[$name])) {
throw new RuntimeException(t('service.backup.manifest_path'));
}
if (method_exists($zip, 'getExternalAttributesIndex')) {
$opsys = 0;
$attr = 0;
if ($zip->getExternalAttributesIndex($i, $opsys, $attr) && (($attr >> 16) & 0170000) === 0120000) {
throw new RuntimeException(t('service.backup.unsafe_path'));
}
}
}
$written = 0;
foreach ($manifestFiles as $name) {
$source = $zip->getStream($name);
if (!$source) {
throw new RuntimeException(t('service.backup.extract_failed'));
}
$target = $stage . '/' . $name;
$dir = dirname($target);
if (!is_dir($dir) && !mkdir($dir, 0700, true) && !is_dir($dir)) {
fclose($source);
throw new RuntimeException(t('service.backup.extract_failed'));
}
$dest = fopen($target, 'xb');
if (!$dest) {
fclose($source);
throw new RuntimeException(t('service.backup.extract_failed'));
}
while (!feof($source)) {
$chunk = fread($source, 1048576);
if ($chunk === false) {
fclose($source);
fclose($dest);
throw new RuntimeException(t('service.backup.extract_failed'));
}
$written += strlen($chunk);
if ($written > 2_000_000_000) {
fclose($source);
fclose($dest);
throw new RuntimeException(t('service.backup.too_large'));
}
fwrite($dest, $chunk);
}
fclose($source);
fclose($dest);
@chmod($target, 0600);
}
} finally {
$zip->close();
}
try {
foreach ($manifest['files'] ?? [] as $name => $metadata) {
if (
!is_string($name) ||
!preg_match(
'#^(database/refuge\.sqlite|public/media/.+|data/grants/.+|data/medical-documents/.+|data/private-media/.+|data/documents/.+|data/association/logo\.(?:png|jpg|webp)|data/(?:Logo_Globinours_64\.png|Logo_Globinours_maxi\.png|favicon\.ico))$#',
$name,
)
) {
throw new RuntimeException(t('service.backup.manifest_path'));
}
$restored = $stage . '/' . $name;
$hash = (string) ($metadata['sha256'] ?? '');
if (!is_file($restored) || $hash === '' || !hash_equals($hash, hash_file('sha256', $restored))) {
throw new RuntimeException(t('service.backup.file_altered', ['name' => $name]));
}
}
$database = $stage . '/database/refuge.sqlite';
if (!is_file($database)) {
throw new RuntimeException(t('service.backup.database_missing'));
}
$expected = $manifest['files']['database/refuge.sqlite']['sha256'] ?? '';
if ($expected === '' || !hash_equals($expected, hash_file('sha256', $database))) {
throw new RuntimeException(t('service.backup.database_hash'));
}
self::validateDatabase($database);
$emergency = self::create('pre-restore');
self::replaceInstallation($stage, $root);
return ['emergency' => $emergency, 'created_at' => (string) ($manifest['created_at'] ?? '')];
} finally {
self::removeTree($stage);
}
}
private static function validateDatabase(string $path): void
{
$db = new SQLite3($path, SQLITE3_OPEN_READONLY);
try {
if ($db->querySingle('PRAGMA integrity_check') !== 'ok') {
throw new RuntimeException(t('service.backup.database_corrupt'));
}
foreach (['animals', 'users', 'animal_history'] as $table) {
$safe = SQLite3::escapeString($table);
if (!(int) $db->querySingle("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='$safe'")) {
throw new RuntimeException(t('service.backup.table_missing', ['table' => $table]));
}
}
} finally {
$db->close();
}
}
private static function replaceInstallation(string $stage, string $root): void
{
$rollback = $root . '/storage/restore-rollback-' . bin2hex(random_bytes(5));
mkdir($rollback, 0700, true);
$targets = ['public/media', 'data/association', 'data/grants', 'data/medical-documents', 'data/private-media'];
$moved = [];
try {
foreach ($targets as $relative) {
$live = $root . '/' . $relative;
$saved = $rollback . '/' . str_replace('/', '__', $relative);
$incoming = $stage . '/' . $relative;
if (is_dir($live) && !rename($live, $saved)) {
throw new RuntimeException(t('service.backup.secure_failed', ['path' => $relative]));
}
if (is_dir($saved)) {
$moved[$relative] = $saved;
}
if (is_dir($incoming) && !rename($incoming, $live)) {
throw new RuntimeException(t('service.backup.restore_path_failed', ['path' => $relative]));
}
if (!is_dir($live)) {
mkdir($live, 0775, true);
}
}
$pdo = DB::pdo();
$pdo->exec('PRAGMA wal_checkpoint(TRUNCATE)');
unset($pdo);
DB::close();
foreach (['-wal', '-shm'] as $suffix) {
@unlink($root . '/data/refuge.sqlite' . $suffix);
}
if (!rename($root . '/data/refuge.sqlite', $rollback . '/refuge.sqlite')) {
throw new RuntimeException(t('service.backup.secure_database_failed'));
}
if (!copy($stage . '/database/refuge.sqlite', $root . '/data/refuge.sqlite')) {
throw new RuntimeException(t('service.backup.restore_database_failed'));
}
@chmod($root . '/data/refuge.sqlite', 0600);
self::removeTree($rollback);
} catch (Throwable $e) {
DB::close();
if (is_file($rollback . '/refuge.sqlite')) {
@unlink($root . '/data/refuge.sqlite');
@rename($rollback . '/refuge.sqlite', $root . '/data/refuge.sqlite');
}
foreach ($targets as $relative) {
$live = $root . '/' . $relative;
$saved = $rollback . '/' . str_replace('/', '__', $relative);
if (is_dir($saved)) {
self::removeTree($live);
@rename($saved, $live);
}
}
self::removeTree($rollback);
throw $e;
}
}
private static function addTree(ZipArchive $zip, string $dir, string $prefix, array &$files): void
{
if (!is_dir($dir)) {
return;
}
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS));
foreach ($iterator as $file) {
if ($file->isFile() && !$file->isLink()) {
self::addFile(
$zip,
$file->getPathname(),
$prefix . '/' . substr($file->getPathname(), strlen($dir) + 1),
$files,
);
}
}
}
private static function addFile(ZipArchive $zip, string $source, string $name, array &$files): void
{
if (!is_file($source)) {
return;
}
$name = str_replace('\\', '/', $name);
if (!$zip->addFile($source, $name)) {
throw new RuntimeException(t('service.backup.add_failed', ['name' => $name]));
}
$files[$name] = ['size' => filesize($source), 'sha256' => hash_file('sha256', $source)];
}
private static function describe(string $path): array
{
$modified = new DateTimeImmutable('@' . (filemtime($path) ?: time()))->setTimezone(
new DateTimeZone('Europe/Paris'),
);
return ['name' => basename($path), 'size' => filesize($path) ?: 0, 'date' => $modified->format('d/m/Y H:i:s')];
}
private static function prune(string $dir): void
{
$files = glob($dir . '/globinours-*.zip') ?: [];
usort($files, static fn($a, $b) => filemtime($b) <=> filemtime($a));
$keep = class_exists(AppSettings::class) ? AppSettings::int('backup_retention', 1, 50) : 10;
foreach (array_slice($files, $keep) as $file) {
@unlink($file);
}
}
private static function removeTree(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST,
);
foreach ($it as $item) {
$item->isDir() ? @rmdir($item->getPathname()) : @unlink($item->getPathname());
}
@rmdir($dir);
}
}

View file

@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
final class CalendarFeedService
{
public static function generateToken(int $userId): string
{
$token = bin2hex(random_bytes(32));
DB::pdo()
->prepare(
"UPDATE users SET agenda_feed_token_hash=:hash,agenda_feed_created_at=datetime('now'),updated_at=datetime('now') WHERE id=:id AND active=1",
)
->execute([':hash' => hash('sha256', $token), ':id' => $userId]);
return $token;
}
public static function revoke(int $userId): void
{
DB::pdo()
->prepare(
"UPDATE users SET agenda_feed_token_hash=NULL,agenda_feed_created_at=NULL,updated_at=datetime('now') WHERE id=?",
)
->execute([$userId]);
}
public static function userForToken(string $token): ?array
{
if (!preg_match('/^[a-f0-9]{64}$/', $token)) {
return null;
}
$s = DB::pdo()->prepare('SELECT id,role FROM users WHERE agenda_feed_token_hash=? AND active=1 LIMIT 1');
$s->execute([hash('sha256', $token)]);
$user = $s->fetch(PDO::FETCH_ASSOC) ?: null;
if (!$user || !PermissionService::can('agenda', 'view', (string) $user['role'])) {
return null;
}
return $user;
}
public static function absoluteUrl(string $path): string
{
$configured = rtrim((string) getenv('APP_URL'), '/');
if ($configured !== '' && preg_match('~^https?://~i', $configured)) {
return $configured . $path;
}
$proto = (string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '');
if (!in_array($proto, ['http', 'https'], true)) {
$proto = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off' ? 'https' : 'http';
}
$host = (string) ($_SERVER['HTTP_X_FORWARDED_HOST'] ?? ($_SERVER['HTTP_HOST'] ?? 'localhost'));
$host = trim(explode(',', $host)[0]);
if (!preg_match('/^[a-z0-9.:-]+$/i', $host)) {
$host = 'localhost';
}
return $proto . '://' . $host . $path;
}
public static function render(array $items, string $name): string
{
$lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Globinours//Agenda refuge//FR',
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
'X-WR-CALNAME:' . self::escape($name),
'X-WR-TIMEZONE:Europe/Paris',
];
$stamp = gmdate('Ymd\THis\Z');
foreach ($items as $item) {
$source = (string) ($item['source_type'] ?? 'agenda');
$sourceId = (int) ($item['source_id'] ?? $item['id']);
$instance = (string) $item['instance_starts_at'];
$uid = $source . '-' . $sourceId . '-' . substr(hash('sha256', $instance), 0, 12) . '@globinours';
$lines[] = 'BEGIN:VEVENT';
$lines[] = 'UID:' . $uid;
$lines[] = 'DTSTAMP:' . $stamp;
if ((int) $item['all_day'] === 1) {
$start = new DateTimeImmutable(substr($instance, 0, 10));
$lines[] = 'DTSTART;VALUE=DATE:' . $start->format('Ymd');
$lines[] = 'DTEND;VALUE=DATE:' . $start->modify('+1 day')->format('Ymd');
} else {
$start = new DateTimeImmutable($instance, new DateTimeZone('Europe/Paris'));
$end = !empty($item['instance_ends_at'])
? new DateTimeImmutable($item['instance_ends_at'], new DateTimeZone('Europe/Paris'))
: $start->modify('+1 hour');
$lines[] = 'DTSTART;TZID=Europe/Paris:' . $start->format('Ymd\THis');
$lines[] = 'DTEND;TZID=Europe/Paris:' . $end->format('Ymd\THis');
}
$lines[] = 'SUMMARY:' . self::escape((string) $item['title']);
$description = trim((string) ($item['description'] ?? ''));
if (!empty($item['animal_name'])) {
$description = trim($description . "\n" . t('animal.animal') . ' : ' . $item['animal_name']);
}
if ($description !== '') {
$lines[] = 'DESCRIPTION:' . self::escape($description);
}
if (!empty($item['location'])) {
$lines[] = 'LOCATION:' . self::escape((string) $item['location']);
}
if (!empty($item['animal_id'])) {
$lines[] = 'URL:' . self::escape(self::absoluteUrl('/animal?id=' . (int) $item['animal_id']));
}
$lines[] = 'STATUS:' . (($item['status'] ?? 'planned') === 'completed' ? 'COMPLETED' : 'CONFIRMED');
$lines[] = 'END:VEVENT';
}
$lines[] = 'END:VCALENDAR';
return implode("\r\n", array_map([self::class, 'fold'], $lines)) . "\r\n";
}
private static function escape(string $value): string
{
return str_replace(['\\', "\r\n", "\n", "\r", ';', ','], ['\\\\', '\\n', '\\n', '\\n', '\\;', '\\,'], $value);
}
private static function fold(string $line): string
{
$out = '';
while (strlen($line) > 73) {
$cut = 73;
while ($cut > 0 && (ord($line[$cut]) & 0xc0) === 0x80) {
$cut--;
}
$out .= substr($line, 0, $cut) . "\r\n ";
$line = substr($line, $cut);
}
return $out . $line;
}
}

41
app/Services/DB.php Normal file
View file

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
final class DB
{
private static ?PDO $pdo = null;
public static function pdo(): PDO
{
if (self::$pdo instanceof PDO) {
return self::$pdo;
}
$dbPath = getenv('GLOBINOURS_DB_PATH') ?: __DIR__ . '/../../data/refuge.sqlite';
$dir = dirname($dbPath);
if (!is_dir($dir)) {
mkdir($dir, 0775, true);
}
$pdo = new PDO('sqlite:' . $dbPath, null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
$pdo->exec('PRAGMA foreign_keys = ON;');
$pdo->exec('PRAGMA journal_mode = WAL;');
$pdo->exec('PRAGMA busy_timeout = 3000;');
@chmod($dbPath, 0600);
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
self::$pdo = $pdo;
return $pdo;
}
public static function close(): void
{
self::$pdo = null;
}
}

View file

@ -0,0 +1,536 @@
<?php
declare(strict_types=1);
final class DemoDataService
{
private const TABLES = [
'agenda_volunteers',
'agenda_items',
'prescription_treatments',
'lab_results',
'medical_prescriptions',
'lab_reports',
'treatment_administrations',
'care_round_observations',
'care_rounds',
'medical_photos',
'animal_photos',
'animal_expenses',
'animal_surgeries',
'animal_health_conditions',
'dewormings',
'vaccinations',
'treatments',
'medical_notes',
'measurements',
'animal_placements',
'adoptions',
'animal_movements',
'animal_deaths',
'animal_location_history',
'animal_history',
'litter_kittens',
'litters',
'bonded_group_members',
'bonded_groups',
'icad_cache',
'asm3_import_links',
'asm3_import_runs',
'asm3_import_jobs',
'directory_contact_roles',
'animals',
'directory_contacts',
'grant_documents',
'grant_applications',
'grant_years',
'care_room_layouts',
'clinics',
'geocode_cache',
'audit_log',
];
public static function reset(PDO $db): array
{
$before = [
'animals' => (int) $db->query('SELECT COUNT(*) FROM animals')->fetchColumn(),
'contacts' => (int) $db->query('SELECT COUNT(*) FROM directory_contacts')->fetchColumn(),
];
$sequence = $db->prepare('DELETE FROM sqlite_sequence WHERE name=?');
$exists = $db->prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?");
foreach (self::TABLES as $table) {
$exists->execute([$table]);
if (!$exists->fetchColumn()) {
continue;
}
$db->exec('DELETE FROM ' . $table);
$sequence->execute([$table]);
}
return $before;
}
public static function seed(PDO $db, ?int $userId): array
{
$contacts = [
'fa' => [
'person',
'Alice Martin',
'06 00 00 00 01',
'alice.demo@example.org',
'12 rue des Lilas',
'29260',
'Lesneven',
],
'adopter' => [
'person',
'Marc Durand',
'06 00 00 00 02',
'marc.demo@example.org',
'8 rue du Port',
'29200',
'Brest',
],
'depositor' => [
'person',
'Sophie Le Gall',
'06 00 00 00 03',
'sophie.demo@example.org',
'4 place du Marché',
'29000',
'Ville fictive',
],
'vet' => [
'person',
'Dr Camille Morel',
'02 00 00 00 04',
'veterinaire.demo@example.org',
'2 avenue des Chats',
'29260',
'Lesneven',
],
'clinic' => [
'organization',
'Clinique vétérinaire Démo',
'02 00 00 00 05',
'clinique.demo@example.org',
'2 avenue des Chats',
'29260',
'Lesneven',
],
'crematorium' => [
'organization',
'Crématorium animalier Démo',
'02 00 00 00 06',
'crematorium.demo@example.org',
'1 route du Souvenir',
'29400',
'Landivisiau',
],
];
$contactIds = [];
$insert = $db->prepare(
'INSERT INTO directory_contacts(kind,name,phone,email,address,postal_code,city,notes) VALUES(?,?,?,?,?,?,?,?)',
);
$role = $db->prepare('INSERT INTO directory_contact_roles(contact_id,role) VALUES(?,?)');
foreach ($contacts as $key => $c) {
$insert->execute([...$c, 'Donnée fictive de démonstration']);
$contactIds[$key] = (int) $db->lastInsertId();
$roleName = match ($key) {
'adopter' => 'adoptant',
'vet' => 'veterinaire',
'clinic' => 'cabinet',
'crematorium' => 'crematorium',
'depositor' => 'deposant',
default => 'fa',
};
$role->execute([$contactIds[$key], $roleName]);
}
$animals = [
[
'DEMO-001',
'Moka',
'F',
'2019-05-14',
'refuge',
'silver',
'Bicolore Noir / Blanc',
'yes',
'yes',
'yes',
'yes',
'yes',
'available',
],
[
'DEMO-002',
'Pixel',
'M',
'2021-09-03',
'refuge',
'twix',
'Roux',
'yes',
'yes',
'yes',
'yes',
'no',
'available',
],
[
'DEMO-003',
'Twix',
'M',
'2019-04-15',
'quarantaine',
null,
'Roux',
'yes',
'yes',
'yes',
'yes',
'yes',
'not_available',
],
[
'DEMO-004',
'Nox',
'M',
'2023-02-18',
'soin',
null,
'Noir',
'no',
'yes',
'unknown',
'yes',
'no',
'not_available',
],
[
'DEMO-005',
'Caramel',
'F',
'2020-07-21',
'fa',
null,
'Écaille de tortue',
'yes',
'yes',
'yes',
'yes',
'yes',
'available',
],
[
'DEMO-006',
'Plume',
'F',
'2022-04-08',
'refuge',
'silver',
'Tricolore / Calico',
'yes',
'yes',
'yes',
'yes',
'yes',
'available',
],
[
'DEMO-007',
'Plume-Bébé-1',
'F',
date('Y-m-d', strtotime('-4 months')),
'refuge',
'silver',
'Bicolore Gris / Blanc',
'unknown',
'yes',
'unknown',
'unknown',
'unknown',
'not_available',
],
[
'DEMO-008',
'Plume-Bébé-2',
'M',
date('Y-m-d', strtotime('-4 months')),
'refuge',
'silver',
'Gris',
'unknown',
'yes',
'unknown',
'unknown',
'unknown',
'not_available',
],
[
'DEMO-009',
'Plume-Bébé-3',
'U',
date('Y-m-d', strtotime('-4 months')),
'refuge',
'silver',
'Tigré / Tabby',
'unknown',
'yes',
'unknown',
'unknown',
'unknown',
'not_available',
],
[
'DEMO-010',
'Lune',
'F',
'2021-01-12',
'refuge',
'twix',
'Blanc',
'yes',
'no',
'yes',
'yes',
'yes',
'available',
],
[
'DEMO-011',
'Soleil',
'M',
'2021-01-12',
'refuge',
'twix',
'Roux',
'yes',
'no',
'yes',
'yes',
'yes',
'available',
],
[
'DEMO-012',
'Étoile',
'F',
'2012-06-01',
'decede',
null,
'Noir',
'yes',
'unknown',
'unknown',
'unknown',
'unknown',
'not_available',
],
];
$animalIds = [];
$animal = $db->prepare(
"INSERT INTO animals(internal_code,name,species,sex,birth_date,status,refuge_room,care_box_key,color,sterilized,sterilization_status,compatibility_cats,compatibility_dogs,compatibility_children,house_trained,adoption_availability,intake_date,intake_type,intake_reason,rescue_location_name,current_address,chip_id,identification_type,identification_registration_status,archived_at,notes) VALUES(:code,:name,'chat',:sex,:birth,:status,:room,:box,:color,:sterilized,:sterilization,:cats,:dogs,:children,:clean,:availability,date('now','-'||:days||' days'),'found','stray_found','Commune fictive','5 rue de la Démonstration, 29260 Lesneven',:chip,:identification,:registration,:archived,'Donnée fictive de démonstration')",
);
foreach ($animals as $i => $a) {
[
$code,
$name,
$sex,
$birth,
$status,
$room,
$color,
$sterilization,
$cats,
$dogs,
$children,
$clean,
$availability,
] = $a;
$sterilized = $sterilization === 'yes' ? 1 : 0;
$chip = $i < 6 ? '250269900' . str_pad((string) ($i + 1), 6, '0', STR_PAD_LEFT) : null;
$animal->execute([
':code' => $code,
':name' => $name,
':sex' => $sex,
':birth' => $birth,
':status' => $status,
':room' => $room,
':box' => $status === 'soin' ? 'care-top-1' : null,
':color' => $color,
':sterilized' => $sterilized,
':sterilization' => $sterilization,
':cats' => $cats,
':dogs' => $dogs,
':children' => $children,
':clean' => $clean,
':availability' => $availability,
':days' => 90 + $i * 12,
':chip' => $chip,
':identification' => $chip ? 'microchip' : 'unknown',
':registration' => $chip ? 'registered' : 'unknown',
':archived' => $status === 'decede' ? date('Y-m-d H:i:s') : null,
]);
$animalIds[$name] = (int) $db->lastInsertId();
}
$db->prepare('INSERT INTO litters(mother_id,birth_date,notes) VALUES(?,?,?)')->execute([
$animalIds['Plume'],
$animals[6][3],
'Portée fictive de démonstration',
]);
$litter = (int) $db->lastInsertId();
$link = $db->prepare('INSERT INTO litter_kittens(litter_id,animal_id,position) VALUES(?,?,?)');
foreach (['Plume-Bébé-1', 'Plume-Bébé-2', 'Plume-Bébé-3'] as $i => $name) {
$link->execute([$litter, $animalIds[$name], $i + 1]);
}
$db->prepare('INSERT INTO bonded_groups(name,notes) VALUES(?,?)')->execute([
'Duo Lune & Soleil',
'Chats fictifs à adopter ensemble',
]);
$group = (int) $db->lastInsertId();
$member = $db->prepare('INSERT INTO bonded_group_members(group_id,animal_id,position) VALUES(?,?,?)');
$member->execute([$group, $animalIds['Lune'], 1]);
$member->execute([$group, $animalIds['Soleil'], 2]);
$twix = $animalIds['Twix'];
$db->prepare(
"UPDATE animals SET breed='Européen',birth_is_estimated=0,fiv=0,felv=0,quarantine_until=date('now','+10 days'),intake_circumstances='Trouvé seul, bilan sanitaire programmé à larrivée.',depositor_contact_id=?,rescue_location_name='Commune fictive',current_address='Quarantaine — box supérieur gauche',identification_date=date('now','-2 years'),notes='Fiche vitrine entièrement fictive : elle illustre un dossier animal complet.' WHERE id=?",
)->execute([$contactIds['depositor'], $twix]);
$db->prepare(
"INSERT OR IGNORE INTO ref_medications(name,molecule,form,notes) VALUES('Doxybactin','doxycycline','comprimé','Référence de démonstration')",
)->execute();
$med = (int) $db->query("SELECT id FROM ref_medications WHERE name='Doxybactin'")->fetchColumn();
$db->prepare(
"INSERT INTO treatments(animal_id,medication_id,route,dose_text,start_date,end_date,ongoing,notes,created_by,give_morning,give_evening) VALUES(?,?,'PO','1/2 comprimé matin et soir',date('now','-2 days'),date('now','+5 days'),1,'Traitement fictif de démonstration',?,1,1)",
)->execute([$twix, $med, $userId]);
$db->prepare(
"INSERT INTO medical_notes(animal_id,noted_at,kind,reason,symptoms,exam,diagnosis,plan,weight_kg,temperature_c,created_by,vet_contact_id,clinic_contact_id) VALUES(?,datetime('now','-1 day'),'consult','Visite sanitaire dentrée','Éternuements occasionnels','État général satisfaisant, auscultation sans anomalie','Coryza léger','Surveillance, traitement court et contrôle avant sortie de quarantaine',4.8,38.4,?,?,?)",
)->execute([$twix, $userId, $contactIds['vet'], $contactIds['clinic']]);
$weight = $db->prepare(
"INSERT INTO measurements(animal_id,type,value,unit,measured_at,notes,created_by) VALUES(?,'weight',?,'kg',datetime('now',?),'Mesure fictive',?)",
);
foreach ([[4.6, '-30 days'], [4.7, '-14 days'], [4.8, '-1 day']] as $w) {
$weight->execute([$twix, $w[0], $w[1], $userId]);
}
$db->prepare(
"INSERT OR IGNORE INTO ref_vaccines(name,notes) VALUES('TCL','Vaccin de démonstration')",
)->execute();
$vaccine = (int) $db->query("SELECT id FROM ref_vaccines WHERE name='TCL'")->fetchColumn();
$db->prepare(
"INSERT INTO vaccinations(animal_id,vaccine_id,done_date,due_date,lot,manufacturer,notes,created_by,vet_contact_id,clinic_contact_id) VALUES(?,?,date('now','-11 months'),date('now','+1 month'),'LOT-DEMO-1','Laboratoire Démo','Vaccination fictive à jour',?,?,?)",
)->execute([$twix, $vaccine, $userId, $contactIds['vet'], $contactIds['clinic']]);
$dewormer = (int) $db->query('SELECT id FROM ref_dewormers ORDER BY id LIMIT 1')->fetchColumn();
$db->prepare(
"INSERT INTO dewormings(animal_id,dewormer_id,administered_on,dose_text,weight_kg,next_due_date,notes,created_by) VALUES(?,?,date('now','-10 days'),'1 pipette',4.7,date('now','+80 days'),'Administration fictive',?)",
)->execute([$twix, $dewormer, $userId]);
$db->prepare(
"INSERT INTO animal_health_conditions(animal_id,name,status,diagnosed_at,resolved_at,notes) VALUES(?,'Gingivite légère','resolved',date('now','-6 months'),date('now','-5 months'),'Antécédent fictif résolu')",
)->execute([$twix]);
$db->prepare(
"INSERT INTO lab_reports(animal_id,sampled_on,report_type,laboratory_name,veterinarian_contact_id,clinic_contact_id,notes,created_by) VALUES(?,date('now','-1 day'),'Bilan complet','Laboratoire Démo',?,?,'Prise de sang fictive — valeurs dans les intervalles usuels',?)",
)->execute([$twix, $contactIds['vet'], $contactIds['clinic'], $userId]);
$report = (int) $db->lastInsertId();
$result = $db->prepare(
'INSERT INTO lab_results(report_id,parameter_name,value,unit,reference_min,reference_max,notes,position) VALUES(?,?,?,?,?,?,?,?)',
);
foreach (
[
['Leucocytes', 8.4, '10⁹/L', 3.5, 20.7, 10],
['Érythrocytes', 9.6, '10¹²/L', 7.7, 12.8, 20],
['Hémoglobine', 13.8, 'g/dL', 10, 17, 30],
['Hématocrite', 42.1, '%', 33.7, 55.4, 40],
['Plaquettes', 312, '10⁹/L', 125, 618, 50],
['Créatinine', 12, 'mg/L', 3, 21, 60],
['Urée', 0.42, 'g/L', 0.214, 0.642, 70],
]
as $r
) {
$result->execute([$report, $r[0], $r[1], $r[2], $r[3], $r[4], 'Résultat fictif', $r[5]]);
}
$db->prepare(
"INSERT INTO animal_photos(animal_id,filename,original_name,mime,size_bytes,is_primary,is_public,caption) VALUES(?,'twix.webp','twix.jpg','image/webp',0,1,1,'Twix — photographie de démonstration')",
)->execute([$twix]);
$db->prepare(
"INSERT INTO agenda_items(item_type,title,description,starts_at,ends_at,location,status,priority,animal_id,contact_id,reminder_minutes,created_by) VALUES('veterinary','Contrôle de sortie de quarantaine','Rendez-vous fictif : contrôle clinique et validation de la sortie.',datetime('now','+7 days','09:00'),datetime('now','+7 days','09:30'),'Clinique vétérinaire Démo','planned','high',?,?,60,?)",
)->execute([$twix, $contactIds['clinic'], $userId]);
$db->prepare(
"INSERT INTO animal_placements(animal_id,event_type,event_date,contact_id,reason,notes,created_by) VALUES(?,'foster',date('now','-30 days'),?,'Placement de démonstration','Donnée fictive',?)",
)->execute([$animalIds['Caramel'], $contactIds['fa'], $userId]);
$db->prepare(
"INSERT INTO animal_deaths(animal_id,deceased_date,cause_code,cause_details,occurred_in_care,place_type,euthanized,veterinarian_contact_id,crematorium_contact_id,body_disposition,created_by) VALUES(?,date('now','-60 days'),'old_age','Décès fictif',1,'veterinaire',1,?,?,'collective_cremation',?)",
)->execute([$animalIds['Étoile'], $contactIds['vet'], $contactIds['crematorium'], $userId]);
$history = $db->prepare(
"INSERT INTO animal_history(animal_id,action,field,new_value,user_id,created_at) VALUES(?,'created','demo','Donnée entièrement fictive',?,datetime('now'))",
);
foreach ($animalIds as $id) {
$history->execute([$id, $userId]);
}
return [
'animals' => count($animalIds),
'contacts' => count($contactIds),
'litters' => 1,
'bonded_groups' => 1,
'treatments' => 1,
'vaccinations' => 1,
'lab_reports' => 1,
'agenda_items' => 1,
'photos' => 1,
];
}
public static function clearFiles(): void
{
foreach (
[
dirname(__DIR__, 2) . '/public/media/animals',
dirname(__DIR__, 2) . '/data/grants',
dirname(__DIR__, 2) . '/data/medical-documents',
]
as $root
) {
if (!is_dir($root)) {
continue;
}
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST,
);
foreach ($it as $file) {
$path = $file->getPathname();
if ($file->isLink() || $file->isFile()) {
@unlink($path);
} elseif ($file->isDir()) {
@rmdir($path);
}
}
}
}
public static function installFiles(PDO $db): void
{
$animalId = (int) $db->query("SELECT id FROM animals WHERE internal_code='DEMO-003' LIMIT 1")->fetchColumn();
if ($animalId <= 0) {
return;
}
$source = dirname(__DIR__, 2) . '/resources/demo/twix.webp';
$directory = dirname(__DIR__, 2) . '/public/media/animals/' . $animalId;
$target = $directory . '/twix.webp';
if (!is_file($source)) {
throw new RuntimeException(t('service.image.invalid_upload'));
}
if (!is_dir($directory) && !mkdir($directory, 0755, true) && !is_dir($directory)) {
throw new RuntimeException(t('service.image.directory_failed'));
}
if (!copy($source, $target)) {
throw new RuntimeException(t('service.image.save_failed'));
}
@chmod($target, 0644);
$db->prepare("UPDATE animal_photos SET size_bytes=? WHERE animal_id=? AND filename='twix.webp'")->execute([
filesize($target),
$animalId,
]);
}
}

View file

@ -0,0 +1,537 @@
<?php
declare(strict_types=1);
final class DocumentsService
{
private const ADOPTION_TEMPLATE = __DIR__ . '/../../resources/documents/CONTRAT ADOPTION V1.5.odt';
private const TEMPLATES = [
'adoption' => self::ADOPTION_TEMPLATE,
'abandon' => __DIR__ . '/../../resources/documents/ABANDON V1.0.odt',
'benevole' => __DIR__ . '/../../resources/documents/CONTRAT BENEVOLE V2.0.odt',
'fa' => __DIR__ . '/../../resources/documents/PROPOSITION FA V1.1.odt',
'pre_adoption' => __DIR__ . '/../../resources/documents/CERTIFICAT VISITE PRE-ADOPTION V1.1.odt',
];
public static function adoptionTemplate(): string
{
if (!is_file(self::ADOPTION_TEMPLATE)) {
throw new RuntimeException(t('service.document.adoption_missing'));
}
return self::ADOPTION_TEMPLATE;
}
public static function template(string $type): string
{
$path = self::TEMPLATES[$type] ?? null;
if (!$path || !is_file($path)) {
throw new RuntimeException(t('service.document.template_missing'));
}
return $path;
}
public static function generateAbandon(array $animal): string
{
return self::transformTemplate('abandon', 'abandon-' . self::safeName((string) $animal['name']), function (
DOMXPath $xp,
) use ($animal): void {
$sex = match ($animal['sex'] ?? 'U') {
'M' => 'Mâle',
'F' => 'Femelle',
default => 'Non renseigné',
};
$birth = trim((string) ($animal['birth_date'] ?? ''));
$age = '—';
if ($birth !== '') {
$born = new DateTimeImmutable($birth);
$now = new DateTimeImmutable();
$diff = $born->diff($now);
$age = $diff->y > 0 ? $diff->y . ' an' . ($diff->y > 1 ? 's' : '') : $diff->m . ' mois';
}
$chip = trim((string) ($animal['chip'] ?: $animal['chip_id'] ?? ''));
self::replaceParagraph($xp, 'Nom de lanimal', [
['Nom de lanimal : ', 'label'],
[(string) $animal['name'], 'value'],
['tab'],
['Sexe : ', 'label'],
[$sex, 'value'],
]);
self::replaceParagraph($xp, 'Race :', [
['Race : ', 'label'],
[(string) ($animal['breed'] ?? '—'), 'value'],
['tab'],
['Âge : ', 'label'],
[$age, 'value'],
['break'],
['Vaccination : ', 'label'],
['À vérifier', 'value'],
['tab'],
['Vermifugation : ', 'label'],
['À vérifier', 'value'],
['break'],
['Numéro de tatouage ou puce électronique : ', 'label'],
[$chip !== '' ? $chip : '—', 'value'],
]);
});
}
public static function generateContact(string $type, array $contact): string
{
if (!in_array($type, ['benevole', 'fa'], true)) {
throw new InvalidArgumentException(t('service.document.invalid_type'));
}
return self::transformTemplate($type, $type . '-' . self::safeName((string) $contact['name']), function (
DOMXPath $xp,
) use ($type, $contact): void {
$name = (string) $contact['name'];
$address = (string) ($contact['address'] ?? '');
$postal = (string) ($contact['postal_code'] ?? '');
$city = (string) ($contact['city'] ?? '');
$phone = (string) ($contact['phone'] ?? '');
$email = (string) ($contact['email'] ?? '');
if ($type === 'benevole') {
self::replaceParagraph(
$xp,
'Nom :',
[['Nom : ', 'label'], [$name, 'value'], ['tab'], ['Prénom : ', 'label'], ['', 'value']],
0,
);
self::replaceParagraph($xp, 'Adresse :', [['Adresse : ', 'label'], [$address, 'value']], 0);
self::replaceParagraph(
$xp,
'Code Postal :',
[['Code Postal : ', 'label'], [$postal, 'value'], ['tab'], ['Ville : ', 'label'], [$city, 'value']],
0,
);
self::replaceParagraph(
$xp,
'Numéro de téléphone',
[
['Numéro de téléphone : ', 'label'],
[$phone, 'value'],
['tab'],
['Adresse e-mail : ', 'label'],
[$email, 'value'],
],
0,
);
} else {
self::replaceParagraph($xp, 'NOM / Prénom :', [['NOM / Prénom : ', 'label'], [$name, 'value']], 0);
self::replaceParagraph($xp, 'Adresse :', [['Adresse : ', 'label'], [$address, 'value']], 0);
self::replaceParagraph(
$xp,
'Code postal :',
[['Code postal : ', 'label'], [$postal, 'value'], ['tab'], ['Ville : ', 'label'], [$city, 'value']],
0,
);
self::replaceParagraph(
$xp,
'Téléphone :',
[
['Téléphone : ', 'label'],
[$phone, 'value'],
['tab'],
['Adresse mail : ', 'label'],
[$email, 'value'],
],
0,
);
}
});
}
public static function generateAdoption(array $animal, ?array $adoption, ?array $vaccine): string
{
$dir = self::tempDir();
$name = self::safeName((string) $animal['name']);
$target = $dir . '/contrat-adoption-' . $name . '.odt';
if (!copy(self::adoptionTemplate(), $target)) {
throw new RuntimeException(t('service.document.copy_failed'));
}
$zip = new ZipArchive();
if ($zip->open($target) !== true) {
throw new RuntimeException(t('service.document.open_failed'));
}
$xml = $zip->getFromName('content.xml');
if ($xml === false) {
$zip->close();
throw new RuntimeException(t('service.document.content_invalid'));
}
$dom = new DOMDocument();
$dom->preserveWhiteSpace = true;
if (!$dom->loadXML($xml)) {
$zip->close();
throw new RuntimeException(t('service.document.xml_invalid'));
}
$xp = new DOMXPath($dom);
$xp->registerNamespace('text', 'urn:oasis:names:tc:opendocument:xmlns:text:1.0');
$xp->registerNamespace('office', 'urn:oasis:names:tc:opendocument:xmlns:office:1.0');
self::addGeneratedFieldStyles($dom, $xp);
$date = static fn(?string $value): string => $value ? date('d/m/Y', strtotime($value)) : '';
$sex = match ($animal['sex'] ?? 'U') {
'M' => '☒ Mâle ☐ Femelle',
'F' => '☐ Mâle ☒ Femelle',
default => '☐ Mâle ☐ Femelle',
};
$origin = trim((string) ($animal['breed'] ?? ''));
$chip = trim((string) ($animal['chip'] ?: $animal['chip_id'] ?? ''));
$testStatus =
'FIV ' .
self::testLabel((string) ($animal['fiv_status'] ?? 'unknown')) .
' / FeLV ' .
self::testLabel((string) ($animal['felv_status'] ?? 'unknown'));
$signatureDate = $date($adoption['adoption_date'] ?? date('Y-m-d'));
self::setSpan($xp, 'T26', (string) $animal['name']);
self::setLabeledSpan($xp, 'T28', 'Sexe : ', $sex);
self::setSpan($xp, 'T31', ' ' . $date($animal['birth_date'] ?? null));
self::setSpan($xp, 'T33', 'Robe : ', null, 'GeneratedField');
self::setSpan($xp, 'T34', (string) ($animal['color'] ?? ''));
self::setSpan($xp, 'T41', $origin !== '' ? $origin : '—');
self::setSpan($xp, 'T42', '');
self::setLabeledSpan($xp, 'T45', 'Puce électronique N° : ', $chip !== '' ? $chip : '—');
self::setLabeledSpan(
$xp,
'T49',
' : ',
(int) ($animal['sterilized'] ?? 0) === 1
? '☒ Effectuée ☐ À effectuer avant le :'
: '☐ Effectuée ☒ À effectuer avant le :',
);
self::setLabeledSpan($xp, 'T53', 'Date du test FIV-FELV : ', $testStatus, 'tab');
self::setLabeledSpan($xp, 'T55', 'Date de la dernière vaccination : ', $date($vaccine['done_date'] ?? null));
self::setLabeledSpan($xp, 'T56', 'Rappel à effectuer le : ', $date($vaccine['due_date'] ?? null), 'tab');
self::setSpan($xp, 'T98', (string) ($adoption['adopter_name'] ?? ''));
self::setSpan($xp, 'T101', (string) ($adoption['adopter_address'] ?? ''));
self::setSpan($xp, 'T104', (string) ($adoption['adopter_postal_code'] ?? ''));
self::setSpan($xp, 'T107', (string) ($adoption['adopter_city'] ?? ''));
self::setSpan($xp, 'T110', '');
self::setSpan($xp, 'T113', (string) ($adoption['adopter_phone'] ?? ''));
self::setSpan($xp, 'T116', (string) ($adoption['adopter_email'] ?? ''));
$signatureCity = AppSettings::get('association_city') ?: '—';
self::setLabeledSpan($xp, 'T155', 'Fait à : ', $signatureCity, null, 'GeneratedSignature');
self::setLabeledSpan($xp, 'T156', 'Le : ', $signatureDate, 'tab', 'GeneratedSignature');
self::setLabeledSpan($xp, 'T247', 'Fait à : ', $signatureCity, null, 'GeneratedSignature');
self::setLabeledSpan($xp, 'T253', 'Le : ', $signatureDate, 'tab', 'GeneratedSignature');
self::setSpan($xp, 'T283', (string) $animal['name']);
self::setLabeledSpan($xp, 'T367', 'Fait à : ', $signatureCity, null, 'GeneratedSignature');
self::setLabeledSpan($xp, 'T373', 'Le : ', $signatureDate, 'tab', 'GeneratedSignature');
foreach (
[
'T25',
'T30',
'T33',
'T36',
'T40',
'T45',
'T48',
'T52',
'T53',
'T55',
'T56',
'T97',
'T100',
'T103',
'T106',
'T109',
'T112',
'T115',
'T281',
]
as $labelStyle
) {
self::applySpanStyle($xp, $labelStyle);
}
$updated = $dom->saveXML();
if ($updated === false || !$zip->addFromString('content.xml', $updated)) {
$zip->close();
throw new RuntimeException(t('service.document.update_contract_failed'));
}
$zip->close();
return $target;
}
public static function toPdf(string $odtPath): string
{
$dir = dirname($odtPath);
$profile = $dir . '/lo-profile';
mkdir($profile, 0775, true);
$cmd = [
'/usr/bin/env',
'-u',
'DISPLAY',
'SAL_USE_VCLPLUGIN=svp',
'/usr/bin/libreoffice',
'-env:UserInstallation=' . self::fileUri($profile),
'--headless',
'--nologo',
'--nodefault',
'--nolockcheck',
'--nofirststartwizard',
'--convert-to',
'pdf',
'--outdir',
$dir,
$odtPath,
];
$pipes = [];
$process = proc_open($cmd, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
if (!is_resource($process)) {
throw new RuntimeException(t('service.document.libreoffice_failed'));
}
$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
$code = proc_close($process);
$pdf = $dir . '/' . pathinfo($odtPath, PATHINFO_FILENAME) . '.pdf';
if ($code !== 0 || !is_file($pdf)) {
throw new RuntimeException(t('service.document.pdf_failed', ['details' => trim($stdout . ' ' . $stderr)]));
}
return $pdf;
}
public static function cleanup(string $path): void
{
$dir = dirname($path);
if (!str_starts_with($dir, sys_get_temp_dir() . '/globinours-doc-')) {
return;
}
$items = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST,
);
foreach ($items as $item) {
$item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname());
}
rmdir($dir);
}
private static function setSpan(
DOMXPath $xp,
string $style,
string $value,
?string $prefix = null,
string $generatedStyle = 'GeneratedValue',
): void {
$nodes = $xp->query("//text:span[@text:style-name='$style']");
if (!$nodes || $nodes->length === 0) {
return;
}
$node = $nodes->item(0);
while ($node->firstChild) {
$node->removeChild($node->firstChild);
}
$node->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:text:1.0', 'text:style-name', $generatedStyle);
if ($prefix === 'tab') {
$node->appendChild(
$node->ownerDocument->createElementNS('urn:oasis:names:tc:opendocument:xmlns:text:1.0', 'text:tab'),
);
}
$node->appendChild($node->ownerDocument->createTextNode($value));
}
private static function setLabeledSpan(
DOMXPath $xp,
string $style,
string $label,
string $value,
?string $prefix = null,
string $labelStyle = 'GeneratedField',
): void {
$nodes = $xp->query("//text:span[@text:style-name='$style']");
if (!$nodes || $nodes->length === 0) {
return;
}
$node = $nodes->item(0);
while ($node->firstChild) {
$node->removeChild($node->firstChild);
}
$node->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:text:1.0', 'text:style-name', $labelStyle);
if ($prefix === 'tab') {
$node->appendChild(
$node->ownerDocument->createElementNS('urn:oasis:names:tc:opendocument:xmlns:text:1.0', 'text:tab'),
);
}
$node->appendChild($node->ownerDocument->createTextNode($label));
$valueSpan = $node->ownerDocument->createElementNS(
'urn:oasis:names:tc:opendocument:xmlns:text:1.0',
'text:span',
);
$valueSpan->setAttributeNS(
'urn:oasis:names:tc:opendocument:xmlns:text:1.0',
'text:style-name',
'GeneratedValue',
);
$valueSpan->appendChild($node->ownerDocument->createTextNode($value));
$node->appendChild($valueSpan);
}
private static function applySpanStyle(DOMXPath $xp, string $style, string $generatedStyle = 'GeneratedField'): void
{
$nodes = $xp->query("//text:span[@text:style-name='$style']");
if (!$nodes || $nodes->length === 0) {
return;
}
$nodes
->item(0)
->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:text:1.0', 'text:style-name', $generatedStyle);
}
private static function addGeneratedFieldStyles(DOMDocument $dom, DOMXPath $xp): void
{
$automatic = $xp->query('//office:automatic-styles')->item(0);
if (!$automatic) {
return;
}
self::appendTextStyle($dom, $automatic, 'GeneratedField', '11pt');
self::appendTextStyle($dom, $automatic, 'GeneratedValue', '11pt', true);
self::appendTextStyle($dom, $automatic, 'GeneratedSignature', '11pt');
}
private static function appendTextStyle(
DOMDocument $dom,
DOMNode $automatic,
string $name,
string $size,
bool $bold = false,
): void {
$style = $dom->createElementNS('urn:oasis:names:tc:opendocument:xmlns:style:1.0', 'style:style');
$style->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:style:1.0', 'style:name', $name);
$style->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:style:1.0', 'style:family', 'text');
$props = $dom->createElementNS('urn:oasis:names:tc:opendocument:xmlns:style:1.0', 'style:text-properties');
$props->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:style:1.0', 'style:font-name', 'Open Sans');
$props->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:style:1.0', 'style:font-name-asian', 'Open Sans');
$props->setAttributeNS(
'urn:oasis:names:tc:opendocument:xmlns:style:1.0',
'style:font-name-complex',
'Open Sans',
);
$props->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0', 'fo:font-size', $size);
$weight = $bold ? 'bold' : 'normal';
$props->setAttributeNS(
'urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0',
'fo:font-weight',
$weight,
);
$props->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:style:1.0', 'style:font-size-asian', $size);
$props->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:style:1.0', 'style:font-size-complex', $size);
$props->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:style:1.0', 'style:font-weight-asian', $weight);
$props->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:style:1.0', 'style:font-weight-complex', $weight);
$style->appendChild($props);
$automatic->appendChild($style);
}
private static function transformTemplate(string $type, string $filename, callable $transform): string
{
$dir = self::tempDir();
$target = $dir . '/' . $filename . '.odt';
if (!copy(self::template($type), $target)) {
throw new RuntimeException(t('service.document.copy_failed'));
}
$zip = new ZipArchive();
if ($zip->open($target) !== true) {
throw new RuntimeException(t('service.document.open_failed'));
}
$xml = $zip->getFromName('content.xml');
if ($xml === false) {
$zip->close();
throw new RuntimeException(t('service.document.content_invalid'));
}
$dom = new DOMDocument();
$dom->preserveWhiteSpace = true;
if (!$dom->loadXML($xml)) {
$zip->close();
throw new RuntimeException(t('service.document.xml_invalid'));
}
$xp = new DOMXPath($dom);
$xp->registerNamespace('text', 'urn:oasis:names:tc:opendocument:xmlns:text:1.0');
$xp->registerNamespace('office', 'urn:oasis:names:tc:opendocument:xmlns:office:1.0');
self::addGeneratedFieldStyles($dom, $xp);
$transform($xp);
$updated = $dom->saveXML();
if ($updated === false || !$zip->addFromString('content.xml', $updated)) {
$zip->close();
throw new RuntimeException(t('service.document.update_failed'));
}
$zip->close();
return $target;
}
private static function replaceParagraph(DOMXPath $xp, string $startsWith, array $parts, int $occurrence = 0): void
{
$matches = [];
foreach ($xp->query('//text:p') as $paragraph) {
$plain = preg_replace('/\s+/u', ' ', str_replace("\u{00A0}", ' ', $paragraph->textContent));
if (str_starts_with(trim((string) $plain), $startsWith)) {
$matches[] = $paragraph;
}
}
$node = $matches[$occurrence] ?? null;
if (!$node) {
return;
}
while ($node->firstChild) {
$node->removeChild($node->firstChild);
}
foreach ($parts as $part) {
if ($part === ['tab']) {
$node->appendChild(
$node->ownerDocument->createElementNS('urn:oasis:names:tc:opendocument:xmlns:text:1.0', 'text:tab'),
);
continue;
}
if ($part === ['break']) {
$node->appendChild(
$node->ownerDocument->createElementNS(
'urn:oasis:names:tc:opendocument:xmlns:text:1.0',
'text:line-break',
),
);
continue;
}
[$text, $kind] = $part;
$span = $node->ownerDocument->createElementNS(
'urn:oasis:names:tc:opendocument:xmlns:text:1.0',
'text:span',
);
$span->setAttributeNS(
'urn:oasis:names:tc:opendocument:xmlns:text:1.0',
'text:style-name',
$kind === 'value' ? 'GeneratedValue' : 'GeneratedField',
);
$span->appendChild($node->ownerDocument->createTextNode($text));
$node->appendChild($span);
}
}
private static function testLabel(string $status): string
{
return match ($status) {
'neg' => 'négatif',
'pos' => 'positif',
default => 'non renseigné',
};
}
private static function safeName(string $name): string
{
$name = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $name) ?: 'chat';
return strtolower(trim(preg_replace('/[^a-zA-Z0-9]+/', '-', $name), '-')) ?: 'chat';
}
private static function tempDir(): string
{
$dir = sys_get_temp_dir() . '/globinours-doc-' . bin2hex(random_bytes(8));
if (!mkdir($dir, 0775, true)) {
throw new RuntimeException(t('service.document.temp_failed'));
}
return $dir;
}
private static function fileUri(string $path): string
{
return 'file://' . str_replace('%2F', '/', rawurlencode($path));
}
}

View file

@ -0,0 +1,178 @@
<?php
declare(strict_types=1);
final class ExportService
{
private const DATASETS = [
'animals' => [
'Animaux',
'animaux.csv',
"SELECT a.id,a.internal_code code_interne,a.name nom,a.species espece,a.sex sexe,a.birth_date date_naissance,a.birth_is_estimated naissance_estimee,a.chip_id numero_identification,a.identification_type type_identification,a.identification_date date_identification,a.identification_registration_status statut_enregistrement_icad,a.adoption_availability disponibilite_adoption,a.adoption_available_from disponible_a_partir_du,a.adoption_unavailability_reason motif_indisponibilite,a.breed race,a.color robe,a.hair_type type_poils,a.compatibility_dogs compatible_chiens,a.compatibility_cats compatible_chats,a.compatibility_children compatible_enfants,a.house_trained proprete,a.status statut,a.refuge_room salle_refuge,a.care_box_key box_soin,a.fiv_status statut_fiv,a.felv_status statut_felv,a.sterilization_status statut_sterilisation,a.sterilization_date date_sterilisation,sv.name veterinaire_sterilisation,sc.name cabinet_sterilisation,a.intake_date date_entree,a.intake_type mode_entree,CASE WHEN a.intake_owner_care_home=1 THEN 'owner_care_home' ELSE a.intake_reason END motif_entree,dep.name deposant,a.intake_circumstances circonstances_entree,a.quarantine_until fin_quarantaine,a.rescue_location_name commune_origine,a.rescue_address adresse_origine,a.notes,a.created_at cree_le,a.updated_at modifie_le,a.archived_at archive_le,a.deleted_at supprime_le,a.deletion_reason motif_suppression FROM animals a LEFT JOIN directory_contacts dep ON dep.id=a.depositor_contact_id LEFT JOIN directory_contacts sv ON sv.id=a.sterilization_vet_contact_id LEFT JOIN directory_contacts sc ON sc.id=a.sterilization_clinic_contact_id ORDER BY a.id",
],
'contacts' => [
'Annuaire',
'annuaire.csv',
'SELECT c.id,c.kind type,c.name nom,GROUP_CONCAT(DISTINCT r.role) roles,c.phone telephone,c.email,c.address adresse,c.postal_code code_postal,c.city ville,c.country pays,o.name structure_liee,c.notes,c.created_at cree_le,c.updated_at modifie_le,c.deleted_at supprime_le FROM directory_contacts c LEFT JOIN directory_contact_roles r ON r.contact_id=c.id LEFT JOIN directory_contacts o ON o.id=c.organization_id GROUP BY c.id ORDER BY c.name COLLATE NOCASE',
],
'adoptions' => [
'Adoptions',
'adoptions.csv',
'SELECT ad.id,a.internal_code code_animal,a.name animal,ad.adoption_date date_adoption,ad.adopter_name adoptant,ad.adopter_phone telephone,ad.adopter_email email,ad.adopter_address adresse,ad.adopter_postal_code code_postal,ad.adopter_city ville,ad.adopter_country pays,ad.notes,ad.created_at cree_le FROM adoptions ad JOIN animals a ON a.id=ad.animal_id ORDER BY ad.adoption_date,ad.id',
],
'placements' => [
'Parcours daccueil et retours',
'placements-retours.csv',
'SELECT ap.id,a.internal_code code_animal,a.name animal,ap.event_type type_evenement,ap.event_date date_evenement,dc.name contact,dc.city ville,ap.reason motif,ap.notes,COALESCE(u.display_name,u.username) saisi_par,ap.created_at cree_le FROM animal_placements ap JOIN animals a ON a.id=ap.animal_id LEFT JOIN directory_contacts dc ON dc.id=ap.contact_id LEFT JOIN users u ON u.id=ap.created_by ORDER BY ap.event_date,ap.id',
],
'medical' => [
'Notes médicales',
'notes-medicales.csv',
'SELECT mn.id,a.internal_code code_animal,a.name animal,mn.noted_at date_note,mn.kind type,mn.reason motif,mn.symptoms symptomes,mn.exam examen,mn.diagnosis,mn.plan conduite,mn.temperature_c temperature,mn.weight_kg poids,vet.name veterinaire,clinic.name cabinet,COALESCE(u.display_name,u.username) saisi_par,mn.created_at cree_le FROM medical_notes mn JOIN animals a ON a.id=mn.animal_id LEFT JOIN directory_contacts vet ON vet.id=mn.vet_contact_id LEFT JOIN directory_contacts clinic ON clinic.id=mn.clinic_contact_id LEFT JOIN users u ON u.id=mn.created_by ORDER BY mn.noted_at,mn.id',
],
'treatments' => [
'Traitements',
'traitements.csv',
'SELECT t.id,a.internal_code code_animal,a.name animal,m.name medicament,t.dose_text dose,t.route voie,t.start_date debut,t.end_date fin,t.ongoing en_cours,t.give_morning matin,t.give_evening soir,t.give_as_needed si_besoin,vet.name veterinaire,clinic.name cabinet,t.notes FROM treatments t JOIN animals a ON a.id=t.animal_id JOIN ref_medications m ON m.id=t.medication_id LEFT JOIN directory_contacts vet ON vet.id=t.vet_contact_id LEFT JOIN directory_contacts clinic ON clinic.id=t.clinic_contact_id ORDER BY t.start_date,t.id',
],
'vaccinations' => [
'Vaccinations',
'vaccinations.csv',
'SELECT v.id,a.internal_code code_animal,a.name animal,rv.name vaccin,v.done_date date_realisation,v.due_date prochain_rappel,v.manufacturer fabricant,v.lot,v.batch_expires_on expiration_lot,COALESCE(v.administered_by_name,au.display_name,au.username) administre_par,COALESCE(cu.display_name,cu.username) saisi_par,vet.name veterinaire,clinic.name cabinet,v.notes FROM vaccinations v JOIN animals a ON a.id=v.animal_id JOIN ref_vaccines rv ON rv.id=v.vaccine_id LEFT JOIN directory_contacts vet ON vet.id=v.vet_contact_id LEFT JOIN directory_contacts clinic ON clinic.id=v.clinic_contact_id LEFT JOIN users au ON au.id=v.administered_by_user_id LEFT JOIN users cu ON cu.id=v.created_by ORDER BY v.done_date,v.id',
],
'deaths' => [
'Décès',
'deces.csv',
'SELECT d.animal_id,a.internal_code code_animal,a.name animal,d.deceased_date date_deces,d.date_precision precision_date,d.death_age_value age_deces_valeur,d.death_age_unit age_deces_unite,d.cause_code cause,d.cause_details details_cause,d.occurred_in_care sous_responsabilite,d.place_type type_lieu,d.place_details lieu,d.euthanized euthanasie,vet.name veterinaire,crem.name crematorium,d.body_disposition devenir_corps,d.cremation_date date_cremation,d.memorial_medal_color couleur_medaille,d.memorial_medal_count nombre_medailles,d.recovered_source source_recuperation,d.notes,COALESCE(u.display_name,u.username) enregistre_par,d.created_at cree_le,d.updated_at modifie_le FROM animal_deaths d JOIN animals a ON a.id=d.animal_id LEFT JOIN directory_contacts vet ON vet.id=d.veterinarian_contact_id LEFT JOIN directory_contacts crem ON crem.id=d.crematorium_contact_id LEFT JOIN users u ON u.id=d.created_by ORDER BY d.deceased_date,d.animal_id',
],
'measurements' => [
'Mesures',
'mesures.csv',
'SELECT me.id,a.internal_code code_animal,a.name animal,me.measured_at date_mesure,me.type,me.value valeur,me.unit unite,me.notes,COALESCE(u.display_name,u.username) saisi_par FROM measurements me JOIN animals a ON a.id=me.animal_id LEFT JOIN users u ON u.id=me.created_by ORDER BY me.measured_at,me.id',
],
'litters' => [
'Portées',
'portees.csv',
'SELECT l.id,m.internal_code code_mere,m.name mere,f.internal_code code_pere,f.name pere,l.birth_date date_naissance,GROUP_CONCAT(k.internal_code) codes_chatons,GROUP_CONCAT(k.name) chatons,l.notes,l.created_at cree_le FROM litters l LEFT JOIN animals m ON m.id=l.mother_id LEFT JOIN animals f ON f.id=l.father_id LEFT JOIN litter_kittens lk ON lk.litter_id=l.id LEFT JOIN animals k ON k.id=lk.animal_id GROUP BY l.id ORDER BY l.birth_date,l.id',
],
'bonded' => [
'Groupes inséparables',
'groupes-inseparables.csv',
'SELECT g.id,g.name nom_groupe,g.active actif,GROUP_CONCAT(a.internal_code) codes_animaux,GROUP_CONCAT(a.name) animaux,g.notes,g.created_at cree_le,g.dissolved_at dissous_le FROM bonded_groups g LEFT JOIN bonded_group_members gm ON gm.group_id=g.id LEFT JOIN animals a ON a.id=gm.animal_id GROUP BY g.id ORDER BY g.id',
],
'care_rounds' => [
'Tournées',
'tournees.csv',
'SELECT r.id,r.round_date date_tournee,r.period periode,a.internal_code code_animal,a.name animal,o.food alimentation,o.water eau,o.urine,o.stool selles,o.general_state etat_general,o.comment,o.checked_at valide_le,COALESCE(u.display_name,u.username,r.performed_by) effectue_par FROM care_rounds r LEFT JOIN care_round_observations o ON o.round_id=r.id LEFT JOIN animals a ON a.id=o.animal_id LEFT JOIN users u ON u.id=r.performed_by_user_id ORDER BY r.round_date,r.period,o.checked_at',
],
'administrations' => [
'Administrations de traitements',
'administrations-traitements.csv',
'SELECT ta.id,r.round_date date_tournee,r.period periode,a.internal_code code_animal,a.name animal,m.name medicament,ta.status statut,ta.comment,ta.administered_at administre_le FROM treatment_administrations ta JOIN care_rounds r ON r.id=ta.round_id JOIN animals a ON a.id=ta.animal_id JOIN treatments t ON t.id=ta.treatment_id JOIN ref_medications m ON m.id=t.medication_id ORDER BY r.round_date,r.period,ta.id',
],
'media_index' => [
'Index des médias',
'index-medias.csv',
"SELECT 'animal' type_media,p.id,a.internal_code code_animal,a.name animal,p.filename fichier,p.original_name nom_original,p.mime,p.size_bytes taille_octets,p.is_primary principale,p.is_public publique,p.caption legende,p.care_round_id tournee_id,NULL note_medicale_id,p.created_at ajoute_le FROM animal_photos p JOIN animals a ON a.id=p.animal_id UNION ALL SELECT 'medical',mp.id,a.internal_code,a.name,mp.filename,NULL,NULL,NULL,0,0,NULL,NULL,mp.medical_note_id,mp.created_at FROM medical_photos mp JOIN animals a ON a.id=mp.animal_id ORDER BY 14,2",
],
'movements' => [
'Mouvements',
'mouvements.csv',
'SELECT m.id,a.internal_code code_animal,a.name animal,m.kind type,m.place emplacement,m.lieu motif,m.contact_name contact,m.contact_phone telephone,m.contact_email email,m.note,m.created_at date_mouvement FROM animal_movements m JOIN animals a ON a.id=m.animal_id ORDER BY m.created_at,m.id',
],
'locations' => [
'Historique des lieux de vie',
'historique-emplacements.csv',
'SELECT lh.id,a.internal_code code_animal,a.name animal,lh.from_status ancien_statut,lh.from_refuge_room ancienne_salle,lh.from_care_box_key ancien_box,lh.from_address ancienne_adresse,lh.to_status nouveau_statut,lh.to_refuge_room nouvelle_salle,lh.to_care_box_key nouveau_box,lh.to_address nouvelle_adresse,lh.reason motif,lh.source origine_action,COALESCE(u.display_name,u.username) utilisateur,lh.moved_at date_deplacement FROM animal_location_history lh JOIN animals a ON a.id=lh.animal_id LEFT JOIN users u ON u.id=lh.user_id ORDER BY lh.moved_at,lh.id',
],
'history' => [
'Historique',
'historique.csv',
'SELECT h.id,a.internal_code code_animal,a.name animal,h.type,h.label,h.details,COALESCE(u.display_name,u.username) utilisateur,h.created_at date_action FROM animal_history h JOIN animals a ON a.id=h.animal_id LEFT JOIN users u ON u.id=h.user_id ORDER BY h.created_at,h.id',
],
];
public static function catalog(): array
{
foreach (self::DATASETS as $key => [$label, $file]) {
$out[$key] = ['label' => t('export.' . $key), 'filename' => $file];
}
return $out ?? [];
}
public static function downloadCsv(string $key): void
{
[$label, $filename] = self::definition($key);
header('Content-Type: text/csv; charset=UTF-8');
header('Content-Disposition: attachment; filename="' . $filename . '"');
header('X-Content-Type-Options: nosniff');
$stream = fopen('php://output', 'wb');
self::write($stream, $key);
fclose($stream);
}
public static function downloadZip(): void
{
if (!class_exists(ZipArchive::class)) {
throw new RuntimeException(t('service.export.zip_missing'));
}
$path = tempnam(sys_get_temp_dir(), 'globinours-export-');
$zip = new ZipArchive();
if ($zip->open($path, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
throw new RuntimeException(t('service.export.prepare_failed'));
}
try {
foreach (self::DATASETS as $key => [, $filename]) {
$stream = fopen('php://temp', 'w+b');
self::write($stream, $key);
rewind($stream);
$zip->addFromString($filename, stream_get_contents($stream));
fclose($stream);
}
$zip->addFromString(
'LISEZ-MOI.txt',
"Export de portabilité Globinours\r\nGénéré le " .
date('d/m/Y à H:i') .
"\r\nEncodage : UTF-8 avec séparateur point-virgule.\r\nLes comptes utilisateurs, mots de passe, médias et secrets ne sont pas inclus.\r\n",
);
} finally {
$zip->close();
}
header('Content-Type: application/zip');
header('Content-Disposition: attachment; filename="globinours-export-' . date('Y-m-d-His') . '.zip"');
header('Content-Length: ' . filesize($path));
header('X-Content-Type-Options: nosniff');
readfile($path);
@unlink($path);
}
private static function write($stream, string $key): void
{
[, , $sql] = self::definition($key);
fwrite($stream, "\xEF\xBB\xBF");
$stmt = DB::pdo()->query($sql);
$headers = [];
for ($i = 0; $i < $stmt->columnCount(); $i++) {
$headers[] = (string) ($stmt->getColumnMeta($i)['name'] ?? 'colonne_' . $i);
}
fputcsv($stream, $headers, ';', '"', '\\', "\r\n");
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
fputcsv($stream, array_map([self::class, 'safeCell'], $row), ';', '"', '\\', "\r\n");
}
}
private static function safeCell(mixed $value): string
{
if (is_int($value) || is_float($value)) {
return (string) $value;
}
$value = $value === null ? '' : (string) $value;
if ($value !== '' && in_array($value[0], ['=', '+', '-', '@'], true)) {
$value = "'" . $value;
}
return $value;
}
private static function definition(string $key): array
{
if (!isset(self::DATASETS[$key])) {
throw new InvalidArgumentException(t('service.export.unknown_dataset'));
}
return self::DATASETS[$key];
}
}

View file

@ -0,0 +1,62 @@
<?php
final class GeoService
{
public static function geocode(PDO $db, string $query): ?array
{
$q = trim(preg_replace('~\s+~', ' ', $query));
if ($q === '') {
return null;
}
// Cache DB
$st = $db->prepare('SELECT lat,lng FROM geocode_cache WHERE q=:q LIMIT 1');
$st->execute([':q' => $q]);
$row = $st->fetch(PDO::FETCH_ASSOC);
if ($row && $row['lat'] !== null && $row['lng'] !== null) {
return ['lat' => (float) $row['lat'], 'lng' => (float) $row['lng']];
}
// Nominatim (OpenStreetMap) — respecter l'usage: User-Agent + requêtes raisonnables
$url = 'https://nominatim.openstreetmap.org/search?format=jsonv2&limit=1&q=' . rawurlencode($q);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_HTTPHEADER => ['Accept: application/json', 'User-Agent: Globinours/1.0 (contact: local-refuge)'],
]);
$body = curl_exec($ch);
if ($body === false) {
curl_close($ch);
return null;
}
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code < 200 || $code >= 300) {
return null;
}
$json = json_decode($body, true);
if (!is_array($json) || empty($json[0]['lat']) || empty($json[0]['lon'])) {
// Stocker "vide" en cache évite de spammer
$db->prepare(
"INSERT OR REPLACE INTO geocode_cache(q,lat,lng,raw_json,fetched_at) VALUES(:q,NULL,NULL,:raw,datetime('now'))",
)->execute([':q' => $q, ':raw' => $body]);
return null;
}
$lat = (float) $json[0]['lat'];
$lng = (float) $json[0]['lon'];
$db->prepare(
"INSERT OR REPLACE INTO geocode_cache(q,lat,lng,raw_json,fetched_at) VALUES(:q,:lat,:lng,:raw,datetime('now'))",
)->execute([':q' => $q, ':lat' => $lat, ':lng' => $lng, ':raw' => $body]);
return ['lat' => $lat, 'lng' => $lng];
}
}

View file

@ -0,0 +1,70 @@
<?php
final class GeocodeService
{
/**
* Géocodage via Nominatim (OpenStreetMap)
* Retour: ['lat'=>float, 'lng'=>float, 'display_name'=>string] ou null
*
* IMPORTANT:
* - Respecte le fair use : pas de spam (cache côté DB chez toi, et TTL)
* - User-Agent explicite
*/
public static function geocode(string $query): ?array
{
$query = trim($query);
if ($query === '') {
return null;
}
// Nominatim conseille d'envoyer un UA + un contact
$email = getenv('GEOCODER_EMAIL') ?: 'contact@localhost';
$ua = 'TwixRefuge/1.0 (geocoding; contact: ' . $email . ')';
$url =
'https://nominatim.openstreetmap.org/search?format=jsonv2&limit=1&addressdetails=0&q=' .
rawurlencode($query);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => 12,
CURLOPT_CONNECTTIMEOUT => 6,
CURLOPT_HTTPHEADER => ['User-Agent: ' . $ua, 'Accept: application/json'],
CURLOPT_ENCODING => '', // gzip/br si dispo
]);
$body = curl_exec($ch);
if ($body === false) {
curl_close($ch);
return null;
}
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status < 200 || $status >= 300) {
return null;
}
$json = json_decode($body, true);
if (!is_array($json) || empty($json[0])) {
return null;
}
$item = $json[0];
$lat = isset($item['lat']) ? (float) $item['lat'] : null;
$lng = isset($item['lon']) ? (float) $item['lon'] : null;
if ($lat === null || $lng === null) {
return null;
}
return [
'lat' => $lat,
'lng' => $lng,
'display_name' => (string) ($item['display_name'] ?? ''),
];
}
}

View file

@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
final class GlobalSearchService
{
public static function search(string $query, int $limit = 8): array
{
$query = trim(preg_replace('/\s+/u', ' ', $query) ?? '');
if (mb_strlen($query) < 2) {
return [];
}
$db = DB::pdo();
$like = '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $query) . '%';
$out = [];
if (PermissionService::can('animals')) {
$out['animals'] = self::rows(
$db,
"SELECT id,name,internal_code,status,species FROM animals WHERE deleted_at IS NULL AND (name LIKE :q ESCAPE '\\' OR internal_code LIKE :q ESCAPE '\\' OR COALESCE(chip,'') LIKE :q ESCAPE '\\') ORDER BY name COLLATE NOCASE LIMIT :lim",
$like,
$limit,
static fn($r) => [
'title' => $r['name'],
'detail' => implode(' · ', array_filter([$r['internal_code'], $r['species'], $r['status']])),
'url' => '/animal?id=' . (int) $r['id'],
],
);
}
if (PermissionService::can('directory')) {
$out['directory'] = self::rows(
$db,
"SELECT id,name,kind,phone,email,city FROM directory_contacts WHERE deleted_at IS NULL AND (name LIKE :q ESCAPE '\\' OR COALESCE(phone,'') LIKE :q ESCAPE '\\' OR COALESCE(email,'') LIKE :q ESCAPE '\\' OR COALESCE(city,'') LIKE :q ESCAPE '\\') ORDER BY name COLLATE NOCASE LIMIT :lim",
$like,
$limit,
static fn($r) => [
'title' => $r['name'],
'detail' => implode(' · ', array_filter([$r['city'], $r['phone'], $r['email']])),
'url' => '/directory/view?id=' . (int) $r['id'],
],
);
}
if (PermissionService::can('agenda')) {
$out['agenda'] = self::rows(
$db,
"SELECT id,title,starts_at,location,status FROM agenda_items WHERE title LIKE :q ESCAPE '\\' OR COALESCE(description,'') LIKE :q ESCAPE '\\' OR COALESCE(location,'') LIKE :q ESCAPE '\\' ORDER BY starts_at DESC LIMIT :lim",
$like,
$limit,
static fn($r) => [
'title' => $r['title'],
'detail' => implode(
' · ',
array_filter([substr((string) $r['starts_at'], 0, 16), $r['location'], $r['status']]),
),
'url' => '/agenda?q=' . rawurlencode($r['title']),
],
);
}
if (PermissionService::can('accounting')) {
$out['accounting'] = self::rows(
$db,
"SELECT ai.id,ai.reference,ai.invoice_date,ai.status,dc.name vendor FROM accounting_invoices ai LEFT JOIN directory_contacts dc ON dc.id=ai.vendor_contact_id WHERE ai.deleted_at IS NULL AND (ai.reference LIKE :q ESCAPE '\\' OR COALESCE(ai.notes,'') LIKE :q ESCAPE '\\' OR COALESCE(dc.name,'') LIKE :q ESCAPE '\\') ORDER BY ai.invoice_date DESC LIMIT :lim",
$like,
$limit,
static fn($r) => [
'title' => $r['reference'],
'detail' => implode(' · ', array_filter([$r['vendor'], $r['invoice_date'], $r['status']])),
'url' => '/accounting/edit?id=' . (int) $r['id'],
],
);
}
return array_filter($out);
}
private static function rows(PDO $db, string $sql, string $like, int $limit, callable $map): array
{
$s = $db->prepare($sql);
$s->bindValue(':q', $like);
$s->bindValue(':lim', max(1, min(20, $limit)), PDO::PARAM_INT);
$s->execute();
return array_map($map, $s->fetchAll(PDO::FETCH_ASSOC));
}
}

View file

@ -0,0 +1,313 @@
<?php
declare(strict_types=1);
final class GrantService
{
public const STATUSES = [
'draft' => 'Brouillon',
'ready' => 'Prête',
'submitted' => 'Déposée',
'awarded' => 'Accordée',
'refused' => 'Refusée',
'withdrawn' => 'Abandonnée',
];
public const CATEGORIES = [
'official_form' => 'Formulaire officiel de lorganisme',
'statutes' => 'Statuts',
'prefecture' => 'Récépissé de préfecture',
'jo' => 'Publication au Journal officiel',
'rib' => 'RIB',
'insurance' => 'Attestation dassurance',
'ag' => 'Procès-verbal dassemblée générale',
'activity' => 'Rapport dactivité',
'accounts' => 'Comptes approuvés',
'budget' => 'Budget prévisionnel',
'other' => 'Autre',
];
public const REQUIRED = [
'official_form',
'statutes',
'prefecture',
'jo',
'rib',
'insurance',
'ag',
'activity',
'accounts',
'budget',
];
public static function statusLabel(string $key): string
{
return t('grant.status.' . $key, [], self::STATUSES[$key] ?? $key);
}
public static function categoryLabel(string $key): string
{
return t('grant.category.' . $key, [], self::CATEGORIES[$key] ?? $key);
}
public static function ensureYear(int $year): array
{
$db = DB::pdo();
$s = $db->prepare('INSERT OR IGNORE INTO grant_years(year) VALUES(?)');
$s->execute([$year]);
$s = $db->prepare('SELECT * FROM grant_years WHERE year=?');
$s->execute([$year]);
return $s->fetch() ?: [];
}
public static function statistics(int $year): array
{
$db = DB::pdo();
$start = sprintf('%04d-01-01', $year);
$end = sprintf('%04d-12-31', $year);
$scalar = static function (string $sql, array $params = []) use ($db): float {
$s = $db->prepare($sql);
$s->execute($params);
return (float) ($s->fetchColumn() ?: 0);
};
$p = [':start' => $start, ':end' => $end];
$entries = $scalar(
"SELECT COUNT(*) FROM (SELECT m.animal_id FROM animal_movements m JOIN animals a ON a.id=m.animal_id WHERE a.deleted_at IS NULL AND m.kind='entry' AND date(m.created_at) BETWEEN :start AND :end UNION SELECT a.id FROM animals a WHERE a.deleted_at IS NULL AND date(a.intake_date) BETWEEN :start AND :end)",
$p,
);
$exits = $scalar(
"SELECT (SELECT COUNT(*) FROM adoptions ad JOIN animals a ON a.id=ad.animal_id WHERE a.deleted_at IS NULL AND date(ad.adoption_date) BETWEEN :start AND :end)+(SELECT COUNT(*) FROM animal_deaths d JOIN animals a ON a.id=d.animal_id WHERE a.deleted_at IS NULL AND date(COALESCE(d.deceased_date,printf('%04d-01-01',d.death_year))) BETWEEN :start AND :end AND NOT EXISTS(SELECT 1 FROM adoptions ad WHERE ad.animal_id=d.animal_id))",
$p,
);
$adoptions = $scalar('SELECT COUNT(*) FROM adoptions WHERE date(adoption_date) BETWEEN :start AND :end', $p);
$deaths = $scalar(
"SELECT COUNT(*) FROM animal_deaths WHERE date(COALESCE(deceased_date,printf('%04d-01-01',death_year))) BETWEEN :start AND :end",
$p,
);
$births = $scalar(
"SELECT COUNT(*) FROM litter_kittens lk JOIN litters l ON l.id=lk.litter_id JOIN animals a ON a.id=lk.animal_id WHERE a.deleted_at IS NULL AND date(COALESCE(NULLIF(l.birth_date,''),NULLIF(a.birth_date,''))) BETWEEN :start AND :end",
$p,
);
$vaccinations = $scalar('SELECT COUNT(*) FROM vaccinations WHERE date(done_date) BETWEEN :start AND :end', $p);
$treatments = $scalar('SELECT COUNT(*) FROM treatments WHERE date(start_date) BETWEEN :start AND :end', $p);
$avg = $scalar(
"SELECT AVG(julianday(adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))) FROM adoptions ad JOIN animals a ON a.id=ad.animal_id WHERE date(adoption_date) BETWEEN :start AND :end AND date(adoption_date)>=date(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))",
$p,
);
$current = $scalar(
"SELECT COUNT(*) FROM animals WHERE deleted_at IS NULL AND archived_at IS NULL AND status NOT IN ('adopte','decede')",
);
return compact(
'entries',
'exits',
'adoptions',
'deaths',
'births',
'vaccinations',
'treatments',
'avg',
'current',
);
}
public static function detailedStatistics(int $year): array
{
$db = DB::pdo();
$start = sprintf('%04d-01-01', $year);
$end = sprintf('%04d-12-31', $year);
$p = [':start' => $start, ':end' => $end];
$rows = static function (string $sql, array $params) use ($db): array {
$s = $db->prepare($sql);
$s->execute($params);
return $s->fetchAll(PDO::FETCH_ASSOC);
};
$map = static function (array $items, string $key = 'label'): array {
$out = [];
foreach ($items as $item) {
$out[(string) $item[$key]] = (int) $item['total'];
}
return $out;
};
$base =
"WITH entry_sources AS (SELECT m.animal_id,date(m.created_at) entry_date FROM animal_movements m JOIN animals source_animal ON source_animal.id=m.animal_id WHERE source_animal.deleted_at IS NULL AND m.kind='entry' AND date(m.created_at) BETWEEN :start AND :end UNION SELECT id animal_id,date(intake_date) entry_date FROM animals WHERE deleted_at IS NULL AND date(intake_date) BETWEEN :start AND :end), entered AS (SELECT animal_id,MIN(entry_date) entry_date FROM entry_sources GROUP BY animal_id) ";
$sex = $map(
$rows(
$base .
"SELECT CASE a.sex WHEN 'F' THEN 'Femelles' WHEN 'M' THEN 'Mâles' ELSE 'Inconnu' END label,COUNT(*) total FROM entered e JOIN animals a ON a.id=e.animal_id GROUP BY a.sex ORDER BY total DESC",
$p,
),
);
$ages = $map(
$rows(
$base .
"SELECT CASE WHEN a.birth_date IS NULL THEN 'Non renseigné' WHEN (julianday(e.entry_date)-julianday(a.birth_date))/365.25<1 THEN 'Moins dun an' WHEN (julianday(e.entry_date)-julianday(a.birth_date))/365.25<3 THEN '1 à 2 ans' WHEN (julianday(e.entry_date)-julianday(a.birth_date))/365.25<8 THEN '3 à 7 ans' ELSE '8 ans et plus' END label,COUNT(*) total FROM entered e JOIN animals a ON a.id=e.animal_id GROUP BY label ORDER BY total DESC",
$p,
),
);
$originRows = $rows(
$base . 'SELECT a.rescue_location_name,a.rescue_address FROM entered e JOIN animals a ON a.id=e.animal_id',
$p,
);
$origins = [];
foreach ($originRows as $originRow) {
$label = self::municipality((string) ($originRow['rescue_location_name'] ?: $originRow['rescue_address']));
$origins[$label] = ($origins[$label] ?? 0) + 1;
}
arsort($origins, SORT_NUMERIC);
$origins = array_slice($origins, 0, 12, true);
$deathCauses = $map(
$rows(
"SELECT cause_code label,COUNT(*) total FROM animal_deaths WHERE date(COALESCE(deceased_date,printf('%04d-01-01',death_year))) BETWEEN :start AND :end GROUP BY cause_code ORDER BY total DESC",
$p,
),
);
$stays = $map(
$rows(
"SELECT CASE WHEN julianday(ad.adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))<30 THEN 'Moins dun mois' WHEN julianday(ad.adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))<90 THEN '1 à 3 mois' WHEN julianday(ad.adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))<180 THEN '3 à 6 mois' WHEN julianday(ad.adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))<365 THEN '6 à 12 mois' ELSE 'Plus dun an' END label,COUNT(*) total FROM adoptions ad JOIN animals a ON a.id=ad.animal_id WHERE date(ad.adoption_date) BETWEEN :start AND :end AND date(ad.adoption_date)>=date(COALESCE(NULLIF(a.intake_date,''),date(a.created_at))) GROUP BY label ORDER BY total DESC",
$p,
),
);
return compact('sex', 'ages', 'origins', 'deathCauses', 'stays');
}
public static function comparison(int $year): array
{
$current = self::statistics($year);
$previous = self::statistics($year - 1);
$out = [];
foreach (['entries', 'exits', 'adoptions', 'deaths', 'births', 'vaccinations', 'treatments', 'avg'] as $key) {
$a = (float) $current[$key];
$b = (float) $previous[$key];
$out[$key] = [
'current' => $a,
'previous' => $b,
'difference' => $a - $b,
'percent' => $b != 0 ? (($a - $b) / $b) * 100 : null,
];
}
return $out;
}
public static function municipality(string $value): string
{
$value = trim(preg_replace('/\s+/u', ' ', $value) ?? '');
if ($value === '') {
return 'Non renseignée';
}
if (preg_match('/\b\d{5}\s+([^,;]+)(?:[,;]|$)/u', $value, $match)) {
$value = trim($match[1]);
} elseif (str_contains($value, ',')) {
$parts = array_values(array_filter(array_map('trim', explode(',', $value))));
$value = (string) end($parts);
}
$value = preg_replace('/^(?:FRANCE|FR)\s*[-–—]?\s*/iu', '', $value) ?? $value;
return $value !== '' ? mb_convert_case($value, MB_CASE_TITLE, 'UTF-8') : 'Non renseignée';
}
public static function applications(int $year): array
{
$s = DB::pdo()->prepare(
'SELECT * FROM grant_applications WHERE year=? ORDER BY COALESCE(deadline,\'9999-12-31\'), organization_name',
);
$s->execute([$year]);
return $s->fetchAll();
}
public static function documents(int $year): array
{
$s = DB::pdo()->prepare(
'SELECT d.*,u.display_name uploader,ga.organization_name application_name FROM grant_documents d LEFT JOIN users u ON u.id=d.uploaded_by LEFT JOIN grant_applications ga ON ga.id=d.application_id WHERE d.year IS NULL OR d.year=? ORDER BY d.category,d.created_at DESC',
);
$s->execute([$year]);
return $s->fetchAll();
}
public static function checklist(int $year): array
{
$applications = self::applications($year);
$documents = self::documents($year);
$today = date('Y-m-d');
$soon = date('Y-m-d', strtotime('+30 days'));
$result = [];
foreach ($applications as $application) {
$items = [];
$valid = 0;
$warnings = 0;
foreach (self::REQUIRED as $category) {
$matches = array_values(
array_filter($documents, static function (array $document) use ($category, $application): bool {
if ($document['category'] !== $category) {
return false;
}
if ($category === 'official_form') {
return (int) ($document['application_id'] ?? 0) === (int) $application['id'];
}
return empty($document['application_id']) ||
(int) $document['application_id'] === (int) $application['id'];
}),
);
usort(
$matches,
static fn(array $a, array $b): int => strcmp((string) $b['created_at'], (string) $a['created_at']),
);
$document = $matches[0] ?? null;
$state = 'missing';
if ($document) {
$expiry = (string) ($document['valid_until'] ?? '');
if ($expiry !== '' && $expiry < $today) {
$state = 'expired';
} elseif ($expiry !== '' && $expiry <= $soon) {
$state = 'expiring';
} else {
$state = 'valid';
}
}
if ($state === 'valid') {
$valid++;
} elseif ($state === 'expiring') {
$valid++;
$warnings++;
} else {
$warnings++;
}
$items[$category] = ['state' => $state, 'document' => $document];
}
$total = count(self::REQUIRED);
$result[(int) $application['id']] = [
'items' => $items,
'valid' => $valid,
'total' => $total,
'warnings' => $warnings,
'complete' => $valid === $total,
];
}
return $result;
}
public static function alerts(int $year): array
{
$applications = self::applications($year);
$checklist = self::checklist($year);
$today = date('Y-m-d');
$soon = date('Y-m-d', strtotime('+30 days'));
$deadlines = 0;
$overdue = 0;
$incomplete = 0;
$documents = 0;
foreach ($applications as $application) {
if (in_array($application['status'], ['awarded', 'refused', 'withdrawn'], true)) {
continue;
}
$deadline = (string) ($application['deadline'] ?? '');
if ($deadline !== '' && $deadline < $today) {
$overdue++;
} elseif ($deadline !== '' && $deadline <= $soon) {
$deadlines++;
}
if (!($checklist[(int) $application['id']]['complete'] ?? false)) {
$incomplete++;
}
}
foreach (self::documents($year) as $document) {
$expiry = (string) ($document['valid_until'] ?? '');
if ($expiry !== '' && $expiry <= $soon) {
$documents++;
}
}
return compact('deadlines', 'overdue', 'incomplete', 'documents');
}
}

51
app/Services/I18n.php Normal file
View file

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
final class I18n
{
public const LOCALES = ['fr' => 'Français', 'en' => 'English'];
private static string $locale = 'fr';
private static array $catalogues = [];
public static function setLocale(string $locale): void
{
self::$locale = isset(self::LOCALES[$locale]) ? $locale : 'fr';
}
public static function locale(): string
{
return self::$locale;
}
public static function translate(string $key, array $replace = []): string
{
$value = self::catalogue(self::$locale)[$key] ?? (self::catalogue('fr')[$key] ?? $key);
foreach ($replace as $name => $replacement) {
$value = str_replace(':' . $name, (string) $replacement, $value);
}
return $value;
}
public static function choice(string $singular, string $plural, int|float $count, array $replace = []): string
{
return self::translate($count == 1 ? $singular : $plural, ['count' => $count] + $replace);
}
private static function catalogue(string $locale): array
{
if (isset(self::$catalogues[$locale])) {
return self::$catalogues[$locale];
}
$path = __DIR__ . '/../../resources/lang/' . $locale . '.php';
$catalogue = is_file($path) ? require $path : [];
return self::$catalogues[$locale] = is_array($catalogue) ? $catalogue : [];
}
}
if (!function_exists('t')) {
function t(string $key, array $replace = []): string
{
return I18n::translate($key, $replace);
}
}
if (!function_exists('tn')) {
function tn(string $singular, string $plural, int|float $count, array $replace = []): string
{
return I18n::choice($singular, $plural, $count, $replace);
}
}

View file

@ -0,0 +1,363 @@
<?php
final class IcadService
{
private PDO $db;
public function __construct(PDO $db)
{
$this->db = $db;
}
// ========= CACHE =========
public function getCachedByAnimal(int $animalId): ?array
{
$st = $this->db->prepare('SELECT * FROM icad_cache WHERE animal_id=:id LIMIT 1');
$st->execute([':id' => $animalId]);
$row = $st->fetch(PDO::FETCH_ASSOC);
if (!$row) {
return null;
}
$data = $row['data_json'] ? json_decode($row['data_json'], true) : null;
return [
'animal_id' => (int) $row['animal_id'],
'chip_id' => (string) ($row['chip_id'] ?? ''),
'status' => (string) ($row['status'] ?? 'empty'), // ok|error|empty
'error' => (string) ($row['error_msg'] ?? ''),
'fetched_at' => (string) ($row['fetched_at'] ?? ''),
'data' => is_array($data) ? $data : null,
];
}
public function isStale(?array $cache, int $ttlSeconds = 86400): bool
{
if (!$cache || empty($cache['fetched_at'])) {
return true;
}
// si en erreur → on retente
if (($cache['status'] ?? '') !== 'ok') {
return true;
}
$t = strtotime($cache['fetched_at']);
if ($t === false) {
return true;
}
return time() - $t > $ttlSeconds;
}
private function saveCache(int $animalId, string $chipId, string $status, ?string $err, ?array $data): void
{
$st = $this->db->prepare("
INSERT INTO icad_cache(animal_id, chip_id, data_json, fetched_at, status, error_msg)
VALUES(:aid,:chip,:json,datetime('now'),:st,:err)
ON CONFLICT(animal_id) DO UPDATE SET
chip_id = excluded.chip_id,
data_json = excluded.data_json,
fetched_at = excluded.fetched_at,
status = excluded.status,
error_msg = excluded.error_msg
");
$st->execute([
':aid' => $animalId,
':chip' => $chipId,
':json' => $data ? json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null,
':st' => $status,
':err' => $err,
]);
}
// ========= PUBLIC API =========
public function refreshByChip(int $animalId, string $chipId): array
{
$chipId = preg_replace('/\D+/', '', $chipId);
if ($chipId === '') {
throw new RuntimeException(t('service.icad.invalid_chip'));
}
$login = getenv('ICAD_LOGIN') ?: '';
$pass = getenv('ICAD_PASSWORD') ?: '';
if ($login === '' || $pass === '') {
throw new RuntimeException(t('service.icad.credentials_missing'));
}
$cookie = sys_get_temp_dir() . '/twix_icad_' . bin2hex(random_bytes(6)) . '.cookie';
try {
// 1) GET HOME => token _token du formulaire login ACTEUR
$r1 = $this->req('https://www.i-cad.fr/', $cookie);
$token = $this->extractToken($r1['body'], '_token'); // prend le 1er _token trouvé
// 2) POST login/check/acteur (XHR)
$post = http_build_query([
'_token' => $token,
'login' => $login,
'password' => $pass,
]);
$r2 = $this->req('https://www.i-cad.fr/login/check/acteur', $cookie, 'POST', $post, [
'Accept: */*',
'Content-Type: application/x-www-form-urlencoded; charset=UTF-8',
'X-Requested-With: XMLHttpRequest',
'Origin: https://www.i-cad.fr',
'Referer: https://www.i-cad.fr/',
]);
// 3) Vérif session
$r2b = $this->req('https://www.i-cad.fr/pro', $cookie);
if (stripos($r2b['body'], 'Se connecter') !== false) {
throw new RuntimeException(t('service.icad.login_failed'));
}
// 4) GET recherche. I-CAD a renommé le formulaire
// search_animal en animal_search en 2026 : on accepte les deux.
$r3 = $this->req('https://www.i-cad.fr/identification/recherche', $cookie);
[$searchForm, $searchToken] = $this->extractAnimalSearchForm($r3['body']);
// 5) POST recherche
$post2 = http_build_query([
$searchForm . '[insert]' => $chipId,
$searchForm . '[tatou]' => '',
$searchForm . '[espece]' => '',
$searchForm . '[sexe]' => '',
$searchForm . '[robe]' => '',
$searchForm . '[_token]' => $searchToken,
]);
$r4 = $this->req('https://www.i-cad.fr/identification/recherche', $cookie, 'POST', $post2, [
'Content-Type: application/x-www-form-urlencoded',
'Origin: https://www.i-cad.fr',
'Referer: https://www.i-cad.fr/identification/recherche',
]);
$animalPath = $this->extractAnimalPath($r4);
if (!$animalPath) {
throw new RuntimeException(t('service.icad.no_result'));
}
// 6) GET fiche animal
$r5 = $this->req('https://www.i-cad.fr' . $animalPath, $cookie);
$data = $this->parseAnimalHtml($r5['body'], $animalPath);
$this->saveCache($animalId, $chipId, 'ok', null, $data);
return $data;
} catch (Throwable $e) {
$this->saveCache($animalId, $chipId, 'error', $e->getMessage(), null);
throw $e;
} finally {
@unlink($cookie);
}
}
// ========= HTTP =========
private function req(
string $url,
string $cookieFile,
string $method = 'GET',
?string $body = null,
array $headers = [],
): array {
$ch = curl_init($url);
$defaultHeaders = [
'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language: fr,en-US;q=0.9,en;q=0.8',
'Connection: keep-alive',
'DNT: 1',
'Sec-GPC: 1',
];
$allHeaders = array_merge($defaultHeaders, $headers);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 10,
CURLOPT_HEADER => true,
CURLOPT_COOKIEJAR => $cookieFile,
CURLOPT_COOKIEFILE => $cookieFile,
CURLOPT_USERAGENT => 'Mozilla/5.0 (X11; Linux x86_64; rv:147.0) Gecko/20100101 Firefox/147.0',
CURLOPT_TIMEOUT => 25,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_ENCODING => '', // gzip/br si dispo
CURLOPT_HTTPHEADER => $allHeaders,
]);
if ($method === 'POST') {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $body ?? '');
}
$raw = curl_exec($ch);
if ($raw === false) {
$err = curl_error($ch);
curl_close($ch);
throw new RuntimeException(t('service.icad.network_error', ['error' => $err]));
}
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$hs = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
$effUrl = curl_getinfo($ch, CURLINFO_EFFECTIVE_URL);
curl_close($ch);
return [
'status' => $status,
'headers' => substr($raw, 0, $hs),
'body' => substr($raw, $hs),
'effective_url' => $effUrl,
];
}
// ========= TOKENS / PARSING =========
private function extractToken(string $html, string $fieldName): string
{
$field = preg_quote($fieldName, '/');
// 1) cas standard: name="X" value="Y"
$re = '/<input\b[^>]*\bname=(["\'])' . $field . '\1[^>]*\bvalue=(["\'])(.*?)\2[^>]*>/is';
if (preg_match($re, $html, $m)) {
return html_entity_decode($m[3], ENT_QUOTES);
}
// 2) fallback: value avant name
$re2 = '/<input\b[^>]*\bvalue=(["\'])(.*?)\1[^>]*\bname=(["\'])' . $field . '\3[^>]*>/is';
if (preg_match($re2, $html, $m)) {
return html_entity_decode($m[2], ENT_QUOTES);
}
// 3) si fieldName == _token : on prend le 1er _token trouvé
if ($fieldName === '_token') {
if (preg_match('/<input\b[^>]*\bname=(["\'])_token\1[^>]*\bvalue=(["\'])(.*?)\2[^>]*>/is', $html, $m)) {
return html_entity_decode($m[3], ENT_QUOTES);
}
}
throw new RuntimeException(t('service.icad.token_missing', ['field' => $fieldName]));
}
private function extractAnimalSearchForm(string $html): array
{
foreach (['animal_search', 'search_animal'] as $formName) {
try {
return [$formName, $this->extractToken($html, $formName . '[_token]')];
} catch (RuntimeException $e) {
// Essayer le nom historique suivant.
}
}
throw new RuntimeException(t('service.icad.form_missing'));
}
private function extractAnimalPath(array $resp): ?string
{
// souvent une redirection finale /animal/XXXX dans effective_url
$p = parse_url($resp['effective_url'] ?? '', PHP_URL_PATH);
if (is_string($p) && preg_match('~^/animal/\d+~', $p)) {
return $p;
}
// fallback HTML
if (preg_match('~href=["\'](/animal/\d+)["\']~', $resp['body'], $m)) {
return $m[1];
}
if (preg_match('~(/animal/\d+)~', $resp['body'], $m)) {
return $m[1];
}
return null;
}
private function parseAnimalHtml(string $html, string $animalPath): array
{
libxml_use_internal_errors(true);
$dom = new DOMDocument();
$dom->loadHTML($html);
$xp = new DOMXPath($dom);
$txt = function (string $xpath) use ($xp): string {
$n = $xp->query($xpath);
if (!$n || $n->length === 0) {
return '';
}
return trim(preg_replace('~\s+~', ' ', $n->item(0)->textContent));
};
$name = $txt('//*[@id="animal_informations"]//*[contains(@class,"title")]//span[contains(@class,"emphasis")]');
$insertRaw = $txt('//*[@id="animal_informations"]//*[contains(@class,"id")]//span[contains(@class,"number")]');
$insert = preg_replace('~\s+~', '', $insertRaw);
// infos label/value
$rows = $xp->query('//*[@id="animal_informations"]//*[contains(@class,"table-row")]');
$map = [];
foreach ($rows as $row) {
$ln = $xp->query('.//*[contains(@class,"label")]', $row)->item(0);
$vn = $xp->query('.//*[contains(@class,"value")]', $row)->item(0);
if (!$ln || !$vn) {
continue;
}
$label = trim(preg_replace('~\s+~', ' ', $ln->textContent));
$value = trim(preg_replace('~\s+~', ' ', $vn->textContent));
if ($label !== '') {
$map[$label] = $value;
}
}
// events
$events = [];
$trs = $xp->query('//*[contains(@class,"event-row")]');
foreach ($trs as $tr) {
$tds = $xp->query('./td', $tr);
if (!$tds || $tds->length < 3) {
continue;
}
$events[] = [
'date' => trim($tds->item(0)->textContent),
'label' => trim(preg_replace('~\s+~', ' ', $tds->item(1)->textContent)),
'recorded_at' => trim($tds->item(2)->textContent),
];
}
// Coordonnées du détenteur.
$ownerName = $txt('//*[@id="down_right_block"]//*[contains(@class,"coords")]//*[contains(@class,"name")]');
$ownerEmail = $txt('//*[@id="down_right_block"]//*[contains(@class,"coords")]//*[contains(@class,"address")]');
$ownerPhone = $txt(
'//*[@id="down_right_block"]//*[contains(@class,"coords")]//*[contains(@class,"phone_numbers")]',
);
$ownerPhone = preg_replace('~\s+~', ' ', $ownerPhone);
// Adresse postale: i-CAD ne la met pas toujours dans un bloc unique, mais on tente
$ownerPostal = $txt('//*[@id="down_right_block"]//*[contains(@class,"coords")]//*[contains(@class,"postal")]');
if ($ownerPostal === '') {
// fallback: parfois ladresse est dans le même bloc coords mais pas taggée
$coordsText = $txt('//*[@id="down_right_block"]//*[contains(@class,"coords")]');
// on évite de tout recracher, juste si on détecte des numéros/rue
$ownerPostal = $coordsText;
}
return [
'icad_url' => 'https://www.i-cad.fr' . $animalPath,
'name' => $name,
'insert' => $insert,
'species' => $map['Espèce'] ?? '',
'sex' => $map['Sexe'] ?? '',
'birth_date' => $map['Né le'] ?? '',
'coat_type' => $map['Type de robe'] ?? '',
'appearance' => $map['Apparence raciale'] ?? '',
'events' => $events,
'owner' => [
'name' => $ownerName,
'phone' => $ownerPhone,
'email' => $ownerEmail,
'postal' => $ownerPostal,
],
];
}
}

50
app/Services/Ids.php Normal file
View file

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
final class Ids
{
public static function slugifyUpper(string $s): string
{
$s = trim($s);
if ($s === '') {
return 'SANS-NOM';
}
// translit: accents -> ascii (si dispo)
$t = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $s);
if ($t !== false) {
$s = $t;
}
$s = strtoupper($s);
$s = preg_replace('/[^A-Z0-9]+/', '-', $s) ?? $s;
$s = trim($s, '-');
return $s !== '' ? $s : 'SANS-NOM';
}
public static function nextAnimalCode(string $name, ?int $year = null): string
{
$year = $year ?? (int) date('Y');
$slug = self::slugifyUpper($name);
$db = DB::pdo();
$stmt = $db->prepare(
'SELECT internal_code FROM animals WHERE internal_code LIKE :p ORDER BY internal_code DESC LIMIT 1',
);
$stmt->execute([':p' => $year . '-%']);
$last = (string) ($stmt->fetchColumn() ?: '');
$n = 1;
if ($last !== '') {
// format attendu: YYYY-NNN-...
$parts = explode('-', $last);
if (count($parts) >= 2 && ctype_digit($parts[1])) {
$n = (int) $parts[1] + 1;
}
}
return sprintf('%d-%03d-%s', $year, $n, $slug);
}
}

View file

@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
final class ImageService
{
private const ALLOWED = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp', 'image/gif' => 'gif'];
public static function inspect(string $path, int $maxBytes = 20_000_000): array
{
if (!is_file($path) || filesize($path) > $maxBytes) {
throw new RuntimeException(t('service.image.missing_large'));
}
$info = @getimagesize($path);
$mime = (string) ($info['mime'] ?? '');
if (!$info || !isset(self::ALLOWED[$mime])) {
throw new RuntimeException(t('service.image.format'));
}
if ((int) $info[0] * (int) $info[1] > 60_000_000) {
throw new RuntimeException(t('service.image.dimensions'));
}
return [
'mime' => $mime,
'extension' => self::ALLOWED[$mime],
'width' => (int) $info[0],
'height' => (int) $info[1],
'size' => (int) filesize($path),
];
}
public static function storeUploaded(
string $tmp,
string $directory,
string $prefix = 'photo',
string $profile = 'photo',
): array {
if (!is_uploaded_file($tmp)) {
throw new RuntimeException(t('service.image.invalid_upload'));
}
return self::store($tmp, $directory, $prefix, $profile, true);
}
public static function storeLocal(
string $source,
string $directory,
string $prefix = 'photo',
string $profile = 'photo',
): array {
return self::store($source, $directory, $prefix, $profile, false);
}
private static function store(
string $tmp,
string $directory,
string $prefix,
string $profile,
bool $moveSource,
): array {
$source = self::inspect($tmp);
if (!is_dir($directory) && !mkdir($directory, 0775, true) && !is_dir($directory)) {
throw new RuntimeException(t('service.image.directory_failed'));
}
$prefix = preg_replace('/[^a-z0-9_-]+/i', '_', trim($prefix)) ?: 'photo';
$useWebp = $profile !== 'logo';
$extension = $useWebp ? 'webp' : $source['extension'];
$filename = $prefix . '_' . date('Ymd_His') . '_' . bin2hex(random_bytes(4)) . '.' . $extension;
$target = rtrim($directory, '/') . '/' . $filename;
$stage = rtrim($directory, '/') . '/.' . $filename . '.part.' . $extension;
$optimized = false;
$binary = self::magickBinary();
if ($binary !== null) {
$max = $profile === 'logo' ? '1800x1800>' : '2560x2560>';
$quality = $profile === 'medical' ? '88' : '82';
$command = [$binary, $tmp . '[0]', '-auto-orient', '-strip', '-resize', $max];
if ($extension === 'png') {
$command = array_merge($command, ['-define', 'png:compression-level=9']);
} else {
$command = array_merge($command, ['-quality', (string) $quality]);
}
$command[] = $stage;
$process = proc_open($command, [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
if (is_resource($process)) {
fclose($pipes[0]);
stream_get_contents($pipes[1]);
fclose($pipes[1]);
stream_get_contents($pipes[2]);
fclose($pipes[2]);
$code = proc_close($process);
$optimized = $code === 0 && is_file($stage) && filesize($stage) > 0 && @getimagesize($stage) !== false;
}
}
if ($optimized) {
if (!rename($stage, $target)) {
@unlink($stage);
throw new RuntimeException(t('service.image.optimized_failed'));
}
} else {
if (is_file($stage)) {
@unlink($stage);
}
$extension = $source['extension'];
$filename = $prefix . '_' . date('Ymd_His') . '_' . bin2hex(random_bytes(4)) . '.' . $extension;
$target = rtrim($directory, '/') . '/' . $filename;
$saved = $moveSource ? move_uploaded_file($tmp, $target) : copy($tmp, $target);
if (!$saved) {
throw new RuntimeException(t('service.image.save_failed'));
}
}
@chmod($target, 0644);
$stored = self::inspect($target);
return array_merge($stored, [
'filename' => $filename,
'path' => $target,
'original_size' => $source['size'],
'optimized' => $optimized,
]);
}
private static function magickBinary(): ?string
{
foreach (
['/usr/bin/magick', '/usr/local/bin/magick', '/usr/bin/convert', '/usr/local/bin/convert']
as $binary
) {
if (is_executable($binary)) {
return $binary;
}
}
return null;
}
}

View file

@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
final class InstallationService
{
public static function runtimeChecks(bool $withFilesystem = true): array
{
$checks = [];
self::add($checks, 'php', version_compare(PHP_VERSION, '8.1.0', '>='), 'required', 'PHP 8.1+', PHP_VERSION);
self::add(
$checks,
'pdo',
extension_loaded('pdo'),
'required',
'PDO',
extension_loaded('pdo') ? 'Disponible' : 'Extension absente',
);
self::add(
$checks,
'sqlite',
extension_loaded('pdo_sqlite'),
'required',
'PDO SQLite',
extension_loaded('pdo_sqlite') ? 'Disponible' : 'Installez lextension PHP SQLite',
);
foreach (
['mbstring' => 'mbstring', 'json' => 'JSON', 'fileinfo' => 'Fileinfo', 'session' => 'Sessions']
as $extension => $label
) {
self::add(
$checks,
$extension,
extension_loaded($extension),
'required',
$label,
extension_loaded($extension) ? 'Disponible' : 'Extension absente',
);
}
self::add(
$checks,
'zip',
class_exists('ZipArchive'),
'recommended',
'ZIP',
class_exists('ZipArchive')
? 'Sauvegardes ZIP disponibles'
: 'Extension ZIP absente : sauvegardes indisponibles',
);
self::add(
$checks,
'curl',
extension_loaded('curl'),
'recommended',
'cURL',
extension_loaded('curl')
? 'Connecteurs externes disponibles'
: 'Extension cURL absente : I-CAD et géocodage indisponibles',
);
self::add(
$checks,
'image',
extension_loaded('imagick') || extension_loaded('gd'),
'recommended',
'Traitement des images',
extension_loaded('imagick')
? 'ImageMagick disponible'
: (extension_loaded('gd')
? 'GD disponible'
: 'ImageMagick ou GD conseillé'),
);
if ($withFilesystem) {
foreach (self::directories() as $key => $path) {
if (!is_dir($path)) {
@mkdir($path, 0775, true);
}
self::add(
$checks,
'dir_' . $key,
is_dir($path) && is_writable($path),
'required',
'Écriture · ' . self::relative($path),
is_dir($path) && is_writable($path) ? 'Accessible en écriture' : 'Droits décriture insuffisants',
);
}
}
return $checks;
}
public static function blockingRuntimeIssues(): array
{
return array_values(
array_filter(
self::runtimeChecks(true),
static fn(array $c): bool => $c['level'] === 'required' && !$c['ok'],
),
);
}
public static function canInstall(array $checks): bool
{
foreach ($checks as $check) {
if ($check['level'] === 'required' && !$check['ok']) {
return false;
}
}
return true;
}
public static function publicPath(): string
{
return dirname(__DIR__, 2) . '/public';
}
public static function directories(): array
{
$root = dirname(__DIR__, 2);
return [
'data' => $root . '/data',
'storage' => $root . '/storage',
'logs' => $root . '/storage/logs',
'backups' => $root . '/storage/backups',
'media' => $root . '/public/media',
'association' => $root . '/data/association',
];
}
private static function add(
array &$checks,
string $key,
bool $ok,
string $level,
string $label,
string $detail,
): void {
$checks[] = ['key' => $key, 'ok' => $ok, 'level' => $level, 'label' => $label, 'detail' => $detail];
}
private static function relative(string $path): string
{
return ltrim(str_replace(dirname(__DIR__, 2), '', $path), '/');
}
}

View file

@ -0,0 +1,211 @@
<?php
declare(strict_types=1);
final class InventoryService
{
public static function products(): array
{
return DB::pdo()
->query(
'SELECT p.*,COALESCE(SUM(b.quantity),0) stock,(SELECT MIN(expires_on) FROM inventory_batches x WHERE x.product_id=p.id AND x.quantity>0 AND x.expires_on IS NOT NULL) next_expiry FROM inventory_products p LEFT JOIN inventory_batches b ON b.product_id=p.id GROUP BY p.id ORDER BY p.category,p.name COLLATE NOCASE',
)
->fetchAll(PDO::FETCH_ASSOC);
}
public static function batches(): array
{
return DB::pdo()
->query(
'SELECT b.*,p.name product_name,p.unit,p.category,dc.name supplier_name FROM inventory_batches b JOIN inventory_products p ON p.id=b.product_id LEFT JOIN directory_contacts dc ON dc.id=b.supplier_contact_id ORDER BY b.quantity>0 DESC,date(b.expires_on),p.name COLLATE NOCASE',
)
->fetchAll(PDO::FETCH_ASSOC);
}
public static function movements(int $limit = 150): array
{
$limit = max(1, min(500, $limit));
return DB::pdo()
->query(
"SELECT m.*,p.name product_name,p.unit,b.batch_number,a.name animal_name,COALESCE(u.display_name,u.username) actor FROM inventory_movements m JOIN inventory_products p ON p.id=m.product_id LEFT JOIN inventory_batches b ON b.id=m.batch_id LEFT JOIN animals a ON a.id=m.animal_id LEFT JOIN users u ON u.id=m.created_by ORDER BY datetime(m.occurred_at) DESC,m.id DESC LIMIT $limit",
)
->fetchAll(PDO::FETCH_ASSOC);
}
public static function saveProduct(array $data): int
{
$category = (string) ($data['category'] ?? '');
$name = trim((string) ($data['name'] ?? ''));
$unit = trim((string) ($data['unit'] ?? ''));
if (!in_array($category, ['medication', 'dewormer', 'vaccine'], true) || $name === '' || $unit === '') {
throw new RuntimeException(t('inventory.invalid_product'));
}
$reference = (int) ($data['reference_id'] ?? 0) ?: null;
$db = DB::pdo();
if (!$reference) {
$table = match ($category) {
'medication' => 'ref_medications',
'dewormer' => 'ref_dewormers',
'vaccine' => 'ref_vaccines',
};
$lookup = $db->prepare("SELECT id FROM $table WHERE name=? COLLATE NOCASE LIMIT 1");
$lookup->execute([$name]);
$reference = (int) $lookup->fetchColumn() ?: null;
}
$minimum = max(0, (float) str_replace(',', '.', (string) ($data['minimum_quantity'] ?? 0)));
$s = $db->prepare(
'INSERT INTO inventory_products(category,reference_id,name,unit,minimum_quantity) VALUES(?,?,?,?,?)',
);
$s->execute([$category, $reference, mb_substr($name, 0, 150), mb_substr($unit, 0, 40), $minimum]);
return (int) $db->lastInsertId();
}
public static function receive(array $data): int
{
$product = (int) ($data['product_id'] ?? 0);
$quantity = (float) str_replace(',', '.', (string) ($data['quantity'] ?? 0));
if (!$product || $quantity <= 0) {
throw new RuntimeException(t('inventory.invalid_quantity'));
}
$expiry = trim((string) ($data['expires_on'] ?? ''));
if ($expiry !== '' && !self::date($expiry)) {
throw new RuntimeException(t('inventory.invalid_date'));
}
$cost = trim((string) ($data['unit_cost'] ?? ''));
$cents = $cost === '' ? null : (int) round((float) str_replace(',', '.', $cost) * 100);
$db = DB::pdo();
$db->beginTransaction();
try {
$s = $db->prepare(
'INSERT INTO inventory_batches(product_id,batch_number,expires_on,supplier_contact_id,unit_cost_cents,quantity,notes) VALUES(?,?,?,?,?,?,?)',
);
$s->execute([
$product,
trim((string) ($data['batch_number'] ?? '')) ?: null,
$expiry ?: null,
(int) ($data['supplier_contact_id'] ?? 0) ?: null,
$cents,
$quantity,
trim((string) ($data['notes'] ?? '')) ?: null,
]);
$batch = (int) $db->lastInsertId();
$db->prepare(
"INSERT INTO inventory_movements(product_id,batch_id,movement_type,quantity_delta,reason,created_by) VALUES(?,?,'entry',?,?,?)",
)->execute([$product, $batch, $quantity, t('inventory.receipt'), Auth::id()]);
$db->commit();
return $batch;
} catch (Throwable $e) {
$db->rollBack();
throw $e;
}
}
public static function move(array $data): void
{
$batch = (int) ($data['batch_id'] ?? 0);
$type = (string) ($data['movement_type'] ?? '');
$quantity = (float) str_replace(',', '.', (string) ($data['quantity'] ?? 0));
if (!$batch || !in_array($type, ['administration', 'loss', 'correction'], true) || $quantity <= 0) {
throw new RuntimeException(t('inventory.invalid_movement'));
}
$db = DB::pdo();
$s = $db->prepare(
'SELECT b.*,p.id product_id FROM inventory_batches b JOIN inventory_products p ON p.id=b.product_id WHERE b.id=?',
);
$s->execute([$batch]);
$row = $s->fetch(PDO::FETCH_ASSOC);
if (!$row) {
throw new RuntimeException(t('inventory.batch_not_found'));
}
$delta =
$type === 'correction'
? (float) str_replace(',', '.', (string) ($data['signed_quantity'] ?? 0))
: -$quantity;
if ($delta == 0 || (float) $row['quantity'] + $delta < 0) {
throw new RuntimeException(t('inventory.insufficient_stock'));
}
$db->beginTransaction();
try {
$db->prepare('UPDATE inventory_batches SET quantity=quantity+? WHERE id=?')->execute([$delta, $batch]);
$db->prepare(
'INSERT INTO inventory_movements(product_id,batch_id,movement_type,quantity_delta,animal_id,reason,created_by) VALUES(?,?,?,?,?,?,?)',
)->execute([
(int) $row['product_id'],
$batch,
$type,
$delta,
(int) ($data['animal_id'] ?? 0) ?: null,
trim((string) ($data['reason'] ?? '')) ?: null,
Auth::id(),
]);
$db->commit();
} catch (Throwable $e) {
$db->rollBack();
throw $e;
}
}
public static function alerts(): array
{
$low = [];
$expired = [];
foreach (self::products() as $p) {
if ((float) $p['stock'] <= (float) $p['minimum_quantity']) {
$low[] = $p;
}
}
foreach (self::batches() as $b) {
if (
(float) $b['quantity'] > 0 &&
$b['expires_on'] &&
$b['expires_on'] <= date('Y-m-d', strtotime('+30 days'))
) {
$expired[] = $b;
}
}
return compact('low', 'expired');
}
public static function consumeBatch(int $batchId, float $quantity, ?int $animalId, string $reason): void
{
if ($batchId <= 0 || $quantity <= 0) {
return;
}
$db = DB::pdo();
$s = $db->prepare(
'SELECT b.product_id,b.quantity,b.unit_cost_cents,b.supplier_contact_id,p.name FROM inventory_batches b JOIN inventory_products p ON p.id=b.product_id WHERE b.id=?',
);
$s->execute([$batchId]);
$batch = $s->fetch(PDO::FETCH_ASSOC);
if (!$batch) {
throw new RuntimeException(t('inventory.batch_not_found'));
}
if ((float) $batch['quantity'] < $quantity) {
throw new RuntimeException(t('inventory.insufficient_stock'));
}
$u = $db->prepare('UPDATE inventory_batches SET quantity=quantity-? WHERE id=? AND quantity>=?');
$u->execute([$quantity, $batchId, $quantity]);
if ($u->rowCount() !== 1) {
throw new RuntimeException(t('inventory.insufficient_stock'));
}
$movement = $db->prepare(
"INSERT INTO inventory_movements(product_id,batch_id,movement_type,quantity_delta,animal_id,reason,created_by) VALUES(?,?,'administration',?,?,?,?)",
);
$movement->execute([(int) $batch['product_id'], $batchId, -$quantity, $animalId, $reason, Auth::id()]);
$movementId = (int) $db->lastInsertId();
if ($animalId && !is_null($batch['unit_cost_cents'])) {
$unit = (int) $batch['unit_cost_cents'];
$db->prepare(
"INSERT INTO animal_expenses(animal_id,clinic_contact_id,source_type,source_id,label,occurred_on,quantity,catalog_unit_cents,discount_percent,total_cents,notes,created_by) VALUES(?,?, 'inventory',?,?,date('now'),?,?,0,?,?,?)",
)->execute([
$animalId,
(int) $batch['supplier_contact_id'] ?: null,
$movementId,
(string) $batch['name'],
$quantity,
$unit,
(int) round($unit * $quantity),
$reason,
Auth::id(),
]);
}
}
private static function date(string $v): bool
{
$d = DateTimeImmutable::createFromFormat('!Y-m-d', $v);
return $d && $d->format('Y-m-d') === $v;
}
}

View file

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
final class LabReferenceService
{
public static function all(): array
{
return DB::pdo()
->query(
'SELECT * FROM lab_reference_ranges ORDER BY analyzer_name COLLATE NOCASE,species_code,sort_order,parameter_code COLLATE NOCASE',
)
->fetchAll(PDO::FETCH_ASSOC);
}
public static function forAnimal(string $species): array
{
$species = SpeciesService::normalize($species);
$s = DB::pdo()->prepare(
'SELECT analyzer_code,analyzer_name,parameter_code,parameter_name,unit,reference_min,reference_max FROM lab_reference_ranges WHERE species_code=? AND active=1 ORDER BY analyzer_name COLLATE NOCASE,sort_order,parameter_code COLLATE NOCASE',
);
$s->execute([$species]);
$out = [];
foreach ($s->fetchAll(PDO::FETCH_ASSOC) as $r) {
$key = $r['analyzer_code'];
$out[$key]['label'] = $r['analyzer_name'];
$out[$key]['parameters'][] = [
'code' => $r['parameter_code'],
'name' => $r['parameter_name'],
'unit' => $r['unit'],
'min' => $r['reference_min'],
'max' => $r['reference_max'],
];
}
return $out;
}
}

View file

@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
final class LocationHistoryService
{
private const TRACKED = ['status', 'refuge_room', 'care_box_key', 'current_address'];
public static function snapshot(array $animal): array
{
$snapshot = [];
foreach (self::TRACKED as $field) {
$value = trim((string) ($animal[$field] ?? ''));
$snapshot[$field] = $value === '' ? null : $value;
}
return $snapshot;
}
public static function record(
PDO $db,
int $animalId,
?array $before,
array $after,
string $source,
?string $reason = null,
?string $movedAt = null,
): bool {
$from = self::snapshot($before ?? []);
$to = self::snapshot($after);
if ($before !== null && $from === $to) {
return false;
}
$stmt = $db->prepare('INSERT INTO animal_location_history (
animal_id,from_status,from_refuge_room,from_care_box_key,from_address,
to_status,to_refuge_room,to_care_box_key,to_address,reason,source,moved_at,user_id
) VALUES (
:animal,:from_status,:from_room,:from_box,:from_address,
:to_status,:to_room,:to_box,:to_address,:reason,:source,:moved_at,:user
)');
$stmt->execute([
':animal' => $animalId,
':from_status' => $from['status'],
':from_room' => $from['refuge_room'],
':from_box' => $from['care_box_key'],
':from_address' => $from['current_address'],
':to_status' => $to['status'],
':to_room' => $to['refuge_room'],
':to_box' => $to['care_box_key'],
':to_address' => $to['current_address'],
':reason' => ($reason = trim((string) $reason)) !== '' ? $reason : null,
':source' => $source,
':moved_at' => $movedAt ?: date('Y-m-d H:i:s'),
':user' => Auth::id(),
]);
return true;
}
public static function label(array $row, string $prefix): string
{
$status = trim((string) ($row[$prefix . '_status'] ?? ''));
$room = trim((string) ($row[$prefix . '_refuge_room'] ?? ''));
$box = trim((string) ($row[$prefix . '_care_box_key'] ?? ''));
$address = trim((string) ($row[$prefix . '_address'] ?? ''));
$label = match ($status) {
'refuge' => $room !== '' ? 'Refuge · ' . self::roomName($room) : 'Refuge · Salle non attribuée',
'soin' => $room !== '' ? self::roomName($room) : 'Infirmerie',
'quarantaine' => $room !== '' ? self::roomName($room) : 'Salle de quarantaine',
'fa' => 'Famille daccueil',
'fa_permanente' => 'FA permanente',
'reserve', 'réservé' => 'Réservé',
'adopte', 'adopté' => 'Adopté',
'hospitalise', 'hospitalisé' => 'Hospitalisé',
'isolation' => 'Isolation',
'decede', 'décédé' => 'Décédé',
'enfui', 'fugue' => 'Enfui',
'' => 'Emplacement antérieur inconnu',
default => ucfirst(str_replace('_', ' ', $status)),
};
if ($box !== '' && in_array($status, ['soin', 'quarantaine'], true)) {
$label .= ' · Box ' . self::boxLabel($box);
}
if (
$address !== '' &&
in_array($status, ['fa', 'fa_permanente', 'adopte', 'adopté', 'hospitalise', 'hospitalisé'], true)
) {
$label .= ' · ' . $address;
}
return $label;
}
private static function boxLabel(string $box): string
{
return match ($box) {
'top_a' => 'haut gauche',
'top_b' => 'haut centre',
'top_c' => 'haut droite',
'top_ab' => 'haut gauche double',
'top_bc' => 'haut droite double',
'top_all' => 'haut complet',
'bottom_a' => 'bas gauche',
'bottom_b' => 'bas droite',
'bottom_all' => 'bas complet',
default => str_replace('_', ' ', $box),
};
}
private static function roomName(string $code): string
{
static $rooms = null;
$rooms ??= ShelterRoomService::byCode(false);
return (string) ($rooms[$code]['name'] ?? $code);
}
}

View file

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
final class Migrations
{
public static function run(string $migrationsDir): void
{
$db = DB::pdo();
$db->exec('CREATE TABLE IF NOT EXISTS _migrations (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL);');
$files = glob(rtrim($migrationsDir, '/') . '/*.sql') ?: [];
sort($files);
foreach ($files as $file) {
$name = basename($file);
$stmt = $db->prepare('SELECT 1 FROM _migrations WHERE name = :n');
$stmt->execute([':n' => $name]);
if ($stmt->fetchColumn()) {
continue;
}
$sql = file_get_contents($file);
if ($sql === false) {
throw new RuntimeException("Cannot read migration: $name");
}
// Les toutes premières installations utilisaient déjà deleted_at avant
// que cette colonne ne soit correctement inscrite dans une migration.
if ($name === '025_animal_archive_and_trash.sql') {
$columns = array_column($db->query('PRAGMA table_info(animals)')->fetchAll(PDO::FETCH_ASSOC), 'name');
if (!in_array('deleted_at', $columns, true)) {
$db->exec('ALTER TABLE animals ADD COLUMN deleted_at TEXT');
}
}
// Répare les bases créées par une candidate rc.3 incomplète, dans
// laquelle les médias privés étaient utilisés avant d'être migrés.
if ($name === '089_media_schema_repair.sql') {
$columns = array_column(
$db->query('PRAGMA table_info(animal_photos)')->fetchAll(PDO::FETCH_ASSOC),
'name',
);
if (!in_array('is_public', $columns, true)) {
$db->exec('ALTER TABLE animal_photos ADD COLUMN is_public INTEGER NOT NULL DEFAULT 1');
}
}
$db->beginTransaction();
try {
$db->exec($sql);
$ins = $db->prepare("INSERT INTO _migrations(name, applied_at) VALUES(:n, datetime('now'))");
$ins->execute([':n' => $name]);
$db->commit();
} catch (Throwable $e) {
$db->rollBack();
throw new RuntimeException('Migration ' . $name . ' : ' . $e->getMessage(), 0, $e);
}
}
}
}

View file

@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
final class NotificationService
{
public static function alerts(): array
{
$db = DB::pdo();
$alerts = [];
$queries = [
[
'vaccines',
'danger',
t('notification.vaccines_overdue'),
"SELECT COUNT(*) FROM vaccinations v JOIN animals a ON a.id=v.animal_id WHERE v.due_date IS NOT NULL AND date(v.due_date)<date('now') AND a.deleted_at IS NULL AND a.archived_at IS NULL AND lower(a.status) NOT IN ('decede','décédé','adopte','adopté')",
'/dashboard#vaccine-deadlines',
],
[
'treatments',
'danger',
t('notification.treatments_overdue'),
"SELECT COUNT(*) FROM treatments t JOIN animals a ON a.id=t.animal_id WHERE t.ongoing=1 AND t.end_date IS NOT NULL AND date(t.end_date)<date('now') AND a.deleted_at IS NULL AND a.archived_at IS NULL",
'/dashboard#active-treatments',
],
[
'dewormings',
'warning',
t('notification.dewormings_due'),
"SELECT COUNT(*) FROM dewormings d JOIN animals a ON a.id=d.animal_id WHERE d.next_due_date IS NOT NULL AND date(d.next_due_date)<=date('now','+7 day') AND a.deleted_at IS NULL AND a.archived_at IS NULL AND NOT EXISTS(SELECT 1 FROM dewormings newer WHERE newer.animal_id=d.animal_id AND date(newer.administered_on)>date(d.administered_on))",
'/dashboard',
],
];
foreach ($queries as [$key, $severity, $label, $sql, $url]) {
try {
$count = (int) $db->query($sql)->fetchColumn();
if ($count) {
$alerts[] = compact('key', 'severity', 'label', 'count', 'url');
}
} catch (Throwable) {
}
}
if (class_exists(InventoryService::class)) {
try {
$stock = InventoryService::alerts();
if ($stock['low']) {
$alerts[] = [
'key' => 'inventory-low',
'severity' => 'warning',
'label' => t('notification.inventory_low'),
'count' => count($stock['low']),
'url' => '/settings/inventory',
];
}
if ($stock['expired']) {
$alerts[] = [
'key' => 'inventory-expiry',
'severity' => 'danger',
'label' => t('notification.inventory_expiry'),
'count' => count($stock['expired']),
'url' => '/settings/inventory',
];
}
} catch (Throwable) {
}
}
if (class_exists(AgendaService::class)) {
try {
$due = AgendaService::due();
if ($due) {
$alerts[] = [
'key' => 'agenda',
'severity' => 'warning',
'label' => t('notification.agenda_due'),
'count' => count($due),
'url' => '/agenda',
];
}
} catch (Throwable) {
}
}
$backups = BackupService::list();
$days = 999;
if (
$backups &&
($date = DateTimeImmutable::createFromFormat(
'd/m/Y H:i:s',
(string) $backups[0]['date'],
new DateTimeZone('Europe/Paris'),
))
) {
$days = (int) $date->diff(new DateTimeImmutable('now', new DateTimeZone('Europe/Paris')))->format('%a');
}
if ($days > 7) {
$alerts[] = [
'key' => 'backup',
'severity' => $days > 30 ? 'danger' : 'warning',
'label' => t('notification.backup_old'),
'count' => $days,
'url' => '/settings/backups',
];
}
return $alerts;
}
public static function send(bool $force = false): array
{
if (AppSettings::get('notification_email_enabled') !== '1' && !$force) {
return ['sent' => false, 'reason' => 'disabled'];
}
$recipient = AppSettings::get('notification_email_recipient') ?: AppSettings::get('association_email');
if (!filter_var($recipient, FILTER_VALIDATE_EMAIL)) {
return ['sent' => false, 'reason' => 'recipient'];
}
$frequency = AppSettings::get('notification_email_frequency');
$last = AppSettings::get('notification_last_sent_at');
if (!$force && $last !== '') {
$elapsed = time() - (strtotime($last) ?: 0);
$minimum = $frequency === 'daily' ? 20 * 3600 : 6 * 86400;
if ($elapsed < $minimum) {
return ['sent' => false, 'reason' => 'not_due'];
}
}
$alerts = self::alerts();
$association = AppSettings::get('association_name') ?: 'Globinours';
$subject = '[' . $association . '] ' . t('notification.email_subject');
$lines = [t('notification.email_intro', ['association' => $association]), ''];
if (!$alerts) {
$lines[] = t('notification.no_alert');
} else {
foreach ($alerts as $a) {
$lines[] = '- ' . $a['label'] . ' : ' . $a['count'];
}
}
$lines[] = '';
$lines[] = t('notification.email_footer');
$sent = @mail($recipient, $subject, implode("\n", $lines), 'Content-Type: text/plain; charset=UTF-8');
if ($sent) {
AppSettings::save(['notification_last_sent_at' => date('Y-m-d H:i:s')], Auth::id());
}
return ['sent' => $sent, 'reason' => $sent ? 'sent' : 'mail_failed', 'count' => count($alerts)];
}
}

View file

@ -0,0 +1,258 @@
<?php
declare(strict_types=1);
final class PermissionService
{
public const ROLES = [
'admin' => 'Administrateur',
'responsable' => 'Responsable',
'benevole' => 'Bénévole',
'lecture' => 'Lecture seule',
];
public const MODULES = [
'dashboard' => ['Dashboard', 'Priorités et situation quotidienne'],
'animals' => ['Animaux', 'Fiches, portées, liens, médias et adoptions'],
'medical' => ['Médical', 'Notes, traitements, vaccinations et décès'],
'care' => ['Tournées', 'Passages tablette et configuration des box'],
'directory' => ['Annuaire', 'Personnes, structures et coordonnées'],
'agenda' => ['Agenda', 'Rendez-vous, transports, visites et tâches partagées'],
'statistics' => ['Statistiques', 'Analyses annuelles et tendances'],
'administrative' => ['Administratif', 'Registre, documents et exports'],
'accounting' => ['Comptabilité', 'Factures, avoirs et règlements'],
'grants' => ['Subventions', 'Bilans, demandes et justificatifs'],
];
public static function roleLabel(string $key): string
{
return t('permission.role.' . $key, [], self::ROLES[$key] ?? $key);
}
public static function moduleLabel(string $key): string
{
return t('permission.module.' . $key, [], self::MODULES[$key][0] ?? $key);
}
public static function moduleDescription(string $key): string
{
return t('permission.module_help.' . $key, [], self::MODULES[$key][1] ?? '');
}
private static array $cache = [];
public static function can(string $module, string $action = 'view', ?string $role = null): bool
{
$role ??= (string) (Auth::user()['role'] ?? '');
if ($role === 'admin') {
return true;
}
if (!isset(self::MODULES[$module], self::ROLES[$role])) {
return false;
}
if (!isset(self::$cache[$role])) {
$s = DB::pdo()->prepare('SELECT module,can_view,can_edit FROM role_permissions WHERE role=?');
$s->execute([$role]);
self::$cache[$role] = [];
foreach ($s->fetchAll(PDO::FETCH_ASSOC) as $row) {
self::$cache[$role][$row['module']] = [
'view' => (bool) $row['can_view'],
'edit' => (bool) $row['can_edit'],
];
}
}
return (bool) (self::$cache[$role][$module][$action === 'edit' ? 'edit' : 'view'] ?? false);
}
public static function matrix(): array
{
$matrix = [];
foreach (array_keys(self::ROLES) as $role) {
foreach (array_keys(self::MODULES) as $module) {
$matrix[$role][$module] = [
'view' => self::can($module, 'view', $role),
'edit' => self::can($module, 'edit', $role),
];
}
}
return $matrix;
}
public static function moduleForPath(string $path): ?string
{
if ($path === '/') {
return 'dashboard';
}
if (str_starts_with($path, '/animal/documents')) {
return 'medical';
}
if (
str_starts_with($path, '/animals') ||
str_starts_with($path, '/animal') ||
str_starts_with($path, '/litter') ||
str_starts_with($path, '/bonded')
) {
foreach (
[
'/animal/add-medical',
'/animal/medical-status',
'/animal/add-treatment',
'/animal/add-vaccine',
'/animal/death',
'/animal/prescription',
'/animal/lab-report',
'/animal/medical-document',
'/animal/surgery',
]
as $medical
) {
if (str_starts_with($path, $medical)) {
return 'medical';
}
}
return 'animals';
}
if (str_starts_with($path, '/dashboard')) {
return 'dashboard';
}
if (str_starts_with($path, '/care-round')) {
return 'care';
}
if (str_starts_with($path, '/directory')) {
return 'directory';
}
if (str_starts_with($path, '/agenda')) {
return 'agenda';
}
if (str_starts_with($path, '/statistics')) {
return 'statistics';
}
if (str_starts_with($path, '/accounting')) {
return 'accounting';
}
if (str_starts_with($path, '/admin/grants')) {
return 'grants';
}
if (str_starts_with($path, '/admin') || str_starts_with($path, '/documents')) {
return 'administrative';
}
return null;
}
public static function enforce(string $path, string $method): void
{
if (
str_starts_with($path, '/settings') ||
str_starts_with($path, '/dev/') ||
in_array($path, ['/logout', '/evolutions'], true)
) {
return;
}
$module = self::moduleForPath($path);
if (!$module) {
return;
}
$action = strtoupper($method) === 'POST' || self::isEditPage($path) ? 'edit' : 'view';
$allowed = self::can($module, $action) && ($module !== 'medical' || self::can('animals', 'view'));
if (!$allowed) {
AuditService::log('authorization_denied', $path, 'Accès refusé au module ' . $module, null, null, [
'module' => $module,
'requested_action' => $action,
]);
http_response_code(403);
if (function_exists('render')) {
render('access_denied.php', [
'title' => t('access.denied'),
'deniedAction' => $action,
'deniedModule' => self::moduleLabel($module),
]);
} else {
echo t('access.denied');
}
exit();
}
}
public static function firstAllowedPath(): string
{
foreach (
[
'dashboard' => '/dashboard',
'animals' => '/animals',
'care' => '/care-round',
'directory' => '/directory',
'agenda' => '/agenda',
'statistics' => '/statistics',
'accounting' => '/accounting',
'administrative' => '/admin/register',
'grants' => '/admin/grants',
]
as $module => $path
) {
if (self::can($module)) {
return $path;
}
}
return '/evolutions';
}
public static function canOpenPath(string $path): bool
{
$module = self::moduleForPath($path);
return $module === null ||
(self::can($module, self::isEditPage($path) ? 'edit' : 'view') &&
($module !== 'medical' || self::can('animals')));
}
public static function currentPermissions(): array
{
$out = [];
foreach (self::MODULES as $module => $unused) {
$out[$module] = ['view' => self::can($module), 'edit' => self::can($module, 'edit')];
}
return $out;
}
private static function isEditPage(string $path): bool
{
return in_array(
$path,
[
'/animal/new',
'/animal/edit',
'/animal/death',
'/animal/surgery/new',
'/litter/new',
'/litter/add-existing',
'/litter/add-existing/save',
'/bonded/new',
'/directory/new',
'/directory/edit',
'/care-round/setup',
],
true,
);
}
public static function save(array $input, ?int $userId): void
{
$db = DB::pdo();
$stmt = $db->prepare(
"INSERT INTO role_permissions(role,module,can_view,can_edit,updated_by,updated_at) VALUES(:role,:module,:view,:edit,:user,datetime('now')) ON CONFLICT(role,module) DO UPDATE SET can_view=excluded.can_view,can_edit=excluded.can_edit,updated_by=excluded.updated_by,updated_at=excluded.updated_at",
);
$db->beginTransaction();
try {
foreach (self::ROLES as $role => $unused) {
if ($role === 'admin') {
continue;
}
foreach (self::MODULES as $module => $unusedModule) {
$view = isset($input[$role][$module]['view']) ? 1 : 0;
if ($module === 'medical' && !isset($input[$role]['animals']['view'])) {
$view = 0;
}
$edit = $view && isset($input[$role][$module]['edit']) ? 1 : 0;
$stmt->execute([
':role' => $role,
':module' => $module,
':view' => $view,
':edit' => $edit,
':user' => $userId,
]);
}
}
$db->commit();
self::$cache = [];
} catch (Throwable $e) {
$db->rollBack();
throw $e;
}
}
}

View file

@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
final class PricingService
{
public const CATEGORIES = [
'consultation' => 'Consultation',
'surgery' => 'Chirurgie',
'identification' => 'Identification',
'test' => 'Test / analyse',
'vaccine' => 'Vaccin',
'euthanasia' => 'Euthanasie',
'cremation' => 'Crémation',
'medication' => 'Médicament',
'dewormer' => 'Vermifuge',
'other' => 'Autre',
];
public const GROUPS = [
'act' => 'Acte',
'surgery_discount' => 'Actes chirurgicaux',
'medication' => 'Médicament hors antibiotique',
'antibiotic' => 'Antibiotique',
'ape_api' => 'APE / API',
'analysis' => 'Analyse',
'fixed' => 'Tarif fixe',
'none' => 'Sans remise',
];
public static function categoryLabel(string $key): string
{
return t('pricing.category.' . $key, [], self::CATEGORIES[$key] ?? $key);
}
public static function groupLabel(string $key): string
{
return t('pricing.group.' . $key, [], self::GROUPS[$key] ?? $key);
}
public static function cents(string $v): int
{
return max(0, (int) round((float) str_replace(',', '.', trim($v)) * 100));
}
public static function tariffs(bool $active = false): array
{
$w = $active ? 'WHERE ct.active=1 AND dc.deleted_at IS NULL' : '';
return DB::pdo()
->query(
"SELECT ct.*,dc.name clinic_name,COALESCE(dr.percent,0) discount_percent,ROUND(ct.amount_cents*(1-COALESCE(dr.percent,0)/100.0)) effective_cents FROM clinic_tariffs ct JOIN directory_contacts dc ON dc.id=ct.clinic_contact_id LEFT JOIN clinic_discount_rules dr ON dr.clinic_contact_id=ct.clinic_contact_id AND dr.discount_group=ct.discount_group $w ORDER BY dc.name COLLATE NOCASE,ct.active DESC,ct.label COLLATE NOCASE",
)
->fetchAll(PDO::FETCH_ASSOC);
}
public static function record(PDO $db, int $animal, int $tariff, float $qty, string $date, string $notes = ''): int
{
$s = $db->prepare(
'SELECT ct.*,COALESCE(dr.percent,0) discount FROM clinic_tariffs ct LEFT JOIN clinic_discount_rules dr ON dr.clinic_contact_id=ct.clinic_contact_id AND dr.discount_group=ct.discount_group WHERE ct.id=? AND ct.active=1',
);
$s->execute([$tariff]);
$t = $s->fetch(PDO::FETCH_ASSOC);
if (!$t) {
throw new RuntimeException(t('error.tariff_not_found'));
}
$qty = max(0.01, $qty);
$total = (int) round($t['amount_cents'] * $qty * (1 - (float) $t['discount'] / 100));
$db->prepare(
'INSERT INTO animal_expenses(animal_id,clinic_contact_id,tariff_id,label,occurred_on,quantity,catalog_unit_cents,discount_percent,total_cents,notes,created_by) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
)->execute([
$animal,
$t['clinic_contact_id'],
$tariff,
$t['label'],
$date,
$qty,
$t['amount_cents'],
$t['discount'],
$total,
$notes ?: null,
Auth::id(),
]);
return $total;
}
}

View file

@ -0,0 +1,188 @@
<?php
declare(strict_types=1);
final class PrivacyService
{
public static function logAccess(
string $type,
?int $id,
?int $animalId,
string $name,
string $action = 'view',
): void {
DB::pdo()
->prepare(
'INSERT INTO private_file_access_log(user_id,resource_type,resource_id,animal_id,action,original_name,ip_address,user_agent) VALUES(?,?,?,?,?,?,?,?)',
)
->execute([
Auth::id(),
$type,
$id,
$animalId,
$action,
mb_substr($name, 0, 240),
$_SERVER['REMOTE_ADDR'] ?? null,
mb_substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 500) ?: null,
]);
}
public static function accessLog(int $limit = 500): array
{
return DB::pdo()
->query(
'SELECT l.*,COALESCE(u.display_name,u.username) actor,a.name animal_name FROM private_file_access_log l LEFT JOIN users u ON u.id=l.user_id LEFT JOIN animals a ON a.id=l.animal_id ORDER BY l.id DESC LIMIT ' .
max(1, min(2000, $limit)),
)
->fetchAll(PDO::FETCH_ASSOC);
}
public static function retentionReview(int $years): array
{
$rows = DB::pdo()
->query(
"SELECT dc.id,dc.name,dc.email,dc.phone,dc.updated_at,group_concat(DISTINCT r.role) roles,MAX(COALESCE(ad.adoption_date,ap.event_date,dc.updated_at)) last_activity,(SELECT COUNT(*) FROM animal_placements active JOIN animals a ON a.id=active.animal_id WHERE active.contact_id=dc.id AND active.event_type='foster' AND a.deleted_at IS NULL AND a.archived_at IS NULL AND lower(a.status)='fa') active_links FROM directory_contacts dc LEFT JOIN directory_contact_roles r ON r.contact_id=dc.id LEFT JOIN adoptions ad ON ad.adopter_contact_id=dc.id LEFT JOIN animal_placements ap ON ap.contact_id=dc.id WHERE dc.deleted_at IS NULL AND dc.kind='person' AND dc.anonymized_at IS NULL GROUP BY dc.id ORDER BY date(last_activity),dc.name COLLATE NOCASE",
)
->fetchAll(PDO::FETCH_ASSOC);
$out = [];
foreach ($rows as $row) {
$roles = array_filter(explode(',', (string) $row['roles']));
$limits = [];
foreach ($roles as $role) {
$limits[] = match ($role) {
'adoptant' => AppSettings::int('privacy_retention_adopter_years', 1, 30),
'fa' => AppSettings::int('privacy_retention_foster_years', 1, 30),
'benevole' => AppSettings::int('privacy_retention_volunteer_years', 1, 30),
default => $years,
};
}
$limit = $limits ? max($limits) : $years;
$last = strtotime((string) $row['last_activity']) ?: time();
if ($last > strtotime('-' . $limit . ' years')) {
continue;
}
$row['policy_years'] = $limit;
$row['blocked'] = (int) $row['active_links'] > 0;
$out[] = $row;
}
return $out;
}
public static function exportContact(int $id): string
{
$db = DB::pdo();
$s = $db->prepare(
"SELECT dc.*,group_concat(r.role,',') roles,org.name organization_name FROM directory_contacts dc LEFT JOIN directory_contact_roles r ON r.contact_id=dc.id LEFT JOIN directory_contacts org ON org.id=dc.organization_id WHERE dc.id=? AND dc.deleted_at IS NULL GROUP BY dc.id",
);
$s->execute([$id]);
$contact = $s->fetch(PDO::FETCH_ASSOC);
if (!$contact) {
throw new RuntimeException(t('privacy.contact_not_found'));
}
$queries = [
'adoptions' =>
'SELECT a.internal_code,a.name animal,ad.adoption_date,ad.notes FROM adoptions ad JOIN animals a ON a.id=ad.animal_id WHERE ad.adopter_contact_id=? ORDER BY ad.adoption_date',
'placements' =>
'SELECT a.internal_code,a.name animal,ap.event_type,ap.event_date,ap.reason,ap.notes FROM animal_placements ap JOIN animals a ON a.id=ap.animal_id WHERE ap.contact_id=? ORDER BY ap.event_date,ap.id',
'deposited_animals' =>
'SELECT internal_code,name,intake_date,intake_type,intake_reason,intake_circumstances FROM animals WHERE depositor_contact_id=? ORDER BY intake_date',
];
$payload = ['generated_at' => new DateTimeImmutable()->format(DATE_ATOM), 'contact' => $contact];
foreach ($queries as $key => $sql) {
$q = $db->prepare($sql);
$q->execute([$id]);
$payload[$key] = $q->fetchAll(PDO::FETCH_ASSOC);
}
$tmp = tempnam(sys_get_temp_dir(), 'globinours-contact-');
if ($tmp === false) {
throw new RuntimeException(t('privacy.export_failed'));
}
@unlink($tmp);
$path = $tmp . '.zip';
$zip = new ZipArchive();
if ($zip->open($path, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
throw new RuntimeException(t('privacy.export_failed'));
}
$zip->addFromString(
'contact.json',
json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
);
foreach (['adoptions', 'placements', 'deposited_animals'] as $key) {
$zip->addFromString($key . '.csv', self::csv($payload[$key]));
}
$zip->addFromString(
'README.txt',
"Export de portabilité Globinours\nGénéré le " .
date('d/m/Y H:i') .
"\nDonnées à conserver dans un emplacement sécurisé.\n",
);
$zip->close();
return $path;
}
public static function anonymize(int $id, string $reason): void
{
$reason = trim($reason);
if ($reason === '') {
throw new RuntimeException(t('privacy.reason_required'));
}
$db = DB::pdo();
$s = $db->prepare(
"SELECT * FROM directory_contacts WHERE id=? AND deleted_at IS NULL AND kind='person' AND anonymized_at IS NULL",
);
$s->execute([$id]);
$contact = $s->fetch(PDO::FETCH_ASSOC);
if (!$contact) {
throw new RuntimeException(t('privacy.contact_not_found'));
}
$backup = BackupService::create('avant-anonymisation-contact-' . $id);
$label = t('privacy.anonymous_contact') . ' #' . $id;
$db->beginTransaction();
try {
$db->prepare(
"UPDATE directory_contacts SET name=?,phone=NULL,email=NULL,address=NULL,postal_code=NULL,city=NULL,country='France',organization_id=NULL,notes=NULL,anonymized_at=datetime('now'),anonymization_reason=?,updated_at=datetime('now') WHERE id=?",
)->execute([$label, $reason, $id]);
$db->prepare(
'UPDATE adoptions SET adopter_name=?,adopter_phone=NULL,adopter_email=NULL,adopter_address=NULL,adopter_postal_code=NULL,adopter_city=NULL WHERE adopter_contact_id=?',
)->execute([$label, $id]);
foreach ([(string) $contact['name']] as $old) {
$db->prepare(
'UPDATE animal_movements SET contact_name=? WHERE lower(trim(contact_name))=lower(trim(?))',
)->execute([$label, $old]);
}
$db->commit();
AuditService::log(
'contact_anonymized',
'/settings/privacy/anonymize',
'Contact anonymisé',
'contact',
$id,
['reason' => $reason, 'backup' => $backup['name'] ?? null],
);
} catch (Throwable $e) {
if ($db->inTransaction()) {
$db->rollBack();
}
throw $e;
}
}
public static function maintenance(): array
{
$months = AppSettings::int('privacy_access_log_months', 1, 120);
$s = DB::pdo()->prepare("DELETE FROM private_file_access_log WHERE datetime(accessed_at)<datetime('now',?)");
$s->execute(['-' . $months . ' months']);
return ['access_logs_removed' => $s->rowCount(), 'retention_months' => $months];
}
private static function csv(array $rows): string
{
if (!$rows) {
return "\xEF\xBB\xBF";
}
$stream = fopen('php://temp', 'r+');
fwrite($stream, "\xEF\xBB\xBF");
fputcsv($stream, array_keys($rows[0]), ';');
foreach ($rows as $row) {
fputcsv($stream, array_values($row), ';');
}
rewind($stream);
$out = stream_get_contents($stream);
fclose($stream);
return (string) $out;
}
}

View file

@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
final class PrivateMediaService
{
public static function root(): string
{
return dirname(__DIR__, 2) . '/data/private-media';
}
public static function animalDir(int $animalId): string
{
return self::root() . '/animals/' . $animalId;
}
public static function medicalDir(int $animalId): string
{
return self::animalDir($animalId) . '/meds';
}
public static function publicDir(int $animalId): string
{
return dirname(__DIR__, 2) . '/public/media/animals/' . $animalId;
}
public static function ensure(string $dir): void
{
if (!is_dir($dir) && !mkdir($dir, 0700, true) && !is_dir($dir)) {
throw new RuntimeException(t('service.image.directory_failed'));
}
@chmod($dir, 0700);
}
public static function moveVisibility(int $animalId, string $filename, bool $toPublic): void
{
$filename = basename($filename);
if ($filename === '' || $filename === '.' || $filename === '..') {
throw new RuntimeException(t('service.private_media.invalid_name'));
}
$from = $toPublic ? self::animalDir($animalId) : self::publicDir($animalId);
$to = $toPublic ? self::publicDir($animalId) : self::animalDir($animalId);
$source = $from . '/' . $filename;
$target = $to . '/' . $filename;
if (!is_file($source)) {
if (is_file($target)) {
return;
}
throw new RuntimeException(t('service.private_media.not_found'));
}
self::ensure($to);
if (!rename($source, $target)) {
throw new RuntimeException(t('service.private_media.move_failed'));
}
@chmod($target, $toPublic ? 0644 : 0600);
}
public static function migrateLegacy(): void
{
$db = DB::pdo();
$private = $db
->query('SELECT animal_id,filename FROM animal_photos WHERE is_public=0')
->fetchAll(PDO::FETCH_ASSOC);
foreach ($private as $row) {
self::moveIfPresent(
self::publicDir((int) $row['animal_id']) . '/' . basename((string) $row['filename']),
self::animalDir((int) $row['animal_id']) . '/' . basename((string) $row['filename']),
);
}
$medical = $db->query('SELECT mp.animal_id,mp.filename FROM medical_photos mp')->fetchAll(PDO::FETCH_ASSOC);
foreach ($medical as $row) {
self::moveIfPresent(
self::publicDir((int) $row['animal_id']) . '/meds/' . basename((string) $row['filename']),
self::medicalDir((int) $row['animal_id']) . '/' . basename((string) $row['filename']),
);
}
}
private static function moveIfPresent(string $source, string $target): void
{
if (!is_file($source) || is_file($target)) {
return;
}
self::ensure(dirname($target));
if (rename($source, $target)) {
@chmod($target, 0600);
}
}
public static function send(): void
{
$kind = (string) ($_GET['kind'] ?? 'photo');
$id = (int) ($_GET['id'] ?? 0);
if ($id <= 0) {
http_response_code(404);
return;
}
$db = DB::pdo();
if ($kind === 'medical') {
if (!PermissionService::can('medical', 'view')) {
http_response_code(403);
return;
}
$s = $db->prepare(
'SELECT mp.id,mp.animal_id,mp.filename FROM medical_photos mp JOIN animals a ON a.id=mp.animal_id WHERE mp.id=? AND a.deleted_at IS NULL',
);
$s->execute([$id]);
$row = $s->fetch(PDO::FETCH_ASSOC);
$path = $row ? self::medicalDir((int) $row['animal_id']) . '/' . basename((string) $row['filename']) : '';
} else {
if (!PermissionService::can('animals', 'view')) {
http_response_code(403);
return;
}
$s = $db->prepare(
'SELECT ap.id,ap.animal_id,ap.filename,ap.care_round_id FROM animal_photos ap JOIN animals a ON a.id=ap.animal_id WHERE ap.id=? AND ap.is_public=0 AND a.deleted_at IS NULL',
);
$s->execute([$id]);
$row = $s->fetch(PDO::FETCH_ASSOC);
if ($row && !empty($row['care_round_id']) && !PermissionService::can('medical', 'view')) {
http_response_code(403);
return;
}
$path = $row ? self::animalDir((int) $row['animal_id']) . '/' . basename((string) $row['filename']) : '';
}
if (empty($row) || !is_file($path)) {
http_response_code(404);
return;
}
$mime = new finfo(FILEINFO_MIME_TYPE)->file($path) ?: 'application/octet-stream';
PrivacyService::logAccess(
'private_' . $kind,
$id,
(int) $row['animal_id'],
basename((string) $row['filename']),
);
AuditService::log(
'private_file_viewed',
'/private-media',
'Média privé consulté',
'animal',
(int) $row['animal_id'],
['kind' => $kind, 'media_id' => $id],
);
header('Content-Type: ' . $mime);
header('Content-Length: ' . filesize($path));
header('Cache-Control: private,no-store');
header('X-Content-Type-Options: nosniff');
readfile($path);
}
}

View file

@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
final class SecurityService
{
public static function publicError(Throwable $e): string
{
$reference = strtoupper(substr(bin2hex(random_bytes(6)), 0, 10));
error_log(
'[Globinours ' .
$reference .
'] ' .
get_class($e) .
': ' .
$e->getMessage() .
' in ' .
$e->getFile() .
':' .
$e->getLine(),
);
return t('error.unexpected_reference', ['reference' => $reference]);
}
public static function hardenRuntimeFiles(): void
{
$root = dirname(__DIR__, 2);
$marker = $root . '/storage/.security-hardened';
if (is_file($marker) && filemtime($marker) > time() - 86400) {
return;
}
foreach (
[
$root . '/data',
$root . '/storage',
$root . '/data/private-media',
$root . '/data/medical-documents',
$root . '/data/grants',
$root . '/storage/backups',
$root . '/storage/logs',
$root . '/storage/asm3-imports',
]
as $dir
) {
if (is_dir($dir)) {
@chmod($dir, 0700);
}
}
foreach (
[
$root . '/.env',
$root . '/data/refuge.sqlite',
$root . '/data/refuge.sqlite-wal',
$root . '/data/refuge.sqlite-shm',
]
as $file
) {
if (is_file($file)) {
@chmod($file, 0600);
}
}
foreach (
[
'storage/backups/*',
'storage/logs/*',
'storage/reports/*',
'data/private-media/*',
'data/medical-documents/*',
'data/grants/*',
]
as $pattern
) {
foreach (glob($root . '/' . $pattern) ?: [] as $file) {
self::hardenTree($file);
}
}
@touch($marker);
@chmod($marker, 0600);
}
private static function hardenTree(string $path): void
{
if (is_file($path)) {
@chmod($path, 0600);
return;
}
if (!is_dir($path)) {
return;
}
@chmod($path, 0700);
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST,
);
foreach ($it as $item) {
@chmod($item->getPathname(), $item->isDir() ? 0700 : 0600);
}
}
}

View file

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
final class ShelterRoomService
{
public static function all(bool $activeOnly = false): array
{
$sql =
'SELECT * FROM shelter_rooms' .
($activeOnly ? ' WHERE active=1' : '') .
' ORDER BY sort_order,name COLLATE NOCASE';
$rooms = DB::pdo()->query($sql)->fetchAll(PDO::FETCH_ASSOC);
$boxes = DB::pdo()
->query('SELECT * FROM shelter_boxes ORDER BY sort_order,name COLLATE NOCASE')
->fetchAll(PDO::FETCH_ASSOC);
foreach ($rooms as &$room) {
$room['boxes'] = array_values(
array_filter(
$boxes,
static fn($box) => (int) $box['room_id'] === (int) $room['id'] &&
(!$activeOnly || (int) $box['active'] === 1),
),
);
}
return $rooms;
}
public static function byCode(bool $activeOnly = true): array
{
$result = [];
foreach (self::all($activeOnly) as $room) {
$result[(string) $room['code']] = $room;
}
return $result;
}
public static function resolveAnimal(array $animal, array $rooms): string
{
$stored = (string) ($animal['refuge_room'] ?? '');
if ($stored !== '' && isset($rooms[$stored])) {
return $stored;
}
$status = (string) ($animal['status'] ?? 'refuge');
foreach ($rooms as $code => $room) {
if ((string) $room['status_code'] === $status) {
return (string) $code;
}
}
return array_key_first($rooms) ?? '';
}
public static function normalizeCode(string $value): string
{
$value = mb_strtolower(trim($value));
$value = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value) ?: $value;
return trim(preg_replace('/[^a-z0-9]+/', '-', $value) ?? '', '-');
}
}

View file

@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
final class SpeciesService
{
private static ?array $labels = null;
public const CATEGORIES = [
'animaux_compagnie' => 'Animaux de compagnie',
'nac' => 'NAC',
'animal_ferme' => 'Animaux de ferme',
'faune_sauvage' => 'Faune sauvage',
'autre' => 'Autre',
];
public static function categoryLabel(string $key): string
{
return t('species.category.' . $key, [], self::CATEGORIES[$key] ?? $key);
}
public static function all(bool $activeOnly = false): array
{
$sql =
'SELECT s.*,(SELECT COUNT(*) FROM animals a WHERE lower(trim(a.species))=lower(s.code)) animal_count FROM ref_species s';
if ($activeOnly) {
$sql .= ' WHERE s.active=1';
}
$sql .= ' ORDER BY s.sort_order,s.name COLLATE NOCASE';
return DB::pdo()->query($sql)->fetchAll(PDO::FETCH_ASSOC);
}
public static function activeWithCurrent(?string $current): array
{
$rows = self::all(true);
$current = self::normalize((string) $current);
if ($current !== '' && !array_filter($rows, static fn(array $r): bool => $r['code'] === $current)) {
$stmt = DB::pdo()->prepare('SELECT * FROM ref_species WHERE code=:code');
$stmt->execute([':code' => $current]);
if ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$rows[] = $row;
}
}
return $rows;
}
public static function normalize(string $value): string
{
$value = mb_strtolower(trim($value));
return match ($value) {
'cat' => 'chat',
'dog' => 'chien',
default => $value,
};
}
public static function exists(string $code, bool $activeOnly = true): bool
{
$sql = 'SELECT 1 FROM ref_species WHERE code=:code' . ($activeOnly ? ' AND active=1' : '');
$stmt = DB::pdo()->prepare($sql);
$stmt->execute([':code' => self::normalize($code)]);
return (bool) $stmt->fetchColumn();
}
public static function isCat(?string $code): bool
{
return self::normalize((string) $code) === 'chat';
}
public static function label(?string $code): string
{
$code = self::normalize((string) $code);
if (self::$labels === null) {
self::$labels = [];
foreach (self::all(false) as $row) {
self::$labels[$row['code']] = $row['name'];
}
}
return self::$labels[$code] ?? ($code !== '' ? ucfirst(str_replace('-', ' ', $code)) : 'Espèce inconnue');
}
}

View file

@ -0,0 +1,705 @@
<?php
declare(strict_types=1);
final class StatisticsService
{
public static function costs(?int $year, ?array $ids = null): array
{
$db = DB::pdo();
$scopeIds = self::costScopeIds($year, $ids);
$empty = [
'total_cents' => 0,
'animals' => 0,
'scope_animals' => count($scopeIds),
'average_all_cents' => 0,
'average_expense_cents' => 0,
'median_expense_cents' => 0,
'by_clinic' => [],
'by_category' => [],
];
if (!$scopeIds) {
return $empty;
}
$where = ['a.deleted_at IS NULL', 'ae.animal_id IN(' . implode(',', array_map('intval', $scopeIds)) . ')'];
$params = [];
if ($year !== null) {
$where[] = 'date(ae.occurred_on) BETWEEN :start AND :end';
$params = [':start' => sprintf('%04d-01-01', $year), ':end' => sprintf('%04d-12-31', $year)];
}
$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 ' .
implode(' AND ', $where);
$map = static function (string $sql) use ($db, $params): array {
$s = $db->prepare($sql);
$s->execute($params);
return $s->fetchAll(PDO::FETCH_ASSOC);
};
$perAnimal = $map(
'SELECT ae.animal_id,SUM(ae.total_cents) total_cents' .
$base .
' GROUP BY ae.animal_id ORDER BY total_cents',
);
$totals = array_map(static fn($row) => (int) $row['total_cents'], $perAnimal);
$total = array_sum($totals);
$animals = count($totals);
return [
'total_cents' => $total,
'animals' => $animals,
'scope_animals' => count($scopeIds),
'average_all_cents' => count($scopeIds) ? (int) round($total / count($scopeIds)) : 0,
'average_expense_cents' => $animals ? (int) round($total / $animals) : 0,
'median_expense_cents' => self::median($totals),
'by_clinic' => $map(
"SELECT dc.id contact_id,COALESCE(dc.name,'—') label,COUNT(DISTINCT ae.animal_id) animals,SUM(ae.total_cents) total_cents,ROUND(SUM(ae.total_cents)*1.0/COUNT(DISTINCT ae.animal_id)) average_cents" .
$base .
' GROUP BY dc.id,dc.name ORDER BY total_cents DESC',
),
'by_category' => $map(
"SELECT COALESCE(ct.category,'other') label,COUNT(DISTINCT ae.animal_id) animals,SUM(ae.total_cents) total_cents,ROUND(SUM(ae.total_cents)*1.0/COUNT(DISTINCT ae.animal_id)) average_cents" .
$base .
' GROUP BY ct.category ORDER BY total_cents DESC',
),
];
}
public static function costsByMunicipality(?int $year): array
{
$db = DB::pdo();
$scopeIds = self::costScopeIds($year, null);
if (!$scopeIds) {
return [];
}
$animals = $db
->query('SELECT id,rescue_location_name,rescue_address FROM animals WHERE deleted_at IS NULL')
->fetchAll(PDO::FETCH_ASSOC);
$cities = [];
$animalCity = [];
foreach ($animals as $row) {
$id = (int) $row['id'];
if (!in_array($id, $scopeIds, true)) {
continue;
}
$city = GrantService::municipality((string) ($row['rescue_location_name'] ?: $row['rescue_address']));
if ($city === 'Non renseignée') {
continue;
}
$animalCity[$id] = $city;
$cities[$city]['animal_ids'][$id] = true;
}
if (!$animalCity) {
return [];
}
$params = [];
$dateSql = '';
if ($year !== null) {
$dateSql = ' AND date(occurred_on) BETWEEN :start AND :end';
$params = [':start' => sprintf('%04d-01-01', $year), ':end' => sprintf('%04d-12-31', $year)];
}
$s = $db->prepare(
'SELECT animal_id,SUM(total_cents) total_cents FROM animal_expenses WHERE animal_id IN(' .
implode(',', array_keys($animalCity)) .
')' .
$dateSql .
' GROUP BY animal_id',
);
$s->execute($params);
foreach ($s->fetchAll(PDO::FETCH_ASSOC) as $row) {
$id = (int) $row['animal_id'];
$city = $animalCity[$id];
$cities[$city]['expense_totals'][$id] = (int) $row['total_cents'];
}
$out = [];
foreach ($cities as $city => $data) {
$totals = array_values($data['expense_totals'] ?? []);
$total = array_sum($totals);
$scope = count($data['animal_ids']);
$with = count($totals);
$out[] = [
'municipality' => $city,
'animals' => $scope,
'animals_with_expense' => $with,
'total_cents' => $total,
'average_all_cents' => $scope ? (int) round($total / $scope) : 0,
'average_expense_cents' => $with ? (int) round($total / $with) : 0,
'median_expense_cents' => self::median($totals),
];
}
usort(
$out,
static fn($a, $b) => $b['total_cents'] <=> $a['total_cents'] ?:
strnatcasecmp($a['municipality'], $b['municipality']),
);
return $out;
}
private static function costScopeIds(?int $year, ?array $ids): 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 {
$start = sprintf('%04d-01-01', $year);
$end = sprintf('%04d-12-31', $year);
$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]);
return array_map('intval', $s->fetchAll(PDO::FETCH_COLUMN));
}
private static function median(array $values): int
{
if (!$values) {
return 0;
}
sort($values, SORT_NUMERIC);
$count = count($values);
$middle = intdiv($count, 2);
return $count % 2 ? (int) $values[$middle] : (int) round(($values[$middle - 1] + $values[$middle]) / 2);
}
private static function idSql(?array $ids, string $column = 'animal_id'): string
{
if ($ids === null) {
return '';
}
if (!$ids) {
return ' AND 1=0';
}
return ' AND ' . $column . ' IN (' . implode(',', array_map('intval', $ids)) . ')';
}
public static function municipalities(): array
{
$rows = DB::pdo()
->query('SELECT id,rescue_location_name,rescue_address FROM animals WHERE deleted_at IS NULL')
->fetchAll(PDO::FETCH_ASSOC);
$items = [];
foreach ($rows as $row) {
$city = GrantService::municipality((string) ($row['rescue_location_name'] ?: $row['rescue_address']));
if ($city === 'Non renseignée') {
continue;
}
$items[$city] = ($items[$city] ?? 0) + 1;
}
uksort($items, static fn($a, $b) => strnatcasecmp($a, $b));
return $items;
}
public static function animalIdsForMunicipality(string $municipality): array
{
if ($municipality === '') {
return [];
}
$rows = DB::pdo()
->query('SELECT id,rescue_location_name,rescue_address FROM animals WHERE deleted_at IS NULL')
->fetchAll(PDO::FETCH_ASSOC);
$ids = [];
foreach ($rows as $row) {
if (
GrantService::municipality((string) ($row['rescue_location_name'] ?: $row['rescue_address'])) ===
$municipality
) {
$ids[] = (int) $row['id'];
}
}
return $ids;
}
public static function municipalityCoverage(): array
{
$row =
DB::pdo()
->query(
"SELECT COUNT(*) total,SUM(CASE WHEN TRIM(COALESCE(rescue_location_name,''))<>'' OR TRIM(COALESCE(rescue_address,''))<>'' THEN 1 ELSE 0 END) known,SUM(CASE WHEN (TRIM(COALESCE(rescue_location_name,''))<>'' OR TRIM(COALESCE(rescue_address,''))<>'') AND COALESCE(rescue_location_confidence,'')<>'inferred_family' THEN 1 ELSE 0 END) documented,SUM(CASE WHEN rescue_location_confidence='inferred_family' THEN 1 ELSE 0 END) inferred,SUM(CASE WHEN intake_type='born_in_care' OR intake_reason='birth' THEN 1 ELSE 0 END) born_in_care,SUM(CASE WHEN TRIM(COALESCE(rescue_location_name,''))='' AND TRIM(COALESCE(rescue_address,''))='' AND intake_type<>'born_in_care' AND intake_reason<>'birth' THEN 1 ELSE 0 END) unknown FROM animals WHERE deleted_at IS NULL",
)
->fetch(PDO::FETCH_ASSOC) ?:
[];
return array_map('intval', $row);
}
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);
}
$db = DB::pdo();
$range = [':start' => sprintf('%04d-01-01', $year), ':end' => sprintf('%04d-12-31', $year)];
$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" .
$filter .
' UNION SELECT a.id FROM animals a WHERE a.deleted_at IS NULL AND date(a.intake_date) BETWEEN :start AND :end' .
$filter .
" UNION SELECT a.id FROM animals a JOIN animal_deaths d ON d.animal_id=a.id WHERE a.deleted_at IS NULL AND NULLIF(TRIM(a.intake_date),'') IS NULL AND NOT EXISTS(SELECT 1 FROM animal_movements em WHERE em.animal_id=a.id AND em.kind='entry') AND date(COALESCE(d.deceased_date,printf('%04d-01-01',d.death_year))) BETWEEN :start AND :end" .
$filter .
')',
);
$s->execute($range);
$stats['handled'] = (int) $s->fetchColumn();
$s = $db->prepare(
'SELECT COUNT(*) FROM medical_notes mn JOIN animals a ON a.id=mn.animal_id WHERE date(mn.noted_at) BETWEEN :start AND :end' .
$filter,
);
$s->execute($range);
$stats['medical_notes'] = (int) $s->fetchColumn();
return $stats;
}
public static function comparison(int $year, ?array $ids = null): array
{
$current = self::annual($year, $ids);
$previous = self::annual($year - 1, $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 $out;
}
public static function years(): array
{
$db = DB::pdo();
$found = [];
$queries = [
'SELECT intake_date d FROM animals WHERE deleted_at IS NULL AND intake_date IS NOT NULL',
"SELECT created_at d FROM animal_movements WHERE kind IN ('entry','exit')",
'SELECT adoption_date d FROM adoptions',
"SELECT COALESCE(deceased_date,printf('%04d-01-01',death_year)) d FROM animal_deaths WHERE deceased_date IS NOT NULL OR death_year IS NOT NULL",
'SELECT birth_date d FROM litters WHERE birth_date IS NOT NULL',
'SELECT done_date d FROM vaccinations',
'SELECT start_date d FROM treatments',
'SELECT noted_at d FROM medical_notes',
'SELECT occurred_on d FROM animal_expenses',
];
foreach ($queries as $sql) {
foreach ($db->query($sql)->fetchAll(PDO::FETCH_COLUMN) as $date) {
$y = (int) substr((string) $date, 0, 4);
if ($y >= 2000 && $y <= 2200) {
$found[$y] = true;
}
}
}
if (!$found) {
$found[(int) date('Y')] = true;
}
$first = min(array_keys($found));
$last = max((int) date('Y'), max(array_keys($found)));
return range($first, $last);
}
public static function timeline(?array $years = null, ?array $ids = null): array
{
$out = [];
foreach ($years ?? self::years() as $year) {
$out[(int) $year] = self::annual((int) $year, $ids);
}
return $out;
}
public static function all(?array $ids = null): array
{
$stats = array_fill_keys(
['entries', 'exits', 'adoptions', 'deaths', 'births', 'vaccinations', 'treatments', 'medical_notes'],
0.0,
);
foreach (self::timeline(null, $ids) as $annual) {
foreach (array_keys($stats) as $key) {
$stats[$key] += (float) $annual[$key];
}
}
$db = DB::pdo();
$stats['deaths'] =
(float) ($db
->query(
'SELECT COUNT(*) FROM animal_deaths d JOIN animals a ON a.id=d.animal_id WHERE a.deleted_at IS NULL' .
self::idSql($ids, 'd.animal_id'),
)
->fetchColumn() ?:
0);
if ($ids === null) {
$stats['handled'] =
(float) ($db->query('SELECT COUNT(*) FROM animals WHERE deleted_at IS NULL')->fetchColumn() ?: 0);
$stats['avg'] =
(float) ($db
->query(
"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 adoption_date IS NOT NULL AND date(adoption_date)>=date(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))",
)
->fetchColumn() ?:
0);
} else {
$stats['handled'] = (float) count($ids);
$stats['avg'] =
(float) ($db
->query(
"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 adoption_date IS NOT NULL AND date(adoption_date)>=date(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))" .
self::idSql($ids, 'a.id'),
)
->fetchColumn() ?:
0);
}
$stats['entries'] = $stats['handled'];
$stats['exits'] =
(float) ($db
->query(
'SELECT (SELECT COUNT(*) FROM adoptions ad JOIN animals a ON a.id=ad.animal_id WHERE a.deleted_at IS NULL' .
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 NOT EXISTS(SELECT 1 FROM adoptions ad WHERE ad.animal_id=d.animal_id)' .
self::idSql($ids, 'd.animal_id') .
")+(SELECT COUNT(*) FROM animals a WHERE a.deleted_at IS NULL AND a.status='enfui' AND NOT EXISTS(SELECT 1 FROM adoptions ad WHERE ad.animal_id=a.id) AND NOT EXISTS(SELECT 1 FROM animal_deaths d WHERE d.animal_id=a.id)" .
self::idSql($ids, 'a.id') .
')',
)
->fetchColumn() ?:
0);
$stats['adoptions'] =
(float) ($db
->query(
'SELECT COUNT(*) FROM adoptions ad JOIN animals a ON a.id=ad.animal_id WHERE a.deleted_at IS NULL' .
self::idSql($ids, 'ad.animal_id'),
)
->fetchColumn() ?:
0);
$stats['births'] =
(float) ($db
->query(
'SELECT COUNT(*) FROM litter_kittens lk JOIN animals a ON a.id=lk.animal_id WHERE a.deleted_at IS NULL' .
self::idSql($ids, 'lk.animal_id'),
)
->fetchColumn() ?:
0);
$stats['vaccinations'] =
(float) ($db
->query(
'SELECT COUNT(*) FROM vaccinations v JOIN animals a ON a.id=v.animal_id WHERE a.deleted_at IS NULL' .
self::idSql($ids, 'v.animal_id'),
)
->fetchColumn() ?:
0);
$stats['medical_notes'] =
(float) ($db
->query(
'SELECT COUNT(*) FROM medical_notes mn JOIN animals a ON a.id=mn.animal_id WHERE a.deleted_at IS NULL' .
self::idSql($ids, 'mn.animal_id'),
)
->fetchColumn() ?:
0);
$stats['current'] = (float) (self::currentHealth($ids)['total'] ?? 0);
return $stats;
}
public static function undatedHandled(?array $ids = null): int
{
return (int) (DB::pdo()
->query(
"SELECT COUNT(*) FROM animals a WHERE a.deleted_at IS NULL AND (a.intake_date IS NULL OR TRIM(a.intake_date)='') AND NOT EXISTS(SELECT 1 FROM animal_movements m WHERE m.animal_id=a.id AND m.kind='entry')" .
self::idSql($ids, 'a.id'),
)
->fetchColumn() ?:
0);
}
public static function undatedDeaths(?array $ids = null): int
{
return (int) (DB::pdo()
->query(
"SELECT COUNT(*) FROM animal_deaths d JOIN animals a ON a.id=d.animal_id WHERE a.deleted_at IS NULL AND (d.deceased_date IS NULL OR TRIM(d.deceased_date)='') AND d.death_year IS NULL" .
self::idSql($ids, 'd.animal_id'),
)
->fetchColumn() ?:
0);
}
public static function undatedBirths(?array $ids = null): int
{
return (int) (DB::pdo()
->query(
"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 COALESCE(NULLIF(l.birth_date,''),NULLIF(a.birth_date,'')) IS NULL" .
self::idSql($ids, 'lk.animal_id'),
)
->fetchColumn() ?:
0);
}
public static function undatedExits(?array $ids = null): int
{
return (int) (DB::pdo()
->query(
"SELECT (SELECT COUNT(*) FROM animal_deaths d JOIN animals a ON a.id=d.animal_id WHERE a.deleted_at IS NULL AND (d.deceased_date IS NULL OR TRIM(d.deceased_date)='') AND d.death_year IS NULL AND NOT EXISTS(SELECT 1 FROM adoptions ad WHERE ad.animal_id=d.animal_id)" .
self::idSql($ids, 'd.animal_id') .
")+(SELECT COUNT(*) FROM animals a WHERE a.deleted_at IS NULL AND a.status='enfui' AND NOT EXISTS(SELECT 1 FROM adoptions ad WHERE ad.animal_id=a.id) AND NOT EXISTS(SELECT 1 FROM animal_deaths d WHERE d.animal_id=a.id)" .
self::idSql($ids, 'a.id') .
')',
)
->fetchColumn() ?:
0);
}
public static function scopeCounts(?array $ids = null): array
{
$db = DB::pdo();
return [
'vaccinated_animals' => (int) $db
->query(
'SELECT COUNT(DISTINCT v.animal_id) FROM vaccinations v JOIN animals a ON a.id=v.animal_id WHERE a.deleted_at IS NULL' .
self::idSql($ids, 'v.animal_id'),
)
->fetchColumn(),
'medical_animals' => (int) $db
->query(
'SELECT COUNT(DISTINCT mn.animal_id) FROM medical_notes mn JOIN animals a ON a.id=mn.animal_id WHERE a.deleted_at IS NULL' .
self::idSql($ids, 'mn.animal_id'),
)
->fetchColumn(),
];
}
private static function details(int $year, array $ids): array
{
$db = DB::pdo();
$p = [':start' => sprintf('%04d-01-01', $year), ':end' => sprintf('%04d-12-31', $year)];
$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') .
' UNION SELECT id animal_id,date(intake_date) entry_date FROM animals WHERE deleted_at IS NULL AND date(intake_date) BETWEEN :start AND :end' .
self::idSql($ids, 'id') .
'), entered AS (SELECT animal_id,MIN(entry_date) entry_date FROM entry_sources GROUP BY animal_id) ';
$map = static function (string $sql) use ($db, $p): array {
$s = $db->prepare($sql);
$s->execute($p);
$out = [];
foreach ($s->fetchAll(PDO::FETCH_ASSOC) as $r) {
$out[(string) $r['label']] = (int) $r['total'];
}
return $out;
};
$sex = $map(
$base .
"SELECT CASE a.sex WHEN 'F' THEN 'Femelles' WHEN 'M' THEN 'Mâles' ELSE 'Inconnu' END label,COUNT(*) total FROM entered e JOIN animals a ON a.id=e.animal_id GROUP BY a.sex ORDER BY total DESC",
);
$ages = $map(
$base .
"SELECT CASE WHEN a.birth_date IS NULL THEN 'Non renseigné' WHEN (julianday(e.entry_date)-julianday(a.birth_date))/365.25<1 THEN 'Moins dun an' WHEN (julianday(e.entry_date)-julianday(a.birth_date))/365.25<3 THEN '1 à 2 ans' WHEN (julianday(e.entry_date)-julianday(a.birth_date))/365.25<8 THEN '3 à 7 ans' ELSE '8 ans et plus' END label,COUNT(*) total FROM entered e JOIN animals a ON a.id=e.animal_id GROUP BY label ORDER BY total DESC",
);
$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 .
' GROUP BY cause_code ORDER BY total DESC',
);
$stays = $map(
"SELECT CASE WHEN julianday(ad.adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))<30 THEN 'Moins dun mois' WHEN julianday(ad.adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))<90 THEN '1 à 3 mois' WHEN julianday(ad.adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))<180 THEN '3 à 6 mois' WHEN julianday(ad.adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))<365 THEN '6 à 12 mois' ELSE 'Plus dun an' END label,COUNT(*) total FROM adoptions ad JOIN animals a ON a.id=ad.animal_id WHERE date(ad.adoption_date) BETWEEN :start AND :end AND date(ad.adoption_date)>=date(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))" .
self::idSql($ids, 'a.id') .
' GROUP BY label ORDER BY total DESC',
);
return compact('sex', 'ages', 'deathCauses', 'stays');
}
public static function distributions(?int $year, ?array $ids = null, ?string $municipality = null): array
{
$db = DB::pdo();
$p = $year === null ? [] : [':start' => sprintf('%04d-01-01', $year), ':end' => sprintf('%04d-12-31', $year)];
$rows = static function (string $sql, array $params = []) use ($db): array {
$s = $db->prepare($sql);
$s->execute($params);
$out = [];
foreach ($s->fetchAll(PDO::FETCH_ASSOC) as $r) {
$out[(string) $r['label']] = (int) $r['total'];
}
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";
$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 .
self::idSql($ids, 'm.animal_id') .
' UNION SELECT id animal_id,date(intake_date) entry_date FROM animals WHERE deleted_at IS NULL' .
$datedAnimal .
$animalDate .
self::idSql($ids, 'id') .
'), entered AS (SELECT animal_id,MIN(entry_date) entry_date FROM entry_sources GROUP BY animal_id) ';
$colors = $rows(
$entered .
"SELECT COALESCE(NULLIF(TRIM(a.color),''),'Non renseignée') label,COUNT(*) total FROM entered e JOIN animals a ON a.id=e.animal_id GROUP BY label ORDER BY total DESC,label",
$p,
);
$breeds = $rows(
$entered .
"SELECT COALESCE(NULLIF(TRIM(a.breed),''),'Non renseignée') label,COUNT(*) total FROM entered e JOIN animals a ON a.id=e.animal_id GROUP BY label ORDER BY total DESC,label",
$p,
);
$species = $rows(
$entered .
"SELECT CASE lower(a.species) WHEN 'cat' THEN 'Chats' WHEN 'chat' THEN 'Chats' WHEN 'dog' THEN 'Chiens' WHEN 'chien' THEN 'Chiens' ELSE COALESCE(NULLIF(TRIM(a.species),''),'Inconnue') END label,COUNT(*) total FROM entered e JOIN animals a ON a.id=e.animal_id GROUP BY label ORDER BY total DESC",
$p,
);
$intakeTypes = $rows(
$entered .
"SELECT COALESCE(NULLIF(a.intake_type,''),'unknown') label,COUNT(*) total FROM entered e JOIN animals a ON a.id=e.animal_id GROUP BY label ORDER BY total DESC",
$p,
);
$intakeReasons = $rows(
$entered .
"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) {
$animalFilter = self::idSql($ids, 'a.id');
$details = [];
$details['sex'] = $rows(
"SELECT CASE a.sex WHEN 'F' THEN 'Femelles' WHEN 'M' THEN 'Mâles' ELSE 'Inconnu' END label,COUNT(*) total FROM animals a WHERE a.deleted_at IS NULL" .
$animalFilter .
' GROUP BY a.sex ORDER BY total DESC',
);
$details['ages'] = $rows(
"SELECT CASE WHEN a.birth_date IS NULL OR NULLIF(TRIM(a.intake_date),'') IS NULL THEN 'Non renseigné' WHEN (julianday(a.intake_date)-julianday(a.birth_date))/365.25<1 THEN 'Moins dun an' WHEN (julianday(a.intake_date)-julianday(a.birth_date))/365.25<3 THEN '1 à 2 ans' WHEN (julianday(a.intake_date)-julianday(a.birth_date))/365.25<8 THEN '3 à 7 ans' ELSE '8 ans et plus' END label,COUNT(*) total FROM animals a WHERE a.deleted_at IS NULL" .
$animalFilter .
' GROUP BY label ORDER BY total DESC',
);
$details['deathCauses'] = $rows(
"SELECT COALESCE(NULLIF(d.cause_code,''),'unknown') label,COUNT(*) total FROM animal_deaths d JOIN animals a ON a.id=d.animal_id WHERE a.deleted_at IS NULL" .
self::idSql($ids, 'd.animal_id') .
' GROUP BY label ORDER BY total DESC',
);
$details['stays'] = $rows(
"SELECT CASE WHEN julianday(ad.adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))<30 THEN 'Moins dun mois' WHEN julianday(ad.adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))<90 THEN '1 à 3 mois' WHEN julianday(ad.adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))<180 THEN '3 à 6 mois' WHEN julianday(ad.adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))<365 THEN '6 à 12 mois' ELSE 'Plus dun an' END label,COUNT(*) total FROM adoptions ad JOIN animals a ON a.id=ad.animal_id WHERE a.deleted_at IS NULL AND ad.adoption_date IS NOT NULL AND date(ad.adoption_date)>=date(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))" .
$animalFilter .
' 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'];
}
foreach ($details as &$items) {
arsort($items, SORT_NUMERIC);
}
unset($items);
$filter = self::idSql($ids, 'id');
$currentStatus = $rows(
"SELECT CASE status WHEN 'refuge' THEN 'Refuge' WHEN 'fa' THEN 'Famille daccueil' WHEN 'fa_permanente' THEN 'Accueil permanent' WHEN 'chat_libre' THEN 'Chats libres suivis' WHEN 'quarantaine' THEN 'Quarantaine' WHEN 'soin' THEN 'Infirmerie' WHEN 'reserve' THEN 'Réservé' ELSE status END label,COUNT(*) total FROM animals WHERE deleted_at IS NULL AND archived_at IS NULL AND lower(status) NOT IN ('adopte','adopté','decede','décédé')" .
$filter .
' GROUP BY status ORDER BY total DESC',
);
$freeCatSites = $rows(
"SELECT COALESCE(NULLIF(TRIM(free_cat_site_name),''),NULLIF(TRIM(free_cat_city),''),'Site non renseigné') label,COUNT(*) total FROM animals WHERE deleted_at IS NULL AND archived_at IS NULL AND status='chat_libre'" .
$filter .
' GROUP BY label ORDER BY total DESC,label',
);
$releaseSql =
$year === null
? "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'
: "SELECT COALESCE(NULLIF(TRIM(free_cat_site_name),''),NULLIF(TRIM(free_cat_city),''),'Site non renseigné') label,COUNT(*) total FROM animals WHERE deleted_at IS NULL AND date(free_cat_released_at) BETWEEN :start AND :end" .
$filter .
' GROUP BY label ORDER BY total DESC,label';
$freeCatReleases = $rows($releaseSql, $p);
return [
'colors' => $colors,
'breeds' => $breeds,
'species' => $species,
'intakeTypes' => $intakeTypes,
'intakeReasons' => $intakeReasons,
'sex' => $details['sex'],
'ages' => $details['ages'],
'origins' => $origins,
'deathCauses' => $details['deathCauses'],
'stays' => $details['stays'],
'currentStatus' => $currentStatus,
'freeCatSites' => $freeCatSites,
'freeCatReleases' => $freeCatReleases,
];
}
public static function currentHealth(?array $ids = null): array
{
$sql =
"SELECT COUNT(*) total,SUM(CASE WHEN status='chat_libre' THEN 1 ELSE 0 END) free_cats,SUM(CASE WHEN sterilization_status='yes' THEN 1 ELSE 0 END) sterilized,SUM(CASE WHEN sterilization_status='no' THEN 1 ELSE 0 END) unsterilized,SUM(CASE WHEN COALESCE(NULLIF(sterilization_status,''),'unknown')='unknown' THEN 1 ELSE 0 END) sterilization_unknown,SUM(CASE WHEN COALESCE(NULLIF(TRIM(chip_id),''),NULLIF(TRIM(chip),'')) IS NOT NULL THEN 1 ELSE 0 END) chipped,SUM(CASE WHEN fiv_status='positive' THEN 1 ELSE 0 END) fiv_positive,SUM(CASE WHEN felv_status='positive' THEN 1 ELSE 0 END) felv_positive FROM animals WHERE deleted_at IS NULL AND archived_at IS NULL AND lower(status) NOT IN ('adopte','adopté','decede','décédé')" .
self::idSql($ids, 'id');
$row = DB::pdo()->query($sql)->fetch(PDO::FETCH_ASSOC) ?: [];
return array_map('intval', $row);
}
}

View file

@ -0,0 +1,207 @@
<?php
declare(strict_types=1);
final class SystemHealthService
{
public static function report(): array
{
$checks = [];
$root = dirname(__DIR__, 2);
$db = DB::pdo();
foreach (InstallationService::runtimeChecks() as $c) {
$checks[] = self::check(
$c['ok'] ? 'ok' : ($c['level'] === 'required' ? 'error' : 'warning'),
'environment',
$c['label'],
$c['detail'],
);
}
try {
$integrity = (string) $db->query('PRAGMA integrity_check')->fetchColumn();
$checks[] = self::check(
$integrity === 'ok' ? 'ok' : 'error',
'database',
t('health.sqlite_integrity'),
$integrity === 'ok' ? t('health.sqlite_ok') : $integrity,
);
} catch (Throwable $e) {
$checks[] = self::check('error', 'database', t('health.sqlite_integrity'), $e->getMessage());
}
try {
$fk = $db->query('PRAGMA foreign_key_check')->fetchAll(PDO::FETCH_ASSOC);
$checks[] = self::check(
!$fk ? 'ok' : 'error',
'database',
t('health.foreign_keys'),
!$fk ? t('health.no_fk_error') : t('health.fk_errors', ['count' => count($fk)]),
);
} catch (Throwable $e) {
$checks[] = self::check('error', 'database', t('health.foreign_keys'), $e->getMessage());
}
$migrationFiles = array_map('basename', glob($root . '/migrations/*.sql') ?: []);
$applied = $db->query('SELECT name FROM _migrations')->fetchAll(PDO::FETCH_COLUMN);
$pending = array_values(array_diff($migrationFiles, $applied));
$checks[] = self::check(
!$pending ? 'ok' : 'warning',
'database',
t('health.migrations'),
!$pending
? t('health.migrations_ok', ['count' => count($applied)])
: t('health.migrations_pending', ['count' => count($pending)]),
);
$dbFile = $root . '/data/refuge.sqlite';
$checks[] = self::check(
is_file($dbFile) && is_readable($dbFile) ? 'ok' : 'error',
'storage',
t('health.database_file'),
is_file($dbFile) ? self::bytes((int) filesize($dbFile)) : t('health.file_missing'),
);
$free = @disk_free_space($root);
$status =
$free === false
? 'warning'
: ($free >= 1_073_741_824
? 'ok'
: ($free >= 268_435_456
? 'warning'
: 'error'));
$checks[] = self::check(
$status,
'storage',
t('health.disk_space'),
$free === false ? t('health.unknown') : self::bytes((int) $free),
);
$backups = BackupService::list();
if (!$backups) {
$checks[] = self::check('error', 'continuity', t('health.last_backup'), t('health.no_backup'));
} else {
$latest = $backups[0];
$date = DateTimeImmutable::createFromFormat(
'd/m/Y H:i:s',
(string) $latest['date'],
new DateTimeZone('Europe/Paris'),
);
$days = $date
? (int) $date->diff(new DateTimeImmutable('now', new DateTimeZone('Europe/Paris')))->format('%a')
: 999;
$checks[] = self::check(
$days <= 7 ? 'ok' : ($days <= 30 ? 'warning' : 'error'),
'continuity',
t('health.last_backup'),
t('health.backup_age', ['date' => $latest['date'], 'days' => $days]),
);
}
[$missing, $orphans] = self::photoConsistency($db, $root);
$checks[] = self::check(
$missing === 0 ? 'ok' : 'warning',
'files',
t('health.missing_photos'),
t('health.file_count', ['count' => $missing]),
);
$checks[] = self::check(
$orphans === 0 ? 'ok' : 'warning',
'files',
t('health.orphan_photos'),
t('health.file_count', ['count' => $orphans]),
);
$display = (string) ini_get('display_errors');
$production = getenv('APP_ENV') !== 'development';
$checks[] = self::check(
!$production || !in_array(strtolower($display), ['1', 'on', 'yes', 'true'], true) ? 'ok' : 'warning',
'security',
t('health.display_errors'),
$production ? t('health.production_value', ['value' => $display ?: 'Off']) : t('health.development_mode'),
);
$logErrors = filter_var(ini_get('log_errors'), FILTER_VALIDATE_BOOL);
$errorLog = trim((string) ini_get('error_log'));
$checks[] = self::check(
$logErrors ? 'ok' : 'warning',
'security',
t('health.error_log'),
$logErrors ? ($errorLog ?: t('health.system_log')) : t('health.logging_disabled'),
);
$applicationLog = $root . '/storage/logs/php-errors.log';
$recent = self::recentLogLines($applicationLog, 7);
$checks[] = self::check(
$recent === 0 ? 'ok' : 'warning',
'security',
t('health.recent_errors'),
$recent ? t('health.recent_error_count', ['count' => $recent]) : t('health.no_recent_error'),
);
$summary = ['ok' => 0, 'warning' => 0, 'error' => 0];
foreach ($checks as $c) {
$summary[$c['status']]++;
}
return [
'checks' => $checks,
'summary' => $summary,
'generated_at' => new DateTimeImmutable('now', new DateTimeZone('Europe/Paris'))->format('d/m/Y H:i:s'),
'php' => PHP_VERSION,
'sqlite' => (string) $db->query('SELECT sqlite_version()')->fetchColumn(),
];
}
private static function photoConsistency(PDO $db, string $root): array
{
$expected = [];
$missing = 0;
try {
foreach ($db->query('SELECT animal_id,filename FROM animal_photos')->fetchAll(PDO::FETCH_ASSOC) as $row) {
$path =
$root .
'/public/media/animals/' .
(int) $row['animal_id'] .
'/' .
basename((string) $row['filename']);
$expected[$path] = true;
if (!is_file($path)) {
$missing++;
}
}
} catch (Throwable) {
return [0, 0];
}
$orphans = 0;
foreach (glob($root . '/public/media/animals/*/*') ?: [] as $file) {
if (is_file($file) && !isset($expected[$file]) && !str_starts_with(basename($file), '.')) {
$orphans++;
}
}
return [$missing, $orphans];
}
private static function check(string $status, string $group, string $title, string $detail): array
{
return compact('status', 'group', 'title', 'detail');
}
private static function recentLogLines(string $path, int $days): int
{
if (!is_file($path) || filesize($path) === 0) {
return 0;
}
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
$dates = [];
for ($i = 0; $i < $days; $i++) {
$dates[] = new DateTimeImmutable("-$i days")->format('d-M-Y');
}
$count = 0;
foreach (array_slice($lines, -1000) as $line) {
foreach ($dates as $date) {
if (str_contains($line, $date)) {
$count++;
break;
}
}
}
return $count;
}
private static function bytes(int $bytes): string
{
foreach (['o', 'Ko', 'Mo', 'Go', 'To'] as $unit) {
if ($bytes < 1024 || $unit === 'To') {
return number_format($bytes, $unit === 'o' ? 0 : 1, ',', ' ') . ' ' . $unit;
}
$bytes = (int) round($bytes / 1024);
}
return '';
}
}

View file

@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
final class TrustedDeviceService
{
private const COOKIE = 'globinours_device';
public static function create(int $userId, string $name = ''): void
{
$selector = bin2hex(random_bytes(9));
$token = random_bytes(32);
$expires = time() + 60 * 60 * 24 * 90;
$name = trim($name) ?: self::deviceLabel();
DB::pdo()
->prepare(
'INSERT INTO trusted_devices(user_id,selector,token_hash,device_name,user_agent,ip_address,expires_at) VALUES(?,?,?,?,?,?,?)',
)
->execute([
$userId,
$selector,
hash('sha256', $token),
mb_substr($name, 0, 100),
mb_substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 500) ?: null,
$_SERVER['REMOTE_ADDR'] ?? null,
date('Y-m-d H:i:s', $expires),
]);
self::cookie($selector . '.' . bin2hex($token), $expires);
}
public static function authenticate(): ?int
{
$raw = (string) ($_COOKIE[self::COOKIE] ?? '');
if (!preg_match('/^([a-f0-9]{18})\.([a-f0-9]{64})$/', $raw, $m)) {
return null;
}
try {
$s = DB::pdo()->prepare(
"SELECT td.*,u.active FROM trusted_devices td JOIN users u ON u.id=td.user_id WHERE td.selector=? AND td.revoked_at IS NULL AND datetime(td.expires_at)>datetime('now') AND (td.last_used_at IS NULL OR datetime(td.last_used_at)>datetime('now','-30 days'))",
);
$s->execute([$m[1]]);
$row = $s->fetch(PDO::FETCH_ASSOC);
if (
!$row ||
!hash_equals((string) $row['token_hash'], hash('sha256', hex2bin($m[2]) ?: '')) ||
(int) $row['active'] !== 1
) {
self::forget();
return null;
}
$new = random_bytes(32);
DB::pdo()
->prepare(
"UPDATE trusted_devices SET token_hash=?,last_used_at=datetime('now'),ip_address=? WHERE id=?",
)
->execute([hash('sha256', $new), $_SERVER['REMOTE_ADDR'] ?? null, $row['id']]);
self::cookie($m[1] . '.' . bin2hex($new), strtotime((string) $row['expires_at']));
return (int) $row['user_id'];
} catch (Throwable) {
return null;
}
}
public static function currentSelector(): ?string
{
$raw = (string) ($_COOKIE[self::COOKIE] ?? '');
return preg_match('/^([a-f0-9]{18})\./', $raw, $m) ? $m[1] : null;
}
public static function revokeCurrent(): void
{
$selector = self::currentSelector();
if ($selector) {
try {
DB::pdo()
->prepare("UPDATE trusted_devices SET revoked_at=datetime('now') WHERE selector=?")
->execute([$selector]);
} catch (Throwable) {
}
}
self::forget();
}
public static function devices(?int $userId = null): array
{
$userId ??= Auth::id();
$s = DB::pdo()->prepare(
'SELECT * FROM trusted_devices WHERE user_id=? ORDER BY revoked_at IS NULL DESC,last_used_at DESC',
);
$s->execute([$userId]);
return $s->fetchAll(PDO::FETCH_ASSOC);
}
public static function revoke(int $id, int $userId, bool $admin = false): void
{
$sql =
'UPDATE trusted_devices SET revoked_at=datetime(\'now\') WHERE id=:id' .
($admin ? '' : ' AND user_id=:uid');
$params = [':id' => $id];
if (!$admin) {
$params[':uid'] = $userId;
}
DB::pdo()->prepare($sql)->execute($params);
if (
self::currentSelector() &&
self::currentSelector() ===
(string) DB::pdo()
->query('SELECT selector FROM trusted_devices WHERE id=' . (int) $id)
->fetchColumn()
) {
self::forget();
}
}
private static function deviceLabel(): string
{
$ua = strtolower((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''));
if (str_contains($ua, 'globinoursandroid')) {
return 'Application Android';
}
if (str_contains($ua, 'android')) {
return 'Smartphone ou tablette Android';
}
if (str_contains($ua, 'iphone') || str_contains($ua, 'ipad')) {
return 'iPhone ou iPad';
}
return 'Navigateur web';
}
private static function cookie(string $value, int $expires): void
{
setcookie(self::COOKIE, $value, [
'expires' => $expires,
'path' => '/',
'secure' => Auth::isSecureRequest(),
'httponly' => true,
'samesite' => 'Strict',
]);
$_COOKIE[self::COOKIE] = $value;
}
private static function forget(): void
{
setcookie(self::COOKIE, '', [
'expires' => time() - 3600,
'path' => '/',
'secure' => Auth::isSecureRequest(),
'httponly' => true,
'samesite' => 'Strict',
]);
unset($_COOKIE[self::COOKIE]);
}
}