fix(planning): correct operational meter rounds

This commit is contained in:
root 2026-08-24 09:56:19 +00:00
parent 86a3e96dcf
commit ff2ed7542d
2 changed files with 97 additions and 25 deletions

View file

@ -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
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 schedule.assigned_to_id is not None:
schedules = schedules.filter_by(user_id=schedule.assigned_to_id)
if schedules.first() is None:
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,

View file

@ -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: