feat: suivre la température pendant les tournées

This commit is contained in:
Alexandre NOEL 2026-09-15 15:43:00 +02:00
commit d35434c522
15 changed files with 129 additions and 23 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.21] — 2026-09-15
### Suivi de la température
- ajout de la température à chaque contrôle d’animal pendant une tournée, préremplie à 38,5 °C et modifiable ;
- validation des températures enregistrées entre 30 et 45 °C ;
- regroupement du poids et de la température dans un graphique médical commun, avec une échelle dédiée à chaque unité ;
- reprise dans la courbe des températures saisies dans les notes médicales et pendant les tournées.
## [1.0.0-rc.20] — 2026-09-13 ## [1.0.0-rc.20] — 2026-09-13
### Localisation des animaux ### Localisation des animaux

View file

@ -89,3 +89,7 @@ Ajout d’une date et d’une heure modifiables aux contrôles médicaux, avec r
## `1.0.0-rc.20` — Localisations cohérentes ## `1.0.0-rc.20` — Localisations cohérentes
Simplification des lieux affichés dans la fiche, conservation de l’adresse actuelle et réparation des coordonnées manquantes après un import ou un placement. Simplification des lieux affichés dans la fiche, conservation de l’adresse actuelle et réparation des coordonnées manquantes après un import ou un placement.
## `1.0.0-rc.21` — Température pendant les tournées
Ajout d’une température préremplie à 38,5 °C lors du contrôle de chaque animal et affichage conjoint des courbes de poids et de température dans son dossier médical.

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.20`. 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.21`. 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.20 1.0.0-rc.21

View file

@ -704,6 +704,21 @@ trait AnimalBrowseActions
$weightsStmt->execute([':id' => $id]); $weightsStmt->execute([':id' => $id]);
$weights = $weightsStmt->fetchAll(); $weights = $weightsStmt->fetchAll();
$temperaturesStmt = $db->prepare("
SELECT measured_at, value FROM (
SELECT noted_at AS measured_at, temperature_c AS value
FROM medical_notes
WHERE animal_id = :medical_id AND temperature_c IS NOT NULL
UNION ALL
SELECT observation.checked_at AS measured_at, observation.temperature_c AS value
FROM care_round_observations observation
WHERE observation.animal_id = :round_id AND observation.temperature_c IS NOT NULL
)
ORDER BY datetime(measured_at) ASC
");
$temperaturesStmt->execute([':medical_id' => $id, ':round_id' => $id]);
$temperatures = $temperaturesStmt->fetchAll(PDO::FETCH_ASSOC);
$stmt = $db->prepare(" $stmt = $db->prepare("
SELECT value SELECT value
FROM measurements FROM measurements
@ -952,6 +967,7 @@ trait AnimalBrowseActions
'adopters' => $adopters, 'adopters' => $adopters,
'death' => $death, 'death' => $death,
'weights' => $weights, 'weights' => $weights,
'temperatures' => $temperatures,
'lastWeight' => $lastWeight, 'lastWeight' => $lastWeight,
'prevWeight' => $prevWeight, 'prevWeight' => $prevWeight,
'photos' => $photos, 'photos' => $photos,

View file

@ -139,6 +139,18 @@ final class CareRoundsController
$value = (string) ($_POST[$field] ?? $opts[0]); $value = (string) ($_POST[$field] ?? $opts[0]);
$values[$field] = in_array($value, $opts, true) ? $value : $opts[0]; $values[$field] = in_array($value, $opts, true) ? $value : $opts[0];
} }
$temperatureInput = str_replace(',', '.', trim((string) ($_POST['temperature_c'] ?? '')));
if (!is_numeric($temperatureInput)) {
http_response_code(400);
echo h(t('care.invalid_temperature'));
return;
}
$temperature = (float) $temperatureInput;
if ($temperature < 30 || $temperature > 45) {
http_response_code(400);
echo h(t('care.invalid_temperature'));
return;
}
$db->prepare( $db->prepare(
'UPDATE care_rounds SET performed_by_user_id=COALESCE(performed_by_user_id,:user) WHERE id=:id', 'UPDATE care_rounds SET performed_by_user_id=COALESCE(performed_by_user_id,:user) WHERE id=:id',
)->execute([':user' => Auth::id(), ':id' => $roundId]); )->execute([':user' => Auth::id(), ':id' => $roundId]);
@ -220,7 +232,7 @@ final class CareRoundsController
$db->beginTransaction(); $db->beginTransaction();
try { try {
$stmt = $db->prepare( $stmt = $db->prepare(
"INSERT INTO care_round_observations(round_id,animal_id,food,water,urine,stool,general_state,comment,alert_signature) VALUES(:round,:animal,:food,:water,:urine,:stool,:general,:comment,:signature) ON CONFLICT(round_id,animal_id) DO UPDATE SET food=excluded.food,water=excluded.water,urine=excluded.urine,stool=excluded.stool,general_state=excluded.general_state,comment=excluded.comment,alert_signature=excluded.alert_signature,checked_at=datetime('now')", "INSERT INTO care_round_observations(round_id,animal_id,food,water,urine,stool,general_state,temperature_c,comment,alert_signature) VALUES(:round,:animal,:food,:water,:urine,:stool,:general,:temperature,:comment,:signature) ON CONFLICT(round_id,animal_id) DO UPDATE SET food=excluded.food,water=excluded.water,urine=excluded.urine,stool=excluded.stool,general_state=excluded.general_state,temperature_c=excluded.temperature_c,comment=excluded.comment,alert_signature=excluded.alert_signature,checked_at=datetime('now')",
); );
$stmt->execute([ $stmt->execute([
':round' => $roundId, ':round' => $roundId,
@ -230,6 +242,7 @@ final class CareRoundsController
':urine' => $values['urine'], ':urine' => $values['urine'],
':stool' => $values['stool'], ':stool' => $values['stool'],
':general' => $values['general_state'], ':general' => $values['general_state'],
':temperature' => $temperature,
':comment' => $comment ?: null, ':comment' => $comment ?: null,
':signature' => $signature, ':signature' => $signature,
]); ]);

View file

@ -235,14 +235,14 @@
<div class="card mt-3"> <div class="card mt-3">
<div class="card-header"> <div class="card-header">
<h3 class="card-title m-0"><?= h(t('animal.weight_evolution')) ?></h3> <h3 class="card-title m-0"><?= h(t('animal.measurements_evolution')) ?></h3>
</div> </div>
<?php if (!$weights): ?> <?php if (!$weights && !$temperatures): ?>
<div class="card-body text-muted"><?= h(t('animal.no_weight')) ?></div> <div class="card-body text-muted"><?= h(t('animal.no_measurement')) ?></div>
<?php else: ?> <?php else: ?>
<div style="height:260px"> <div style="height:260px">
<canvas id="weightChart"></canvas> <canvas id="healthMeasurementsChart"></canvas>
</div> </div>
<?php endif; ?> <?php endif; ?>

View file

@ -944,25 +944,47 @@ $currentTime = date('H:i');
</script> </script>
<script> <script>
(function() { (function() {
const el = document.getElementById('weightChart'); const el = document.getElementById('healthMeasurementsChart');
if (!el) return; if (!el) return;
const rows = <?= json_encode($weights ?? [], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?> ; const weights = <?= json_encode($weights ?? [], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
const temperatures = <?= json_encode($temperatures ?? [], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
const normalizeDate = value => String(value || '').replace(' ', 'T');
const labels = [...new Set([
...weights.map(row => normalizeDate(row.measured_at)),
...temperatures.map(row => normalizeDate(row.measured_at))
])].filter(Boolean).sort();
const valuesByDate = rows => new Map(rows.map(row => [normalizeDate(row.measured_at), Number(row.value)]));
const weightValues = valuesByDate(weights);
const temperatureValues = valuesByDate(temperatures);
// labels = dates, data = poids
const labels = rows.map(r => (r.measured_at || '').slice(0, 10)); // YYYY-MM-DD
const data = rows.map(r => Number(r.value));
// Chart.js default color (on ne fixe rien volontairement)
new Chart(el, { new Chart(el, {
type: 'line', type: 'line',
data: { data: {
labels, labels: labels.map(value => new Date(value).toLocaleString(undefined, {
datasets: [{ dateStyle: 'short',
timeStyle: 'short'
})),
datasets: [
{
label: <?= json_encode(t('chart.weight'), JSON_UNESCAPED_UNICODE) ?> , label: <?= json_encode(t('chart.weight'), JSON_UNESCAPED_UNICODE) ?> ,
data, data: labels.map(label => weightValues.get(label) ?? null),
borderColor: '#206bc4',
backgroundColor: '#206bc4',
tension: 0.25, tension: 0.25,
pointRadius: 2 pointRadius: 3,
spanGaps: true,
yAxisID: 'weight'
},
{
label: <?= json_encode(t('chart.temperature'), JSON_UNESCAPED_UNICODE) ?>,
data: labels.map(label => temperatureValues.get(label) ?? null),
borderColor: '#d63939',
backgroundColor: '#d63939',
tension: 0.25,
pointRadius: 3,
spanGaps: true,
yAxisID: 'temperature'
}] }]
}, },
options: { options: {
@ -974,12 +996,25 @@ $currentTime = date('H:i');
} }
}, },
scales: { scales: {
y: { weight: {
type: 'linear',
position: 'left',
title: { title: {
display: true, display: true,
text: 'kg' text: 'kg'
} }
}, },
temperature: {
type: 'linear',
position: 'right',
grid: {
drawOnChartArea: false
},
title: {
display: true,
text: '°C'
}
},
x: { x: {
title: { title: {
display: true, display: true,

View file

@ -4,6 +4,7 @@
/** @var array $treatments */ /** @var array $treatments */
/** @var array $vaccinations */ /** @var array $vaccinations */
/** @var array $weights */ /** @var array $weights */
/** @var array $temperatures */
/** @var array adoption */ /** @var array adoption */
require_once __DIR__ . '/../Services/IcadService.php'; require_once __DIR__ . '/../Services/IcadService.php';

View file

@ -347,6 +347,16 @@ $fieldOptions = [
type="radio" name="<?= h($field) ?>" value="<?= h($value) ?>" type="radio" name="<?= h($field) ?>" value="<?= h($value) ?>"
<?= (($obs[$field] ?? array_key_first($options)) === $value) ? 'checked' : '' ?>><span><?= h($label) ?></span></label><?php endforeach; ?> <?= (($obs[$field] ?? array_key_first($options)) === $value) ? 'checked' : '' ?>><span><?= h($label) ?></span></label><?php endforeach; ?>
</div><?php endforeach; ?> </div><?php endforeach; ?>
<div class="mb-2">
<label class="form-label fw-bold" for="temperature-<?= (int)$a['id'] ?>">🌡️ <?= h(t('care.temperature')) ?></label>
<div class="input-group">
<input class="form-control" id="temperature-<?= (int)$a['id'] ?>"
name="temperature_c" type="number" min="30" max="45" step="0.1"
inputmode="decimal" required
value="<?= h(number_format((float)($obs['temperature_c'] ?? 38.5), 1, '.', '')) ?>">
<span class="input-group-text">°C</span>
</div>
</div>
<?php foreach ($treatmentsByAnimal[(int)$a['id']] ?? [] as $t): ?><div <?php foreach ($treatmentsByAnimal[(int)$a['id']] ?? [] as $t): ?><div
class="card card-sm bg-purple-lt mb-2"> class="card card-sm bg-purple-lt mb-2">
<div class="card-body py-2"><strong>💊 <?= h($t['medication_name']) ?></strong> <div class="card-body py-2"><strong>💊 <?= h($t['medication_name']) ?></strong>

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.20</span> <span class="badge bg-blue-lt mb-2">1.0.0-rc.21</span>
<h3>Localisations cohérentes</h3> <h3>Température pendant les tournées</h3>
<p class="text-muted">Lieu de récupération et lieu actuel clairement distingués, avec réparation <p class="text-muted">Température proposée à 38,5 °C pendant chaque contrôle et graphique médical
des coordonnées manquantes après un import ou un placement.</p> commun pour suivre son évolution avec celle du poids.</p>
</div> </div>
</section> </section>
</div> </div>

View file

@ -0,0 +1 @@
ALTER TABLE care_round_observations ADD COLUMN temperature_c REAL;

View file

@ -199,4 +199,9 @@ return [
'updates.download_log'=>'Download technical log', 'updates.download_log'=>'Download technical log',
'updates.log_missing'=>'No update log is available.', 'updates.log_missing'=>'No update log is available.',
'error.invalid_medical_datetime'=>'Enter a valid check-up date and time.', 'error.invalid_medical_datetime'=>'Enter a valid check-up date and time.',
'animal.measurements_evolution'=>'Weight and temperature trends',
'animal.no_measurement'=>'No weight or temperature measurement recorded.',
'care.temperature'=>'Temperature',
'care.invalid_temperature'=>'Temperature must be between 30 and 45 °C.',
'chart.temperature'=>'Temperature (°C)',
]; ];

View file

@ -199,4 +199,9 @@ return [
'updates.download_log'=>'Télécharger le journal technique', 'updates.download_log'=>'Télécharger le journal technique',
'updates.log_missing'=>'Aucun journal de mise à jour n’est disponible.', 'updates.log_missing'=>'Aucun journal de mise à jour n’est disponible.',
'error.invalid_medical_datetime'=>'Indiquez une date et une heure de contrôle valides.', 'error.invalid_medical_datetime'=>'Indiquez une date et une heure de contrôle valides.',
'animal.measurements_evolution'=>'Évolution du poids et de la température',
'animal.no_measurement'=>'Aucune mesure de poids ou de température enregistrée.',
'care.temperature'=>'Température',
'care.invalid_temperature'=>'La température doit être comprise entre 30 et 45 °C.',
'chart.temperature'=>'Température (°C)',
]; ];

View file

@ -68,6 +68,13 @@ try {
$actual = (int) $db->query('SELECT COUNT(*) FROM _migrations')->fetchColumn(); $actual = (int) $db->query('SELECT COUNT(*) FROM _migrations')->fetchColumn();
$assert($actual === $expected, "$actual/$expected migrations"); $assert($actual === $expected, "$actual/$expected migrations");
}); });
$test('Les tournées peuvent enregistrer une température', function () use ($db, $assert): void {
$columns = array_column(
$db->query('PRAGMA table_info(care_round_observations)')->fetchAll(PDO::FETCH_ASSOC),
'name',
);
$assert(in_array('temperature_c', $columns, true), 'Colonne temperature_c absente');
});
$test("Les voies d’administration des médicaments sont initialisées", function () use ($db, $assert): void { $test("Les voies d’administration des médicaments sont initialisées", function () use ($db, $assert): void {
$routes = $db $routes = $db
->query('SELECT accro FROM ref_med_administration ORDER BY id') ->query('SELECT accro FROM ref_med_administration ORDER BY id')