Rend les salles et tournées configurables

Refs #10 #21
This commit is contained in:
Alexandre NOEL 2026-09-08 00:33:25 +02:00
commit e7176b6371
17 changed files with 189 additions and 25 deletions

View file

@ -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/). 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 ## [1.0.0-rc.10] — 2026-09-08
### Correction des fiches animales ### Correction des fiches animales

View file

@ -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 ## `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. 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.

View file

@ -1 +1 @@
1.0.0-rc.10 1.0.0-rc.11

View file

@ -617,6 +617,15 @@ trait AnimalRecordActions
$fiv = $fiv ?? (int) ($old['fiv'] ?? 0); $fiv = $fiv ?? (int) ($old['fiv'] ?? 0);
$felv = $felv ?? (int) ($old['felv'] ?? 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; $careBox = in_array($status, ['soin', 'quarantaine'], true) ? $old['care_box_key'] ?? null : null;
if ($isArchivedCorrection) { if ($isArchivedCorrection) {
$refugeRoom = $old['refuge_room'] ?? null; $refugeRoom = $old['refuge_room'] ?? null;

View file

@ -4,10 +4,16 @@ declare(strict_types=1);
final class CareRoundsController 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 public static function index(): void
{ {
$db = DB::pdo(); $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'] ?? '')) $date = preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) ($_GET['date'] ?? ''))
? (string) $_GET['date'] ? (string) $_GET['date']
: date('Y-m-d'); : date('Y-m-d');
@ -65,6 +71,21 @@ final class CareRoundsController
foreach ($rows as $r) { foreach ($rows as $r) {
$unconfigured[(int) $r['animal_id']][] = $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', [ render('care_round.php', [
'title' => t('care.title'), 'title' => t('care.title'),
'pageDescription' => t('care.description'), 'pageDescription' => t('care.description'),
@ -72,7 +93,7 @@ final class CareRoundsController
'date' => $date, 'date' => $date,
'round' => $round, 'round' => $round,
'layouts' => $layouts, 'layouts' => $layouts,
'rooms' => ShelterRoomService::all(true), 'rooms' => $rooms,
'animals' => $animals, 'animals' => $animals,
'treatmentsByAnimal' => $treatments, 'treatmentsByAnimal' => $treatments,
'unconfiguredTreatments' => $unconfigured, 'unconfiguredTreatments' => $unconfigured,
@ -95,10 +116,14 @@ final class CareRoundsController
return; return;
} }
$animalCheck = $db->prepare( $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]); $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); http_response_code(404);
return; return;
} }
@ -255,7 +280,7 @@ final class CareRoundsController
$roundStmt->execute([':id' => $roundId]); $roundStmt->execute([':id' => $roundId]);
$roundInfo = $roundStmt->fetch(); $roundInfo = $roundStmt->fetch();
$roundDate = $roundInfo ? date('d/m/Y', strtotime((string) $roundInfo['round_date'])) : date('d/m/Y'); $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( $history = $db->prepare(
"INSERT INTO animal_history(animal_id,type,label,details,user_id) VALUES(:animal,'care_round',:label,:details,:user)", "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; throw $e;
} }
$period = ($_POST['period'] ?? 'morning') === 'evening' ? 'evening' : 'morning'; $period = self::period($_POST['period'] ?? 'morning');
$date = (string) ($_POST['date'] ?? date('Y-m-d')); $date = (string) ($_POST['date'] ?? date('Y-m-d'));
header('Location: /care-round?period=' . $period . '&date=' . rawurlencode($date) . '#animal-' . $animalId); header('Location: /care-round?period=' . $period . '&date=' . rawurlencode($date) . '#animal-' . $animalId);
exit(); exit();
@ -293,7 +318,8 @@ final class CareRoundsController
$roundId = self::ensureRound($db); $roundId = self::ensureRound($db);
$room = (string) ($_POST['room'] ?? ''); $room = (string) ($_POST['room'] ?? '');
$rooms = ShelterRoomService::byCode(true); $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); http_response_code(400);
return; 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'] ?? '')) $date = preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) ($_POST['date'] ?? ''))
? (string) $_POST['date'] ? (string) $_POST['date']
: date('Y-m-d'); : date('Y-m-d');
@ -329,7 +354,7 @@ final class CareRoundsController
} }
$animals = $db $animals = $db
->query( ->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(); ->fetchAll();
render('care_setup.php', [ render('care_setup.php', [
@ -384,6 +409,11 @@ final class CareRoundsController
continue; continue;
} }
$room = $rooms[$roomCode]; $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( $allowedBoxes = array_column(
array_filter($room['boxes'], static fn($box) => (int) $box['active'] === 1), array_filter($room['boxes'], static fn($box) => (int) $box['active'] === 1),
'code', 'code',
@ -434,7 +464,7 @@ final class CareRoundsController
DB::pdo() DB::pdo()
->prepare('UPDATE treatments SET give_morning=?,give_evening=?,give_as_needed=? WHERE id=?') ->prepare('UPDATE treatments SET give_morning=?,give_evening=?,give_as_needed=? WHERE id=?')
->execute([$values[0], $values[1], $values[2], $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); header('Location: /care-round?period=' . $period);
exit(); exit();
} }
@ -444,7 +474,7 @@ final class CareRoundsController
$date = preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) ($_POST['date'] ?? '')) $date = preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) ($_POST['date'] ?? ''))
? (string) $_POST['date'] ? (string) $_POST['date']
: date('Y-m-d'); : date('Y-m-d');
$period = ($_POST['period'] ?? 'morning') === 'evening' ? 'evening' : 'morning'; $period = self::period($_POST['period'] ?? 'morning');
$requested = (int) ($_POST['round_id'] ?? 0); $requested = (int) ($_POST['round_id'] ?? 0);
if ($requested) { if ($requested) {
$s = $db->prepare('SELECT id FROM care_rounds WHERE id=? AND round_date=? AND period=?'); $s = $db->prepare('SELECT id FROM care_rounds WHERE id=? AND round_date=? AND period=?');

View file

@ -215,6 +215,7 @@ final class SettingsController
render('settings_rooms.php', [ render('settings_rooms.php', [
'title' => t('rooms.title'), 'title' => t('rooms.title'),
'rooms' => ShelterRoomService::all(), 'rooms' => ShelterRoomService::all(),
'species' => SpeciesService::all(),
'saved' => isset($_GET['saved']), 'saved' => isset($_GET['saved']),
'error' => (string) ($_GET['error'] ?? ''), 'error' => (string) ($_GET['error'] ?? ''),
]); ]);
@ -236,6 +237,18 @@ final class SettingsController
$type = (string) ($row['room_type'] ?? 'collective'); $type = (string) ($row['room_type'] ?? 'collective');
$status = (string) ($row['status_code'] ?? 'refuge'); $status = (string) ($row['status_code'] ?? 'refuge');
$color = (string) ($row['color'] ?? '#6c757d'); $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 ( if (
$name === '' || $name === '' ||
mb_strlen($name) > 100 || mb_strlen($name) > 100 ||
@ -246,7 +259,7 @@ final class SettingsController
throw new RuntimeException(t('rooms.invalid')); throw new RuntimeException(t('rooms.invalid'));
} }
$db->prepare( $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([ )->execute([
$name, $name,
$type, $type,
@ -255,6 +268,10 @@ final class SettingsController
isset($row['bulk_validation']) ? 1 : 0, isset($row['bulk_validation']) ? 1 : 0,
isset($row['active']) ? 1 : 0, isset($row['active']) ? 1 : 0,
max(0, (int) ($row['sort_order'] ?? 100)), max(0, (int) ($row['sort_order'] ?? 100)),
$allowedSpecies,
$allowedSex,
$requiredFiv,
implode(',', $periods),
$id, $id,
]); ]);
self::syncBoxes($db, $id, (string) ($row['boxes'] ?? '')); 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'] ?? '')) $color = preg_match('/^#[0-9a-fA-F]{6}$/', (string) ($new['color'] ?? ''))
? (string) $new['color'] ? (string) $new['color']
: '#6c757d'; : '#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 = $db->prepare('SELECT 1 FROM shelter_rooms WHERE code=? OR name=? COLLATE NOCASE');
$duplicate->execute([$code, $newName]); $duplicate->execute([$code, $newName]);
if ($duplicate->fetchColumn()) { if ($duplicate->fetchColumn()) {
throw new RuntimeException(t('rooms.duplicate')); throw new RuntimeException(t('rooms.duplicate'));
} }
$db->prepare( $db->prepare(
'INSERT INTO shelter_rooms(code,name,room_type,status_code,color,bulk_validation,sort_order) VALUES(?,?,?,?,?,?,?)', '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]); )->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'] ?? '')); self::syncBoxes($db, (int) $db->lastInsertId(), (string) ($new['boxes'] ?? ''));
} }
$db->commit(); $db->commit();

View file

@ -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(); $db->beginTransaction();
try { try {
$db->exec($sql); $db->exec($sql);
$ins = $db->prepare("INSERT INTO _migrations(name, applied_at) VALUES(:n, datetime('now'))"); $ins = $db->prepare("INSERT INTO _migrations(name, applied_at) VALUES(:n, datetime('now'))");
$ins->execute([':n' => $name]); $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(); $db->commit();
if ($relaxesForeignKeys) {
$db->exec('PRAGMA foreign_keys = ON');
}
} catch (Throwable $e) { } catch (Throwable $e) {
$db->rollBack(); $db->rollBack();
if ($relaxesForeignKeys) {
$db->exec('PRAGMA foreign_keys = ON');
}
throw new RuntimeException('Migration ' . $name . ' : ' . $e->getMessage(), 0, $e); throw new RuntimeException('Migration ' . $name . ' : ' . $e->getMessage(), 0, $e);
} }
} }

View file

@ -4,6 +4,8 @@ declare(strict_types=1);
final class ShelterRoomService final class ShelterRoomService
{ {
public const PERIODS = ['morning', 'noon', 'afternoon', 'evening'];
public static function all(bool $activeOnly = false): array public static function all(bool $activeOnly = false): array
{ {
$sql = $sql =
@ -56,4 +58,28 @@ final class ShelterRoomService
$value = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value) ?: $value; $value = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value) ?: $value;
return trim(preg_replace('/[^a-z0-9]+/', '-', $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);
}
} }

View file

@ -140,6 +140,7 @@ $estMonths = $birth_estimated_months > 0 ? ($birth_estimated_months % 12) : '';
<?php foreach (($shelterRooms ?? []) as $room): ?><option <?php foreach (($shelterRooms ?? []) as $room): ?><option
value="<?= h($room['code']) ?>" value="<?= h($room['code']) ?>"
data-status="<?= h($room['status_code']) ?>" data-status="<?= h($room['status_code']) ?>"
<?= $isEdit && !ShelterRoomService::accepts($room, $animal) ? 'disabled' : '' ?>
<?= (($animal['refuge_room'] ?? '') === $room['code']) ? 'selected' : '' ?>> <?= (($animal['refuge_room'] ?? '') === $room['code']) ? 'selected' : '' ?>>
<?= h($room['name']) ?></option><?php endforeach; ?> <?= h($room['name']) ?></option><?php endforeach; ?>
</select> </select>

View file

@ -276,12 +276,11 @@ $fieldOptions = [
<div class="care-mode"> <div class="care-mode">
<div class="card mb-3"> <div class="card mb-3">
<div class="card-body d-flex align-items-center gap-2 care-round-toolbar"> <div class="card-body d-flex align-items-center gap-2 care-round-toolbar">
<strong <strong class="me-2"><?= h(t('care.round_of', ['period' => t('care.' . $period)])) ?></strong>
class="me-2"><?= h(t('care.round_of', ['period' => t($period === 'morning' ? 'care.morning' : 'care.evening')])) ?></strong> <?php foreach (['morning' => '☀️','noon' => '🕛','afternoon' => '🌤️','evening' => '🌙'] as $periodCode => $icon): ?>
<a class="btn <?= $period === 'morning' ? 'btn-primary' : 'btn-outline-primary' ?>" <a class="btn <?= $period === $periodCode ? 'btn-primary' : 'btn-outline-primary' ?>"
href="/care-round?period=morning&date=<?= h($date) ?>">☀️ <?= h(t('care.morning')) ?></a> href="/care-round?period=<?= h($periodCode) ?>&date=<?= h($date) ?>"><?= $icon ?> <?= h(t('care.' . $periodCode)) ?></a>
<a class="btn <?= $period === 'evening' ? 'btn-primary' : 'btn-outline-primary' ?>" <?php endforeach; ?>
href="/care-round?period=evening&date=<?= h($date) ?>">🌙 <?= h(t('care.evening')) ?></a>
<div class="care-round-actions"><input class="form-control" type="date" value="<?= h($date) ?>" <div class="care-round-actions"><input class="form-control" type="date" value="<?= h($date) ?>"
onchange="location.href='/care-round?period=<?= h($period) ?>&date='+this.value"> onchange="location.href='/care-round?period=<?= h($period) ?>&date='+this.value">
<button class="btn btn-outline-secondary" type="button" <button class="btn btn-outline-secondary" type="button"

View file

@ -66,6 +66,7 @@ foreach ($rooms as $room) {
<td><select class="form-select care-room-select" name="room[<?= (int)$animal['id'] ?>]" <td><select class="form-select care-room-select" name="room[<?= (int)$animal['id'] ?>]"
data-animal="<?= (int)$animal['id'] ?>"><?php foreach ($rooms as $room): ?><option data-animal="<?= (int)$animal['id'] ?>"><?php foreach ($rooms as $room): ?><option
value="<?= h($room['code']) ?>" value="<?= h($room['code']) ?>"
<?= !ShelterRoomService::accepts($room, $animal) ? 'disabled' : '' ?>
<?= $currentRoom === $room['code'] ? 'selected' : '' ?>><?= h($room['name']) ?> <?= $currentRoom === $room['code'] ? 'selected' : '' ?>><?= h($room['name']) ?>
</option><?php endforeach; ?></select></td> </option><?php endforeach; ?></select></td>
<td><select class="form-select care-box-select" name="box[<?= (int)$animal['id'] ?>]" <td><select class="form-select care-box-select" name="box[<?= (int)$animal['id'] ?>]"

View file

@ -31,10 +31,9 @@
<div class="col-lg-6"> <div class="col-lg-6">
<section class="card h-100"> <section class="card h-100">
<div class="card-body"> <div class="card-body">
<span class="badge bg-blue-lt mb-2">1.0.0-rc.10</span> <span class="badge bg-blue-lt mb-2">1.0.0-rc.11</span>
<h3>Fiabilité des fiches animales</h3> <h3>Salles et tournées configurables</h3>
<p class="text-muted">Enregistrement du statut rétabli, codes internes stabilisés et noms accentués <p class="text-muted">Critères d’accueil par salle et créneaux de soins adaptés à l’organisation du refuge.</p>
correctement translittérés.</p>
</div> </div>
</section> </section>
</div> </div>

View file

@ -47,6 +47,11 @@
name="rooms[<?= (int)$room['id'] ?>][bulk_validation]" name="rooms[<?= (int)$room['id'] ?>][bulk_validation]"
<?= $room['bulk_validation'] ? 'checked' : '' ?>><span <?= $room['bulk_validation'] ? 'checked' : '' ?>><span
class="form-check-label"><?= h(t('rooms.bulk_validation')) ?></span></label></div> class="form-check-label"><?= h(t('rooms.bulk_validation')) ?></span></label></div>
<div class="col-12"><hr><div class="form-label"><?= h(t('rooms.admission_rules')) ?></div></div>
<div class="col-md-4"><label class="form-label"><?= h(t('animal.species')) ?></label><select class="form-select" name="rooms[<?= (int)$room['id'] ?>][allowed_species]"><option value=""><?= h(t('common.all')) ?></option><?php foreach ($species as $option): ?><option value="<?= h($option['code']) ?>" <?= ($room['allowed_species'] ?? '') === $option['code'] ? 'selected' : '' ?>><?= h($option['name']) ?></option><?php endforeach; ?></select></div>
<div class="col-md-4"><label class="form-label"><?= h(t('animal.sex')) ?></label><select class="form-select" name="rooms[<?= (int)$room['id'] ?>][allowed_sex]"><option value=""><?= h(t('common.all')) ?></option><option value="M" <?= ($room['allowed_sex'] ?? '') === 'M' ? 'selected' : '' ?>><?= h(t('sex.male')) ?></option><option value="F" <?= ($room['allowed_sex'] ?? '') === 'F' ? 'selected' : '' ?>><?= h(t('sex.female')) ?></option></select></div>
<div class="col-md-4"><label class="form-label">FIV</label><select class="form-select" name="rooms[<?= (int)$room['id'] ?>][required_fiv]"><option value="any"><?= h(t('common.all')) ?></option><option value="positive" <?= ($room['required_fiv'] ?? 'any') === 'positive' ? 'selected' : '' ?>><?= h(t('animals.test_positive')) ?></option><option value="negative" <?= ($room['required_fiv'] ?? 'any') === 'negative' ? 'selected' : '' ?>><?= h(t('animals.test_negative')) ?></option></select></div>
<div class="col-12"><label class="form-label"><?= h(t('rooms.care_periods')) ?></label><div class="d-flex flex-wrap gap-3"><?php foreach (ShelterRoomService::PERIODS as $period): ?><label class="form-check"><input class="form-check-input" type="checkbox" name="rooms[<?= (int)$room['id'] ?>][care_periods][]" value="<?= h($period) ?>" <?= in_array($period, ShelterRoomService::periods($room), true) ? 'checked' : '' ?>><span class="form-check-label"><?= h(t('care.' . $period)) ?></span></label><?php endforeach; ?></div></div>
</div> </div>
</div> </div>
</div><?php endforeach; ?> </div><?php endforeach; ?>
@ -78,6 +83,10 @@
<div class="col-12"><label class="form-check"><input class="form-check-input" type="checkbox" <div class="col-12"><label class="form-check"><input class="form-check-input" type="checkbox"
name="new_room[bulk_validation]"><span name="new_room[bulk_validation]"><span
class="form-check-label"><?= h(t('rooms.bulk_validation')) ?></span></label></div> class="form-check-label"><?= h(t('rooms.bulk_validation')) ?></span></label></div>
<div class="col-md-4"><label class="form-label"><?= h(t('animal.species')) ?></label><select class="form-select" name="new_room[allowed_species]"><option value=""><?= h(t('common.all')) ?></option><?php foreach ($species as $option): ?><option value="<?= h($option['code']) ?>"><?= h($option['name']) ?></option><?php endforeach; ?></select></div>
<div class="col-md-4"><label class="form-label"><?= h(t('animal.sex')) ?></label><select class="form-select" name="new_room[allowed_sex]"><option value=""><?= h(t('common.all')) ?></option><option value="M"><?= h(t('sex.male')) ?></option><option value="F"><?= h(t('sex.female')) ?></option></select></div>
<div class="col-md-4"><label class="form-label">FIV</label><select class="form-select" name="new_room[required_fiv]"><option value="any"><?= h(t('common.all')) ?></option><option value="positive"><?= h(t('animals.test_positive')) ?></option><option value="negative"><?= h(t('animals.test_negative')) ?></option></select></div>
<div class="col-12"><label class="form-label"><?= h(t('rooms.care_periods')) ?></label><div class="d-flex flex-wrap gap-3"><?php foreach (ShelterRoomService::PERIODS as $period): ?><label class="form-check"><input class="form-check-input" type="checkbox" name="new_room[care_periods][]" value="<?= h($period) ?>" <?= in_array($period, ['morning','evening'], true) ? 'checked' : '' ?>><span class="form-check-label"><?= h(t('care.' . $period)) ?></span></label><?php endforeach; ?></div></div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -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;

View file

@ -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.', '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.', '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.', '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.',
]; ];

View file

@ -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à.', '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.', '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.', '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.',
]; ];

View file

@ -106,11 +106,27 @@ try {
} }
$assert($rooms['silver']['name'] === 'Salle collective 1'); $assert($rooms['silver']['name'] === 'Salle collective 1');
$assert($rooms['twix']['name'] === 'Salle collective 2'); $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( $assert(
count($rooms['care']['boxes']) === 5 && count($rooms['quarantine']['boxes']) === 5, count($rooms['care']['boxes']) === 5 && count($rooms['quarantine']['boxes']) === 5,
'Les cinq box initiaux ne sont pas disponibles', '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 { $test('Les paramètres sont enregistrés et relus', function () use ($assert): void {
AppSettings::save(['association_name' => 'Refuge Test', 'app_language' => 'en'], null); AppSettings::save(['association_name' => 'Refuge Test', 'app_language' => 'en'], null);
$assert(AppSettings::get('association_name') === 'Refuge Test'); $assert(AppSettings::get('association_name') === 'Refuge Test');