From 601775891f8683bfc0fb89163cd50a4d1aeafff8 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 24 Aug 2026 00:17:48 +0000 Subject: [PATCH] feat(planning): add scheduled meter reading work --- app_new/core/models/__init__.py | 8 +- app_new/core/models/planning.py | 119 ++++++++ app_new/core/services/day_planner.py | 29 +- .../core/services/meter_reading_planning.py | 272 ++++++++++++++++++ app_new/planning/meter_readings.py | 184 ++++++++++++ app_new/planning/routes.py | 1 + app_new/templates/planning/index.html | 4 +- .../templates/planning/meter_occurrence.html | 7 + .../planning/meter_round_detail.html | 3 + .../templates/planning/meter_round_form.html | 3 + app_new/templates/planning/meter_rounds.html | 3 + .../planning/meter_schedule_form.html | 17 ++ .../templates/planning/meter_schedules.html | 6 + app_new/templates/planning/my_day.html | 11 + .../versions/o0d1e2f3g4h5_meter_reading_c2.py | 129 +++++++++ tests/integration/test_meter_checkpoint_c2.py | 229 +++++++++++++++ 16 files changed, 1021 insertions(+), 4 deletions(-) create mode 100644 app_new/core/services/meter_reading_planning.py create mode 100644 app_new/planning/meter_readings.py create mode 100644 app_new/templates/planning/meter_occurrence.html create mode 100644 app_new/templates/planning/meter_round_detail.html create mode 100644 app_new/templates/planning/meter_round_form.html create mode 100644 app_new/templates/planning/meter_rounds.html create mode 100644 app_new/templates/planning/meter_schedule_form.html create mode 100644 app_new/templates/planning/meter_schedules.html create mode 100644 migrations/versions/o0d1e2f3g4h5_meter_reading_c2.py create mode 100644 tests/integration/test_meter_checkpoint_c2.py diff --git a/app_new/core/models/__init__.py b/app_new/core/models/__init__.py index ade3bdf..eba7b17 100644 --- a/app_new/core/models/__init__.py +++ b/app_new/core/models/__init__.py @@ -12,7 +12,9 @@ from .maintenance import ( from .company import Company, Part, PartStockMovement, Alert, Service from .planning import ( WorkSchedule, WorkScheduleTemplate, AnnualTimeConfig, TimeEntry, CollegeClosure, ClosureSchedule, ClosureWorkDay, PersonalLeave, Training, TrainingParticipant, - Meter, MeterReading, MeterReadingCorrection, Consumable, ConsumableUsage, EquipmentConsumable, + Meter, MeterReading, MeterReadingCorrection, MeterReadingSchedule, MeterReadingOccurrence, + MeterReadingRound, MeterReadingRoundMember, MeterReadingRoundOccurrence, + Consumable, ConsumableUsage, EquipmentConsumable, PreventiveTask, PreventiveTaskConsumable, ScheduledTask, TaskExecutionSegment, TechnicianAvailability, AdminTask, ZoneAccessRule ) @@ -36,7 +38,9 @@ __all__ = [ 'Lot', 'LotTask', 'LotService', 'WorkRequest', 'WorkRequestQuote', 'WorkRequestEvent', 'Company', 'Part', 'PartStockMovement', 'InterventionPart', 'Alert', 'Service', 'WorkSchedule', 'WorkScheduleTemplate', 'AnnualTimeConfig', 'TimeEntry', 'CollegeClosure', 'ClosureSchedule', 'ClosureWorkDay', 'PersonalLeave', 'Training', 'TrainingParticipant', - 'Meter', 'MeterReading', 'MeterReadingCorrection', 'Consumable', 'ConsumableUsage', 'EquipmentConsumable', + 'Meter', 'MeterReading', 'MeterReadingCorrection', 'MeterReadingSchedule', 'MeterReadingOccurrence', + 'MeterReadingRound', 'MeterReadingRoundMember', 'MeterReadingRoundOccurrence', + 'Consumable', 'ConsumableUsage', 'EquipmentConsumable', 'PreventiveTask', 'PreventiveTaskConsumable', 'ScheduledTask', 'TaskExecutionSegment', 'TechnicianAvailability', 'AdminTask', 'ZoneAccessRule', 'AppSettings', 'AuditLog', 'TemplateAuditMark', diff --git a/app_new/core/models/planning.py b/app_new/core/models/planning.py index 3229000..cde63b5 100644 --- a/app_new/core/models/planning.py +++ b/app_new/core/models/planning.py @@ -371,6 +371,125 @@ class MeterReadingCorrection(db.Model): corrected_by = db.relationship("User") +class MeterReadingSchedule(db.Model): + """Règle de planification des relevés d'un compteur.""" + __tablename__ = "meter_reading_schedules" + __table_args__ = ( + db.UniqueConstraint("meter_id", name="uq_meter_reading_schedule_meter"), + db.CheckConstraint( + "frequency in ('WEEKLY', 'MONTHLY', 'SEMI_ANNUAL', 'ANNUAL', 'FIXED_ANNUAL_DATE')", + name="ck_meter_reading_schedule_frequency", + ), + ) + + id = db.Column(db.Integer, primary_key=True) + meter_id = db.Column(db.Integer, db.ForeignKey("meters.id", ondelete="CASCADE"), nullable=False, index=True) + is_active = db.Column(db.Boolean, nullable=False, default=True, server_default="1") + frequency = db.Column(db.String(30), nullable=False) + reference_date = db.Column(db.Date, nullable=False) + target_time = db.Column(db.Time, nullable=True) + duration_minutes = db.Column(db.Integer, nullable=False, default=5) + assigned_to_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + closed_day_policy = db.Column(db.String(30), nullable=False, default="PREVIOUS_WORKING_DAY") + calendar_scope = db.Column(db.String(20), nullable=False, default="school") + fixed_month = db.Column(db.Integer, nullable=True) + fixed_day = db.Column(db.Integer, nullable=True) + created_at = db.Column(db.DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) + updated_at = db.Column(db.DateTime, nullable=False, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) + + meter = db.relationship("Meter", backref=db.backref("reading_schedule", uselist=False)) + assigned_to = db.relationship("User") + occurrences = db.relationship( + "MeterReadingOccurrence", back_populates="schedule", cascade="all, delete-orphan", + order_by="MeterReadingOccurrence.target_date", + ) + + +class MeterReadingRound(db.Model): + """Définition d'une tournée de relevés.""" + __tablename__ = "meter_reading_rounds" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(255), nullable=False) + is_active = db.Column(db.Boolean, nullable=False, default=True, server_default="1") + default_assigned_to_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + estimated_duration_minutes = db.Column(db.Integer, nullable=True) + created_at = db.Column(db.DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) + updated_at = db.Column(db.DateTime, nullable=False, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) + + default_assigned_to = db.relationship("User") + members = db.relationship( + "MeterReadingRoundMember", back_populates="round", cascade="all, delete-orphan", + order_by="MeterReadingRoundMember.position", + ) + occurrences = db.relationship( + "MeterReadingRoundOccurrence", back_populates="round", cascade="all, delete-orphan", + ) + + +class MeterReadingRoundMember(db.Model): + """Compteur/règle et ordre d'une tournée.""" + __tablename__ = "meter_reading_round_members" + __table_args__ = ( + db.UniqueConstraint("round_id", "schedule_id", name="uq_meter_round_schedule"), + db.UniqueConstraint("round_id", "position", name="uq_meter_round_position"), + ) + + id = db.Column(db.Integer, primary_key=True) + round_id = db.Column(db.Integer, db.ForeignKey("meter_reading_rounds.id", ondelete="CASCADE"), nullable=False, index=True) + schedule_id = db.Column(db.Integer, db.ForeignKey("meter_reading_schedules.id", ondelete="CASCADE"), nullable=False, index=True) + position = db.Column(db.Integer, nullable=False) + + round = db.relationship("MeterReadingRound", back_populates="members") + schedule = db.relationship("MeterReadingSchedule") + + +class MeterReadingRoundOccurrence(db.Model): + """Occurrence opérationnelle d'une tournée à une date donnée.""" + __tablename__ = "meter_reading_round_occurrences" + __table_args__ = ( + db.UniqueConstraint("round_id", "operational_date", name="uq_meter_round_occurrence_date"), + ) + + id = db.Column(db.Integer, primary_key=True) + round_id = db.Column(db.Integer, db.ForeignKey("meter_reading_rounds.id", ondelete="CASCADE"), nullable=False, index=True) + operational_date = db.Column(db.Date, nullable=False, index=True) + status = db.Column(db.String(30), nullable=False, default="À FAIRE") + created_at = db.Column(db.DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) + + round = db.relationship("MeterReadingRound", back_populates="occurrences") + occurrences = db.relationship("MeterReadingOccurrence", back_populates="round_occurrence") + + +class MeterReadingOccurrence(db.Model): + """Une échéance unique, persistante et traitable.""" + __tablename__ = "meter_reading_occurrences" + __table_args__ = ( + db.UniqueConstraint("schedule_id", "target_date", name="uq_meter_reading_occurrence_target"), + ) + + id = db.Column(db.Integer, primary_key=True) + schedule_id = db.Column(db.Integer, db.ForeignKey("meter_reading_schedules.id", ondelete="CASCADE"), nullable=False, index=True) + target_date = db.Column(db.Date, nullable=False, index=True) + operational_date = db.Column(db.Date, nullable=False, index=True) + status = db.Column(db.String(30), nullable=False, default="À FAIRE") + assigned_to_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + meter_reading_id = db.Column(db.Integer, db.ForeignKey("meter_readings.id", ondelete="SET NULL"), nullable=True, index=True) + round_occurrence_id = db.Column(db.Integer, db.ForeignKey("meter_reading_round_occurrences.id", ondelete="SET NULL"), nullable=True, index=True) + processed_by_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + processed_at = db.Column(db.DateTime, nullable=True) + no_reading_reason = db.Column(db.String(40), nullable=True) + comment = db.Column(db.Text, nullable=True) + created_at = db.Column(db.DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) + updated_at = db.Column(db.DateTime, nullable=False, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) + + schedule = db.relationship("MeterReadingSchedule", back_populates="occurrences") + assigned_to = db.relationship("User", foreign_keys=[assigned_to_id]) + processed_by = db.relationship("User", foreign_keys=[processed_by_id]) + meter_reading = db.relationship("MeterReading") + round_occurrence = db.relationship("MeterReadingRoundOccurrence", back_populates="occurrences") + + class Consumable(db.Model): """Consommable pour maintenance.""" __tablename__ = "consumables" diff --git a/app_new/core/services/day_planner.py b/app_new/core/services/day_planner.py index b002eaf..b77e6e1 100644 --- a/app_new/core/services/day_planner.py +++ b/app_new/core/services/day_planner.py @@ -20,6 +20,7 @@ TYPE_LABELS = { "admin": "Administratif", "external_company": "Entreprise extérieure", "emergency": "Urgence", + "meter_reading": "Relevé compteur", } CONSTRAINT_LABELS = { "fixed": "Horaire fixe", @@ -39,6 +40,7 @@ class PlanningCandidate: priority: int = 3 constraint: str = "flexible" target_date: Optional[date] = None + due_date: Optional[date] = None earliest_start: Optional[time] = None latest_end: Optional[time] = None duration_minutes: int = 30 @@ -272,12 +274,14 @@ class DayPlanner: @classmethod def from_database(cls, day: date, user_id=None): - """Charge les sources accessibles sans créer de nouvelle table.""" + """Charge les sources accessibles, dont les échéances de relevé C2.""" from ..models.planning import ScheduledTask, AdminTask from ..models.maintenance import Intervention from ...contracts.models import ContractVisit + from .meter_reading_planning import ensure_occurrences_for_day, list_open_occurrences candidates = [] + ensure_occurrences_for_day(day, user_id=user_id) tasks = ScheduledTask.query.filter( ScheduledTask.scheduled_date == day, ScheduledTask.status.in_(("planned", "in_progress", "suspended", "postponed")), @@ -366,4 +370,27 @@ class DayPlanner: fixed_start=fixed_start, fixed_end=fixed_end, explanation=visit.display_mode, )) + for occurrence in list_open_occurrences(day, user_id=user_id): + meter = occurrence.schedule.meter + scope = meter.equipment or meter.building or meter.zone or meter.room or meter.housing_unit + room = meter.room or (meter.equipment.effective_room if meter.equipment else None) + scope_name = getattr(scope, "name", None) + title = f"Relevé compteur — {meter.name}" + if scope_name: + title += f" — {scope_name}" + overdue = occurrence.operational_date < day + candidates.append(PlanningCandidate( + source_type="meter_reading_occurrence", source_id=occurrence.id, + title=title, description=meter.usage or "", + task_type="meter_reading", priority=2 if overdue else 3, + constraint="deadline", target_date=day, due_date=occurrence.target_date, + duration_minutes=occurrence.schedule.duration_minutes, + room_id=room.id if room else None, room_name=room.name if room else None, + zone_name=room.zone.name if room and room.zone else None, + building_name=room.building.name if room and room.building else None, + assigned_to=occurrence.assigned_to_id, + status="en retard" if overdue else "à faire", + explanation=(f"Échéance cible : {occurrence.target_date.strftime('%d/%m/%Y')}" + + (" · date opérationnelle dépassée" if overdue else "")), + )) return cls.propose(day, candidates, user_id=user_id) diff --git a/app_new/core/services/meter_reading_planning.py b/app_new/core/services/meter_reading_planning.py new file mode 100644 index 0000000..9df0d29 --- /dev/null +++ b/app_new/core/services/meter_reading_planning.py @@ -0,0 +1,272 @@ +"""Planification opérationnelle des relevés de compteurs (Checkpoint C2).""" + +from calendar import monthrange +from datetime import date, datetime, time, timedelta + +from sqlalchemy import select +from sqlalchemy.orm import joinedload + +from ...extensions import db +from ..models.planning import ( + MeterReadingOccurrence, MeterReadingRound, MeterReadingRoundMember, + MeterReadingRoundOccurrence, MeterReadingSchedule, +) +from .meter_service import MeterDomainError, record_meter_reading + + +FREQUENCIES = {"WEEKLY", "MONTHLY", "SEMI_ANNUAL", "ANNUAL", "FIXED_ANNUAL_DATE"} +NO_READING_REASONS = { + "INACCESSIBLE": "Compteur inaccessible", + "OCCUPANT_ABSENT": "Occupant absent", + "OCCUPANT_REFUSED": "Occupant refuse de communiquer le relevé", + "READING_UNKNOWN": "Relevé non connu", + "OTHER": "Autre", +} +TODO = "À FAIRE" +TREATED_READING = "TRAITÉE AVEC RELEVÉ" +TREATED_WITHOUT_READING = "TRAITÉE SANS RELEVÉ" +CANCELLED = "ANNULÉE" + + +def _add_months(value, months): + index = value.year * 12 + value.month - 1 + months + year, month = divmod(index, 12) + month += 1 + return date(year, month, min(value.day, monthrange(year, month)[1])) + + +def _target_dates(schedule, start_date, end_date): + reference = schedule.reference_date + if schedule.frequency == "FIXED_ANNUAL_DATE": + month = schedule.fixed_month or reference.month + day = schedule.fixed_day or reference.day + for year in range(start_date.year - 1, end_date.year + 2): + candidate = date(year, month, min(day, monthrange(year, month)[1])) + if start_date <= candidate <= end_date: + yield candidate + return + + step = {"WEEKLY": 7, "MONTHLY": 1, "SEMI_ANNUAL": 6, "ANNUAL": 12}[schedule.frequency] + cursor = reference + if schedule.frequency == "WEEKLY": + if cursor < start_date: + cursor += timedelta(days=((start_date - cursor).days // 7) * 7) + else: + while cursor < start_date: + cursor = _add_months(cursor, step) + while cursor <= end_date: + if cursor >= start_date: + yield cursor + cursor = cursor + timedelta(days=step) if schedule.frequency == "WEEKLY" else _add_months(cursor, step) + + +def operational_date_for(schedule, target_date): + """Conserve la date cible et anticipe uniquement pour un calendrier scolaire.""" + if schedule.calendar_scope != "school" or schedule.closed_day_policy != "PREVIOUS_WORKING_DAY": + return target_date + from .planning_service import PlanningService + # Sans horaires explicites pour le responsable, le moteur ne peut pas + # déduire qu'un jour est travaillé. On conserve donc la date cible ; une + # configuration d'horaires ultérieure permettra l'anticipation précise. + from ..models.planning import WorkSchedule + schedules = WorkSchedule.query.filter_by(is_active=True) + if schedule.assigned_to_id is not None: + schedules = schedules.filter_by(user_id=schedule.assigned_to_id) + if schedules.first() is None: + return target_date + if PlanningService.get_working_hours(target_date, user_id=schedule.assigned_to_id): + return target_date + candidate = target_date - timedelta(days=1) + for _ in range(366): + if PlanningService.get_working_hours(candidate, user_id=schedule.assigned_to_id): + return candidate + candidate -= timedelta(days=1) + raise MeterDomainError("Aucun jour opérationnel trouvé avant l'échéance.") + + +def create_or_update_schedule(*, meter, frequency, reference_date, target_time=None, + duration_minutes=5, assigned_to_id=None, + closed_day_policy="PREVIOUS_WORKING_DAY", + calendar_scope=None, fixed_month=None, fixed_day=None, + is_active=True, commit=True): + if frequency not in FREQUENCIES: + raise MeterDomainError("Fréquence de relevé inconnue.") + if not reference_date: + raise MeterDomainError("Une date de référence est obligatoire.") + if int(duration_minutes or 0) <= 0: + raise MeterDomainError("La durée estimée doit être positive.") + if meter.status == "replaced" or not meter.is_active: + raise MeterDomainError("Un compteur remplacé ou inactif ne peut plus recevoir de règle de relevé.") + if calendar_scope is None: + calendar_scope = "housing" if meter.housing_unit_id else "school" + schedule = meter.reading_schedule + if schedule is None: + schedule = MeterReadingSchedule(meter=meter) + db.session.add(schedule) + schedule.frequency = frequency + schedule.reference_date = reference_date + schedule.target_time = target_time + schedule.duration_minutes = int(duration_minutes) + schedule.assigned_to_id = assigned_to_id + schedule.closed_day_policy = closed_day_policy + schedule.calendar_scope = calendar_scope + schedule.fixed_month = fixed_month + schedule.fixed_day = fixed_day + schedule.is_active = bool(is_active) + if commit: + db.session.commit() + return schedule + + +def _round_occurrence_for(schedule, operational_date): + member = MeterReadingRoundMember.query.filter_by(schedule_id=schedule.id).first() + if not member or not member.round.is_active: + return None + occurrence = MeterReadingRoundOccurrence.query.filter_by( + round_id=member.round_id, operational_date=operational_date, + ).first() + if occurrence is None: + occurrence = MeterReadingRoundOccurrence(round_id=member.round_id, operational_date=operational_date) + db.session.add(occurrence) + db.session.flush() + return occurrence + + +def _refresh_round_status(round_occurrence): + if not round_occurrence: + return + statuses = [item.status for item in round_occurrence.occurrences] + if statuses and all(status != TODO for status in statuses): + round_occurrence.status = "TERMINÉE" + elif any(status != TODO for status in statuses): + round_occurrence.status = "EN COURS" + else: + round_occurrence.status = TODO + + +def generate_occurrences(*, start_date, end_date, schedule_ids=None, commit=True): + """Génère un horizon borné, avec unicité logique par règle/date.""" + query = MeterReadingSchedule.query.options(joinedload(MeterReadingSchedule.meter)).filter_by(is_active=True) + if schedule_ids is not None: + query = query.filter(MeterReadingSchedule.id.in_(schedule_ids)) + 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." + continue + for target_date in _target_dates(schedule, start_date, end_date): + occurrence = MeterReadingOccurrence.query.filter_by( + schedule_id=schedule.id, target_date=target_date, + ).first() + if occurrence is not None: + continue + operational_date = operational_date_for(schedule, target_date) + occurrence = MeterReadingOccurrence( + schedule=schedule, target_date=target_date, operational_date=operational_date, + assigned_to_id=schedule.assigned_to_id, + ) + db.session.add(occurrence) + db.session.flush() + occurrence.round_occurrence = _round_occurrence_for(schedule, operational_date) + created.append(occurrence) + if commit: + db.session.commit() + return created + + +def ensure_occurrences_for_day(day, user_id=None, lookback_days=365, horizon_days=31): + schedules = MeterReadingSchedule.query.filter_by(is_active=True) + if user_id is not None: + schedules = schedules.filter(MeterReadingSchedule.assigned_to_id == user_id) + ids = [item.id for item in schedules.all()] + return generate_occurrences( + start_date=day - timedelta(days=lookback_days), + end_date=day + timedelta(days=horizon_days), + schedule_ids=ids, + ) if ids else [] + + +def list_open_occurrences(day, user_id=None): + query = MeterReadingOccurrence.query.filter( + MeterReadingOccurrence.status == TODO, + MeterReadingOccurrence.operational_date <= day, + ).options( + joinedload(MeterReadingOccurrence.schedule).joinedload(MeterReadingSchedule.meter), + joinedload(MeterReadingOccurrence.assigned_to), + ) + if user_id is not None: + query = query.filter(MeterReadingOccurrence.assigned_to_id == user_id) + return query.order_by(MeterReadingOccurrence.operational_date, MeterReadingOccurrence.id).all() + + +def _locked_occurrence(occurrence_id): + occurrence = db.session.execute( + select(MeterReadingOccurrence).where(MeterReadingOccurrence.id == occurrence_id).with_for_update() + ).scalar_one() + if occurrence.status != TODO: + raise MeterDomainError("Cette échéance est déjà traitée ou annulée.") + return occurrence + + +def record_occurrence_reading(*, occurrence_id, value, user_id, notes=None, + is_reset=False, reset_reason=None, + photo_filename=None, photo_path=None, reading_date=None): + occurrence = _locked_occurrence(occurrence_id) + if occurrence.assigned_to_id not in (None, user_id): + raise MeterDomainError("Cette échéance n'est pas attribuée à cet utilisateur.") + reading = record_meter_reading( + meter=occurrence.schedule.meter, value=value, user_id=user_id, + notes=notes, is_reset=is_reset, reset_reason=reset_reason, + photo_filename=photo_filename, photo_path=photo_path, + reading_date=reading_date, commit=False, + ) + occurrence.status = TREATED_READING + occurrence.meter_reading = reading + occurrence.processed_by_id = user_id + occurrence.processed_at = datetime.now() + _refresh_round_status(occurrence.round_occurrence) + db.session.commit() + return occurrence + + +def record_occurrence_without_reading(*, occurrence_id, reason, user_id, comment=None): + if reason not in NO_READING_REASONS: + raise MeterDomainError("Motif de relevé non obtenu inconnu.") + occurrence = _locked_occurrence(occurrence_id) + if occurrence.assigned_to_id not in (None, user_id): + raise MeterDomainError("Cette échéance n'est pas attribuée à cet utilisateur.") + occurrence.status = TREATED_WITHOUT_READING + occurrence.no_reading_reason = reason + occurrence.comment = (comment or "").strip() or None + occurrence.processed_by_id = user_id + occurrence.processed_at = datetime.now() + _refresh_round_status(occurrence.round_occurrence) + db.session.commit() + return occurrence + + +def create_round(*, name, default_assigned_to_id=None, estimated_duration_minutes=None, commit=True): + name = (name or "").strip() + if not name: + raise MeterDomainError("Le nom de la tournée est obligatoire.") + round_ = MeterReadingRound( + name=name, default_assigned_to_id=default_assigned_to_id, + estimated_duration_minutes=estimated_duration_minutes, + ) + db.session.add(round_) + if commit: + db.session.commit() + return round_ + + +def add_round_member(*, round_, schedule, position, commit=True): + if position is None or int(position) < 1: + raise MeterDomainError("La position de tournée doit être positive.") + member = MeterReadingRoundMember(round=round_, schedule=schedule, position=int(position)) + db.session.add(member) + if commit: + db.session.commit() + return member diff --git a/app_new/planning/meter_readings.py b/app_new/planning/meter_readings.py new file mode 100644 index 0000000..b8d6a8e --- /dev/null +++ b/app_new/planning/meter_readings.py @@ -0,0 +1,184 @@ +"""Routes C2 : règles, échéances et tournées de relevés.""" + +from datetime import date, datetime +from pathlib import Path +from uuid import uuid4 + +from flask import abort, current_app, flash, jsonify, redirect, render_template, request, url_for +from flask_login import current_user, login_required +from werkzeug.utils import secure_filename + +from ..extensions import db +from ..core.models.planning import ( + Meter, MeterReadingOccurrence, MeterReadingRound, MeterReadingSchedule, +) +from ..core.models.user import User +from ..core.services.meter_reading_planning import ( + FREQUENCIES, NO_READING_REASONS, MeterDomainError, add_round_member, create_or_update_schedule, + create_round, ensure_occurrences_for_day, generate_occurrences, + record_occurrence_reading, record_occurrence_without_reading, +) +from .schedules import planning_bp + + +def _admin_required(): + if not current_user.is_admin(): + abort(403) + + +@planning_bp.route('/meter-schedules') +@login_required +def meter_schedules(): + _admin_required() + schedules = MeterReadingSchedule.query.order_by(MeterReadingSchedule.id.desc()).all() + return render_template('planning/meter_schedules.html', schedules=schedules) + + +@planning_bp.route('/meter-schedules/new', methods=['GET', 'POST']) +@login_required +def new_meter_schedule(): + _admin_required() + meters = Meter.query.filter(Meter.status != 'replaced', Meter.is_active.is_(True)).order_by(Meter.name).all() + users = User.query.filter_by(is_active=True).order_by(User.full_name, User.username).all() + if request.method == 'POST': + meter = db.session.get(Meter, request.form.get('meter_id', type=int)) + if not meter: + flash('Compteur invalide.', 'danger') + else: + try: + reference_date = date.fromisoformat(request.form.get('reference_date')) + target_time = datetime.strptime(request.form['target_time'], '%H:%M').time() if request.form.get('target_time') else None + create_or_update_schedule( + meter=meter, frequency=request.form.get('frequency'), reference_date=reference_date, + target_time=target_time, duration_minutes=request.form.get('duration_minutes', type=int, default=5), + assigned_to_id=request.form.get('assigned_to_id', type=int), + calendar_scope=request.form.get('calendar_scope') or None, + fixed_month=request.form.get('fixed_month', type=int), fixed_day=request.form.get('fixed_day', type=int), + ) + flash('Règle de relevé enregistrée.', 'success') + return redirect(url_for('planning.meter_schedules')) + except (ValueError, TypeError) as exc: + flash(str(exc) or 'Date ou heure invalide.', 'danger') + return render_template('planning/meter_schedule_form.html', meters=meters, users=users, frequencies=sorted(FREQUENCIES)) + + +@planning_bp.route('/meter-occurrences/') +@login_required +def meter_occurrence_detail(occurrence_id): + occurrence = MeterReadingOccurrence.query.get_or_404(occurrence_id) + if occurrence.assigned_to_id not in (None, current_user.id) and not current_user.is_admin(): + abort(403) + return render_template( + 'planning/meter_occurrence.html', occurrence=occurrence, + no_reading_reasons=NO_READING_REASONS, + ) + + +def _optional_photo(): + photo = request.files.get('photo') + if not photo or not photo.filename: + return None, None + allowed = {'jpg', 'jpeg', 'png', 'gif'} + if '.' not in photo.filename or photo.filename.rsplit('.', 1)[1].lower() not in allowed: + raise ValueError('La photo doit être une image JPG, PNG ou GIF.') + safe_name = secure_filename(photo.filename) + upload_dir = Path(current_app.config.get('UPLOAD_FOLDER', 'uploads')) / 'meter-readings' + upload_dir.mkdir(parents=True, exist_ok=True) + path = upload_dir / f'{uuid4().hex}_{safe_name}' + photo.save(path) + return path.name, str(path) + + +@planning_bp.route('/meter-occurrences//reading', methods=['POST']) +@login_required +def meter_occurrence_reading(occurrence_id): + photo_path = None + try: + photo_filename, photo_path = _optional_photo() + record_occurrence_reading( + occurrence_id=occurrence_id, value=request.form.get('value'), user_id=current_user.id, + notes=request.form.get('notes'), is_reset=request.form.get('is_reset') == '1', + reset_reason=request.form.get('reset_reason'), photo_filename=photo_filename, photo_path=photo_path, + ) + flash('Relevé enregistré et échéance traitée.', 'success') + except (ValueError, MeterDomainError) as exc: + db.session.rollback() + if photo_path: + Path(photo_path).unlink(missing_ok=True) + flash(str(exc), 'danger') + return redirect(url_for('planning.meter_occurrence_detail', occurrence_id=occurrence_id)) + + +@planning_bp.route('/meter-occurrences//without-reading', methods=['POST']) +@login_required +def meter_occurrence_without_reading(occurrence_id): + try: + record_occurrence_without_reading( + occurrence_id=occurrence_id, reason=request.form.get('reason'), + user_id=current_user.id, comment=request.form.get('comment'), + ) + flash('Échéance traitée sans relevé.', 'success') + except MeterDomainError as exc: + db.session.rollback() + flash(str(exc), 'danger') + return redirect(url_for('planning.meter_occurrence_detail', occurrence_id=occurrence_id)) + + +@planning_bp.route('/meter-rounds') +@login_required +def meter_rounds(): + _admin_required() + rounds = MeterReadingRound.query.order_by(MeterReadingRound.name).all() + return render_template('planning/meter_rounds.html', rounds=rounds) + + +@planning_bp.route('/meter-rounds/new', methods=['GET', 'POST']) +@login_required +def new_meter_round(): + _admin_required() + schedules = MeterReadingSchedule.query.filter_by(is_active=True).order_by(MeterReadingSchedule.id).all() + users = User.query.filter_by(is_active=True).order_by(User.full_name, User.username).all() + if request.method == 'POST': + try: + round_ = create_round( + name=request.form.get('name'), default_assigned_to_id=request.form.get('assigned_to_id', type=int), + estimated_duration_minutes=request.form.get('estimated_duration_minutes', type=int), + commit=False, + ) + selected = request.form.getlist('schedule_ids') + for position, schedule_id in enumerate(selected, 1): + schedule = db.session.get(MeterReadingSchedule, int(schedule_id)) + if schedule: + add_round_member(round_=round_, schedule=schedule, position=position, commit=False) + db.session.commit() + flash('Tournée enregistrée.', 'success') + return redirect(url_for('planning.meter_round_detail', round_id=round_.id)) + except (ValueError, TypeError, MeterDomainError) as exc: + db.session.rollback() + flash(str(exc), 'danger') + return render_template('planning/meter_round_form.html', schedules=schedules, users=users) + + +@planning_bp.route('/meter-rounds/') +@login_required +def meter_round_detail(round_id): + round_ = MeterReadingRound.query.get_or_404(round_id) + if not current_user.is_admin(): + assigned = {member.schedule.assigned_to_id for member in round_.members} + if round_.default_assigned_to_id != current_user.id and current_user.id not in assigned: + abort(403) + target_date = request.args.get('date', date.today().isoformat()) + try: + target_date = date.fromisoformat(target_date) + except ValueError: + target_date = date.today() + generate_occurrences(start_date=target_date, end_date=target_date) + round_occurrence = next((item for item in round_.occurrences if item.operational_date == target_date), None) + members = [] + if round_occurrence: + members = sorted(round_occurrence.occurrences, key=lambda item: item.schedule.meter.name) + members.sort(key=lambda item: next((m.position for m in round_.members if m.schedule_id == item.schedule_id), 9999)) + return render_template( + 'planning/meter_round_detail.html', round=round_, round_occurrence=round_occurrence, + members=members, target_date=target_date, + ) diff --git a/app_new/planning/routes.py b/app_new/planning/routes.py index dd5c5d4..bf002a1 100644 --- a/app_new/planning/routes.py +++ b/app_new/planning/routes.py @@ -6,6 +6,7 @@ from . import schedules from . import closures from . import time_tracking from . import admin_rules +from . import meter_readings from .schedules import planning_bp __all__ = ["planning_bp"] diff --git a/app_new/templates/planning/index.html b/app_new/templates/planning/index.html index 59df517..9b68d7f 100644 --- a/app_new/templates/planning/index.html +++ b/app_new/templates/planning/index.html @@ -20,6 +20,8 @@

Planning

Horaires + {% if current_user.is_admin() %} Relevés{% endif %} + {% if current_user.is_admin() %} Tournées{% endif %} Aide
@@ -146,4 +148,4 @@ -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/app_new/templates/planning/meter_occurrence.html b/app_new/templates/planning/meter_occurrence.html new file mode 100644 index 0000000..c18e74a --- /dev/null +++ b/app_new/templates/planning/meter_occurrence.html @@ -0,0 +1,7 @@ +{% extends "base.html" %} +{% block title %}Relevé — {{ occurrence.schedule.meter.name }}{% endblock %} +{% block content %} +
← Ma journée

{{ occurrence.schedule.meter.name }}

Échéance cible : {{ occurrence.target_date.strftime('%d/%m/%Y') }}

Date opérationnelle : {{ occurrence.operational_date.strftime('%d/%m/%Y') }}

Durée estimée : {{ occurrence.schedule.duration_minutes }} min

{{ occurrence.schedule.meter.usage or 'Usage non précisé' }}

+{% if occurrence.status == 'À FAIRE' %}

{% else %}
{{ occurrence.status }}{% if occurrence.no_reading_reason %} — {{ no_reading_reasons.get(occurrence.no_reading_reason, occurrence.no_reading_reason) }}{% endif %}{% if occurrence.comment %}
{{ occurrence.comment }}{% endif %}
{% endif %} +
+{% endblock %} diff --git a/app_new/templates/planning/meter_round_detail.html b/app_new/templates/planning/meter_round_detail.html new file mode 100644 index 0000000..6a02cda --- /dev/null +++ b/app_new/templates/planning/meter_round_detail.html @@ -0,0 +1,3 @@ +{% extends "base.html" %} +{% block title %}{{ round.name }}{% endblock %} +{% block content %}
← Tournées

{{ round.name }}

{% set done = members|selectattr('status', 'ne', 'À FAIRE')|list|length %}

{{ done }} / {{ members|length }} relevé(s) traité(s) — {{ target_date.strftime('%d/%m/%Y') }}

{% for occurrence in members %}
{{ loop.index }}. {{ occurrence.schedule.meter.name }}
{{ occurrence.schedule.meter.current_value }} {{ occurrence.schedule.meter.unit }} · {{ occurrence.schedule.duration_minutes }} min
{% if occurrence.status == 'À FAIRE' %}Relever{% else %}{{ occurrence.status }}{% endif %}
{% else %}
Aucune occurrence pour cette date.
{% endfor %}
{% endblock %} diff --git a/app_new/templates/planning/meter_round_form.html b/app_new/templates/planning/meter_round_form.html new file mode 100644 index 0000000..2300be5 --- /dev/null +++ b/app_new/templates/planning/meter_round_form.html @@ -0,0 +1,3 @@ +{% extends "base.html" %} +{% block title %}Nouvelle tournée de relevés{% endblock %} +{% block content %}

Nouvelle tournée de relevés

Membres dans l'ordre

{% for schedule in schedules %}
{% endfor %}
{% endblock %} diff --git a/app_new/templates/planning/meter_rounds.html b/app_new/templates/planning/meter_rounds.html new file mode 100644 index 0000000..4d84dd3 --- /dev/null +++ b/app_new/templates/planning/meter_rounds.html @@ -0,0 +1,3 @@ +{% extends "base.html" %} +{% block title %}Tournées de relevés{% endblock %} +{% block content %}

Tournées de relevés

Nouvelle tournée
{% for round in rounds %}{{ round.name }}{{ round.members|length }} compteur(s){% else %}
Aucune tournée.
{% endfor %}
{% endblock %} diff --git a/app_new/templates/planning/meter_schedule_form.html b/app_new/templates/planning/meter_schedule_form.html new file mode 100644 index 0000000..29a255c --- /dev/null +++ b/app_new/templates/planning/meter_schedule_form.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} +{% block title %}Nouvelle règle de relevé{% endblock %} +{% block content %} +

Nouvelle règle de relevé

+
+ +
+
+
+
+
+
+
+
+
Annuler
+
+{% endblock %} diff --git a/app_new/templates/planning/meter_schedules.html b/app_new/templates/planning/meter_schedules.html new file mode 100644 index 0000000..1dcf83c --- /dev/null +++ b/app_new/templates/planning/meter_schedules.html @@ -0,0 +1,6 @@ +{% extends "base.html" %} +{% block title %}Règles de relevé{% endblock %} +{% block content %} +

Règles de relevé

Nouvelle règle
+
{% for schedule in schedules %}{% else %}{% endfor %}
CompteurFréquenceRéférenceCalendrierResponsableDuréeÉtat
{{ schedule.meter.name }}{{ schedule.frequency }}{{ schedule.reference_date.strftime('%d/%m/%Y') }}{{ 'Logement' if schedule.calendar_scope == 'housing' else 'Collège' }}{{ schedule.assigned_to.full_name if schedule.assigned_to else 'Non attribué' }}{{ schedule.duration_minutes }} min{{ 'Active' if schedule.is_active else 'Inactive' }}
Aucune règle configurée.
+{% endblock %} diff --git a/app_new/templates/planning/my_day.html b/app_new/templates/planning/my_day.html index ada3a12..f0c6b5b 100644 --- a/app_new/templates/planning/my_day.html +++ b/app_new/templates/planning/my_day.html @@ -44,6 +44,11 @@

{{ item.title }}

{{ item.location_label }}
+ {% if item.source_type == 'meter_reading_occurrence' and item.due_date %} +
+ Échéance cible : {{ item.due_date.strftime('%d/%m/%Y') }}{% if item.due_date < target_date %} · en retard{% endif %} +
+ {% endif %}
{{ item.type_label }} @@ -52,6 +57,12 @@
{% if item.explanation %}

{{ item.explanation }}

{% endif %} + {% if item.source_type == 'meter_reading_occurrence' %} + + {% endif %} {% if item.source_type == 'scheduled_task' and execution_states.get(item.source_id) %} {% set execution = execution_states.get(item.source_id) %}
Temps réalisé : {{ execution.realized }} min · restant : {{ execution.remaining }} min
diff --git a/migrations/versions/o0d1e2f3g4h5_meter_reading_c2.py b/migrations/versions/o0d1e2f3g4h5_meter_reading_c2.py new file mode 100644 index 0000000..4ebbf39 --- /dev/null +++ b/migrations/versions/o0d1e2f3g4h5_meter_reading_c2.py @@ -0,0 +1,129 @@ +"""Checkpoint C2: règles, occurrences et tournées de relevés.""" + +from alembic import op +import sqlalchemy as sa + + +revision = "o0d1e2f3g4h5" +down_revision = "n9c0d1e2f3g4" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "meter_reading_schedules", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("meter_id", sa.Integer(), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default="1"), + sa.Column("frequency", sa.String(30), nullable=False), + sa.Column("reference_date", sa.Date(), nullable=False), + sa.Column("target_time", sa.Time(), nullable=True), + sa.Column("duration_minutes", sa.Integer(), nullable=False, server_default="5"), + sa.Column("assigned_to_id", sa.Integer(), nullable=True), + sa.Column("closed_day_policy", sa.String(30), nullable=False, server_default="PREVIOUS_WORKING_DAY"), + sa.Column("calendar_scope", sa.String(20), nullable=False, server_default="school"), + sa.Column("fixed_month", sa.Integer(), nullable=True), + sa.Column("fixed_day", sa.Integer(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["meter_id"], ["meters.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["assigned_to_id"], ["users.id"], ondelete="SET NULL"), + sa.UniqueConstraint("meter_id", name="uq_meter_reading_schedule_meter"), + sa.CheckConstraint("frequency in ('WEEKLY', 'MONTHLY', 'SEMI_ANNUAL', 'ANNUAL', 'FIXED_ANNUAL_DATE')", name="ck_meter_reading_schedule_frequency"), + ) + op.create_index("ix_meter_reading_schedules_meter_id", "meter_reading_schedules", ["meter_id"]) + op.create_index("ix_meter_reading_schedules_assigned_to_id", "meter_reading_schedules", ["assigned_to_id"]) + + op.create_table( + "meter_reading_rounds", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("name", sa.String(255), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default="1"), + sa.Column("default_assigned_to_id", sa.Integer(), nullable=True), + sa.Column("estimated_duration_minutes", sa.Integer(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["default_assigned_to_id"], ["users.id"], ondelete="SET NULL"), + ) + op.create_index("ix_meter_reading_rounds_default_assigned_to_id", "meter_reading_rounds", ["default_assigned_to_id"]) + + op.create_table( + "meter_reading_round_members", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("round_id", sa.Integer(), nullable=False), + sa.Column("schedule_id", sa.Integer(), nullable=False), + sa.Column("position", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(["round_id"], ["meter_reading_rounds.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["schedule_id"], ["meter_reading_schedules.id"], ondelete="CASCADE"), + sa.UniqueConstraint("round_id", "schedule_id", name="uq_meter_round_schedule"), + sa.UniqueConstraint("round_id", "position", name="uq_meter_round_position"), + ) + op.create_index("ix_meter_reading_round_members_round_id", "meter_reading_round_members", ["round_id"]) + op.create_index("ix_meter_reading_round_members_schedule_id", "meter_reading_round_members", ["schedule_id"]) + + op.create_table( + "meter_reading_round_occurrences", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("round_id", sa.Integer(), nullable=False), + sa.Column("operational_date", sa.Date(), nullable=False), + sa.Column("status", sa.String(30), nullable=False, server_default="À FAIRE"), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["round_id"], ["meter_reading_rounds.id"], ondelete="CASCADE"), + sa.UniqueConstraint("round_id", "operational_date", name="uq_meter_round_occurrence_date"), + ) + op.create_index("ix_meter_reading_round_occurrences_round_id", "meter_reading_round_occurrences", ["round_id"]) + op.create_index("ix_meter_reading_round_occurrences_operational_date", "meter_reading_round_occurrences", ["operational_date"]) + + op.create_table( + "meter_reading_occurrences", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("schedule_id", sa.Integer(), nullable=False), + sa.Column("target_date", sa.Date(), nullable=False), + sa.Column("operational_date", sa.Date(), nullable=False), + sa.Column("status", sa.String(30), nullable=False, server_default="À FAIRE"), + sa.Column("assigned_to_id", sa.Integer(), nullable=True), + sa.Column("meter_reading_id", sa.Integer(), nullable=True), + sa.Column("round_occurrence_id", sa.Integer(), nullable=True), + sa.Column("processed_by_id", sa.Integer(), nullable=True), + sa.Column("processed_at", sa.DateTime(), nullable=True), + sa.Column("no_reading_reason", sa.String(40), nullable=True), + sa.Column("comment", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["schedule_id"], ["meter_reading_schedules.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["assigned_to_id"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["meter_reading_id"], ["meter_readings.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["round_occurrence_id"], ["meter_reading_round_occurrences.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["processed_by_id"], ["users.id"], ondelete="SET NULL"), + sa.UniqueConstraint("schedule_id", "target_date", name="uq_meter_reading_occurrence_target"), + ) + for name, column in ( + ("schedule_id", "schedule_id"), ("target_date", "target_date"), + ("operational_date", "operational_date"), ("assigned_to_id", "assigned_to_id"), + ("meter_reading_id", "meter_reading_id"), ("round_occurrence_id", "round_occurrence_id"), + ("processed_by_id", "processed_by_id"), + ): + op.create_index(f"ix_meter_reading_occurrences_{name}", "meter_reading_occurrences", [column]) + + +def downgrade(): + for name in ( + "ix_meter_reading_occurrences_processed_by_id", "ix_meter_reading_occurrences_round_occurrence_id", + "ix_meter_reading_occurrences_meter_reading_id", "ix_meter_reading_occurrences_assigned_to_id", + "ix_meter_reading_occurrences_operational_date", "ix_meter_reading_occurrences_target_date", + "ix_meter_reading_occurrences_schedule_id", + ): + op.drop_index(name, table_name="meter_reading_occurrences") + op.drop_table("meter_reading_occurrences") + op.drop_index("ix_meter_reading_round_occurrences_operational_date", table_name="meter_reading_round_occurrences") + op.drop_index("ix_meter_reading_round_occurrences_round_id", table_name="meter_reading_round_occurrences") + op.drop_table("meter_reading_round_occurrences") + op.drop_index("ix_meter_reading_round_members_schedule_id", table_name="meter_reading_round_members") + op.drop_index("ix_meter_reading_round_members_round_id", table_name="meter_reading_round_members") + op.drop_table("meter_reading_round_members") + op.drop_index("ix_meter_reading_rounds_default_assigned_to_id", table_name="meter_reading_rounds") + op.drop_table("meter_reading_rounds") + op.drop_index("ix_meter_reading_schedules_assigned_to_id", table_name="meter_reading_schedules") + op.drop_index("ix_meter_reading_schedules_meter_id", table_name="meter_reading_schedules") + op.drop_table("meter_reading_schedules") diff --git a/tests/integration/test_meter_checkpoint_c2.py b/tests/integration/test_meter_checkpoint_c2.py new file mode 100644 index 0000000..5c9150b --- /dev/null +++ b/tests/integration/test_meter_checkpoint_c2.py @@ -0,0 +1,229 @@ +from datetime import date, time +from time import perf_counter + +import pytest +from sqlalchemy import event + +from app_new import db +from app_new.core.models.college import Building, HousingUnit +from app_new.core.models.planning import ( + Meter, MeterReading, MeterReadingOccurrence, MeterReadingRoundOccurrence, + MeterReadingSchedule, WorkSchedule, +) +from app_new.core.services.meter_reading_planning import ( + CANCELLED, TREATED_READING, TREATED_WITHOUT_READING, add_round_member, + create_or_update_schedule, create_round, generate_occurrences, + operational_date_for, record_occurrence_reading, record_occurrence_without_reading, +) +from app_new.core.services.meter_service import create_meter, replace_meter + + +def _meter(name, building=None, housing=None): + if building is None: + building = Building(name=f"TEST_UI_C2_{name}_BUILDING") + db.session.add(building) + db.session.flush() + return create_meter(name=f"TEST_UI_C2_{name}", building_id=building.id if housing is None else None, housing_unit_id=housing.id if housing else None) + + +def _schedule(meter, admin_user, frequency, reference, **kwargs): + return create_or_update_schedule( + meter=meter, frequency=frequency, reference_date=reference, + assigned_to_id=admin_user["id"], **kwargs, + ) + + +def test_c2_frequencies_and_generation_are_idempotent(app, admin_user): + with app.app_context(): + frequencies = { + "WEEKLY": date(2026, 10, 5), "MONTHLY": date(2026, 10, 1), + "SEMI_ANNUAL": date(2026, 10, 1), "ANNUAL": date(2026, 10, 1), + "FIXED_ANNUAL_DATE": date(2026, 10, 1), + } + schedules = [] + for frequency, reference in frequencies.items(): + meter = _meter(f"FREQ_{frequency}") + schedules.append(_schedule(meter, admin_user, frequency, reference, fixed_month=10, fixed_day=1)) + db.session.commit() + first = generate_occurrences(start_date=date(2026, 10, 1), end_date=date(2027, 10, 10)) + counts = {schedule.id: MeterReadingOccurrence.query.filter_by(schedule_id=schedule.id).count() for schedule in schedules} + second = generate_occurrences(start_date=date(2026, 10, 1), end_date=date(2027, 10, 10)) + assert first + assert second == [] + assert all(MeterReadingOccurrence.query.filter_by(schedule_id=schedule.id).count() == counts[schedule.id] for schedule in schedules) + assert counts[schedules[0].id] == 53 + assert counts[schedules[1].id] == 13 + assert counts[schedules[2].id] == 3 + assert counts[schedules[3].id] == 2 + assert counts[schedules[4].id] == 2 + + +def test_c2_target_and_operational_dates_and_housing_calendar(app, admin_user): + with app.app_context(): + building = Building(name="TEST_UI_C2_CALENDAR_BUILDING") + housing = HousingUnit(name="TEST_UI_C2_HOUSING", building=building) + db.session.add_all([building, housing]) + db.session.flush() + school_meter = _meter("SCHOOL_DATE", building=building) + schedule = _schedule(school_meter, admin_user, "FIXED_ANNUAL_DATE", date(2026, 10, 3)) + db.session.add(WorkSchedule(user_id=admin_user["id"], day_of_week=4, start_time=time(8), end_time=time(17), is_active=True)) + db.session.flush() + generate_occurrences(start_date=date(2026, 10, 3), end_date=date(2026, 10, 3), schedule_ids=[schedule.id]) + occurrence = MeterReadingOccurrence.query.filter_by(schedule_id=schedule.id).one() + assert occurrence.target_date == date(2026, 10, 3) + assert occurrence.operational_date == date(2026, 10, 2) + + housing_meter = _meter("HOUSING_DATE", housing=housing) + housing_schedule = _schedule(housing_meter, admin_user, "ANNUAL", date(2026, 10, 3)) + generate_occurrences(start_date=date(2026, 10, 3), end_date=date(2026, 10, 3), schedule_ids=[housing_schedule.id]) + housing_occurrence = MeterReadingOccurrence.query.filter_by(schedule_id=housing_schedule.id).one() + assert housing_occurrence.operational_date == date(2026, 10, 3) + + +def test_c2_processing_with_reading_and_without_reading_never_creates_zero(app, admin_user): + with app.app_context(): + meter = _meter("PROCESSING") + schedule = _schedule(meter, admin_user, "MONTHLY", date(2026, 10, 1)) + generate_occurrences(start_date=date(2026, 10, 1), end_date=date(2026, 10, 1), schedule_ids=[schedule.id]) + occurrence = MeterReadingOccurrence.query.filter_by(schedule_id=schedule.id).one() + processed = record_occurrence_reading(occurrence_id=occurrence.id, value=42, user_id=admin_user["id"]) + assert processed.status == TREATED_READING + assert MeterReading.query.filter_by(meter_id=meter.id).count() == 1 + + other = _meter("WITHOUT") + other_schedule = _schedule(other, admin_user, "MONTHLY", date(2026, 10, 1)) + generate_occurrences(start_date=date(2026, 10, 1), end_date=date(2026, 10, 1), schedule_ids=[other_schedule.id]) + other_occurrence = MeterReadingOccurrence.query.filter_by(schedule_id=other_schedule.id).one() + without = record_occurrence_without_reading( + occurrence_id=other_occurrence.id, reason="OCCUPANT_REFUSED", user_id=admin_user["id"], comment="TEST_UI_C2 refus" + ) + assert without.status == TREATED_WITHOUT_READING + assert without.comment == "TEST_UI_C2 refus" + assert MeterReading.query.filter_by(meter_id=other.id).count() == 0 + + +def test_c2_http_occurrence_actions_use_shared_service(app, admin_user, authenticated_client): + with app.app_context(): + meter = _meter("HTTP_READING") + schedule = _schedule(meter, admin_user, "MONTHLY", date(2026, 10, 1)) + generate_occurrences(start_date=date(2026, 10, 1), end_date=date(2026, 10, 1), schedule_ids=[schedule.id]) + occurrence = MeterReadingOccurrence.query.filter_by(schedule_id=schedule.id).one() + + response = authenticated_client.post( + f"/planning/meter-occurrences/{occurrence.id}/reading", + data={"value": "123.5", "notes": "TEST_UI_C2_HTTP"}, + ) + assert response.status_code == 302 + db.session.refresh(occurrence) + assert occurrence.status == TREATED_READING + assert MeterReading.query.filter_by(meter_id=meter.id).count() == 1 + + other = _meter("HTTP_WITHOUT") + other_schedule = _schedule(other, admin_user, "MONTHLY", date(2026, 10, 1)) + generate_occurrences(start_date=date(2026, 10, 1), end_date=date(2026, 10, 1), schedule_ids=[other_schedule.id]) + other_occurrence = MeterReadingOccurrence.query.filter_by(schedule_id=other_schedule.id).one() + response = authenticated_client.post( + f"/planning/meter-occurrences/{other_occurrence.id}/without-reading", + data={"reason": "READING_UNKNOWN", "comment": "TEST_UI_C2_HTTP inconnu"}, + ) + assert response.status_code == 302 + db.session.refresh(other_occurrence) + assert other_occurrence.status == TREATED_WITHOUT_READING + assert other_occurrence.no_reading_reason == "READING_UNKNOWN" + assert MeterReading.query.filter_by(meter_id=other.id).count() == 0 + + +def test_c2_open_occurrence_stays_visible_as_overdue_in_my_day(app, admin_user, authenticated_client): + with app.app_context(): + target = date(2026, 10, 5) + meter = _meter("MY_DAY") + _schedule(meter, admin_user, "WEEKLY", date(2026, 9, 28), duration_minutes=5) + db.session.add(WorkSchedule(user_id=admin_user["id"], day_of_week=0, start_time=time(8), end_time=time(17), is_active=True)) + db.session.commit() + response = authenticated_client.get(f"/planning/my-day?date={target.isoformat()}") + assert response.status_code == 200 + assert b"Relev\xc3\xa9 compteur" in response.data + assert b"Saisir le relev\xc3\xa9" in response.data + + +def test_c2_replaced_meter_cancels_open_occurrences(app, admin_user): + with app.app_context(): + meter = _meter("REPLACED") + schedule = _schedule(meter, admin_user, "MONTHLY", date(2026, 10, 1)) + generate_occurrences(start_date=date(2026, 10, 1), end_date=date(2026, 10, 1), schedule_ids=[schedule.id]) + old_occurrence = MeterReadingOccurrence.query.filter_by(schedule_id=schedule.id).one() + replace_meter(old_meter=meter, name="TEST_UI_C2_REPLACEMENT", user_id=admin_user["id"]) + generate_occurrences(start_date=date(2026, 10, 1), end_date=date(2027, 1, 1)) + db.session.refresh(old_occurrence) + assert old_occurrence.status == CANCELLED + assert MeterReadingOccurrence.query.filter_by(schedule_id=schedule.id, status="À FAIRE").count() == 0 + + +def test_c2_round_order_progress_and_resume(app, admin_user, authenticated_client): + with app.app_context(): + first = _meter("ROUND_1") + second = _meter("ROUND_2") + first_schedule = _schedule(first, admin_user, "MONTHLY", date(2026, 10, 1)) + second_schedule = _schedule(second, admin_user, "MONTHLY", date(2026, 10, 1)) + round_ = create_round(name="TEST_UI_C2_TOURNEE", default_assigned_to_id=admin_user["id"]) + add_round_member(round_=round_, schedule=first_schedule, position=1, commit=False) + add_round_member(round_=round_, schedule=second_schedule, position=2, commit=False) + db.session.commit() + generate_occurrences(start_date=date(2026, 10, 1), end_date=date(2026, 10, 1)) + round_occurrence = MeterReadingRoundOccurrence.query.filter_by(round_id=round_.id, operational_date=date(2026, 10, 1)).one() + assert [item.schedule.meter.name for item in sorted(round_occurrence.occurrences, key=lambda item: item.schedule.meter.name)] + response = authenticated_client.get(f"/planning/meter-rounds/{round_.id}?date=2026-10-01") + assert response.status_code == 200 + assert b"TEST_UI_C2_TOURNEE" in response.data + assert b"0 / 2" in response.data + record_occurrence_without_reading( + occurrence_id=round_occurrence.occurrences[0].id, reason="READING_UNKNOWN", user_id=admin_user["id"] + ) + db.session.refresh(round_occurrence) + assert round_occurrence.status == "EN COURS" + + +def test_c2_permissions_keep_configuration_admin_only(app, client): + response = client.get('/planning/meter-schedules/new') + assert response.status_code in (302, 403) + + +def test_c2_planning_sql_smoke_is_measured(app, admin_user, authenticated_client): + with app.app_context(): + schedules = [] + for index in range(3): + meter = _meter(f"PERF_{index}") + schedules.append(_schedule(meter, admin_user, "MONTHLY", date(2026, 10, 1))) + round_ = create_round(name="TEST_UI_C2_PERF_ROUND", default_assigned_to_id=admin_user["id"]) + for position, schedule in enumerate(schedules, 1): + add_round_member(round_=round_, schedule=schedule, position=position, commit=False) + db.session.commit() + + query_count = 0 + + def count_queries(*_args, **_kwargs): + nonlocal query_count + query_count += 1 + + event.listen(db.engine, "before_cursor_execute", count_queries) + try: + start = perf_counter() + daily = authenticated_client.get("/planning/my-day?date=2026-10-01") + daily_seconds = perf_counter() - start + daily_queries = query_count + query_count = 0 + start = perf_counter() + round_response = authenticated_client.get( + f"/planning/meter-rounds/{round_.id}?date=2026-10-01" + ) + round_seconds = perf_counter() - start + round_queries = query_count + finally: + event.remove(db.engine, "before_cursor_execute", count_queries) + + print( + f"C2_PERF my_day_seconds={daily_seconds:.4f} my_day_queries={daily_queries} " + f"round_seconds={round_seconds:.4f} round_queries={round_queries}" + ) + assert daily.status_code == 200 + assert round_response.status_code == 200