feat: permettre la modification des portées

This commit is contained in:
Alexandre NOEL 2026-09-15 18:48:25 +02:00
commit f45cfc40f2
15 changed files with 218 additions and 14 deletions

View file

@ -2,6 +2,16 @@
Toutes les modifications importantes du projet sont consignées ici. Le format s’inspire de [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le projet suit le [versionnage sémantique](https://semver.org/lang/fr/).
## [1.0.0-rc.25] — 2026-09-15
### Modification des portées
- ajout d’un écran permettant de corriger les parents et la date de naissance d’une portée ;
- ajout de nouvelles fiches ou de chats existants à une portée déjà enregistrée ;
- accès direct à la modification de chaque chaton depuis la portée ;
- possibilité de détacher un chaton sans supprimer sa fiche ni aucune de ses données ;
- contrôles empêchant de rattacher un parent comme chaton de la même portée.
## [1.0.0-rc.24] — 2026-09-15
### Permissions et bases historiques

View file

@ -10,6 +10,10 @@ L’import depuis Animal Shelter Manager a été conçu comme un parcours pruden
Avant la version 1.0, le projet a fait l’objet d’un nettoyage structurel et d’un découpage de ses fichiers les plus volumineux. La recherche universelle, les salles configurables, les traductions française et anglaise et une suite de contrôles isolés ont complété cette phase de stabilisation.
## `1.0.0-rc.25` — Portées modifiables
Les portées ne sont plus figées après leur création : parents et date peuvent être corrigés, tandis que les chatons peuvent être ajoutés, modifiés ou détachés sans perte de leur fiche.
## `1.0.0-rc.24` — Permissions durables
Séparation plus stricte entre les données privées et les médias publics afin que les réparations de sécurité et les mises à jour conservent l’accès de Nginx aux images et aux ressources de l’interface. Les deux structures d’historique rencontrées sur les installations anciennes sont également réunifiées pour sécuriser les portées et les autres écritures métier.

View file

@ -200,7 +200,7 @@ L’option `--apply` ne doit être utilisée qu’après lecture des rapports. L
## État du projet
La candidate actuelle est `1.0.0-rc.24`. La branche 1.0 est gelée fonctionnellement : seuls les défauts bloquants découverts pendant les validations terrain sont corrigés avant `1.0.0`.
La candidate actuelle est `1.0.0-rc.25`. La branche 1.0 est gelée fonctionnellement : seuls les défauts bloquants découverts pendant les validations terrain sont corrigés avant `1.0.0`.
Le contrôle de stabilisation est disponible dans [docs/AUDIT-1.0.md](docs/AUDIT-1.0.md).

View file

@ -1 +1 @@
1.0.0-rc.24
1.0.0-rc.25

View file

@ -8,8 +8,10 @@ trait AnimalRelationshipActions
{
$db = DB::pdo();
$sourceAnimalId = (int) ($_GET['animal_id'] ?? 0);
$existingLitterId = (int) ($_GET['litter_id'] ?? 0);
$motherId = 0;
$fatherId = 0;
$birthDate = '';
if ($sourceAnimalId > 0) {
$stmt = $db->prepare(
@ -30,6 +32,20 @@ trait AnimalRelationshipActions
}
}
if ($existingLitterId > 0) {
$stmt = $db->prepare('SELECT mother_id,father_id,birth_date FROM litters WHERE id=:id');
$stmt->execute([':id' => $existingLitterId]);
$existingLitter = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$existingLitter) {
http_response_code(404);
echo h(t('litter.not_found'));
return;
}
$motherId = (int) ($existingLitter['mother_id'] ?? 0);
$fatherId = (int) ($existingLitter['father_id'] ?? 0);
$birthDate = (string) ($existingLitter['birth_date'] ?? '');
}
$eligibleSql = "
SELECT id, name FROM animals
WHERE deleted_at IS NULL AND archived_at IS NULL AND lower(species) IN ('chat', 'cat') AND sex = :sex
@ -53,6 +69,8 @@ trait AnimalRelationshipActions
'sourceAnimalId' => $sourceAnimalId,
'motherId' => $motherId,
'fatherId' => $fatherId,
'birthDate' => $birthDate,
'existingLitterId' => $existingLitterId,
'mothers' => $mothers,
'fathers' => $fathers,
'existingKittens' => $existingKittens,
@ -66,6 +84,7 @@ trait AnimalRelationshipActions
return;
}
$db = DB::pdo();
$existingLitterId = (int) ($_POST['existing_litter_id'] ?? 0);
$motherId = (int) ($_POST['mother_id'] ?? 0) ?: null;
$fatherId = (int) ($_POST['father_id'] ?? 0) ?: null;
$birthDate = trim((string) ($_POST['birth_date'] ?? '')) ?: null;
@ -141,11 +160,22 @@ trait AnimalRelationshipActions
$allowedSexes = ['F', 'M', 'U'];
$db->beginTransaction();
try {
$stmt = $db->prepare(
'INSERT INTO litters(mother_id, father_id, birth_date) VALUES(:mother, :father, :birth)',
);
$stmt->execute([':mother' => $motherId, ':father' => $fatherId, ':birth' => $birthDate]);
$litterId = (int) $db->lastInsertId();
if ($existingLitterId > 0) {
$stmt = $db->prepare('SELECT id,mother_id,father_id,birth_date FROM litters WHERE id=:id');
$stmt->execute([':id' => $existingLitterId]);
$existingLitter = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$existingLitter) {
throw new RuntimeException(t('litter.not_found'));
}
$litterId = (int) $existingLitter['id'];
$motherId = (int) ($existingLitter['mother_id'] ?? 0) ?: null;
$fatherId = (int) ($existingLitter['father_id'] ?? 0) ?: null;
$birthDate = (string) ($existingLitter['birth_date'] ?? '') ?: null;
} else {
$stmt = $db->prepare('INSERT INTO litters(mother_id, father_id, birth_date) VALUES(:mother, :father, :birth)');
$stmt->execute([':mother' => $motherId, ':father' => $fatherId, ':birth' => $birthDate]);
$litterId = (int) $db->lastInsertId();
}
$insertAnimal = $db->prepare("
INSERT INTO animals(name, internal_code, status, species, sex, breed, color, birth_date, birth_is_estimated,intake_date,intake_type,intake_reason, created_at, updated_at)
VALUES(:name, :code, 'refuge', 'chat', :sex, 'Chat Européen', :color, :birth, 0,:birth,'born_in_care','birth', datetime('now'), datetime('now'))
@ -153,11 +183,14 @@ trait AnimalRelationshipActions
$link = $db->prepare(
'INSERT INTO litter_kittens(litter_id, animal_id, position) VALUES(:litter, :animal, :position)',
);
$positionBase = $existingLitterId > 0
? (int) $db->query('SELECT COALESCE(MAX(position),0) FROM litter_kittens WHERE litter_id=' . $litterId)->fetchColumn()
: 0;
foreach ($kittens as $index => $kitten) {
if (!is_array($kitten)) {
continue;
}
$position = $index + 1;
$position = $positionBase + $index + 1;
$name = trim((string) ($kitten['name'] ?? ''));
if ($name === '') {
$name = $baseName . '-Bébé-' . $position;
@ -198,7 +231,7 @@ trait AnimalRelationshipActions
);
self::logHistory($animalId, 'create', 'Chaton ajouté avec sa portée', 'Portée n°' . $litterId);
}
$position = count($kittens);
$position = $positionBase + count($kittens);
foreach ($existingKittenIds as $animalId) {
$link->execute([':litter' => $litterId, ':animal' => $animalId, ':position' => ++$position]);
self::logHistory($animalId, 'update', 'Rattaché à une nouvelle portée', 'Portée n°' . $litterId);
@ -214,6 +247,113 @@ trait AnimalRelationshipActions
}
}
public static function litterEditForm(): void
{
$db = DB::pdo();
$litterId = (int) ($_GET['id'] ?? 0);
$sourceAnimalId = (int) ($_GET['animal_id'] ?? 0);
$stmt = $db->prepare('SELECT l.*,m.name mother_name,f.name father_name FROM litters l LEFT JOIN animals m ON m.id=l.mother_id LEFT JOIN animals f ON f.id=l.father_id WHERE l.id=:id');
$stmt->execute([':id' => $litterId]);
$litter = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$litter) {
http_response_code(404);
echo h(t('litter.not_found'));
return;
}
$parents = $db->query("SELECT id,name,sex FROM animals WHERE deleted_at IS NULL AND lower(species) IN ('chat','cat') ORDER BY name COLLATE NOCASE")->fetchAll(PDO::FETCH_ASSOC);
$stmt = $db->prepare('SELECT a.id,a.name,a.internal_code,a.sex,a.color,a.status,a.archived_at FROM litter_kittens lk JOIN animals a ON a.id=lk.animal_id WHERE lk.litter_id=:id ORDER BY lk.position,a.name COLLATE NOCASE');
$stmt->execute([':id' => $litterId]);
render('litter_edit.php', [
'title' => t('litter.edit'),
'pageDescription' => t('litter.edit_help'),
'litter' => $litter,
'mothers' => array_values(array_filter($parents, static fn(array $animal): bool => $animal['sex'] === 'F')),
'fathers' => array_values(array_filter($parents, static fn(array $animal): bool => $animal['sex'] === 'M')),
'kittens' => $stmt->fetchAll(PDO::FETCH_ASSOC),
'sourceAnimalId' => $sourceAnimalId,
]);
}
public static function updateLitter(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
return;
}
$db = DB::pdo();
$litterId = (int) ($_POST['litter_id'] ?? 0);
$sourceAnimalId = (int) ($_POST['source_animal_id'] ?? 0);
$motherId = (int) ($_POST['mother_id'] ?? 0) ?: null;
$fatherId = (int) ($_POST['father_id'] ?? 0) ?: null;
$birthDate = trim((string) ($_POST['birth_date'] ?? '')) ?: null;
if ($litterId <= 0 || ($motherId !== null && $motherId === $fatherId)) {
http_response_code(400);
echo h(t('litter.invalid_parent'));
return;
}
if ($birthDate !== null && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $birthDate)) {
http_response_code(400);
echo h(t('error.invalid_birth_date'));
return;
}
$ids = array_values(array_filter([$motherId, $fatherId]));
if ($ids) {
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$stmt = $db->prepare("SELECT id,sex FROM animals WHERE id IN ($placeholders) AND deleted_at IS NULL AND lower(species) IN ('chat','cat')");
$stmt->execute($ids);
$valid = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $animal) {
$valid[(int) $animal['id']] = $animal['sex'];
}
if (($motherId && ($valid[$motherId] ?? '') !== 'F') || ($fatherId && ($valid[$fatherId] ?? '') !== 'M')) {
http_response_code(400);
echo h(t('litter.invalid_parent'));
return;
}
$stmt = $db->prepare('SELECT COUNT(*) FROM litter_kittens WHERE litter_id=? AND animal_id IN (' . $placeholders . ')');
$stmt->execute([$litterId, ...$ids]);
if ((int) $stmt->fetchColumn() > 0) {
http_response_code(400);
echo h(t('litter.parent_is_kitten'));
return;
}
}
$exists = $db->prepare('SELECT 1 FROM litters WHERE id=:id');
$exists->execute([':id' => $litterId]);
if (!$exists->fetchColumn()) {
http_response_code(404);
return;
}
$db->prepare('UPDATE litters SET mother_id=:mother,father_id=:father,birth_date=:birth WHERE id=:id')->execute([':mother' => $motherId, ':father' => $fatherId, ':birth' => $birthDate, ':id' => $litterId]);
header('Location: /animal?id=' . ($sourceAnimalId ?: ($motherId ?: ($fatherId ?: 0))) . '#tab-litters');
exit();
}
public static function detachLitterKitten(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
return;
}
$litterId = (int) ($_POST['litter_id'] ?? 0);
$animalId = (int) ($_POST['animal_id'] ?? 0);
$sourceAnimalId = (int) ($_POST['source_animal_id'] ?? 0);
$stmt = DB::pdo()->prepare('DELETE FROM litter_kittens WHERE litter_id=:litter AND animal_id=:animal');
$stmt->execute([':litter' => $litterId, ':animal' => $animalId]);
if ($stmt->rowCount() > 0) {
self::logHistory($animalId, 'update', 'Retiré d’une portée', 'Ancienne portée n°' . $litterId);
}
$remaining = DB::pdo()->prepare('SELECT COUNT(*) FROM litter_kittens WHERE litter_id=:id');
$remaining->execute([':id' => $litterId]);
if ((int) $remaining->fetchColumn() === 0) {
DB::pdo()->prepare('DELETE FROM litters WHERE id=:id')->execute([':id' => $litterId]);
header('Location: /animal?id=' . ($sourceAnimalId ?: $animalId) . '#tab-litters');
exit();
}
header('Location: /litter/edit?id=' . $litterId . '&animal_id=' . $sourceAnimalId);
exit();
}
public static function litterAddExistingForm(): void
{
$db = DB::pdo();

View file

@ -189,6 +189,15 @@ switch ($path) {
case '/litter/save':
AnimalsController::saveLitter();
break;
case '/litter/edit':
AnimalsController::litterEditForm();
break;
case '/litter/update':
AnimalsController::updateLitter();
break;
case '/litter/kitten/detach':
AnimalsController::detachLitterKitten();
break;
case '/litter/add-existing':
AnimalsController::litterAddExistingForm();
break;

View file

@ -211,6 +211,9 @@ final class PermissionService
'/animal/death',
'/animal/surgery/new',
'/litter/new',
'/litter/edit',
'/litter/update',
'/litter/kitten/detach',
'/litter/add-existing',
'/litter/add-existing/save',
'/bonded/new',

View file

@ -39,7 +39,8 @@
<?= $litter['father_id'] ? '<a href="/animal?id=' . (int)$litter['father_id'] . '">' . h($litter['father_name']) . '</a>' : h(t('animal.unknown_male')) ?>
</div>
</div>
<a class="btn btn-outline-primary btn-sm ms-auto"
<a class="btn btn-outline-primary btn-sm ms-auto" href="/litter/edit?id=<?= (int)$litter['id'] ?>&amp;animal_id=<?= (int)$animal['id'] ?>">✏️ <?= h(t('common.edit')) ?></a>
<a class="btn btn-outline-primary btn-sm"
href="/litter/add-existing?litter_id=<?= (int)$litter['id'] ?>&amp;animal_id=<?= (int)$animal['id'] ?>">+
<?= h(t('animal.add_existing_cat')) ?></a>
</div>

View file

@ -31,8 +31,8 @@
<div class="col-lg-6">
<section class="card h-100">
<div class="card-body">
<span class="badge bg-blue-lt mb-2">1.0.0-rc.24</span>
<h3>Fiabilité des portées</h3>
<span class="badge bg-blue-lt mb-2">1.0.0-rc.25</span>
<h3>Portées modifiables et fiables</h3>
<p class="text-muted">La création d’une fratrie ne dépend plus d’un ancien traitement de photo et
son historique reste compatible avec les bases existantes.</p>
</div>

View file

@ -271,7 +271,7 @@ if ($pageDescription === '') {
if (path.startsWith('/admin') || path.startsWith('/documents')) return 'administrative';
return null;
};
const permissionEditPages = new Set(['/animal/new', '/animal/edit', '/animal/death', '/litter/new',
const permissionEditPages = new Set(['/animal/new', '/animal/edit', '/animal/death', '/litter/new', '/litter/edit', '/litter/update', '/litter/kitten/detach',
'/litter/add-existing', '/litter/add-existing/save', '/bonded/new', '/directory/new',
'/directory/edit', '/care-round/setup'
]);

13
app/Views/litter_edit.php Normal file
View file

@ -0,0 +1,13 @@
<?php $returnId = $sourceAnimalId ?: ((int)($litter['mother_id'] ?? 0) ?: (int)($litter['father_id'] ?? 0)); ?>
<form method="post" action="/litter/update" class="card mb-3">
<input type="hidden" name="csrf" value="<?= h(Auth::csrf()) ?>"><input type="hidden" name="litter_id" value="<?= (int)$litter['id'] ?>"><input type="hidden" name="source_animal_id" value="<?= (int)$returnId ?>">
<div class="card-header"><div><h3 class="card-title mb-1"><?= h(t('litter.edit')) ?></h3><div class="text-muted small"><?= h(t('litter.edit_help')) ?></div></div><a class="btn btn-ghost-secondary ms-auto" href="/animal?id=<?= (int)$returnId ?>#tab-litters"><?= h(t('common.cancel')) ?></a></div>
<div class="card-body"><div class="row g-3">
<div class="col-md-4"><label class="form-label"><?= h(t('litter.mother')) ?></label><select class="form-select" name="mother_id"><option value=""><?= h(t('litter.unknown_parent')) ?></option><?php foreach ($mothers as $parent): ?><option value="<?= (int)$parent['id'] ?>" <?= (int)$litter['mother_id'] === (int)$parent['id'] ? 'selected' : '' ?>><?= h($parent['name']) ?></option><?php endforeach; ?></select></div>
<div class="col-md-4"><label class="form-label"><?= h(t('litter.father')) ?></label><select class="form-select" name="father_id"><option value=""><?= h(t('litter.unknown_parent')) ?></option><?php foreach ($fathers as $parent): ?><option value="<?= (int)$parent['id'] ?>" <?= (int)$litter['father_id'] === (int)$parent['id'] ? 'selected' : '' ?>><?= h($parent['name']) ?></option><?php endforeach; ?></select></div>
<div class="col-md-4"><label class="form-label"><?= h(t('litter.birth_date')) ?></label><input class="form-control" type="date" name="birth_date" value="<?= h($litter['birth_date'] ?? '') ?>"></div>
</div></div><div class="card-footer text-end"><button class="btn btn-primary" type="submit"><?= h(t('common.save')) ?></button></div>
</form>
<div class="card"><div class="card-header"><h3 class="card-title"><?= h(t('litter.kittens')) ?></h3><div class="ms-auto d-flex gap-2"><a class="btn btn-outline-primary btn-sm" href="/litter/new?litter_id=<?= (int)$litter['id'] ?>&amp;animal_id=<?= (int)$returnId ?>">+ <?= h(t('litter.new_kittens')) ?></a><a class="btn btn-outline-primary btn-sm" href="/litter/add-existing?litter_id=<?= (int)$litter['id'] ?>&amp;animal_id=<?= (int)$returnId ?>">+ <?= h(t('animal.add_existing_cat')) ?></a></div></div><div class="list-group list-group-flush">
<?php foreach ($kittens as $kitten): ?><div class="list-group-item d-flex align-items-center gap-2"><span class="coat-swatch" style="<?= h(coat_swatch_style($kitten['color'] ?? '')) ?>"></span><strong><?= h($kitten['name']) ?></strong><span class="text-muted small"><?= h($kitten['internal_code']) ?></span><a class="btn btn-outline-primary btn-sm ms-auto" href="/animal/edit?id=<?= (int)$kitten['id'] ?>"><?= h(t('common.edit')) ?></a><form method="post" action="/litter/kitten/detach" onsubmit="return confirm(<?= h(json_encode(t('litter.detach_confirm'), JSON_UNESCAPED_UNICODE)) ?>)"><input type="hidden" name="csrf" value="<?= h(Auth::csrf()) ?>"><input type="hidden" name="litter_id" value="<?= (int)$litter['id'] ?>"><input type="hidden" name="animal_id" value="<?= (int)$kitten['id'] ?>"><input type="hidden" name="source_animal_id" value="<?= (int)$returnId ?>"><button class="btn btn-outline-danger btn-sm" type="submit"><?= h(t('litter.detach')) ?></button></form></div><?php endforeach; ?>
</div></div>

View file

@ -8,6 +8,7 @@ $prefillCount = max(0, min(20, (int)($_GET['count'] ?? 4)));
?>
<form method="post" action="/litter/save" class="card">
<input type="hidden" name="csrf" value="<?= h(Auth::csrf()) ?>">
<input type="hidden" name="existing_litter_id" value="<?= (int)($existingLitterId ?? 0) ?>">
<div class="card-header d-flex align-items-center justify-content-between">
<h3 class="card-title"><?= h(t('litter.create')) ?></h3>
<a href="<?= $sourceAnimalId ? '/animal?id=' . (int)$sourceAnimalId . '#tab-litters' : '/animals' ?>"
@ -43,7 +44,7 @@ $prefillCount = max(0, min(20, (int)($_GET['count'] ?? 4)));
<div class="col-md-4">
<label class="form-label"><?= h(t('litter.birth_date')) ?> <span
class="text-muted"><?= h(t('common.optional_parentheses')) ?></span></label>
<input class="form-control" type="date" name="birth_date" placeholder="jj/mm/aaaa">
<input class="form-control" type="date" name="birth_date" value="<?= h($birthDate ?? '') ?>" placeholder="jj/mm/aaaa">
</div>
</div>

View file

@ -204,4 +204,10 @@ return [
'care.temperature'=>'Temperature',
'care.invalid_temperature'=>'Temperature must be between 30 and 45 °C.',
'chart.temperature'=>'Temperature (°C)',
'litter.edit'=>'Edit litter',
'litter.edit_help'=>'Correct the parents, birth date or linked kittens.',
'litter.kittens'=>'Kittens in this litter',
'litter.detach'=>'Detach',
'litter.detach_confirm'=>'Remove this cat from the litter? Its record and data will be preserved.',
'litter.parent_is_kitten'=>'A parent cannot also be a kitten in this litter.',
];

View file

@ -204,4 +204,10 @@ return [
'care.temperature'=>'Température',
'care.invalid_temperature'=>'La température doit être comprise entre 30 et 45 °C.',
'chart.temperature'=>'Température (°C)',
'litter.edit'=>'Modifier la portée',
'litter.edit_help'=>'Corriger les parents, la date ou les chatons rattachés.',
'litter.kittens'=>'Chatons de la portée',
'litter.detach'=>'Détacher',
'litter.detach_confirm'=>'Retirer ce chat de la portée ? Sa fiche et ses données seront conservées.',
'litter.parent_is_kitten'=>'Un parent ne peut pas être également chaton de cette portée.',
];

View file

@ -87,6 +87,17 @@ try {
'L’historique des portées ne renseigne pas son action',
);
});
$test('Une portée peut être corrigée sans supprimer ses animaux', function () use ($assert): void {
$routes = file_get_contents(__DIR__ . '/../app/Routes/web.php');
$controller = file_get_contents(__DIR__ . '/../app/Controllers/AnimalRelationshipActions.php');
$view = file_get_contents(__DIR__ . '/../app/Views/litter_edit.php');
foreach (['/litter/edit', '/litter/update', '/litter/kitten/detach'] as $route) {
$assert(str_contains($routes, $route), 'Route de portée absente : ' . $route);
}
$assert(str_contains($controller, 'DELETE FROM litter_kittens'), 'Le détachement du chaton est absent');
$assert(!str_contains($controller, 'DELETE FROM animals'), 'Le détachement supprimerait une fiche animale');
$assert(str_contains($view, '/animal/edit?id='), 'La fiche du chaton n’est pas modifiable depuis la portée');
});
$test('Les médias publics restent accessibles au serveur web', function () use ($assert): void {
$security = file_get_contents(__DIR__ . '/../app/Services/SecurityService.php');
$images = file_get_contents(__DIR__ . '/../app/Services/ImageService.php');