"""Règles métier du cycle de vie des équipements et de leurs échéances.""" from datetime import date, datetime, timedelta, timezone from app_new.extensions import db from app_new.core.models.equipment import Equipment, EquipmentLifecycleEvent from app_new.core.models.maintenance import Intervention from app_new.core.models.planning import ScheduledTask OPEN_TASK_STATUSES = ("planned", "in_progress", "suspended") def record_event(equipment, event_type, user_id=None, previous_status=None, new_status=None, quantity=1, reason=None, intervention_id=None): """Enregistre un événement avec la localisation et le lot tels qu'ils sont aujourd'hui.""" room = equipment.effective_room lot = equipment.effective_lot event = EquipmentLifecycleEvent( equipment=equipment, event_type=event_type, previous_status=previous_status, new_status=new_status, quantity=max(quantity or 1, 1), reason=reason, room_id_snapshot=room.id if room else None, room_name_snapshot=room.name if room else None, lot_id_snapshot=lot.id if lot else None, lot_name_snapshot=lot.name if lot else None, intervention_id=intervention_id, created_by_id=user_id, ) db.session.add(event) return event def extract_units(group, quantity, user_id=None, reason=None): """Individualise des unités d'un groupe sans modifier son total physique.""" if not group.is_group: if quantity != 1: raise ValueError("Un équipement individuel représente une seule unité.") return [group] quantity = max(int(quantity or 0), 0) if quantity < 1 or quantity > group.unindividualized_quantity: raise ValueError( f"Quantité invalide : {group.unindividualized_quantity} unité(s) seulement sont disponibles." ) root = group while root.parent_id: root = root.parent next_number = max([item.individual_number or 0 for item in root.all_children] or [0]) + 1 created = [] room = group.effective_room for offset in range(quantity): unit = Equipment( name=f"{root.name} #{next_number + offset}", description=root.description, status="en_service", lifecycle_status="en_service", parent_id=group.id, room_id=room.id if room else None, is_group=False, quantity=1, tracked_individually=True, individual_number=next_number + offset, mobility=root.mobility, ) db.session.add(unit) db.session.flush() record_event(unit, "individualisation", user_id, new_status="en_service", reason=reason) created.append(unit) return created def _open_tasks(equipment): return ScheduledTask.query.filter( ScheduledTask.equipment_id == equipment.id, ScheduledTask.status.in_(OPEN_TASK_STATUSES), ).all() def _suspend_preventive(equipment, reason): now = datetime.now(timezone.utc) for task in _open_tasks(equipment): rule = task.lot_task if rule and (rule.is_safety_critical or rule.unavailable_policy == "keep"): continue task.status = "suspended" task.suspension_reason = reason task.suspended_at = now def _cancel_preventive(equipment, reason, keep_safety=False): for task in _open_tasks(equipment): if keep_safety and task.lot_task and task.lot_task.is_safety_critical: continue task.status = "cancelled" task.postpone_reason = reason task.suspension_reason = None def _resume_preventive(equipment): """Réactive les échéances suspendues en appliquant la règle du lot.""" from app_new.core.services.maintenance_engine import next_working_day today = date.today() for task in _open_tasks(equipment): if task.status != "suspended": continue rule = task.lot_task if rule and rule.after_repair_policy == "reset" and rule.jours_entre_interventions: target = today + timedelta(days=rule.jours_entre_interventions) else: target = max(task.scheduled_date or today, today) task.scheduled_date = next_working_day(target) task.status = "planned" task.suspension_reason = None task.suspended_at = None task.lifecycle_status_snapshot = "en_service" def create_corrective_intervention(source, units, reason, user_id=None): room = source.effective_room lot = source.effective_lot quantity = len(units) intervention = Intervention( title=f"Réparation — {quantity} × {source.name}"[:300], description=reason or "Équipement retiré provisoirement pour diagnostic et réparation.", type="curatif", status="en_attente", priority="normale", equipment_id=source.id, room_id=room.id if room else None, lot_id=lot.id if lot else None, quantity_affected=quantity, individual_numbers=",".join(str(unit.individual_number) for unit in units if unit.individual_number), # L'intervention reste non affectée jusqu'à sa prise en charge. L'auteur # de l'action est conservé dans le journal de cycle de vie. ) db.session.add(intervention) db.session.flush() return intervention def start_repair(source, quantity=1, reason=None, user_id=None, create_intervention=True): units = extract_units(source, quantity, user_id=user_id, reason=reason) if source.is_group else [source] intervention = create_corrective_intervention(source, units, reason, user_id) if create_intervention else None now = datetime.now(timezone.utc) for unit in units: previous = unit.lifecycle_status unit.lifecycle_status = "en_reparation" unit.status = "en_maintenance" unit.unavailable_since = now _suspend_preventive(unit, "Équipement indisponible pendant sa réparation") record_event(unit, "retrait_reparation", user_id, previous, unit.lifecycle_status, reason=reason, intervention_id=intervention.id if intervention else None) return units, intervention def complete_repair(equipment, reason=None, user_id=None): previous = equipment.lifecycle_status equipment.lifecycle_status = "en_service" equipment.status = "en_service" equipment.unavailable_since = None equipment.create_scheduled_tasks_from_lot(commit=False) _resume_preventive(equipment) record_event(equipment, "reparation_terminee", user_id, previous, "en_service", reason=reason) def propose_disposal(source, quantity=1, reason=None, planned_date=None, user_id=None): units = extract_units(source, quantity, user_id=user_id, reason=reason) if source.is_group else [source] for unit in units: unit.create_scheduled_tasks_from_lot(commit=False) previous = unit.lifecycle_status unit.status = "a_jeter" unit.lifecycle_status = "mise_au_rebut_planifiee" unit.planned_disposal_date = planned_date unit.disposal_reason = reason _cancel_preventive(unit, "Mise au rebut proposée", keep_safety=True) record_event(unit, "rebut_propose", user_id, previous, unit.lifecycle_status, reason=reason) return units def cancel_disposal(equipment, reason=None, user_id=None): previous = equipment.lifecycle_status equipment.status = "en_service" equipment.lifecycle_status = "en_service" equipment.planned_disposal_date = None equipment.disposal_reason = None _resume_preventive(equipment) record_event(equipment, "rebut_annule", user_id, previous, "en_service", reason=reason) def confirm_disposal(equipment, reason=None, user_id=None): previous = equipment.lifecycle_status equipment.status = "jete" equipment.lifecycle_status = "jete" equipment.disposal_date = date.today() equipment.disposal_reason = reason or equipment.disposal_reason _cancel_preventive(equipment, "Équipement sorti définitivement du patrimoine") record_event(equipment, "rebut_confirme", user_id, previous, "jete", reason=equipment.disposal_reason) equipment.room_id = None def update_future_task_location(equipment): room = equipment.effective_room for task in _open_tasks(equipment): if task.intervention_id: continue task.room_id = room.id if room else None task.room_name_snapshot = room.name if room else None