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 calendar import monthrange
|
||||||
from datetime import date, datetime, time, timedelta
|
from datetime import date, datetime, time, timedelta
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import and_, or_, select
|
||||||
from sqlalchemy.orm import joinedload
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
from ...extensions import db
|
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)
|
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."""
|
"""Conserve la date cible et anticipe uniquement pour un calendrier scolaire."""
|
||||||
if schedule.calendar_scope != "school" or schedule.closed_day_policy != "PREVIOUS_WORKING_DAY":
|
if schedule.calendar_scope != "school" or schedule.closed_day_policy != "PREVIOUS_WORKING_DAY":
|
||||||
return target_date
|
return target_date
|
||||||
from .planning_service import PlanningService
|
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
|
# 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
|
# déduire qu'un jour est travaillé. On conserve donc la date cible ; une
|
||||||
# configuration d'horaires ultérieure permettra l'anticipation précise.
|
# configuration d'horaires ultérieure permettra l'anticipation précise.
|
||||||
from ..models.planning import WorkSchedule
|
from ..models.planning import WorkSchedule
|
||||||
schedules = WorkSchedule.query.filter_by(is_active=True)
|
user_key = schedule.assigned_to_id
|
||||||
if schedule.assigned_to_id is not None:
|
schedule_key = ("explicit_schedule", user_key)
|
||||||
schedules = schedules.filter_by(user_id=schedule.assigned_to_id)
|
if schedule_key not in calendar_cache:
|
||||||
if schedules.first() is None:
|
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
|
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
|
return target_date
|
||||||
candidate = target_date - timedelta(days=1)
|
candidate = target_date - timedelta(days=1)
|
||||||
for _ in range(366):
|
for _ in range(366):
|
||||||
if PlanningService.get_working_hours(candidate, user_id=schedule.assigned_to_id):
|
if working_hours(candidate):
|
||||||
return candidate
|
return candidate
|
||||||
candidate -= timedelta(days=1)
|
candidate -= timedelta(days=1)
|
||||||
raise MeterDomainError("Aucun jour opérationnel trouvé avant l'échéance.")
|
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
|
return schedule
|
||||||
|
|
||||||
|
|
||||||
def _round_occurrence_for(schedule, operational_date):
|
def _round_members_for_schedules(schedule_ids):
|
||||||
member = MeterReadingRoundMember.query.filter_by(schedule_id=schedule.id).first()
|
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:
|
if not member or not member.round.is_active:
|
||||||
return None
|
return None
|
||||||
occurrence = MeterReadingRoundOccurrence.query.filter_by(
|
occurrence = MeterReadingRoundOccurrence.query.filter_by(
|
||||||
|
|
@ -157,30 +178,53 @@ def _refresh_round_status(round_occurrence):
|
||||||
round_occurrence.status = TODO
|
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."""
|
"""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)
|
query = MeterReadingSchedule.query.options(joinedload(MeterReadingSchedule.meter)).filter_by(is_active=True)
|
||||||
if schedule_ids is not None:
|
if schedule_ids is not None:
|
||||||
query = query.filter(MeterReadingSchedule.id.in_(schedule_ids))
|
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 = []
|
created = []
|
||||||
for schedule in query.all():
|
for schedule in schedules:
|
||||||
if schedule.meter.status == "replaced" or not schedule.meter.is_active:
|
if schedule.meter.status == "replaced" or not schedule.meter.is_active:
|
||||||
cancel_open_occurrences_for_meter(schedule.meter)
|
cancel_open_occurrences_for_meter(schedule.meter)
|
||||||
continue
|
continue
|
||||||
for target_date in _target_dates(schedule, start_date, end_date):
|
for target_date in target_dates_by_schedule[schedule.id]:
|
||||||
occurrence = MeterReadingOccurrence.query.filter_by(
|
occurrence = existing_by_key.get((schedule.id, target_date))
|
||||||
schedule_id=schedule.id, target_date=target_date,
|
|
||||||
).first()
|
|
||||||
if occurrence is not None:
|
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
|
continue
|
||||||
operational_date = operational_date_for(schedule, target_date)
|
|
||||||
occurrence = MeterReadingOccurrence(
|
occurrence = MeterReadingOccurrence(
|
||||||
schedule=schedule, target_date=target_date, operational_date=operational_date,
|
schedule=schedule, target_date=target_date, operational_date=computed_operational_date,
|
||||||
assigned_to_id=schedule.assigned_to_id,
|
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.add(occurrence)
|
||||||
db.session.flush()
|
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)
|
created.append(occurrence)
|
||||||
if commit:
|
if commit:
|
||||||
db.session.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):
|
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:
|
if user_id is not None:
|
||||||
schedules = schedules.filter(MeterReadingSchedule.assigned_to_id == user_id)
|
schedules = schedules.filter(or_(
|
||||||
ids = [item.id for item in schedules.all()]
|
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(
|
return generate_occurrences(
|
||||||
start_date=day - timedelta(days=lookback_days),
|
start_date=day - timedelta(days=lookback_days),
|
||||||
end_date=day + timedelta(days=horizon_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 []
|
) 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):
|
def list_open_occurrences(day, user_id=None):
|
||||||
query = MeterReadingOccurrence.query.filter(
|
query = MeterReadingOccurrence.query.filter(
|
||||||
MeterReadingOccurrence.status == TODO,
|
MeterReadingOccurrence.status == TODO,
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ from ..core.models.planning import (
|
||||||
from ..core.models.user import User
|
from ..core.models.user import User
|
||||||
from ..core.services.meter_reading_planning import (
|
from ..core.services.meter_reading_planning import (
|
||||||
FREQUENCIES, NO_READING_REASONS, MeterDomainError, add_round_member, create_or_update_schedule,
|
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,
|
record_occurrence_reading, record_occurrence_without_reading,
|
||||||
)
|
)
|
||||||
from .schedules import planning_bp
|
from .schedules import planning_bp
|
||||||
|
|
@ -172,7 +172,10 @@ def meter_round_detail(round_id):
|
||||||
target_date = date.fromisoformat(target_date)
|
target_date = date.fromisoformat(target_date)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
target_date = date.today()
|
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)
|
round_occurrence = next((item for item in round_.occurrences if item.operational_date == target_date), None)
|
||||||
members = []
|
members = []
|
||||||
if round_occurrence:
|
if round_occurrence:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue