465 lines
19 KiB
Python
465 lines
19 KiB
Python
"""
|
|
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)
|
|
|
|
# Relations
|
|
equipments = db.relationship("Equipment", back_populates="category", overlaps="category")
|
|
lots = db.relationship("Lot", back_populates="category")
|
|
|
|
def __repr__(self):
|
|
return f"<EquipmentCategory {self.name}>"
|
|
|
|
|
|
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."""
|
|
return bool(self.recurrence_monitoring and self.curative_count >= 3)
|
|
|
|
@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):
|
|
"""Crée les tâches planifiées à partir des tâches du lot héritées."""
|
|
from .maintenance import LotTask
|
|
from .planning import ScheduledTask
|
|
from datetime import date, timedelta
|
|
|
|
# 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 = []
|
|
|
|
# Récupérer les tâches du lot
|
|
lot_tasks = LotTask.query.filter_by(lot_id=effective_lot_id).all()
|
|
for lt in lot_tasks:
|
|
# Vérifier si une tâche planifiée existe déjà
|
|
existing = ScheduledTask.query.filter_by(
|
|
lot_task_id=lt.id,
|
|
equipment_id=self.id
|
|
).first()
|
|
|
|
if not existing:
|
|
# Calculer la première date planifiée
|
|
scheduled_date = date.today()
|
|
if lt.jours_entre_interventions:
|
|
scheduled_date = date.today() + timedelta(days=lt.jours_entre_interventions)
|
|
|
|
# Créer la tâche planifiée
|
|
scheduled_task = ScheduledTask(
|
|
lot_task_id=lt.id,
|
|
equipment_id=self.id,
|
|
room_id=self.room_id or (self.effective_room.id if self.effective_room else None),
|
|
scheduled_date=scheduled_date,
|
|
estimated_duration=lt.duree_minutes,
|
|
status='planned'
|
|
)
|
|
db.session.add(scheduled_task)
|
|
created_tasks.append(scheduled_task)
|
|
|
|
if created_tasks:
|
|
db.session.commit()
|
|
|
|
return created_tasks
|
|
|
|
def __repr__(self):
|
|
return f"<Equipment {self.name}>"
|
|
|
|
|
|
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"<EquipmentDocument {self.filename}>"
|
|
|
|
|
|
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"<EquipmentRoomHistory {self.equipment_id}>"
|
|
|
|
|
|
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"<RoomConstraint {self.room_id} {self.constraint_type}>"
|
|
|
|
|
|
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"<EquipmentRestriction {self.equipment_id} - {self.restriction_type}>"
|