"""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