165 lines
8 KiB
Python
165 lines
8 KiB
Python
"""Moteur unique de génération des échéances depuis les tâches de lot."""
|
|
from datetime import date, timedelta
|
|
|
|
from app_new.extensions import db
|
|
from app_new.core.models.planning import Meter, ScheduledTask
|
|
from app_new.core.models.maintenance import Intervention, LotTask
|
|
from app_new.core.services.planning_service import PlanningService
|
|
|
|
|
|
def _targets(lot, application_scope="auto", equipment_ids=None):
|
|
"""Résout la portée d'une règle sans créer une tâche par chaise par défaut."""
|
|
result = []
|
|
for root in lot.equipments.filter_by(is_deleted=False):
|
|
nodes = [root] + [item for item in root.all_children if not item.is_deleted]
|
|
scope = application_scope
|
|
if scope == "auto":
|
|
if root.tracked_individually:
|
|
scope = "unit"
|
|
else:
|
|
scope = "group" if root.effective_management_mode in ("quantitatif", "structure", "consommable") else "unit"
|
|
|
|
if scope in ("group", "location"):
|
|
localized_groups = []
|
|
for item in nodes:
|
|
if not item.is_group or not item.effective_room:
|
|
continue
|
|
if not any(child.is_group and not child.is_deleted for child in item.children):
|
|
localized_groups.append(item)
|
|
result.extend(localized_groups or [root])
|
|
else:
|
|
leaves = [item for item in nodes if not item.is_group]
|
|
# Un groupe quantitatif non individualisé reste une cible collective.
|
|
result.extend(leaves or [root])
|
|
|
|
# Une initialisation demandée depuis une fiche doit toujours pouvoir cibler
|
|
# cette fiche, notamment une exception individualisée issue d'un groupe.
|
|
if equipment_ids:
|
|
from app_new.core.models.equipment import Equipment
|
|
explicit = Equipment.query.filter(Equipment.id.in_(set(equipment_ids)), Equipment.is_deleted.is_(False)).all()
|
|
result.extend(item for item in explicit if item.effective_lot_id == lot.id)
|
|
return list({item.id: item for item in result}.values())
|
|
|
|
|
|
def next_working_day(candidate):
|
|
for _ in range(370):
|
|
if PlanningService.get_working_hours(candidate):
|
|
return candidate
|
|
candidate += timedelta(days=1)
|
|
raise ValueError("Aucune journée travaillée disponible dans les 12 prochains mois.")
|
|
|
|
|
|
def _last_completion(task, equipment):
|
|
dates = []
|
|
scheduled = ScheduledTask.query.filter(
|
|
ScheduledTask.lot_task_id == task.id,
|
|
ScheduledTask.equipment_id == equipment.id,
|
|
ScheduledTask.status.in_(['completed', 'done']),
|
|
).order_by(ScheduledTask.completed_at.desc(), ScheduledTask.scheduled_date.desc()).first()
|
|
if scheduled:
|
|
if scheduled.completed_at:
|
|
dates.append(scheduled.completed_at.date())
|
|
elif scheduled.scheduled_date:
|
|
dates.append(scheduled.scheduled_date)
|
|
intervention = Intervention.query.filter(
|
|
Intervention.lot_task_id == task.id,
|
|
Intervention.equipment_id == equipment.id,
|
|
Intervention.status.in_(['terminee', 'cloturee']),
|
|
Intervention.is_deleted.is_(False),
|
|
).order_by(Intervention.completed_at.desc(), Intervention.completed_date.desc()).first()
|
|
if intervention:
|
|
if intervention.completed_at:
|
|
dates.append(intervention.completed_at.date())
|
|
elif intervention.completed_date:
|
|
dates.append(intervention.completed_date)
|
|
elif intervention.scheduled_date:
|
|
dates.append(intervention.scheduled_date)
|
|
return max(dates) if dates else None
|
|
|
|
|
|
def _calendar_due(task, equipment, today):
|
|
last = _last_completion(task, equipment)
|
|
interval = task.jours_entre_interventions or 0
|
|
return last + timedelta(days=interval) if last and interval else today
|
|
|
|
|
|
def generate_due_tasks(task: LotTask, event=None, today=None, equipment_ids=None, commit=True):
|
|
"""Génère les échéances sans doublon et retourne les objets créés."""
|
|
today = today or date.today()
|
|
if not task.is_active:
|
|
return []
|
|
if task.trigger_type in ("event", "weather") and task.trigger_event != event:
|
|
return []
|
|
if task.trigger_type == "season":
|
|
start, end = task.season_start_month or 1, task.season_end_month or 12
|
|
inside = start <= today.month <= end if start <= end else today.month >= start or today.month <= end
|
|
if not inside:
|
|
return []
|
|
created = []
|
|
if not task.lot or not task.lot.is_present:
|
|
return []
|
|
requested_ids = set(equipment_ids) if equipment_ids is not None else None
|
|
for equipment in _targets(task.lot, task.application_scope, requested_ids):
|
|
if equipment_ids is not None and equipment.id not in set(equipment_ids):
|
|
continue
|
|
if equipment.lifecycle_status == "jete" or equipment.status == "jete":
|
|
continue
|
|
unavailable = equipment.lifecycle_status in ("en_reparation", "mise_au_rebut_planifiee")
|
|
if unavailable and not task.is_safety_critical and task.unavailable_policy == "postpone":
|
|
continue
|
|
if task.trigger_type == "meter":
|
|
meter = Meter.query.filter_by(equipment_id=equipment.id, is_active=True).order_by(Meter.current_value.desc()).first()
|
|
base_value = meter.last_maintenance_value if meter else None
|
|
used_since_maintenance = (meter.current_value - (base_value or meter.initial_value or 0)) if meter else 0
|
|
if not meter or task.meter_threshold is None or used_since_maintenance < task.meter_threshold:
|
|
continue
|
|
existing = ScheduledTask.query.filter(
|
|
ScheduledTask.lot_task_id == task.id,
|
|
ScheduledTask.equipment_id == equipment.id,
|
|
ScheduledTask.status.in_(['planned', 'in_progress', 'suspended']),
|
|
).first()
|
|
if existing:
|
|
continue
|
|
due = _calendar_due(task, equipment, today) if task.trigger_type == 'calendar' else today
|
|
if task.trigger_type == 'calendar' and due > today + timedelta(days=max(task.advance_days or 0, 0)):
|
|
continue
|
|
if task.trigger_type == 'season':
|
|
completed_this_season = ScheduledTask.query.filter(
|
|
ScheduledTask.lot_task_id == task.id,
|
|
ScheduledTask.equipment_id == equipment.id,
|
|
ScheduledTask.status.in_(['completed', 'done']),
|
|
db.extract('year', ScheduledTask.scheduled_date) == today.year,
|
|
).first()
|
|
if completed_this_season:
|
|
continue
|
|
due = next_working_day(max(due, today))
|
|
room = equipment.effective_room
|
|
status = "suspended" if unavailable and not task.is_safety_critical and task.unavailable_policy == "suspend" else "planned"
|
|
item = ScheduledTask(lot_task_id=task.id, equipment_id=equipment.id,
|
|
room_id=room.id if room else None,
|
|
scheduled_date=due, estimated_duration=task.effective_duration(), status=status,
|
|
suspension_reason="Équipement indisponible" if status == "suspended" else None,
|
|
room_name_snapshot=room.name if room else None,
|
|
lot_name_snapshot=task.lot.name,
|
|
equipment_name_snapshot=equipment.name,
|
|
lifecycle_status_snapshot=equipment.lifecycle_status)
|
|
db.session.add(item)
|
|
created.append(item)
|
|
if commit:
|
|
db.session.commit()
|
|
elif created:
|
|
db.session.flush()
|
|
return created
|
|
|
|
|
|
def reconcile_planned_tasks():
|
|
"""Déplace les échéances ouvertes placées sur un jour non travaillé."""
|
|
changed = []
|
|
tasks = ScheduledTask.query.filter(ScheduledTask.status.in_(['planned', 'in_progress'])).all()
|
|
for item in tasks:
|
|
if item.scheduled_date and not PlanningService.get_working_hours(item.scheduled_date, user_id=item.assigned_to_id):
|
|
item.scheduled_date = next_working_day(item.scheduled_date + timedelta(days=1))
|
|
changed.append(item)
|
|
if changed:
|
|
db.session.commit()
|
|
return changed
|