fix(planning): correct operational meter rounds
This commit is contained in:
parent
86a3e96dcf
commit
ff2ed7542d
2 changed files with 97 additions and 25 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue