From ff2ed7542d8792a2e5a93a9febd6d4b99cdfc98e Mon Sep 17 00:00:00 2001 From: root Date: Mon, 24 Aug 2026 09:56:19 +0000 Subject: [PATCH] fix(planning): correct operational meter rounds --- .../core/services/meter_reading_planning.py | 115 ++++++++++++++---- app_new/planning/meter_readings.py | 7 +- 2 files changed, 97 insertions(+), 25 deletions(-) diff --git a/app_new/core/services/meter_reading_planning.py b/app_new/core/services/meter_reading_planning.py index b5478d8..56cb102 100644 --- a/app_new/core/services/meter_reading_planning.py +++ b/app_new/core/services/meter_reading_planning.py @@ -3,7 +3,7 @@ from calendar import monthrange from datetime import date, datetime, time, timedelta -from sqlalchemy import select +from sqlalchemy import and_, or_, select from sqlalchemy.orm import joinedload from ...extensions import db @@ -73,25 +73,37 @@ def _target_dates(schedule, start_date, end_date): cursor = cursor + timedelta(days=step) if schedule.frequency == "WEEKLY" else _add_months(cursor, step) -def operational_date_for(schedule, target_date): +def operational_date_for(schedule, target_date, *, calendar_cache=None): """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 + calendar_cache = calendar_cache if calendar_cache is not None else {} # 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: + user_key = schedule.assigned_to_id + schedule_key = ("explicit_schedule", user_key) + if schedule_key not in calendar_cache: + schedules = WorkSchedule.query.filter_by(is_active=True) + if user_key is not None: + schedules = schedules.filter_by(user_id=user_key) + calendar_cache[schedule_key] = schedules.first() is not None + if not calendar_cache[schedule_key]: return target_date - if PlanningService.get_working_hours(target_date, user_id=schedule.assigned_to_id): + + def working_hours(day): + key = ("working_hours", user_key, day) + if key not in calendar_cache: + calendar_cache[key] = PlanningService.get_working_hours(day, user_id=user_key) + return calendar_cache[key] + + if working_hours(target_date): 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): + if working_hours(candidate): return candidate candidate -= timedelta(days=1) raise MeterDomainError("Aucun jour opérationnel trouvé avant l'échéance.") @@ -131,8 +143,17 @@ def create_or_update_schedule(*, meter, frequency, reference_date, target_time=N return schedule -def _round_occurrence_for(schedule, operational_date): - member = MeterReadingRoundMember.query.filter_by(schedule_id=schedule.id).first() +def _round_members_for_schedules(schedule_ids): + if not schedule_ids: + return {} + members = MeterReadingRoundMember.query.filter( + MeterReadingRoundMember.schedule_id.in_(schedule_ids), + ).options(joinedload(MeterReadingRoundMember.round)).all() + return {member.schedule_id: member for member in members if member.round.is_active} + + +def _round_occurrence_for(schedule, operational_date, member=None): + member = member or 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( @@ -157,30 +178,53 @@ def _refresh_round_status(round_occurrence): round_occurrence.status = TODO -def generate_occurrences(*, start_date, end_date, schedule_ids=None, commit=True): +def generate_occurrences(*, start_date, end_date, schedule_ids=None, operational_date=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)) + schedules = query.all() + target_dates_by_schedule = { + schedule.id: list(_target_dates(schedule, start_date, end_date)) + for schedule in schedules + } + existing = MeterReadingOccurrence.query.filter( + MeterReadingOccurrence.schedule_id.in_([schedule.id for schedule in schedules]), + MeterReadingOccurrence.target_date >= start_date, + MeterReadingOccurrence.target_date <= end_date, + ).all() if schedules else [] + existing_by_key = {(item.schedule_id, item.target_date): item for item in existing} + members_by_schedule = _round_members_for_schedules([schedule.id for schedule in schedules]) + calendar_cache = {} created = [] - for schedule in query.all(): + for schedule in schedules: if schedule.meter.status == "replaced" or not schedule.meter.is_active: cancel_open_occurrences_for_meter(schedule.meter) 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() + for target_date in target_dates_by_schedule[schedule.id]: + occurrence = existing_by_key.get((schedule.id, target_date)) if occurrence is not None: + member = members_by_schedule.get(schedule.id) + if occurrence.status == TODO and occurrence.assigned_to_id is None and not schedule.assigned_to_id and member: + occurrence.assigned_to_id = member.round.default_assigned_to_id + continue + computed_operational_date = operational_date_for( + schedule, target_date, calendar_cache=calendar_cache, + ) + if operational_date is not None and computed_operational_date != operational_date: 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, + schedule=schedule, target_date=target_date, operational_date=computed_operational_date, + assigned_to_id=schedule.assigned_to_id or ( + members_by_schedule[schedule.id].round.default_assigned_to_id + if schedule.id in members_by_schedule else None + ), ) db.session.add(occurrence) db.session.flush() - occurrence.round_occurrence = _round_occurrence_for(schedule, operational_date) + occurrence.round_occurrence = _round_occurrence_for( + schedule, computed_operational_date, members_by_schedule.get(schedule.id), + ) created.append(occurrence) if commit: db.session.commit() @@ -188,10 +232,20 @@ def generate_occurrences(*, start_date, end_date, schedule_ids=None, commit=True def ensure_occurrences_for_day(day, user_id=None, lookback_days=365, horizon_days=31): - schedules = MeterReadingSchedule.query.filter_by(is_active=True) + schedules = MeterReadingSchedule.query.filter_by(is_active=True).outerjoin( + MeterReadingRoundMember, MeterReadingRoundMember.schedule_id == MeterReadingSchedule.id, +).outerjoin( + MeterReadingRound, MeterReadingRound.id == MeterReadingRoundMember.round_id, +) if user_id is not None: - schedules = schedules.filter(MeterReadingSchedule.assigned_to_id == user_id) - ids = [item.id for item in schedules.all()] + schedules = schedules.filter(or_( + MeterReadingSchedule.assigned_to_id == user_id, + and_( + MeterReadingSchedule.assigned_to_id.is_(None), + MeterReadingRound.default_assigned_to_id == user_id, + ), + )) + ids = [item.id for item in schedules.distinct().all()] return generate_occurrences( start_date=day - timedelta(days=lookback_days), end_date=day + timedelta(days=horizon_days), @@ -199,6 +253,21 @@ def ensure_occurrences_for_day(day, user_id=None, lookback_days=365, horizon_day ) if ids else [] +def ensure_occurrences_for_operational_date(day, *, round_id=None, schedule_ids=None): + """Génère uniquement les échéances d'une tournée réalisables ce jour.""" + if schedule_ids is None: + query = MeterReadingRoundMember.query.filter_by(round_id=round_id) + schedule_ids = [member.schedule_id for member in query.all()] + if not schedule_ids: + return [] + return generate_occurrences( + start_date=day, + end_date=day + timedelta(days=366), + schedule_ids=schedule_ids, + operational_date=day, + ) + + def list_open_occurrences(day, user_id=None): query = MeterReadingOccurrence.query.filter( MeterReadingOccurrence.status == TODO, diff --git a/app_new/planning/meter_readings.py b/app_new/planning/meter_readings.py index b8d6a8e..cfba39b 100644 --- a/app_new/planning/meter_readings.py +++ b/app_new/planning/meter_readings.py @@ -15,7 +15,7 @@ from ..core.models.planning import ( 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, + create_round, ensure_occurrences_for_operational_date, generate_occurrences, record_occurrence_reading, record_occurrence_without_reading, ) from .schedules import planning_bp @@ -172,7 +172,10 @@ def meter_round_detail(round_id): target_date = date.fromisoformat(target_date) except ValueError: target_date = date.today() - generate_occurrences(start_date=target_date, end_date=target_date) + ensure_occurrences_for_operational_date( + target_date, round_id=round_.id, + schedule_ids=[member.schedule_id for member in round_.members], + ) round_occurrence = next((item for item in round_.occurrences if item.operational_date == target_date), None) members = [] if round_occurrence: