214 lines
10 KiB
Python
214 lines
10 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 _task_targets(task, equipment_ids=None):
|
|
"""Résout le périmètre déclaré par la règle préventive."""
|
|
if getattr(task, "scope_mode", "dynamic") == "manual":
|
|
targets = list(task.applicable_equipments())
|
|
if equipment_ids is not None:
|
|
wanted = set(equipment_ids)
|
|
targets = [e for e in targets if e.id in wanted]
|
|
return targets
|
|
return _targets(task.lot, task.application_scope, equipment_ids)
|
|
|
|
|
|
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 _last_completions(task):
|
|
"""Charge les dernières réalisations en deux requêtes, pas par équipement."""
|
|
from collections import defaultdict
|
|
result = defaultdict(list)
|
|
scheduled = ScheduledTask.query.filter(
|
|
ScheduledTask.lot_task_id == task.id,
|
|
ScheduledTask.status.in_(['completed', 'done']),
|
|
).all()
|
|
for item in scheduled:
|
|
value = item.completed_at.date() if item.completed_at else item.scheduled_date
|
|
if value and item.equipment_id:
|
|
result[item.equipment_id].append(value)
|
|
interventions = Intervention.query.filter(
|
|
Intervention.lot_task_id == task.id,
|
|
Intervention.status.in_(['terminee', 'cloturee']),
|
|
Intervention.is_deleted.is_(False),
|
|
).all()
|
|
for item in interventions:
|
|
value = item.completed_at.date() if item.completed_at else item.completed_date or item.scheduled_date
|
|
if value and item.equipment_id:
|
|
result[item.equipment_id].append(value)
|
|
return {equipment_id: max(values) for equipment_id, values in result.items()}
|
|
|
|
|
|
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 []
|
|
# Une action conditionnelle/curative est déclenchée par un constat ou une
|
|
# intervention, jamais par une échéance calendaire silencieuse.
|
|
if getattr(task, "task_nature", "unknown") == "conditional_curative" and event is None:
|
|
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
|
|
existing_by_equipment = {
|
|
item.equipment_id for item in ScheduledTask.query.filter(
|
|
ScheduledTask.lot_task_id == task.id,
|
|
ScheduledTask.status.in_(['planned', 'in_progress', 'suspended', 'needs_duration']),
|
|
).all()
|
|
}
|
|
last_completions = _last_completions(task) if task.trigger_type == 'calendar' else {}
|
|
for equipment in _task_targets(task, 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
|
|
if equipment.id in existing_by_equipment:
|
|
continue
|
|
last = last_completions.get(equipment.id)
|
|
interval = task.jours_entre_interventions or 0
|
|
due = last + timedelta(days=interval) if last and interval 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"
|
|
# Une règle sans durée reste visible comme « à renseigner », mais ne
|
|
# doit pas être engagée automatiquement dans une journée.
|
|
estimated_duration = task.duration_for_equipment(equipment)
|
|
final_status = status if status == "suspended" else ("needs_duration" if not estimated_duration else status)
|
|
item = ScheduledTask(lot_task_id=task.id, equipment_id=equipment.id,
|
|
room_id=room.id if room else None,
|
|
company_id=task.company_id,
|
|
contract_id=task.contract_id,
|
|
scheduled_date=due, estimated_duration=estimated_duration, status=final_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
|