diff --git a/app_new/core/services/meter_reading_planning.py b/app_new/core/services/meter_reading_planning.py index 9df0d29..b5478d8 100644 --- a/app_new/core/services/meter_reading_planning.py +++ b/app_new/core/services/meter_reading_planning.py @@ -28,6 +28,19 @@ TREATED_WITHOUT_READING = "TRAITÉE SANS RELEVÉ" CANCELLED = "ANNULÉE" +def cancel_open_occurrences_for_meter(meter, *, commit=False): + """Clôture explicitement les échéances ouvertes d'un compteur remplacé.""" + schedules = MeterReadingSchedule.query.filter_by(meter_id=meter.id).all() + for schedule in schedules: + schedule.is_active = False + for occurrence in MeterReadingOccurrence.query.filter_by(schedule_id=schedule.id, status=TODO).all(): + occurrence.status = CANCELLED + occurrence.no_reading_reason = "METER_REPLACED" + occurrence.comment = "Occurrence annulée automatiquement : compteur remplacé ou inactif." + if commit: + db.session.commit() + + def _add_months(value, months): index = value.year * 12 + value.month - 1 + months year, month = divmod(index, 12) @@ -152,10 +165,7 @@ def generate_occurrences(*, start_date, end_date, schedule_ids=None, commit=True created = [] for schedule in query.all(): if schedule.meter.status == "replaced" or not schedule.meter.is_active: - for old in MeterReadingOccurrence.query.filter_by(schedule_id=schedule.id, status=TODO).all(): - old.status = CANCELLED - old.no_reading_reason = "METER_REPLACED" - old.comment = "Occurrence annulée automatiquement : compteur remplacé ou inactif." + cancel_open_occurrences_for_meter(schedule.meter) continue for target_date in _target_dates(schedule, start_date, end_date): occurrence = MeterReadingOccurrence.query.filter_by( diff --git a/app_new/core/services/meter_service.py b/app_new/core/services/meter_service.py index 7b0872d..2e9be98 100644 --- a/app_new/core/services/meter_service.py +++ b/app_new/core/services/meter_service.py @@ -176,6 +176,10 @@ def replace_meter(*, old_meter, name, user_id=None, commit=True, **kwargs): ) old_meter.status = "replaced" old_meter.is_active = False + # C2 : les échéances ouvertes sont clôturées dans la même transaction que + # le remplacement ; l'historique reste consultable. + from .meter_reading_planning import cancel_open_occurrences_for_meter + cancel_open_occurrences_for_meter(old_meter) db.session.add(new_meter) if commit: db.session.commit() diff --git a/docs/development/DECISIONS_020_DEV3.md b/docs/development/DECISIONS_020_DEV3.md index ab9aceb..c2a11ad 100644 --- a/docs/development/DECISIONS_020_DEV3.md +++ b/docs/development/DECISIONS_020_DEV3.md @@ -92,7 +92,7 @@ Les décisions sont ajoutées au fil des checkpoints. Les modèles existants son La date cible reste distincte de la date opérationnelle. L'anticipation réutilise le moteur horaire/calendrier existant pour les règles scolaires ; les logements sont hors calendrier scolaire par défaut. Sans horaires explicites, le système conserve la date cible plutôt que d'inventer un jour travaillé. -**TRAITEMENT** : une occurrence avec relevé appelle le service C1 `record_meter_reading`. Une occurrence sans relevé conserve un motif distinguant notamment inaccessible, absent, refus et relevé non connu, sans créer de mesure artificielle. Une occurrence ouverte d'un compteur remplacé est annulée avec une raison système et reste historisée. +**TRAITEMENT** : une occurrence avec relevé appelle le service C1 `record_meter_reading`. Une occurrence sans relevé conserve un motif distinguant notamment inaccessible, absent, refus et relevé non connu, sans créer de mesure artificielle. Une occurrence ouverte d'un compteur remplacé est annulée immédiatement dans la transaction de remplacement avec une raison système et reste historisée. **INTÉGRATION** : les occurrences sont une source du `DayPlanner`, avec responsable, durée et retard calculé ; elles ne créent pas d'intervention et ne simulent pas de créneau si le planificateur ne sait pas en attribuer un. Les routes de configuration sont administrateur ; le traitement d'une occurrence est limité à l'utilisateur assigné ou à un administrateur.