fix: fiabiliser les fiches animales et vaccinations

This commit is contained in:
Alexandre NOEL 2026-09-15 21:11:21 +02:00
commit 361bef8561
20 changed files with 157 additions and 65 deletions

View file

@ -2,6 +2,18 @@
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.26] — 2026-09-15
### Données animales et suivi vétérinaire
- suppression sûre de l’ancienne contrainte SQLite limitant les salles à « silver » et « twix » ;
- reconstruction transactionnelle de la table des animaux avec conservation de toutes les fiches, relations, vues et index ;
- ajout d’un test de sauvegarde réelle contrôlant le nombre d’animaux, l’intégrité SQLite et les clés étrangères ;
- suppression des avertissements `open_basedir` pendant la détection de l’outil d’optimisation des images.
- remplacement des anciens états I-CAD par « Inconnu », « Enregistré », « En attente de changement » et « Propriétaire changé » ;
- retrait du champ redondant « Administré par » lors de la saisie d’un vaccin ;
- affichage du cabinet vétérinaire dans le récapitulatif des vaccinations et son export.
## [1.0.0-rc.25] — 2026-09-15 ## [1.0.0-rc.25] — 2026-09-15
### Modification des portées ### Modification des portées

View file

@ -10,6 +10,11 @@ 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. 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.26` — Salles réellement configurables
Retrait de la dernière contrainte héritée qui limitait encore les fiches animales aux deux premières salles historiques. La migration conserve les animaux et toutes leurs relations tout en autorisant les salles définies dans la configuration.
Les états d’enregistrement I-CAD suivent désormais le changement de propriétaire, tandis que le suivi vaccinal s’appuie sans doublon sur le cabinet et le vétérinaire de l’annuaire.
## `1.0.0-rc.25` — Portées modifiables ## `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. 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.

View file

@ -200,7 +200,7 @@ L’option `--apply` ne doit être utilisée qu’après lecture des rapports. L
## État du projet ## État du projet
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`. La candidate actuelle est `1.0.0-rc.26`. 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). 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.25 1.0.0-rc.26

View file

@ -612,12 +612,11 @@ trait AnimalBrowseActions
// Vaccins // Vaccins
$stmt = $db->prepare(' $stmt = $db->prepare('
SELECT v.*, rv.name AS vaccine_name, COALESCE(dc.name, c.name) AS clinic_name,COALESCE(v.administered_by_name,au.display_name,au.username) AS administrator_name SELECT v.*, rv.name AS vaccine_name, COALESCE(dc.name, c.name) AS clinic_name
FROM vaccinations v FROM vaccinations v
JOIN ref_vaccines rv ON rv.id = v.vaccine_id JOIN ref_vaccines rv ON rv.id = v.vaccine_id
LEFT JOIN clinics c ON c.id = v.clinic_id LEFT JOIN clinics c ON c.id = v.clinic_id
LEFT JOIN directory_contacts dc ON dc.id = v.clinic_contact_id LEFT JOIN directory_contacts dc ON dc.id = v.clinic_contact_id
LEFT JOIN users au ON au.id=v.administered_by_user_id
WHERE v.animal_id = :id WHERE v.animal_id = :id
ORDER BY v.done_date DESC, v.id DESC ORDER BY v.done_date DESC, v.id DESC
LIMIT 50 LIMIT 50
@ -833,7 +832,7 @@ trait AnimalBrowseActions
CASE WHEN COALESCE(v.lot,'')!='' THEN '\nLot : ' || v.lot ELSE '' END || CASE WHEN COALESCE(v.lot,'')!='' THEN '\nLot : ' || v.lot ELSE '' END ||
CASE WHEN COALESCE(v.manufacturer,'')!='' THEN '\nFabricant : ' || v.manufacturer ELSE '' END || CASE WHEN COALESCE(v.manufacturer,'')!='' THEN '\nFabricant : ' || v.manufacturer ELSE '' END ||
CASE WHEN COALESCE(v.batch_expires_on,'')!='' THEN '\nExpiration du lot : ' || v.batch_expires_on ELSE '' END || CASE WHEN COALESCE(v.batch_expires_on,'')!='' THEN '\nExpiration du lot : ' || v.batch_expires_on ELSE '' END ||
CASE WHEN COALESCE(v.administered_by_name,'')!='' THEN '\nAdministré par : ' || v.administered_by_name WHEN v.administered_by_user_id IS NOT NULL THEN '\nAdministré par : ' || COALESCE((SELECT display_name FROM users WHERE id=v.administered_by_user_id),(SELECT username FROM users WHERE id=v.administered_by_user_id),'Compte inconnu') ELSE '' END || CASE WHEN v.clinic_contact_id IS NOT NULL THEN '\nCabinet vétérinaire : ' || COALESCE((SELECT name FROM directory_contacts WHERE id=v.clinic_contact_id),'Cabinet inconnu') WHEN v.clinic_id IS NOT NULL THEN '\nCabinet vétérinaire : ' || COALESCE((SELECT name FROM clinics WHERE id=v.clinic_id),'Cabinet inconnu') ELSE '' END ||
CASE WHEN COALESCE(v.notes,'')!='' THEN '\nNotes : ' || v.notes ELSE '' END CASE WHEN COALESCE(v.notes,'')!='' THEN '\nNotes : ' || v.notes ELSE '' END
) AS details, ) AS details,
datetime(v.done_date || ' 12:00:00') AS at datetime(v.done_date || ' 12:00:00') AS at

View file

@ -630,15 +630,6 @@ trait AnimalMedicalActions
echo h(t('error.invalid_batch_expiry')); echo h(t('error.invalid_batch_expiry'));
return; return;
} }
$administratorUserId = (int) ($_POST['administered_by_user_id'] ?? 0) ?: null;
$administratorName = trim((string) ($_POST['administered_by_name'] ?? ''));
if ($administratorUserId) {
$check = $db->prepare('SELECT 1 FROM users WHERE id=:id AND active=1');
$check->execute([':id' => $administratorUserId]);
if (!$check->fetchColumn()) {
$administratorUserId = null;
}
}
$notes = trim((string) ($_POST['notes'] ?? '')); $notes = trim((string) ($_POST['notes'] ?? ''));
$clinic_id = (int) ($_POST['clinic_id'] ?? 0); $clinic_id = (int) ($_POST['clinic_id'] ?? 0);
$clinic_id = $clinic_id > 0 ? $clinic_id : null; $clinic_id = $clinic_id > 0 ? $clinic_id : null;
@ -668,8 +659,8 @@ trait AnimalMedicalActions
$vet_name = $vetContact['name'] ?? $vet_name; $vet_name = $vetContact['name'] ?? $vet_name;
$stmt = $db->prepare(' $stmt = $db->prepare('
INSERT INTO vaccinations(animal_id, vaccine_id, done_date, due_date, lot,manufacturer,batch_expires_on,administered_by_user_id,administered_by_name, notes, clinic_id, vet_name, clinic_contact_id, vet_contact_id, created_by) INSERT INTO vaccinations(animal_id, vaccine_id, done_date, due_date, lot,manufacturer,batch_expires_on, notes, clinic_id, vet_name, clinic_contact_id, vet_contact_id, created_by)
VALUES(:aid,:vid,:done,:due,:lot,:manufacturer,:batch_expiry,:administrator_user,:administrator_name,:notes,:clinic_id,:vet_name,:clinic_contact_id,:vet_contact_id,:created_by) VALUES(:aid,:vid,:done,:due,:lot,:manufacturer,:batch_expiry,:notes,:clinic_id,:vet_name,:clinic_contact_id,:vet_contact_id,:created_by)
'); ');
$stmt->execute([ $stmt->execute([
':aid' => $id, ':aid' => $id,
@ -679,8 +670,6 @@ trait AnimalMedicalActions
':lot' => $lot !== '' ? $lot : null, ':lot' => $lot !== '' ? $lot : null,
':manufacturer' => $manufacturer ?: null, ':manufacturer' => $manufacturer ?: null,
':batch_expiry' => $batchExpiresOn ?: null, ':batch_expiry' => $batchExpiresOn ?: null,
':administrator_user' => $administratorUserId,
':administrator_name' => $administratorName ?: null,
':notes' => $notes !== '' ? $notes : null, ':notes' => $notes !== '' ? $notes : null,
':clinic_id' => $clinic_id, ':clinic_id' => $clinic_id,
':vet_name' => $vet_name, ':vet_name' => $vet_name,
@ -707,10 +696,7 @@ trait AnimalMedicalActions
($manufacturer !== '' ? "\nFabricant : $manufacturer" : '') . ($manufacturer !== '' ? "\nFabricant : $manufacturer" : '') .
($lot !== '' ? "\nLot : $lot" : '') . ($lot !== '' ? "\nLot : $lot" : '') .
($batchExpiresOn !== '' ? "\nExpiration du lot : $batchExpiresOn" : '') . ($batchExpiresOn !== '' ? "\nExpiration du lot : $batchExpiresOn" : '') .
($administratorName !== '' || $administratorUserId "\nCabinet vétérinaire : " . ($clinicContact['name'] ?? ''),
? "\nAdministré par : " .
($administratorName !== '' ? $administratorName : 'utilisateur #' . $administratorUserId)
: ''),
); );
$db->prepare("UPDATE animals SET updated_at = datetime('now') WHERE id = :id")->execute([':id' => $id]); $db->prepare("UPDATE animals SET updated_at = datetime('now') WHERE id = :id")->execute([':id' => $id]);

View file

@ -376,7 +376,10 @@ trait AnimalRecordActions
} }
$identificationDate = trim((string) ($_POST['identification_date'] ?? '')); $identificationDate = trim((string) ($_POST['identification_date'] ?? ''));
$identificationRegistration = (string) ($_POST['identification_registration_status'] ?? 'unknown'); $identificationRegistration = (string) ($_POST['identification_registration_status'] ?? 'unknown');
if (!in_array($identificationRegistration, ['unknown', 'pending', 'registered'], true)) { if ($identificationRegistration === 'pending') {
$identificationRegistration = 'change_pending';
}
if (!in_array($identificationRegistration, ['unknown', 'registered', 'change_pending', 'owner_changed'], true)) {
$identificationRegistration = 'unknown'; $identificationRegistration = 'unknown';
} }
if ($chip_id === '') { if ($chip_id === '') {

View file

@ -152,7 +152,7 @@ final class Asm3MigrationPlanner
'identification_date' => $date($chip !== '' ? $r['IDENTICHIPDATE'] ?? null : $r['TATTOODATE'] ?? null), 'identification_date' => $date($chip !== '' ? $r['IDENTICHIPDATE'] ?? null : $r['TATTOODATE'] ?? null),
'identification_registration_status' => match ((string) ($r['IDENTICHIPSTATUS'] ?? '')) { 'identification_registration_status' => match ((string) ($r['IDENTICHIPSTATUS'] ?? '')) {
'1' => 'registered', '1' => 'registered',
'0' => 'pending', '0' => 'change_pending',
default => 'unknown', default => 'unknown',
}, },
'sterilization_status' => $yes($r['NEUTERED'] ?? 0) ? 'yes' : 'unknown', 'sterilization_status' => $yes($r['NEUTERED'] ?? 0) ? 'yes' : 'unknown',

View file

@ -38,7 +38,7 @@ final class ExportService
'vaccinations' => [ 'vaccinations' => [
'Vaccinations', 'Vaccinations',
'vaccinations.csv', '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', '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,clinic.name 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 cu ON cu.id=v.created_by ORDER BY v.done_date,v.id',
], ],
'deaths' => [ 'deaths' => [
'Décès', 'Décès',

View file

@ -131,7 +131,7 @@ final class ImageService
['/usr/bin/magick', '/usr/local/bin/magick', '/usr/bin/convert', '/usr/local/bin/convert'] ['/usr/bin/magick', '/usr/local/bin/magick', '/usr/bin/convert', '/usr/local/bin/convert']
as $binary as $binary
) { ) {
if (is_executable($binary)) { if (@is_executable($binary)) {
return $binary; return $binary;
} }
} }

View file

@ -91,13 +91,21 @@ final class Migrations
} }
} }
$relaxesForeignKeys = $name === '093_room_rules_and_care_periods.sql'; $relaxesForeignKeys = in_array(
$name,
['093_room_rules_and_care_periods.sql', '097_remove_legacy_room_constraint.sql'],
true,
);
if ($relaxesForeignKeys) { if ($relaxesForeignKeys) {
$db->exec('PRAGMA foreign_keys = OFF'); $db->exec('PRAGMA foreign_keys = OFF');
} }
$db->beginTransaction(); $db->beginTransaction();
try { try {
$db->exec($sql); if ($name === '097_remove_legacy_room_constraint.sql') {
self::modernizeAnimalConstraints($db);
} else {
$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) { if ($relaxesForeignKeys && $db->query('PRAGMA foreign_key_check')->fetchColumn() !== false) {
@ -116,4 +124,62 @@ final class Migrations
} }
} }
} }
private static function modernizeAnimalConstraints(PDO $db): void
{
$createSql = (string) $db
->query("SELECT sql FROM sqlite_master WHERE type='table' AND name='animals'")
->fetchColumn();
$constraint = "/refuge_room\\s+TEXT\\s+CHECK\\s*\\(\\s*refuge_room\\s+IN\\s*\\(\\s*'silver'\\s*,\\s*'twix'\\s*\\)\\s+OR\\s+refuge_room\\s+IS\\s+NULL\\s*\\)/i";
$registrationConstraint = "/identification_registration_status\\s+TEXT\\s+NOT\\s+NULL\\s+DEFAULT\\s+'unknown'\\s+CHECK\\s*\\(\\s*identification_registration_status\\s+IN\\s*\\(\\s*'unknown'\\s*,\\s*'pending'\\s*,\\s*'registered'\\s*\\)\\s*\\)/i";
if (
$createSql === ''
|| (!preg_match($constraint, $createSql) && !preg_match($registrationConstraint, $createSql))
) {
return;
}
$newSql = preg_replace(
'/^CREATE TABLE\\s+(?:IF NOT EXISTS\\s+)?["`\[]?animals["`\]]?/i',
'CREATE TABLE animals_room_unrestricted',
$createSql,
1,
);
$newSql = preg_replace($constraint, 'refuge_room TEXT', (string) $newSql, 1);
$newSql = preg_replace(
$registrationConstraint,
"identification_registration_status TEXT NOT NULL DEFAULT 'unknown' " .
"CHECK(identification_registration_status IN ('unknown','registered','change_pending','owner_changed','pending'))",
(string) $newSql,
1,
);
if (!$newSql || preg_match($constraint, $newSql)) {
throw new RuntimeException('Unable to remove the legacy shelter-room constraint.');
}
$objects = $db
->query("SELECT sql FROM sqlite_master WHERE tbl_name='animals' AND type IN ('index','trigger') AND sql IS NOT NULL")
->fetchAll(PDO::FETCH_COLUMN);
$views = $db
->query("SELECT name,sql FROM sqlite_master WHERE type='view' AND sql IS NOT NULL ORDER BY rowid")
->fetchAll(PDO::FETCH_ASSOC);
$columns = array_column($db->query('PRAGMA table_info(animals)')->fetchAll(PDO::FETCH_ASSOC), 'name');
$quotedColumns = implode(',', array_map(static fn(string $column): string => '"' . str_replace('"', '""', $column) . '"', $columns));
foreach ($views as $view) {
$viewName = '"' . str_replace('"', '""', (string) $view['name']) . '"';
$db->exec('DROP VIEW ' . $viewName);
}
$db->exec($newSql);
$db->exec("INSERT INTO animals_room_unrestricted ($quotedColumns) SELECT $quotedColumns FROM animals");
$db->exec("UPDATE animals_room_unrestricted SET identification_registration_status='change_pending' WHERE identification_registration_status='pending'");
$db->exec('DROP TABLE animals');
$db->exec('ALTER TABLE animals_room_unrestricted RENAME TO animals');
foreach ($objects as $objectSql) {
$db->exec((string) $objectSql);
}
foreach ($views as $view) {
$db->exec((string) $view['sql']);
}
}
} }

View file

@ -410,7 +410,7 @@ ORDER BY created_at ASC
<th><?= h(t('animal.done_on')) ?></th> <th><?= h(t('animal.done_on')) ?></th>
<th><?= h(t('animal.reminder_due')) ?></th> <th><?= h(t('animal.reminder_due')) ?></th>
<th><?= h(t('animal.batch_traceability')) ?></th> <th><?= h(t('animal.batch_traceability')) ?></th>
<th><?= h(t('animal.administered_by')) ?></th> <th><?= h(t('animal.veterinary_clinic')) ?></th>
<th><?= h(t('common.notes')) ?></th> <th><?= h(t('common.notes')) ?></th>
</tr> </tr>
</thead> </thead>
@ -424,7 +424,7 @@ ORDER BY created_at ASC
<div class="text-muted small"> <div class="text-muted small">
<?= h(t('animal.expires_on', ['date' => date('d/m/Y', strtotime((string)$v['batch_expires_on']))])) ?> <?= h(t('animal.expires_on', ['date' => date('d/m/Y', strtotime((string)$v['batch_expires_on']))])) ?>
</div><?php endif; ?></td> </div><?php endif; ?></td>
<td><?= !empty($v['administrator_name']) ? h($v['administrator_name']) : '—' ?></td> <td><?= !empty($v['clinic_name']) ? h($v['clinic_name']) : '—' ?></td>
<td><?= $v['notes'] ? h($v['notes']) : '—' ?></td> <td><?= $v['notes'] ? h($v['notes']) : '—' ?></td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>

View file

@ -312,38 +312,12 @@ $currentTime = date('H:i');
<div class="col-md-4"><label class="form-label"><?= h(t('inventory.quantity')) ?></label><input <div class="col-md-4"><label class="form-label"><?= h(t('inventory.quantity')) ?></label><input
class="form-control" type="number" min="0" step="0.01" name="inventory_quantity"></div> class="form-control" type="number" min="0" step="0.01" name="inventory_quantity"></div>
<div class="col-md-6">
<label class="form-label"><?= h(t('animal.administered_by')) ?></label>
<select class="form-select" name="administered_by_user_id" id="vaccineAdministrator">
<option value=""><?= h(t('common.not_provided')) ?></option>
<?php foreach (($activeUsers ?? []) as $user): ?><option value="<?= (int)$user['id'] ?>">
<?= h($user['name']) ?></option><?php endforeach; ?><option value="other">
<?= h(t('animal.other_person')) ?></option>
</select>
</div>
<div class="col-md-6 d-none" id="vaccineAdministratorOther">
<label class="form-label"><?= h(t('animal.person_name')) ?></label>
<input class="form-control" name="administered_by_name"
placeholder="<?= h(t('animal.person_placeholder')) ?>">
</div>
<div class="col-md-12"> <div class="col-md-12">
<label class="form-label"><?= h(t('common.notes')) ?></label> <label class="form-label"><?= h(t('common.notes')) ?></label>
<input class="form-control" name="notes" placeholder="Optionnel"> <input class="form-control" name="notes" placeholder="Optionnel">
</div> </div>
</div> </div>
<script>
document.addEventListener('DOMContentLoaded', () => {
const select = document.getElementById('vaccineAdministrator');
const wrap = document.getElementById('vaccineAdministratorOther');
const sync = () => wrap?.classList.toggle('d-none', select?.value !== 'other');
select?.addEventListener('change', sync);
sync();
});
</script>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">

View file

@ -370,12 +370,15 @@ $estMonths = $birth_estimated_months > 0 ? ($birth_estimated_months % 12) : '';
<option value="unknown" <option value="unknown"
<?= (($animal['identification_registration_status'] ?? 'unknown') === 'unknown') ? 'selected' : '' ?>> <?= (($animal['identification_registration_status'] ?? 'unknown') === 'unknown') ? 'selected' : '' ?>>
<?= h(t('common.unknown')) ?></option> <?= h(t('common.unknown')) ?></option>
<option value="pending"
<?= (($animal['identification_registration_status'] ?? 'unknown') === 'pending') ? 'selected' : '' ?>>
<?= h(t('animal.form.pending')) ?></option>
<option value="registered" <option value="registered"
<?= (($animal['identification_registration_status'] ?? 'unknown') === 'registered') ? 'selected' : '' ?>> <?= (($animal['identification_registration_status'] ?? 'unknown') === 'registered') ? 'selected' : '' ?>>
<?= h(t('animal.form.registered')) ?></option> <?= h(t('animal.form.registered')) ?></option>
<option value="change_pending"
<?= in_array(($animal['identification_registration_status'] ?? 'unknown'), ['pending', 'change_pending'], true) ? 'selected' : '' ?>>
<?= h(t('animal.form.change_pending')) ?></option>
<option value="owner_changed"
<?= (($animal['identification_registration_status'] ?? 'unknown') === 'owner_changed') ? 'selected' : '' ?>>
<?= h(t('animal.form.owner_changed')) ?></option>
</select></div> </select></div>
<div class="col-12"> <div class="col-12">

View file

@ -296,9 +296,9 @@ if ($felvPos) {
<?php $availability = $animal['adoption_availability'] ?? 'unknown'; <?php $availability = $animal['adoption_availability'] ?? 'unknown';
$availabilityMeta = ['unknown' => [t('animal.availability_define'),'bg-secondary-lt'],'not_available' => [t('animals.unavailable_title'),'bg-orange-lt'],'available' => [t('animals.available_title'),'bg-green-lt']]; $availabilityMeta = ['unknown' => [t('animal.availability_define'),'bg-secondary-lt'],'not_available' => [t('animals.unavailable_title'),'bg-orange-lt'],'available' => [t('animals.available_title'),'bg-green-lt']];
[$availabilityLabel,$availabilityClass] = $availabilityMeta[$availability] ?? $availabilityMeta['unknown']; [$availabilityLabel,$availabilityClass] = $availabilityMeta[$availability] ?? $availabilityMeta['unknown'];
$registrationLabels = ['unknown' => t('animal.registration_unknown'),'pending' => t('animal.registration_pending'),'registered' => t('animal.registration_done')]; $registrationLabels = ['unknown' => t('animal.registration_unknown'),'pending' => t('animal.registration_change_pending'),'change_pending' => t('animal.registration_change_pending'),'registered' => t('animal.registration_done'),'owner_changed' => t('animal.registration_owner_changed')];
$registration = $animal['identification_registration_status'] ?? 'unknown'; $registration = $animal['identification_registration_status'] ?? 'unknown';
$registrationClass = $registration === 'registered' ? 'bg-green-lt' : ($registration === 'pending' ? 'bg-yellow-lt' : 'bg-secondary-lt'); ?> $registrationClass = in_array($registration, ['registered', 'owner_changed'], true) ? 'bg-green-lt' : (in_array($registration, ['pending', 'change_pending'], true) ? 'bg-yellow-lt' : 'bg-secondary-lt'); ?>
<div class="mt-2 d-flex flex-wrap align-items-center gap-1"><span <div class="mt-2 d-flex flex-wrap align-items-center gap-1"><span
class="badge <?= h($registrationClass) ?>"><?= h($registrationLabels[$registration] ?? $registrationLabels['unknown']) ?></span><span class="badge <?= h($registrationClass) ?>"><?= h($registrationLabels[$registration] ?? $registrationLabels['unknown']) ?></span><span
class="badge <?= h($availabilityClass) ?>"><?= h($availabilityLabel) ?></span><?php if (!empty($animal['website_published'])): ?><span class="badge <?= h($availabilityClass) ?>"><?= h($availabilityLabel) ?></span><?php if (!empty($animal['website_published'])): ?><span

View file

@ -31,10 +31,10 @@
<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.25</span> <span class="badge bg-blue-lt mb-2">1.0.0-rc.26</span>
<h3>Portées modifiables et fiables</h3> <h3>Données animales fiabilisées</h3>
<p class="text-muted">La création d’une fratrie ne dépend plus d’un ancien traitement de photo et <p class="text-muted">Les salles configurables, les états I-CAD et le suivi vaccinal restent
son historique reste compatible avec les bases existantes.</p> cohérents sur les installations neuves comme sur les bases historiques.</p>
</div> </div>
</section> </section>
</div> </div>

View file

@ -0,0 +1,3 @@
-- La reconstruction conditionnelle est réalisée par Migrations::run afin de
-- préserver toutes les colonnes des bases historiques.
SELECT 1;

View file

@ -210,4 +210,9 @@ return [
'litter.detach'=>'Detach', 'litter.detach'=>'Detach',
'litter.detach_confirm'=>'Remove this cat from the litter? Its record and data will be preserved.', '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.', 'litter.parent_is_kitten'=>'A parent cannot also be a kitten in this litter.',
'animal.form.change_pending'=>'Ownership change pending',
'animal.form.owner_changed'=>'Owner changed',
'animal.registration_change_pending'=>'I-CAD: ownership change pending',
'animal.registration_owner_changed'=>'I-CAD: owner changed',
'animal.veterinary_clinic'=>'Veterinary clinic',
]; ];

View file

@ -210,4 +210,9 @@ return [
'litter.detach'=>'Détacher', 'litter.detach'=>'Détacher',
'litter.detach_confirm'=>'Retirer ce chat de la portée ? Sa fiche et ses données seront conservées.', '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.', 'litter.parent_is_kitten'=>'Un parent ne peut pas être également chaton de cette portée.',
'animal.form.change_pending'=>'En attente de changement',
'animal.form.owner_changed'=>'Propriétaire changé',
'animal.registration_change_pending'=>'I-CAD : en attente de changement',
'animal.registration_owner_changed'=>'I-CAD : propriétaire changé',
'animal.veterinary_clinic'=>'Clinique vétérinaire',
]; ];

View file

@ -167,6 +167,37 @@ try {
$assert(!ShelterRoomService::accepts($room, ['species' => 'chat', 'sex' => 'F', 'fiv' => 1])); $assert(!ShelterRoomService::accepts($room, ['species' => 'chat', 'sex' => 'F', 'fiv' => 1]));
$assert(!ShelterRoomService::accepts($room, ['species' => 'chat', 'sex' => 'M', 'fiv' => 0])); $assert(!ShelterRoomService::accepts($room, ['species' => 'chat', 'sex' => 'M', 'fiv' => 0]));
}); });
$test('Une salle configurable peut être enregistrée sur un animal', function () use ($db, $assert): void {
$schema = (string) $db->query("SELECT sql FROM sqlite_master WHERE type='table' AND name='animals'")->fetchColumn();
$assert(!str_contains($schema, "refuge_room IN ('silver', 'twix')"), 'Ancienne contrainte de salle encore active');
$code = 'TEST-ROOM-' . bin2hex(random_bytes(3));
$db->prepare("INSERT INTO animals(name,internal_code,refuge_room) VALUES('Test salle',:code,'nouvelle-salle')")->execute([':code' => $code]);
$id = (int) $db->lastInsertId();
$assert((string) $db->query('SELECT refuge_room FROM animals WHERE id=' . $id)->fetchColumn() === 'nouvelle-salle');
$db->prepare('DELETE FROM animals WHERE id=:id')->execute([':id' => $id]);
});
$test('Les quatre états I-CAD sont acceptés', function () use ($db, $assert): void {
$code = 'TEST-ICAD-' . bin2hex(random_bytes(3));
$db->prepare("INSERT INTO animals(name,internal_code,identification_registration_status) VALUES('Test I-CAD',:code,'unknown')")
->execute([':code' => $code]);
$id = (int) $db->lastInsertId();
foreach (['unknown', 'registered', 'change_pending', 'owner_changed'] as $status) {
$db->prepare('UPDATE animals SET identification_registration_status=:status WHERE id=:id')
->execute([':status' => $status, ':id' => $id]);
$assert(
(string) $db->query('SELECT identification_registration_status FROM animals WHERE id=' . $id)->fetchColumn() === $status,
'État I-CAD refusé : ' . $status,
);
}
$db->prepare('DELETE FROM animals WHERE id=:id')->execute([':id' => $id]);
});
$test('La vaccination repose sur le cabinet vétérinaire', function () use ($assert): void {
$modal = (string) file_get_contents(dirname(__DIR__) . '/app/Views/_animal_show_modals.php');
$summary = (string) file_get_contents(dirname(__DIR__) . '/app/Views/_animal_show_medical.php');
$assert(!str_contains($modal, 'name="administered_by_user_id"'));
$assert(!str_contains($modal, 'name="administered_by_name"'));
$assert(str_contains($summary, "\$v['clinic_name']"));
});
$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');