""" Modèle Equipment - GMAO Collège Gestion des équipements Compatible avec la base de données existante """ from datetime import datetime, timezone from ...extensions import db class EquipmentCategory(db.Model): """Catégorie d'équipement.""" __tablename__ = "equipment_categories" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(100), nullable=False) description = db.Column(db.Text, nullable=True) recurrence_alert_enabled = db.Column(db.Boolean, nullable=False, default=False) recurrence_threshold = db.Column(db.Integer, nullable=False, default=3) recurrence_window_days = db.Column(db.Integer, nullable=False, default=365) # Relations equipments = db.relationship("Equipment", back_populates="category", overlaps="category") lots = db.relationship("Lot", back_populates="category") def __repr__(self): return f"" class Equipment(db.Model): """Équipement du collège.""" __tablename__ = "equipments" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(200), nullable=False) code = db.Column(db.String(50), nullable=True) description = db.Column(db.Text, nullable=True) status = db.Column(db.String(20), nullable=True) category_id = db.Column(db.Integer, db.ForeignKey("equipment_categories.id"), nullable=True) lot_id = db.Column(db.Integer, db.ForeignKey("lots.id"), nullable=True) room_id = db.Column(db.Integer, db.ForeignKey("rooms.id"), nullable=True) parent_id = db.Column(db.Integer, db.ForeignKey("equipments.id"), nullable=True) is_group = db.Column(db.Boolean, default=False) quantity = db.Column(db.Integer, default=1) tracked_individually = db.Column(db.Boolean, default=False) individual_number = db.Column(db.Integer, nullable=True) position = db.Column(db.String(255), nullable=True) position_x = db.Column(db.Float, nullable=True) position_y = db.Column(db.Float, nullable=True) install_date = db.Column(db.Date, nullable=True) warranty_end = db.Column(db.Date, nullable=True) mobility = db.Column(db.String(20), nullable=False, default="non_precise") serial_number = db.Column(db.String(120), nullable=True, index=True) manufacturer = db.Column(db.String(120), nullable=True) model_reference = db.Column(db.String(120), nullable=True) supplier_id = db.Column(db.Integer, db.ForeignKey("companies.id"), nullable=True) supplier_reference = db.Column(db.String(120), nullable=True) purchase_price = db.Column(db.Numeric(12, 2), nullable=True) purchase_date = db.Column(db.Date, nullable=True) lifecycle_status = db.Column(db.String(30), nullable=False, default="en_service") unavailable_since = db.Column(db.DateTime, nullable=True) planned_disposal_date = db.Column(db.Date, nullable=True) disposal_date = db.Column(db.Date, nullable=True) disposal_reason = db.Column(db.String(500), nullable=True) recurrence_monitoring = db.Column(db.Boolean, nullable=False, default=False) is_deleted = db.Column(db.Boolean, default=False, nullable=False) deleted_at = db.Column(db.DateTime, nullable=True) created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) # Relations - sans backref pour éviter conflits room = db.relationship("Room", back_populates="equipments") category = db.relationship("EquipmentCategory", back_populates="equipments") lot = db.relationship("Lot", backref=db.backref("equipments", lazy="dynamic")) parent = db.relationship("Equipment", remote_side=[id], backref=db.backref("children", lazy="dynamic")) interventions = db.relationship("Intervention", backref="equipment") documents = db.relationship("EquipmentDocument", back_populates="equipment") consumables = db.relationship("EquipmentConsumable", back_populates="equipment", lazy="dynamic") room_history = db.relationship("EquipmentRoomHistory", backref="equipment", order_by="desc(EquipmentRoomHistory.changed_at)") supplier = db.relationship("Company", foreign_keys=[supplier_id]) @property def effective_lot_id(self): """Lot effectif (hérité du parent si non défini).""" if self.lot_id: return self.lot_id if self.parent_id and self.parent: return self.parent.effective_lot_id return None @property def effective_category_id(self): """Catégorie effective (héritée du parent si non définie).""" if self.category_id: return self.category_id if self.parent_id and self.parent: return self.parent.effective_category_id return None @property def effective_lot(self): """Lot effectif (hérité du parent si non défini).""" lot_id = self.effective_lot_id if lot_id: from .maintenance import Lot return Lot.query.get(lot_id) return None @property def effective_category(self): """Catégorie effective (héritée du parent si non définie).""" category_id = self.effective_category_id if category_id: return EquipmentCategory.query.get(category_id) return None @property def all_children(self): """Tous les enfants (récursif).""" children = list(self.children) for child in self.children: children.extend(child.all_children) return children @property def individual_children(self): """Enfants individuels uniquement.""" return [c for c in self.all_children if not c.is_group] @property def full_path(self): """Chemin complet de localisation.""" if self.room: return f"{self.room.building.name} / {self.room.name}" if self.room.building else self.room.name return "—" @property def curative_count(self): """Nombre d'interventions curatives.""" return len([i for i in self.interventions if i.type == 'curatif']) @property def tree_depth(self): """Profondeur dans l'arbre (0 = racine).""" depth = 0 node = self while node.parent_id: depth += 1 node = node.parent if depth > 20: # securite anti-boucle break return depth @property def tree_path(self): """Chemin hierarchique complet : 'Fenetres college > Fenetre B01 > Fenetre gauche'.""" parts = [self.name] node = self while node.parent_id: node = node.parent parts.insert(0, node.name) if len(parts) > 20: break return ' > '.join(parts) def count_descendants(self): """Nombre total de descendants (recursif).""" count = self.children.count() for child in self.children: count += child.count_descendants() return count def is_leaf(self): """True si c'est une feuille (pas d'enfants).""" return self.children.count() == 0 @property def needs_deep_work(self): """Nécessite une intervention profonde.""" category = self.effective_category enabled = self.recurrence_monitoring or bool(category and category.recurrence_alert_enabled) threshold = category.recurrence_threshold if category else 3 if not enabled: return False from datetime import timedelta window = category.recurrence_window_days if category else 365 cutoff = datetime.now(timezone.utc) - timedelta(days=window) return len([i for i in self.interventions if i.type == 'curatif' and i.created_at and i.created_at >= cutoff]) >= threshold @property def is_mobile(self): return self.mobility == "mobile" @property def children_count(self): """Nombre d'enfants directs.""" return Equipment.query.filter_by(parent_id=self.id).count() @property def total_quantity(self): """Quantite physique declaree, individualisation comprise.""" return self.quantity @property def individualized_quantity(self): """Nombre d'unites du groupe disposant deja de leur propre fiche.""" if not self.is_group: return 0 return sum( max(child.quantity or 1, 1) for child in self.children if not child.is_group ) @property def unindividualized_quantity(self): """Nombre d'unites encore gerees uniquement sous forme quantitative.""" if not self.is_group: return 0 return max((self.quantity or 0) - self.individualized_quantity, 0) @property def total_children(self): """Nombre total d'enfants.""" return self.children_count @property def is_low_stock(self): """Vérifie si le stock est faible.""" return False @property def is_individual(self): """Vérifie si c'est un élément individuel.""" return self.parent_id is not None @property def effective_room(self): """Salle propre, ou salle heritee du parent si elle n'est pas definie.""" if self.room_id and self.room: return self.room if self.is_individual and self.parent: return self.parent.effective_room return self.room def create_scheduled_tasks_from_lot(self, commit=True): """Crée les échéances via le moteur unique de maintenance.""" from .maintenance import LotTask from ..services.maintenance_engine import generate_due_tasks # Utiliser le lot effectif (hérité du parent si non défini) effective_lot_id = self.effective_lot_id if not effective_lot_id: return [] created_tasks = [] lot_tasks = LotTask.query.filter_by(lot_id=effective_lot_id, is_active=True).all() for lt in lot_tasks: created_tasks.extend(generate_due_tasks(lt, equipment_ids={self.id}, commit=commit)) return created_tasks def __repr__(self): return f"" class EquipmentDocument(db.Model): """Document attaché à un équipement.""" __tablename__ = "equipment_documents" id = db.Column(db.Integer, primary_key=True) equipment_id = db.Column(db.Integer, db.ForeignKey("equipments.id"), nullable=False) filename = db.Column(db.String(255), nullable=False) filepath = db.Column(db.String(500), nullable=False) description = db.Column(db.Text, nullable=True) uploaded_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) uploaded_by_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True) # Relations equipment = db.relationship("Equipment", back_populates="documents") def __repr__(self): return f"" class EquipmentRoomHistory(db.Model): """Historique des déplacements d'équipements.""" __tablename__ = "equipment_room_history" id = db.Column(db.Integer, primary_key=True) equipment_id = db.Column(db.Integer, db.ForeignKey("equipments.id"), nullable=False) old_room_id = db.Column(db.Integer, db.ForeignKey("rooms.id"), nullable=True) new_room_id = db.Column(db.Integer, db.ForeignKey("rooms.id"), nullable=True) changed_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) changed_by_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True) reason = db.Column(db.String(255), nullable=True) # Relations old_room = db.relationship("Room", foreign_keys=[old_room_id]) new_room = db.relationship("Room", foreign_keys=[new_room_id]) def __repr__(self): return f"" class EquipmentQuantityMovement(db.Model): """Mouvement d'une quantité entre deux salles ou groupes.""" __tablename__ = "equipment_quantity_movements" id = db.Column(db.BigInteger, primary_key=True, autoincrement=True) root_equipment_id = db.Column(db.Integer, db.ForeignKey("equipments.id"), nullable=False, index=True) source_equipment_id = db.Column(db.Integer, db.ForeignKey("equipments.id"), nullable=True) target_equipment_id = db.Column(db.Integer, db.ForeignKey("equipments.id"), nullable=True) source_room_id = db.Column(db.Integer, db.ForeignKey("rooms.id"), nullable=True) target_room_id = db.Column(db.Integer, db.ForeignKey("rooms.id"), nullable=False) quantity = db.Column(db.Integer, nullable=False) reason = db.Column(db.String(255), nullable=True) moved_by_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), nullable=True) moved_at = db.Column(db.DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) source_room = db.relationship("Room", foreign_keys=[source_room_id]) target_room = db.relationship("Room", foreign_keys=[target_room_id]) class RoomConstraint(db.Model): """Contrainte d'indisponibilite d'une salle (cours PRONOTE, horaire special, etc.).""" __tablename__ = "room_constraints" id = db.Column(db.Integer, primary_key=True) room_id = db.Column(db.Integer, db.ForeignKey("rooms.id"), nullable=True) # NULL = toutes les salles constraint_type = db.Column(db.String(20), nullable=False) # 'time', 'occupancy', 'event' day_of_week = db.Column(db.Integer, nullable=True) # 0=Dimanche, 1=Lundi... 6=Samedi, NULL=tous les jours start_time = db.Column(db.Time, nullable=True) end_time = db.Column(db.Time, nullable=True) reason = db.Column(db.String(255), nullable=True) source = db.Column(db.String(50), default='manual') # 'pronote', 'manual', 'vacances' is_active = db.Column(db.Boolean, default=True) effective_date_start = db.Column(db.Date, nullable=True) effective_date_end = db.Column(db.Date, nullable=True) created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) room = db.relationship("Room", backref="constraints_list") @property def day_name(self): names = {0: 'Dimanche', 1: 'Lundi', 2: 'Mardi', 3: 'Mercredi', 4: 'Jeudi', 5: 'Vendredi', 6: 'Samedi'} return names.get(self.day_of_week, 'Tous') @property def is_time_restriction(self): return self.constraint_type == 'time' @property def is_occupancy(self): return self.constraint_type == 'occupancy' def applies_to_date(self, check_date): """Verifie si la contrainte s'applique a cette date.""" if not self.is_active: return False if self.day_of_week is not None and check_date.weekday() != self.day_of_week: return False if self.effective_date_start and check_date < self.effective_date_start: return False if self.effective_date_end and check_date > self.effective_date_end: return False return True def conflicts_with(self, check_date, start_time, end_time): """Verifie si cette contrainte bloque un creneau donne.""" if not self.applies_to_date(check_date): return False if self.is_occupancy: return True # Salle occupee toute la journee if self.start_time and self.end_time: # Chevauchement d'horaires return start_time < self.end_time and end_time > self.start_time return False def __repr__(self): return f"" def is_room_available(room_id, check_date, start_time, end_time): """Verifie si une salle est disponible a une date/heure donnee. Croise les contraintes RoomConstraint. Retourne (available: bool, reason: str or None). """ constraints = RoomConstraint.query.filter( db.or_(RoomConstraint.room_id == room_id, RoomConstraint.room_id.is_(None)), RoomConstraint.is_active == True ).all() for c in constraints: if c.conflicts_with(check_date, start_time, end_time): room_name = c.room.name if c.room else 'Toutes salles' return False, f"{room_name}: {c.reason or c.constraint_type}" return True, None class EquipmentRestriction(db.Model): """Restrictions d'accès pour un équipement.""" __tablename__ = "equipment_restrictions" id = db.Column(db.Integer, primary_key=True) equipment_id = db.Column(db.Integer, db.ForeignKey("equipments.id"), nullable=False) # Type de restriction restriction_type = db.Column(db.String(20), nullable=False) # 'time' ou 'room_occupancy' # Pour restriction par horaire start_time = db.Column(db.Time, nullable=True) # Heure de début end_time = db.Column(db.Time, nullable=True) # Heure de fin days_of_week = db.Column(db.String(20), nullable=True) # "0,1,2,3,4,5,6" pour les jours # Pour restriction par occupation de salle room_id = db.Column(db.Integer, db.ForeignKey("rooms.id"), nullable=True) # Salle à surveiller # Message explicatif message = db.Column(db.String(255), nullable=True) # "Intervention impossible pendant les cours" # Métadonnées is_active = db.Column(db.Boolean, default=True) created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) created_by_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True) # Relations equipment = db.relationship("Equipment", backref="restrictions") room = db.relationship("Room", backref="restrictions") def days_list(self): """Retourne la liste des jours.""" if not self.days_of_week: return [] days_names = {0: 'Dimanche', 1: 'Lundi', 2: 'Mardi', 3: 'Mercredi', 4: 'Jeudi', 5: 'Vendredi', 6: 'Samedi'} return [days_names.get(int(d), '') for d in self.days_of_week.split(',') if d.isdigit()] def is_time_restriction(self): return self.restriction_type == 'time' def is_room_occupancy_restriction(self): return self.restriction_type == 'room_occupancy' def __repr__(self): return f""