diff --git a/CHANGELOG.md b/CHANGELOG.md index 2063341..fe1b643 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ Toutes les modifications importantes du projet sont consignées ici. Le format s’inspire de [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le projet suit le [versionnage sémantique](https://semver.org/lang/fr/). +## [1.0.0-rc.11] — 2026-09-08 + +### Configuration des salles et des tournées + +- critères facultatifs d’accueil par salle : espèce, sexe et statut FIV (`#10`) ; +- contrôle de ces critères depuis la fiche animale et la configuration des déplacements ; +- créneaux de tournée configurables par salle parmi matin, midi, après-midi et soir (`#21`) ; +- conservation de matin et soir comme réglage par défaut des salles existantes. + ## [1.0.0-rc.10] — 2026-09-08 ### Correction des fiches animales diff --git a/HISTORIQUE.md b/HISTORIQUE.md index 07ab5b7..24e65ce 100644 --- a/HISTORIQUE.md +++ b/HISTORIQUE.md @@ -49,3 +49,7 @@ Extension des liens sociaux du site public, clarification des salles et des tari ## `1.0.0-rc.10` — Statuts des animaux Correction du champ de statut du formulaire animal, stabilisation des codes internes et translittération des noms accentués. + +## `1.0.0-rc.11` — Salles et tournées + +Ajout de critères d’accueil par salle et de quatre créneaux de tournée configurables sans modifier les réglages existants. diff --git a/VERSION b/VERSION index 82f25cf..21254c9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.0-rc.10 +1.0.0-rc.11 diff --git a/app/Controllers/AnimalRecordActions.php b/app/Controllers/AnimalRecordActions.php index 0de05ca..9935cb6 100644 --- a/app/Controllers/AnimalRecordActions.php +++ b/app/Controllers/AnimalRecordActions.php @@ -617,6 +617,15 @@ trait AnimalRecordActions $fiv = $fiv ?? (int) ($old['fiv'] ?? 0); $felv = $felv ?? (int) ($old['felv'] ?? 0); + if ($refugeRoom !== null && !ShelterRoomService::accepts($availableRooms[$refugeRoom], [ + 'species' => $species, + 'sex' => $sex, + 'fiv' => $fiv, + ])) { + http_response_code(400); + echo h(t('rooms.animal_not_allowed')); + return; + } $careBox = in_array($status, ['soin', 'quarantaine'], true) ? $old['care_box_key'] ?? null : null; if ($isArchivedCorrection) { $refugeRoom = $old['refuge_room'] ?? null; diff --git a/app/Controllers/CareRoundsController.php b/app/Controllers/CareRoundsController.php index 07ee509..64d74ad 100644 --- a/app/Controllers/CareRoundsController.php +++ b/app/Controllers/CareRoundsController.php @@ -4,10 +4,16 @@ declare(strict_types=1); final class CareRoundsController { + private static function period(mixed $value): string + { + $value = (string) $value; + return in_array($value, ShelterRoomService::PERIODS, true) ? $value : 'morning'; + } + public static function index(): void { $db = DB::pdo(); - $period = ($_GET['period'] ?? 'morning') === 'evening' ? 'evening' : 'morning'; + $period = self::period($_GET['period'] ?? 'morning'); $date = preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) ($_GET['date'] ?? '')) ? (string) $_GET['date'] : date('Y-m-d'); @@ -65,6 +71,21 @@ final class CareRoundsController foreach ($rows as $r) { $unconfigured[(int) $r['animal_id']][] = $r; } + $allRooms = ShelterRoomService::all(true); + $rooms = array_values(array_filter( + $allRooms, + static fn(array $room): bool => in_array($period, ShelterRoomService::periods($room), true), + )); + $allRoomsByCode = array_column($allRooms, null, 'code'); + $visibleRoomCodes = array_column($rooms, 'code'); + $animals = array_values(array_filter( + $animals, + static fn(array $animal): bool => in_array( + ShelterRoomService::resolveAnimal($animal, $allRoomsByCode), + $visibleRoomCodes, + true, + ), + )); render('care_round.php', [ 'title' => t('care.title'), 'pageDescription' => t('care.description'), @@ -72,7 +93,7 @@ final class CareRoundsController 'date' => $date, 'round' => $round, 'layouts' => $layouts, - 'rooms' => ShelterRoomService::all(true), + 'rooms' => $rooms, 'animals' => $animals, 'treatmentsByAnimal' => $treatments, 'unconfiguredTreatments' => $unconfigured, @@ -95,10 +116,14 @@ final class CareRoundsController return; } $animalCheck = $db->prepare( - "SELECT 1 FROM animals WHERE id=? AND deleted_at IS NULL AND archived_at IS NULL AND lower(status) IN ('refuge','soin','quarantaine')", + "SELECT * FROM animals WHERE id=? AND deleted_at IS NULL AND archived_at IS NULL AND lower(status) IN ('refuge','soin','quarantaine')", ); $animalCheck->execute([$animalId]); - if (!$animalCheck->fetchColumn()) { + $animal = $animalCheck->fetch(PDO::FETCH_ASSOC); + $roomsByCode = ShelterRoomService::byCode(true); + $roomCode = $animal ? ShelterRoomService::resolveAnimal($animal, $roomsByCode) : ''; + $period = self::period($_POST['period'] ?? 'morning'); + if (!$animal || !isset($roomsByCode[$roomCode]) || !in_array($period, ShelterRoomService::periods($roomsByCode[$roomCode]), true)) { http_response_code(404); return; } @@ -255,7 +280,7 @@ final class CareRoundsController $roundStmt->execute([':id' => $roundId]); $roundInfo = $roundStmt->fetch(); $roundDate = $roundInfo ? date('d/m/Y', strtotime((string) $roundInfo['round_date'])) : date('d/m/Y'); - $roundPeriod = ($roundInfo['period'] ?? 'morning') === 'evening' ? 'soir' : 'matin'; + $roundPeriod = t('care.' . self::period($roundInfo['period'] ?? 'morning')); $history = $db->prepare( "INSERT INTO animal_history(animal_id,type,label,details,user_id) VALUES(:animal,'care_round',:label,:details,:user)", ); @@ -277,7 +302,7 @@ final class CareRoundsController } throw $e; } - $period = ($_POST['period'] ?? 'morning') === 'evening' ? 'evening' : 'morning'; + $period = self::period($_POST['period'] ?? 'morning'); $date = (string) ($_POST['date'] ?? date('Y-m-d')); header('Location: /care-round?period=' . $period . '&date=' . rawurlencode($date) . '#animal-' . $animalId); exit(); @@ -293,7 +318,8 @@ final class CareRoundsController $roundId = self::ensureRound($db); $room = (string) ($_POST['room'] ?? ''); $rooms = ShelterRoomService::byCode(true); - if (!$roundId || !isset($rooms[$room]) || empty($rooms[$room]['bulk_validation'])) { + $period = self::period($_POST['period'] ?? 'morning'); + if (!$roundId || !isset($rooms[$room]) || empty($rooms[$room]['bulk_validation']) || !in_array($period, ShelterRoomService::periods($rooms[$room]), true)) { http_response_code(400); return; } @@ -312,7 +338,6 @@ final class CareRoundsController } } - $period = ($_POST['period'] ?? 'morning') === 'evening' ? 'evening' : 'morning'; $date = preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) ($_POST['date'] ?? '')) ? (string) $_POST['date'] : date('Y-m-d'); @@ -329,7 +354,7 @@ final class CareRoundsController } $animals = $db ->query( - "SELECT id,name,status,refuge_room,care_box_key,current_address FROM animals WHERE deleted_at IS NULL AND archived_at IS NULL AND lower(status) IN ('refuge','soin','quarantaine') ORDER BY name COLLATE NOCASE", + "SELECT id,name,species,sex,fiv,fiv_status,status,refuge_room,care_box_key,current_address FROM animals WHERE deleted_at IS NULL AND archived_at IS NULL AND lower(status) IN ('refuge','soin','quarantaine') ORDER BY name COLLATE NOCASE", ) ->fetchAll(); render('care_setup.php', [ @@ -384,6 +409,11 @@ final class CareRoundsController continue; } $room = $rooms[$roomCode]; + $animalRules = $db->prepare('SELECT species,sex,fiv,fiv_status FROM animals WHERE id=:id'); + $animalRules->execute([':id' => $animalId]); + if (!ShelterRoomService::accepts($room, $animalRules->fetch(PDO::FETCH_ASSOC) ?: [])) { + continue; + } $allowedBoxes = array_column( array_filter($room['boxes'], static fn($box) => (int) $box['active'] === 1), 'code', @@ -434,7 +464,7 @@ final class CareRoundsController DB::pdo() ->prepare('UPDATE treatments SET give_morning=?,give_evening=?,give_as_needed=? WHERE id=?') ->execute([$values[0], $values[1], $values[2], $id]); - $period = ($_POST['period'] ?? 'morning') === 'evening' ? 'evening' : 'morning'; + $period = self::period($_POST['period'] ?? 'morning'); header('Location: /care-round?period=' . $period); exit(); } @@ -444,7 +474,7 @@ final class CareRoundsController $date = preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) ($_POST['date'] ?? '')) ? (string) $_POST['date'] : date('Y-m-d'); - $period = ($_POST['period'] ?? 'morning') === 'evening' ? 'evening' : 'morning'; + $period = self::period($_POST['period'] ?? 'morning'); $requested = (int) ($_POST['round_id'] ?? 0); if ($requested) { $s = $db->prepare('SELECT id FROM care_rounds WHERE id=? AND round_date=? AND period=?'); diff --git a/app/Controllers/SettingsController.php b/app/Controllers/SettingsController.php index d4ce8d4..6a2be35 100644 --- a/app/Controllers/SettingsController.php +++ b/app/Controllers/SettingsController.php @@ -215,6 +215,7 @@ final class SettingsController render('settings_rooms.php', [ 'title' => t('rooms.title'), 'rooms' => ShelterRoomService::all(), + 'species' => SpeciesService::all(), 'saved' => isset($_GET['saved']), 'error' => (string) ($_GET['error'] ?? ''), ]); @@ -236,6 +237,18 @@ final class SettingsController $type = (string) ($row['room_type'] ?? 'collective'); $status = (string) ($row['status_code'] ?? 'refuge'); $color = (string) ($row['color'] ?? '#6c757d'); + $allowedSpecies = SpeciesService::normalize((string) ($row['allowed_species'] ?? '')); + if (!SpeciesService::exists($allowedSpecies, false)) { + $allowedSpecies = null; + } + $allowedSex = in_array($row['allowed_sex'] ?? '', ['M', 'F'], true) ? (string) $row['allowed_sex'] : null; + $requiredFiv = in_array($row['required_fiv'] ?? '', ['positive', 'negative'], true) + ? (string) $row['required_fiv'] + : 'any'; + $periods = array_values(array_intersect(ShelterRoomService::PERIODS, (array) ($row['care_periods'] ?? []))); + if (!$periods) { + throw new RuntimeException(t('rooms.period_required')); + } if ( $name === '' || mb_strlen($name) > 100 || @@ -246,7 +259,7 @@ final class SettingsController throw new RuntimeException(t('rooms.invalid')); } $db->prepare( - "UPDATE shelter_rooms SET name=?,room_type=?,status_code=?,color=?,bulk_validation=?,active=?,sort_order=?,updated_at=datetime('now') WHERE id=?", + "UPDATE shelter_rooms SET name=?,room_type=?,status_code=?,color=?,bulk_validation=?,active=?,sort_order=?,allowed_species=?,allowed_sex=?,required_fiv=?,care_periods=?,updated_at=datetime('now') WHERE id=?", )->execute([ $name, $type, @@ -255,6 +268,10 @@ final class SettingsController isset($row['bulk_validation']) ? 1 : 0, isset($row['active']) ? 1 : 0, max(0, (int) ($row['sort_order'] ?? 100)), + $allowedSpecies, + $allowedSex, + $requiredFiv, + implode(',', $periods), $id, ]); self::syncBoxes($db, $id, (string) ($row['boxes'] ?? '')); @@ -278,14 +295,24 @@ final class SettingsController $color = preg_match('/^#[0-9a-fA-F]{6}$/', (string) ($new['color'] ?? '')) ? (string) $new['color'] : '#6c757d'; + $allowedSpecies = SpeciesService::normalize((string) ($new['allowed_species'] ?? '')); + if (!SpeciesService::exists($allowedSpecies, false)) { + $allowedSpecies = null; + } + $allowedSex = in_array($new['allowed_sex'] ?? '', ['M', 'F'], true) ? (string) $new['allowed_sex'] : null; + $requiredFiv = in_array($new['required_fiv'] ?? '', ['positive', 'negative'], true) + ? (string) $new['required_fiv'] + : 'any'; + $periods = array_values(array_intersect(ShelterRoomService::PERIODS, (array) ($new['care_periods'] ?? []))); + $periods = $periods ?: ['morning', 'evening']; $duplicate = $db->prepare('SELECT 1 FROM shelter_rooms WHERE code=? OR name=? COLLATE NOCASE'); $duplicate->execute([$code, $newName]); if ($duplicate->fetchColumn()) { throw new RuntimeException(t('rooms.duplicate')); } $db->prepare( - 'INSERT INTO shelter_rooms(code,name,room_type,status_code,color,bulk_validation,sort_order) VALUES(?,?,?,?,?,?,?)', - )->execute([$code, $newName, $type, $status, $color, isset($new['bulk_validation']) ? 1 : 0, 100]); + 'INSERT INTO shelter_rooms(code,name,room_type,status_code,color,bulk_validation,sort_order,allowed_species,allowed_sex,required_fiv,care_periods) VALUES(?,?,?,?,?,?,?,?,?,?,?)', + )->execute([$code, $newName, $type, $status, $color, isset($new['bulk_validation']) ? 1 : 0, 100, $allowedSpecies, $allowedSex, $requiredFiv, implode(',', $periods)]); self::syncBoxes($db, (int) $db->lastInsertId(), (string) ($new['boxes'] ?? '')); } $db->commit(); diff --git a/app/Services/Migrations.php b/app/Services/Migrations.php index a2ceb6a..a76a1ff 100644 --- a/app/Services/Migrations.php +++ b/app/Services/Migrations.php @@ -65,14 +65,27 @@ final class Migrations } } + $relaxesForeignKeys = $name === '093_room_rules_and_care_periods.sql'; + if ($relaxesForeignKeys) { + $db->exec('PRAGMA foreign_keys = OFF'); + } $db->beginTransaction(); try { $db->exec($sql); $ins = $db->prepare("INSERT INTO _migrations(name, applied_at) VALUES(:n, datetime('now'))"); $ins->execute([':n' => $name]); + if ($relaxesForeignKeys && $db->query('PRAGMA foreign_key_check')->fetchColumn() !== false) { + throw new RuntimeException('Foreign key check failed after room migration'); + } $db->commit(); + if ($relaxesForeignKeys) { + $db->exec('PRAGMA foreign_keys = ON'); + } } catch (Throwable $e) { $db->rollBack(); + if ($relaxesForeignKeys) { + $db->exec('PRAGMA foreign_keys = ON'); + } throw new RuntimeException('Migration ' . $name . ' : ' . $e->getMessage(), 0, $e); } } diff --git a/app/Services/ShelterRoomService.php b/app/Services/ShelterRoomService.php index 5fb3279..e9f133d 100644 --- a/app/Services/ShelterRoomService.php +++ b/app/Services/ShelterRoomService.php @@ -4,6 +4,8 @@ declare(strict_types=1); final class ShelterRoomService { + public const PERIODS = ['morning', 'noon', 'afternoon', 'evening']; + public static function all(bool $activeOnly = false): array { $sql = @@ -56,4 +58,28 @@ final class ShelterRoomService $value = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value) ?: $value; return trim(preg_replace('/[^a-z0-9]+/', '-', $value) ?? '', '-'); } + + public static function periods(array $room): array + { + $periods = array_values(array_intersect( + self::PERIODS, + explode(',', (string) ($room['care_periods'] ?? 'morning,evening')), + )); + return $periods ?: ['morning', 'evening']; + } + + public static function accepts(array $room, array $animal): bool + { + $species = trim((string) ($room['allowed_species'] ?? '')); + $sex = trim((string) ($room['allowed_sex'] ?? '')); + $fiv = (string) ($room['required_fiv'] ?? 'any'); + if ($species !== '' && SpeciesService::normalize((string) ($animal['species'] ?? '')) !== $species) { + return false; + } + if ($sex !== '' && (string) ($animal['sex'] ?? '') !== $sex) { + return false; + } + $positive = (int) ($animal['fiv'] ?? 0) === 1 || (string) ($animal['fiv_status'] ?? '') === 'positive'; + return $fiv === 'any' || ($fiv === 'positive' ? $positive : !$positive); + } } diff --git a/app/Views/animal_form.php b/app/Views/animal_form.php index bf0d4f8..02203ab 100644 --- a/app/Views/animal_form.php +++ b/app/Views/animal_form.php @@ -140,6 +140,7 @@ $estMonths = $birth_estimated_months > 0 ? ($birth_estimated_months % 12) : ''; diff --git a/app/Views/care_round.php b/app/Views/care_round.php index 244fef4..2a3ff4d 100644 --- a/app/Views/care_round.php +++ b/app/Views/care_round.php @@ -276,12 +276,11 @@ $fieldOptions = [
- t($period === 'morning' ? 'care.morning' : 'care.evening')])) ?> - ☀️ - 🌙 + t('care.' . $period)])) ?> + '☀️','noon' => '🕛','afternoon' => '🌤️','evening' => '🌙'] as $periodCode => $icon): ?> + +
+
+
+
@@ -78,6 +83,10 @@
+
+
+
+
diff --git a/migrations/093_room_rules_and_care_periods.sql b/migrations/093_room_rules_and_care_periods.sql new file mode 100644 index 0000000..650bb9f --- /dev/null +++ b/migrations/093_room_rules_and_care_periods.sql @@ -0,0 +1,19 @@ +ALTER TABLE shelter_rooms ADD COLUMN allowed_species TEXT; +ALTER TABLE shelter_rooms ADD COLUMN allowed_sex TEXT; +ALTER TABLE shelter_rooms ADD COLUMN required_fiv TEXT NOT NULL DEFAULT 'any' CHECK(required_fiv IN ('any','positive','negative')); +ALTER TABLE shelter_rooms ADD COLUMN care_periods TEXT NOT NULL DEFAULT 'morning,evening'; + +CREATE TABLE care_rounds_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + round_date TEXT NOT NULL, + period TEXT NOT NULL CHECK(period IN ('morning','noon','afternoon','evening')), + performed_by TEXT, + started_at TEXT NOT NULL DEFAULT (datetime('now')), + completed_at TEXT, + performed_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + UNIQUE(round_date, period) +); +INSERT INTO care_rounds_new(id,round_date,period,performed_by,started_at,completed_at,performed_by_user_id) +SELECT id,round_date,period,performed_by,started_at,completed_at,performed_by_user_id FROM care_rounds; +DROP TABLE care_rounds; +ALTER TABLE care_rounds_new RENAME TO care_rounds; diff --git a/resources/lang/en.php b/resources/lang/en.php index 2daa378..dff3907 100644 --- a/resources/lang/en.php +++ b/resources/lang/en.php @@ -124,4 +124,5 @@ return [ 'rooms.duplicate'=>'A room with this name or code already exists.','settings.species_duplicate'=>'A species with this name or code already exists.', 'settings.invalid_rna'=>'The RNA number must start with W followed by 9 digits.','settings.invalid_siret'=>'The SIRET number must contain 14 digits and be valid.','rooms.name_too_long'=>'A room name cannot exceed 100 characters.','rooms.box_name_too_long'=>'A box name cannot exceed 100 characters.','animal.name_too_long'=>'An animal name cannot exceed 150 characters.', 'pricing.adoption_fees_help'=>'Indicative amounts shared by all animals, according to their sterilisation status.','rooms.inactive_help'=>'An unchecked room becomes inactive, while its previous assignments remain in the history. Its internal code never changes.', + 'care.noon'=>'noon','care.afternoon'=>'afternoon','rooms.admission_rules'=>'Optional admission criteria','rooms.care_periods'=>'Rounds scheduled for this room','rooms.period_required'=>'Select at least one care-round period per room.','rooms.animal_not_allowed'=>'This animal does not match the admission criteria of the selected room.', ]; diff --git a/resources/lang/fr.php b/resources/lang/fr.php index ca5b3bd..087870f 100644 --- a/resources/lang/fr.php +++ b/resources/lang/fr.php @@ -124,4 +124,5 @@ return [ 'rooms.duplicate'=>'Une salle portant ce nom ou ce code existe déjà.','settings.species_duplicate'=>'Une espèce portant ce nom ou ce code existe déjà.', 'settings.invalid_rna'=>'Le numéro RNA doit commencer par W et être suivi de 9 chiffres.','settings.invalid_siret'=>'Le numéro SIRET doit contenir 14 chiffres et être valide.','rooms.name_too_long'=>'Le nom d’une salle ne peut pas dépasser 100 caractères.','rooms.box_name_too_long'=>'Le nom d’un box ne peut pas dépasser 100 caractères.','animal.name_too_long'=>'Le nom d’un animal ne peut pas dépasser 150 caractères.', 'pricing.adoption_fees_help'=>'Montants indicatifs communs à tous les animaux, selon leur état de stérilisation.','rooms.inactive_help'=>'Une salle décochée devient inactive, mais ses anciennes affectations restent dans l’historique. Son code interne ne change jamais.', + 'care.noon'=>'midi','care.afternoon'=>'après-midi','rooms.admission_rules'=>'Critères d’accueil facultatifs','rooms.care_periods'=>'Tournées prévues dans cette salle','rooms.period_required'=>'Sélectionnez au moins un créneau de tournée par salle.','rooms.animal_not_allowed'=>'Cet animal ne correspond pas aux critères d’accueil de la salle choisie.', ]; diff --git a/tests/run.php b/tests/run.php index ddf6f81..4ba0a86 100644 --- a/tests/run.php +++ b/tests/run.php @@ -106,11 +106,27 @@ try { } $assert($rooms['silver']['name'] === 'Salle collective 1'); $assert($rooms['twix']['name'] === 'Salle collective 2'); + $assert(ShelterRoomService::periods($rooms['silver']) === ['morning', 'evening']); + $assert(ShelterRoomService::accepts($rooms['silver'], ['species' => 'chien', 'sex' => 'F', 'fiv' => 0])); $assert( count($rooms['care']['boxes']) === 5 && count($rooms['quarantine']['boxes']) === 5, 'Les cinq box initiaux ne sont pas disponibles', ); }); + $test('Les tournées acceptent quatre créneaux configurables', function () use ($db, $assert): void { + foreach (ShelterRoomService::PERIODS as $period) { + $db->prepare('INSERT INTO care_rounds(round_date,period) VALUES(?,?)')->execute(['2099-01-01', $period]); + } + $assert((int) $db->query("SELECT COUNT(*) FROM care_rounds WHERE round_date='2099-01-01'")->fetchColumn() === 4); + $db->exec("DELETE FROM care_rounds WHERE round_date='2099-01-01'"); + }); + $test('Les critères d’accueil d’une salle sont appliqués', function () use ($assert): void { + $room = ['allowed_species' => 'chat', 'allowed_sex' => 'M', 'required_fiv' => 'positive']; + $assert(ShelterRoomService::accepts($room, ['species' => 'chat', 'sex' => 'M', 'fiv' => 1])); + $assert(!ShelterRoomService::accepts($room, ['species' => 'chien', 'sex' => 'M', 'fiv' => 1])); + $assert(!ShelterRoomService::accepts($room, ['species' => 'chat', 'sex' => 'F', 'fiv' => 1])); + $assert(!ShelterRoomService::accepts($room, ['species' => 'chat', 'sex' => 'M', 'fiv' => 0])); + }); $test('Les paramètres sont enregistrés et relus', function () use ($assert): void { AppSettings::save(['association_name' => 'Refuge Test', 'app_language' => 'en'], null); $assert(AppSettings::get('association_name') === 'Refuge Test');