gmao/app_new/equipments/main.py

931 lines
42 KiB
Python
Raw Normal View History

2026-08-14 18:02:25 +02:00
"""Equipments Main Routes - GMAO Collège"""
from flask import Blueprint, render_template, redirect, url_for, request, flash, jsonify
from flask_login import login_required, current_user
from datetime import datetime, timezone
from types import SimpleNamespace
2026-08-14 18:02:25 +02:00
from app_new.extensions import db
from ..core.models.equipment import Equipment, EquipmentCategory, EquipmentDocument, EquipmentRoomHistory, EquipmentRestriction, EquipmentQuantityMovement
2026-08-14 18:02:25 +02:00
from ..core.models.college import Room, Building, RoomType, Zone
from ..core.models.maintenance import Lot, Intervention
from ..core.models.planning import Meter, MeterReading, Consumable, EquipmentConsumable, ScheduledTask
from app_new.constants import EQUIPMENT_STATUSES
from app_new.utils import format_status, status_badge_class
from sqlalchemy.orm import selectinload, joinedload
from ..core.services.equipment_lifecycle import (
cancel_disposal, complete_repair as complete_equipment_repair,
confirm_disposal, propose_disposal, record_event, start_repair as start_equipment_repair,
update_future_task_location,
)
from ..core.authorization import permission_required
2026-08-14 18:02:25 +02:00
def _empty_to_none(v):
"""Convertit une chaîne vide en None."""
return None if v == '' else v
def _date_or_none(value):
return datetime.strptime(value, '%Y-%m-%d').date() if value else None
2026-08-14 18:02:25 +02:00
main_bp = Blueprint("equipments", __name__, template_folder='templates')
"""
Equipments Routes - GMAO Collège
Gestion des équipements
"""
from flask import Blueprint, render_template, redirect, url_for, request, flash, jsonify
from flask_login import login_required, current_user
from datetime import datetime
from app_new.extensions import db
from ..core.models.equipment import Equipment, EquipmentCategory, EquipmentDocument, EquipmentRoomHistory, EquipmentRestriction, EquipmentQuantityMovement
2026-08-14 18:02:25 +02:00
from ..core.models.college import Room, Building, RoomType, Zone
from ..core.models.maintenance import Lot, Intervention
from ..core.models.planning import Meter, MeterReading, Consumable, EquipmentConsumable
from app_new.constants import EQUIPMENT_STATUSES
def _empty_to_none(v):
"""Convertit une chaîne vide en None."""
return None if v == '' else v
equipments_bp = Blueprint('equipments', __name__, template_folder='templates')
@main_bp.route('/')
@login_required
def index():
"""Liste des équipements - hiérarchie parent > zone > salle > individuel."""
search = request.args.get('search', '')
status = request.args.get('status', '')
category_id = request.args.get('category', type=int)
building_id = request.args.get('building', type=int)
view = request.args.get('view', 'families')
if view == 'zones':
# Vue inventaire : une ligne par type et par salle, avec quantités
# agrégées. Les groupes parents sans salle sont ignorés afin de ne
# pas compter deux fois leurs groupes localisés.
grouped = {}
rows = (Equipment.query.options(
joinedload(Equipment.room).joinedload(Room.zone),
joinedload(Equipment.room).joinedload(Room.building),
joinedload(Equipment.category),
joinedload(Equipment.parent).joinedload(Equipment.category),
joinedload(Equipment.parent).joinedload(Equipment.room),
).filter_by(is_deleted=False).all())
for eq in rows:
room = eq.effective_room
if not room:
continue
if eq.is_group:
children = list(eq.children)
# Un groupe structurel situé au-dessus de groupes par salle ne
# représente pas une quantité supplémentaire.
if any(child.is_group and not child.is_deleted for child in children):
continue
display_quantity = eq.unindividualized_quantity
if display_quantity < 1:
continue
else:
display_quantity = eq.quantity or 1
if search and search.lower() not in (eq.name or '').lower():
continue
if category_id and eq.effective_category_id != category_id:
continue
if building_id and room.building_id != building_id:
continue
if status and eq.status != status:
continue
building = room.building
zone = room.zone
b = grouped.setdefault(building.id, {'name': building.name, 'zones': {}})
z = b['zones'].setdefault(zone.id if zone else 0, {'name': zone.name if zone else 'Sans zone', 'rooms': {}})
r = z['rooms'].setdefault(room.id, {'name': room.name, 'types': {}})
# La vue doit distinguer les types réellement inventoriés
# (Chaises, Prises, Portes…), et non seulement les grandes
# catégories métier (Mobilier, Courants forts…).
type_name = (eq.name or '').split(' #')[0].strip() or 'Sans nom'
key = type_name.casefold()
effective_category = eq.effective_category
display_name = type_name
item = r['types'].setdefault(key, {'name': display_name, 'category': effective_category.name if effective_category else '', 'quantity': 0, 'available': 0, 'repair': 0, 'disposal': 0, 'ids': []})
item['quantity'] += display_quantity
if eq.lifecycle_status == 'en_reparation':
item['repair'] += display_quantity
elif eq.lifecycle_status in ('mise_au_rebut_planifiee', 'jete'):
item['disposal'] += display_quantity
else:
item['available'] += display_quantity
item['ids'].append(eq.id)
zone_tree = [{'id': bid, 'name': b['name'], 'zones': [{'id': zid, 'name': z['name'], 'rooms': [{'id': rid, 'name': r['name'], 'types': list(r['types'].values())} for rid, r in z['rooms'].items()]} for zid, z in b['zones'].items()]} for bid, b in grouped.items()]
categories = EquipmentCategory.query.order_by(EquipmentCategory.name).all()
buildings = Building.query.order_by(Building.name).all()
return render_template('equipments/index.html', zone_tree=zone_tree, view=view, categories=categories, buildings=buildings, statuses=EQUIPMENT_STATUSES, filters={'search': search, 'status': status, 'category': category_id, 'building': building_id})
2026-08-14 18:02:25 +02:00
# Pagination (page courante, 20 parents par page)
page = request.args.get('page', 1, type=int)
per_page = 20
# Parents (équipements racine sans parent)
parent_query = Equipment.query.filter_by(parent_id=None)
if search:
parent_query = parent_query.filter(
db.or_(
Equipment.name.ilike(f'%{search}%'),
Equipment.code.ilike(f'%{search}%')
)
)
if status:
parent_query = parent_query.filter_by(status=status)
if category_id:
parent_query = parent_query.filter_by(category_id=category_id)
if building_id:
parent_query = parent_query.join(Room, Equipment.room_id == Room.id).filter(Room.building_id == building_id)
parents_pagination = parent_query.order_by(Equipment.name).paginate(
page=page, per_page=per_page, error_out=False
)
parents = parents_pagination.items
# Les descendants sont volontairement chargés par l'endpoint hierarchy
# uniquement lorsque l'utilisateur déplie une famille.
2026-08-14 18:02:25 +02:00
for parent in parents:
parent.zones_list = []
def quantity_total(node, seen=None):
seen = seen or set()
if node.id in seen:
return 0
seen.add(node.id)
children = list(node.children)
if not children:
return node.quantity or 1
return sum(quantity_total(child, seen) for child in children if not child.is_deleted)
parent.individual_count = quantity_total(parent)
2026-08-14 18:02:25 +02:00
categories = EquipmentCategory.query.order_by(EquipmentCategory.name).all()
buildings = Building.query.order_by(Building.name).all()
return render_template('equipments/index.html',
parents=parents,
pagination=parents_pagination,
categories=categories,
buildings=buildings,
statuses=EQUIPMENT_STATUSES,
filters={'search': search, 'status': status, 'category': category_id, 'building': building_id}, view=view)
2026-08-14 18:02:25 +02:00
@main_bp.route('/<int:id>/hierarchy')
@login_required
def hierarchy(id):
"""Charge à la demande la hiérarchie localisée d'une famille."""
from sqlalchemy import select
root = Equipment.query.get_or_404(id)
descendants = select(
Equipment.id, Equipment.parent_id, Equipment.name, Equipment.code, Equipment.is_group,
Equipment.status, Equipment.room_id, Equipment.quantity,
Equipment.room_id.label('effective_room_id'),
).where(Equipment.parent_id == root.id, Equipment.is_deleted.is_(False)).cte(recursive=True)
descendants = descendants.union_all(select(
Equipment.id, Equipment.parent_id, Equipment.name, Equipment.code, Equipment.is_group,
Equipment.status, Equipment.room_id, Equipment.quantity,
db.func.coalesce(Equipment.room_id, descendants.c.effective_room_id).label('effective_room_id'),
).where(Equipment.parent_id == descendants.c.id, Equipment.is_deleted.is_(False)))
rows = db.session.execute(
select(descendants.c.id, descendants.c.parent_id, descendants.c.name, descendants.c.code, descendants.c.is_group,
descendants.c.status, descendants.c.quantity, Room.id.label('room_id'), Room.name.label('room_name'),
Zone.id.label('zone_id'), Zone.name.label('zone_name'))
.select_from(descendants)
.join(Room, descendants.c.effective_room_id == Room.id, isouter=True)
.join(Zone, Room.zone_id == Zone.id, isouter=True)
.order_by(Zone.name, Room.name, descendants.c.name)
).all()
# Les équipements quantitatifs sans enfants sont eux-mêmes une famille
# localisée. La requête des descendants ne retournait alors aucune ligne,
# masquant leur salle dans le chargement à la demande.
if not rows and not root.is_deleted:
room = root.effective_room
zone = room.zone if room else None
rows = [SimpleNamespace(
id=root.id,
parent_id=root.parent_id,
name=root.name,
code=root.code,
is_group=root.is_group,
status=root.status,
quantity=root.quantity,
room_id=room.id if room else None,
room_name=room.name if room else None,
zone_id=zone.id if zone else None,
zone_name=zone.name if zone else None,
)]
direct_child_quantities = {}
for row in rows:
direct_child_quantities[row.parent_id] = direct_child_quantities.get(row.parent_id, 0) + (row.quantity or 1)
zones = {}
for row in rows:
zone_key = row.zone_id or 0
room_key = row.room_id or 0
zone = zones.setdefault(zone_key, {'name': row.zone_name or 'Sans zone', 'rooms': {}})
room = zone['rooms'].setdefault(room_key, {'name': row.room_name or 'Sans salle', 'items': []})
quantity = max((row.quantity or 1) - direct_child_quantities.get(row.id, 0), 0) if row.is_group else (row.quantity or 1)
if quantity < 1:
continue
room['items'].append({
'id': row.id, 'name': row.name, 'code': row.code,
'status': row.status, 'quantity': quantity,
'status_label': format_status(row.status),
'status_class': f"bg-{status_badge_class(row.status)}",
'url': url_for('equipments.detail', id=row.id),
})
payload = []
for zone in zones.values():
rooms = list(zone['rooms'].values())
payload.append({'name': zone['name'], 'count': sum(item['quantity'] for room in rooms for item in room['items']), 'rooms': rooms})
return jsonify({'root_id': root.id, 'zones': payload})
2026-08-14 18:02:25 +02:00
@main_bp.route('/create', methods=['GET', 'POST'])
@login_required
def create():
"""Créer un équipement."""
if request.method == 'GET' and request.args.get('advanced') != '1' and not request.args.get('parent_id'):
return redirect(url_for('wizard.equipment_wizard'))
2026-08-14 18:02:25 +02:00
# Récupérer le parent_id depuis query string ou POST
preselected_parent_id = request.args.get('parent_id')
if request.method == 'POST':
parent_id = request.form.get('parent_select') or request.form.get('parent_id') or None
category_id = request.form.get('category_id', type=int)
lot_id = request.form.get('lot_id', type=int)
if lot_id:
lot = db.session.get(Lot, lot_id)
if not category_id or not lot or lot.category_id != category_id:
flash('Le lot sélectionné doit appartenir à la catégorie choisie.', 'danger')
return redirect(url_for('equipments.create', advanced=1)), 400
2026-08-14 18:02:25 +02:00
equipment = Equipment(
name=request.form.get('name'),
category_id=category_id,
2026-08-14 18:02:25 +02:00
room_id=request.form.get('room_id') or None,
lot_id=lot_id,
2026-08-14 18:02:25 +02:00
code=_empty_to_none(request.form.get('code', '').strip()),
description=_empty_to_none(request.form.get('description', '').strip()),
status=request.form.get('status', 'en_service'),
is_group=request.form.get('is_group') == '1',
quantity=int(request.form.get('quantity', 1) or 1),
parent_id=parent_id,
mobility=request.form.get('mobility', 'non_precise'),
management_mode=request.form.get('management_mode') or None,
serial_number=_empty_to_none(request.form.get('serial_number', '').strip()),
manufacturer=_empty_to_none(request.form.get('manufacturer', '').strip()),
model_reference=_empty_to_none(request.form.get('model_reference', '').strip()),
supplier_id=request.form.get('supplier_id') or None,
supplier_reference=_empty_to_none(request.form.get('supplier_reference', '').strip()),
purchase_price=request.form.get('purchase_price') or None,
purchase_date=_date_or_none(request.form.get('purchase_date')),
recurrence_monitoring=request.form.get('recurrence_monitoring') == '1',
2026-08-14 18:02:25 +02:00
)
db.session.add(equipment)
db.session.commit()
# Créer les tâches planifiées si l'équipement a un lot
if equipment.lot_id:
equipment.create_scheduled_tasks_from_lot()
flash('Équipement créé avec succès.', 'success')
return redirect(url_for('equipments.detail', id=equipment.id))
categories = EquipmentCategory.query.order_by(EquipmentCategory.name).all()
rooms = Room.query.order_by(Room.name).all()
buildings = Building.query.order_by(Building.name).all()
parent_equipments = Equipment.query.filter(Equipment.is_group == True, Equipment.parent_id == None).order_by(Equipment.name).all()
lots = Lot.query.order_by(Lot.name).all()
from ..core.models.company import Company
companies = Company.query.order_by(Company.name).all()
2026-08-14 18:02:25 +02:00
return render_template('equipments/form.html',
categories=categories,
rooms=rooms,
buildings=buildings,
parent_equipments=parent_equipments,
lots=lots,
statuses=EQUIPMENT_STATUSES,
companies=companies,
2026-08-14 18:02:25 +02:00
preselected_parent_id=preselected_parent_id)
@main_bp.route('/<int:id>')
@login_required
def detail(id):
"""Détail d'un équipement."""
equipment = Equipment.query.get_or_404(id)
root_id = equipment.parent_id or equipment.id
if equipment.parent and equipment.parent.parent_id:
root_id = equipment.parent.parent_id
quantity_movements = EquipmentQuantityMovement.query.filter_by(
root_equipment_id=root_id
).order_by(EquipmentQuantityMovement.moved_at.desc()).limit(50).all()
return render_template('equipments/detail.html', equipment=equipment, quantity_movements=quantity_movements)
2026-08-14 18:02:25 +02:00
@main_bp.route('/<int:id>/edit', methods=['GET', 'POST'])
@login_required
def edit(id):
"""Modifier un équipement."""
equipment = Equipment.query.get_or_404(id)
if request.method == 'POST':
old_lot_id = equipment.lot_id
category_id = request.form.get('category_id', type=int)
lot_id = request.form.get('lot_id', type=int)
if lot_id:
lot = db.session.get(Lot, lot_id)
if not category_id or not lot or lot.category_id != category_id:
flash('Le lot sélectionné doit appartenir à la catégorie choisie.', 'danger')
return redirect(url_for('equipments.edit', id=id)), 400
2026-08-14 18:02:25 +02:00
equipment.name = request.form.get('name')
equipment.code = _empty_to_none(request.form.get('code'))
equipment.description = _empty_to_none(request.form.get('description'))
equipment.category_id = category_id
equipment.lot_id = lot_id
2026-08-14 18:02:25 +02:00
equipment.room_id = _empty_to_none(request.form.get('room_id'))
equipment.status = request.form.get('status', 'en_service')
equipment.quantity = int(request.form.get('quantity', 1)) or 1
equipment.is_group = bool(request.form.get('is_group'))
equipment.tracked_individually = bool(request.form.get('tracked_individually'))
equipment.position = _empty_to_none(request.form.get('position'))
equipment.mobility = request.form.get('mobility', 'non_precise')
equipment.management_mode = request.form.get('management_mode') or None
equipment.serial_number = _empty_to_none(request.form.get('serial_number', '').strip())
equipment.manufacturer = _empty_to_none(request.form.get('manufacturer', '').strip())
equipment.model_reference = _empty_to_none(request.form.get('model_reference', '').strip())
equipment.supplier_id = request.form.get('supplier_id') or None
equipment.supplier_reference = _empty_to_none(request.form.get('supplier_reference', '').strip())
equipment.purchase_price = request.form.get('purchase_price') or None
equipment.purchase_date = _date_or_none(request.form.get('purchase_date'))
equipment.recurrence_monitoring = bool(request.form.get('recurrence_monitoring'))
2026-08-14 18:02:25 +02:00
# Position X/Y
pos_x = request.form.get('position_x')
pos_y = request.form.get('position_y')
equipment.position_x = float(pos_x) if pos_x else None
equipment.position_y = float(pos_y) if pos_y else None
# Dates
install_date = request.form.get('install_date')
warranty_end = request.form.get('warranty_end')
equipment.install_date = datetime.strptime(install_date, '%Y-%m-%d').date() if install_date else None
equipment.warranty_end = datetime.strptime(warranty_end, '%Y-%m-%d').date() if warranty_end else None
# Parent
parent_id = request.form.get('parent_id')
equipment.parent_id = int(parent_id) if parent_id else None
# Si le lot a changé, créer les tâches planifiées
db.session.commit()
if old_lot_id != equipment.lot_id and equipment.lot_id:
equipment.create_scheduled_tasks_from_lot()
flash('Équipement modifié avec succès.', 'success')
return redirect(url_for('equipments.detail', id=equipment.id))
categories = EquipmentCategory.query.order_by(EquipmentCategory.name).all()
rooms = Room.query.order_by(Room.name).all()
buildings = Building.query.order_by(Building.name).all()
parent_equipments = Equipment.query.filter(Equipment.id != id).order_by(Equipment.name).all()
lots = Lot.query.order_by(Lot.name).all()
from ..core.models.company import Company
companies = Company.query.order_by(Company.name).all()
2026-08-14 18:02:25 +02:00
# Charger les relations
meters = Meter.query.filter_by(equipment_id=id).all()
equipment_consumables = EquipmentConsumable.query.filter_by(equipment_id=id).all()
all_consumables = Consumable.query.filter_by(is_active=True).order_by(Consumable.name).all()
room_history = EquipmentRoomHistory.query.filter_by(equipment_id=id).order_by(EquipmentRoomHistory.changed_at.desc()).all()
return render_template('equipments/edit.html',
equipment=equipment,
categories=categories,
rooms=rooms,
buildings=buildings,
parent_equipments=parent_equipments,
lots=lots,
companies=companies,
2026-08-14 18:02:25 +02:00
meters=meters,
equipment_consumables=equipment_consumables,
all_consumables=all_consumables,
room_history=room_history)
@main_bp.route('/<int:id>/delete', methods=['POST'])
@login_required
def delete(id):
"""Supprimer un équipement (et ses enfants en cascade)."""
equipment = Equipment.query.get_or_404(id)
name = equipment.name
# Collecter tous les IDs (parent + enfants récursifs)
def collect_ids(eq):
ids = [eq.id]
for child in eq.children:
ids.extend(collect_ids(child))
return ids
all_ids = collect_ids(equipment)
# Supprimer les dépendances en cascade (l'ordre importe peu avec les bonnes FK)
EquipmentRoomHistory.query.filter(EquipmentRoomHistory.equipment_id.in_(all_ids)).delete(synchronize_session=False)
EquipmentDocument.query.filter(EquipmentDocument.equipment_id.in_(all_ids)).delete(synchronize_session=False)
EquipmentConsumable.query.filter(EquipmentConsumable.equipment_id.in_(all_ids)).delete(synchronize_session=False)
EquipmentRestriction.query.filter(EquipmentRestriction.equipment_id.in_(all_ids)).delete(synchronize_session=False)
Intervention.query.filter(Intervention.equipment_id.in_(all_ids)).delete(synchronize_session=False)
# Supprimer les équipements (du plus profond au parent)
def delete_recursive(eq):
for child in list(eq.children):
delete_recursive(child)
db.session.delete(eq)
delete_recursive(equipment)
db.session.commit()
flash(f"Équipement '{name}' et ses sous-éléments supprimés.", 'success')
return redirect(url_for('equipments.index'))
def _cascade_soft_delete(equipment):
"""Suppression logique en cascade (si is_active existe)."""
for child in list(equipment.children):
_cascade_soft_delete(child)
if hasattr(equipment, 'is_active'):
equipment.is_active = False
@main_bp.route('/<int:id>/soft_delete', methods=['POST'])
@login_required
def soft_delete(id):
"""Suppression logique d'un équipement et de ses enfants."""
equipment = Equipment.query.get_or_404(id)
name = equipment.name
# Si is_active n'existe pas, supprimer physiquement
if not hasattr(equipment, 'is_active'):
def delete_recursive(eq):
for child in list(eq.children):
delete_recursive(child)
db.session.delete(eq)
delete_recursive(equipment)
else:
_cascade_soft_delete(equipment)
db.session.commit()
flash(f"Équipement '{name}' et ses sous-éléments supprimés.", 'success')
return redirect(url_for('equipments.index'))
@main_bp.route('/categories')
@login_required
def categories():
"""Liste des catégories d'équipements."""
categories = EquipmentCategory.query.order_by(EquipmentCategory.name).all()
return render_template('equipments/categories.html', categories=categories)
@main_bp.route('/categories/<int:id>')
@login_required
def category_detail(id):
"""Détail d'une catégorie d'équipements."""
category = EquipmentCategory.query.get_or_404(id)
equipments = Equipment.query.filter_by(category_id=id).order_by(Equipment.name).all()
return render_template('equipments/category_detail.html', category=category, equipments=equipments)
@main_bp.route('/categories/<int:id>/recurrence-policy', methods=['POST'])
@login_required
def category_recurrence_policy(id):
category = EquipmentCategory.query.get_or_404(id)
category.recurrence_alert_enabled = request.form.get('enabled') == 'on'
category.recurrence_threshold = max(1, request.form.get('threshold', type=int) or 3)
category.recurrence_window_days = max(1, request.form.get('window_days', type=int) or 365)
valid_modes = {'quantitatif', 'individuel_fixe', 'individuel_mobile', 'serialise', 'structure', 'consommable'}
requested_mode = request.form.get('default_management_mode')
if requested_mode in valid_modes:
category.default_management_mode = requested_mode
db.session.commit()
flash('Politique de récurrence mise à jour.', 'success')
return redirect(url_for('equipments.category_detail', id=id))
2026-08-14 18:02:25 +02:00
@main_bp.route('/categories/new', methods=['GET', 'POST'])
@login_required
def create_category():
"""Créer une catégorie."""
if request.method == 'POST':
cat = EquipmentCategory(
name=request.form.get('name'),
description=request.form.get('description')
)
db.session.add(cat)
db.session.commit()
flash(f"Catégorie '{cat.name}' créée.", 'success')
return redirect(url_for('equipments.categories'))
return render_template('equipments/category_form.html', title='Nouvelle catégorie')
@main_bp.route('/<int:id>/status/<status>', methods=['POST'])
2026-08-14 18:02:25 +02:00
@login_required
def change_status(id, status):
"""Changer le statut d'un équipement."""
valid_statuses = ['en_service', 'hs', 'a_jeter', 'jete', 'actif']
if status not in valid_statuses:
flash(f'Statut invalide: {status}', 'danger')
return redirect(url_for('equipments.detail', id=id))
equipment = Equipment.query.get_or_404(id)
equipment.status = status
db.session.commit()
flash('Statut modifié.', 'success')
return redirect(url_for('equipments.detail', id=id))
@main_bp.route('/<int:id>/mark-trash', methods=['POST'])
@login_required
def mark_as_trash(id):
"""Marquer un équipement comme à jeter."""
equipment = Equipment.query.get_or_404(id)
quantity = request.form.get('quantity', 1, type=int) or 1
reason = _empty_to_none(request.form.get('reason', '').strip())
try:
units = propose_disposal(
equipment, quantity, reason, _date_or_none(request.form.get('planned_disposal_date')),
current_user.id,
)
db.session.commit()
except ValueError as exc:
db.session.rollback()
flash(str(exc), 'danger')
return redirect(url_for('equipments.detail', id=equipment.id))
flash(f"{len(units)} unité(s) proposée(s) pour mise au rebut.", 'warning')
return redirect(url_for('equipments.lifecycle_dashboard', status='mise_au_rebut_planifiee'))
2026-08-14 18:02:25 +02:00
@main_bp.route('/<int:id>/cancel-trash', methods=['POST'])
@login_required
def cancel_trash(id):
"""Annuler le statut 'à jeter'."""
equipment = Equipment.query.get_or_404(id)
if equipment.status != 'a_jeter':
flash("Cet équipement n'est pas marqué comme à jeter.", 'warning')
return redirect(url_for('equipments.detail', id=equipment.id))
cancel_disposal(equipment, request.form.get('reason'), current_user.id)
2026-08-14 18:02:25 +02:00
db.session.commit()
flash(f"Équipement '{equipment.name}' remis en service.", 'success')
return redirect(url_for('equipments.detail', id=equipment.id))
@main_bp.route('/<int:id>/confirm-trash', methods=['POST'])
@login_required
def confirm_trashed(id):
"""Confirmer qu'un équipement a été jeté."""
equipment = Equipment.query.get_or_404(id)
confirm_disposal(equipment, request.form.get('reason'), current_user.id)
2026-08-14 18:02:25 +02:00
db.session.commit()
flash(f"Équipement '{equipment.name}' marqué comme jeté.", 'success')
return redirect(url_for('equipments.trashed'))
@main_bp.route('/a-jeter')
@login_required
def to_trash():
"""Liste des équipements à jeter."""
equipments = Equipment.query.filter_by(status='a_jeter').order_by(Equipment.name).all()
return render_template('equipments/trash_list.html', equipments=equipments, title='Équipements à jeter')
@main_bp.route('/lifecycle')
@login_required
def lifecycle_dashboard():
"""Poste de pilotage des équipements indisponibles et sortis du patrimoine."""
status = (request.args.get('status') or 'en_reparation').strip()
allowed = {'all', 'en_reparation', 'mise_au_rebut_planifiee', 'jete'}
if status not in allowed:
status = 'en_reparation'
query = Equipment.query.filter(Equipment.is_deleted.is_(False))
if status != 'all':
query = query.filter(Equipment.lifecycle_status == status)
equipments = query.order_by(Equipment.updated_at.desc()).all()
counts = {
key: Equipment.query.filter_by(is_deleted=False, lifecycle_status=key).count()
for key in ('en_reparation', 'mise_au_rebut_planifiee', 'jete')
}
return render_template('equipments/lifecycle.html', equipments=equipments, status=status, counts=counts)
2026-08-14 18:02:25 +02:00
@main_bp.route('/jetes')
@login_required
def trashed():
"""Liste des équipements jetés."""
equipments = Equipment.query.filter_by(status='jete').order_by(Equipment.updated_at.desc()).all()
return render_template('equipments/trash_list.html', equipments=equipments, title='Anciens équipements')
@main_bp.route('/bulk-confirm-trash', methods=['POST'])
@login_required
def bulk_confirm_trash():
"""Confirmer plusieurs équipements comme jetés."""
ids = request.form.getlist('equipment_ids')
for eq_id in ids:
equipment = Equipment.query.get(eq_id)
if equipment:
confirm_disposal(equipment, user_id=current_user.id)
2026-08-14 18:02:25 +02:00
db.session.commit()
flash(f'{len(ids)} équipements marqués comme jetés.', 'success')
return redirect(url_for('equipments.trashed'))
@main_bp.route('/<int:id>/add-elements', methods=['GET', 'POST'])
@login_required
def add_elements(id):
"""Ajouter des éléments à un groupe existant."""
equipment = Equipment.query.get_or_404(id)
if not equipment.is_group:
flash("Cet équipement n'est pas un groupe.", 'warning')
return redirect(url_for('equipments.detail', id=equipment.id))
if request.method == 'POST':
count = int(request.form.get('count', 1))
if count < 1:
flash('La quantite ajoutee doit etre positive.', 'danger')
return redirect(url_for('equipments.add_elements', id=equipment.id))
first_number = equipment.individualized_quantity + 1
2026-08-14 18:02:25 +02:00
for i in range(count):
new_element = Equipment(
name=f"{equipment.name} #{first_number + i}",
code=f"{equipment.code}-{first_number + i}" if equipment.code else None,
2026-08-14 18:02:25 +02:00
parent_id=equipment.id,
individual_number=first_number + i,
2026-08-14 18:02:25 +02:00
status='en_service',
room_id=equipment.room_id,
quantity=1,
is_group=False,
tracked_individually=True,
2026-08-14 18:02:25 +02:00
)
db.session.add(new_element)
# L'ajout augmente le patrimoine physique du groupe.
equipment.quantity = (equipment.quantity or 0) + count
2026-08-14 18:02:25 +02:00
db.session.commit()
# Créer les tâches planifiées pour les nouveaux éléments
# Récupérer les enfants récemment créés
children = Equipment.query.filter_by(parent_id=equipment.id).order_by(Equipment.id.desc()).limit(count).all()
for child in children:
if child.effective_lot_id:
child.create_scheduled_tasks_from_lot()
flash(f'{count} élément(s) ajouté(s) au groupe.', 'success')
return redirect(url_for('equipments.detail', id=equipment.id))
return render_template('equipments/add_elements.html', equipment=equipment)
@main_bp.route('/<int:id>/individualize', methods=['GET', 'POST'])
@login_required
def individualize(id):
"""Extraire une unite quantitative d'un groupe vers une fiche individuelle."""
2026-08-14 18:02:25 +02:00
equipment = Equipment.query.get_or_404(id)
if not equipment.is_group:
flash("Seul un groupe d'équipements peut fournir une unité à individualiser.", 'warning')
return redirect(url_for('equipments.detail', id=equipment.id))
if equipment.unindividualized_quantity < 1:
flash("Toutes les unités de ce groupe sont déjà individualisées.", 'warning')
2026-08-14 18:02:25 +02:00
return redirect(url_for('equipments.detail', id=equipment.id))
if request.method == 'POST':
next_number = max(
[child.individual_number or 0 for child in equipment.children] or [0]
) + 1
name = (request.form.get('name') or '').strip() or f"{equipment.name} #{next_number}"
code = (request.form.get('code') or '').strip() or None
position = (request.form.get('position') or '').strip() or None
individual = Equipment(
name=name,
code=code,
description=equipment.description,
status='en_service',
parent_id=equipment.id,
is_group=False,
quantity=1,
tracked_individually=True,
individual_number=next_number,
position=position,
room_id=equipment.room_id,
# Le lot et la categorie restent herites du groupe.
lot_id=None,
category_id=None,
)
db.session.add(individual)
2026-08-14 18:02:25 +02:00
db.session.commit()
if individual.effective_lot_id:
individual.create_scheduled_tasks_from_lot()
flash(
f"Unité #{next_number} individualisée. Le groupe conserve un total de "
f"{equipment.quantity} unité(s).",
'success',
)
return redirect(url_for('equipments.detail', id=individual.id))
next_number = max(
[child.individual_number or 0 for child in equipment.children] or [0]
) + 1
return render_template(
'equipments/individualize.html',
equipment=equipment,
next_number=next_number,
)
2026-08-14 18:02:25 +02:00
@main_bp.route('/<int:id>/children')
@login_required
def children(id):
"""Liste tous les enfants d'un groupe."""
equipment = Equipment.query.get_or_404(id)
if not equipment.is_group:
flash("Cet équipement n'est pas un groupe.", 'warning')
return redirect(url_for('equipments.detail', id=equipment.id))
# Récupérer tous les enfants avec pagination
page = request.args.get('page', 1, type=int)
children = Equipment.query.filter_by(parent_id=id)\
.order_by(Equipment.individual_number)\
.paginate(page=page, per_page=50)
return render_template('equipments/children.html',
equipment=equipment,
children=children)
@main_bp.route('/<int:id>/scheduled-tasks/init', methods=['POST'])
@login_required
def init_scheduled_tasks(id):
"""Initialise les tâches planifiées depuis le lot et la catégorie hérités."""
equipment = Equipment.query.get_or_404(id)
# Créer les tâches planifiées
created_tasks = equipment.create_scheduled_tasks_from_lot()
if created_tasks:
flash(f'{len(created_tasks)} tâche(s) planifiée(s) créée(s).', 'success')
else:
flash('Aucune tâche planifiée créée (déjà existantes ou pas de lot/catégorie).', 'info')
return redirect(url_for('equipments.scheduled_tasks', id=equipment.id))
@main_bp.route('/<int:id>/move', methods=['GET', 'POST'])
@login_required
@permission_required('patrimoine.move')
2026-08-14 18:02:25 +02:00
def move_to_room(id):
"""Déplace une unité ou une quantité, sans casser les totaux des groupes."""
2026-08-14 18:02:25 +02:00
equipment = Equipment.query.get_or_404(id)
if equipment.mobility == 'fixe':
flash("Cet équipement est fixe. Sa relocalisation nécessite une modification de sa fiche.", 'danger')
return redirect(url_for('equipments.detail', id=id))
2026-08-14 18:02:25 +02:00
if request.method == 'POST':
target_room_id = request.form.get('room_id', type=int)
target_room = db.session.get(Room, target_room_id) if target_room_id else None
if target_room is None:
flash('La salle cible est invalide.', 'danger')
return redirect(url_for('equipments.move_to_room', id=id))
reason = _empty_to_none((request.form.get('reason') or '').strip())
if equipment.is_group:
quantity = request.form.get('quantity', 1, type=int)
if quantity < 1:
flash('La quantité doit être positive.', 'danger')
return redirect(url_for('equipments.move_to_room', id=id))
root = equipment.parent if equipment.parent_id else equipment
if equipment.parent_id:
available = equipment.unindividualized_quantity
else:
allocated = sum(child.quantity or 0 for child in equipment.children if child.is_group)
available = max((equipment.quantity or 0) - allocated, 0)
if quantity > available:
flash(f'Seulement {available} unité(s) non individualisée(s) sont déplaçables.', 'danger')
return redirect(url_for('equipments.move_to_room', id=id))
target = Equipment.query.filter_by(
parent_id=root.id, room_id=target_room.id, is_group=True, is_deleted=False
).first()
if target is None:
target = Equipment(
name=equipment.name if equipment.parent_id else root.name,
parent_id=root.id, room_id=target_room.id, is_group=True,
quantity=0, status='en_service', mobility=root.mobility,
2026-08-14 18:02:25 +02:00
)
db.session.add(target)
db.session.flush()
if equipment.parent_id and target.id == equipment.id:
flash('La salle cible est déjà la salle source.', 'warning')
return redirect(url_for('equipments.move_to_room', id=id))
source_room_id = equipment.room_id if equipment.parent_id else None
if equipment.parent_id:
equipment.quantity -= quantity
target.quantity = (target.quantity or 0) + quantity
target.create_scheduled_tasks_from_lot(commit=False)
db.session.add(EquipmentQuantityMovement(
root_equipment_id=root.id, source_equipment_id=equipment.id,
target_equipment_id=target.id, source_room_id=source_room_id,
target_room_id=target_room.id, quantity=quantity, reason=reason,
moved_by_id=current_user.id,
))
record_event(root, 'transfert_quantite', current_user.id, quantity=quantity,
reason=f"{reason or 'Déplacement'}{target_room.name}")
2026-08-14 18:02:25 +02:00
db.session.commit()
flash(f'{quantity} unité(s) déplacée(s) vers {target_room.name}.', 'success')
return redirect(url_for('equipments.detail', id=root.id))
old_room_id = equipment.effective_room.id if equipment.effective_room else None
if old_room_id == target_room.id:
flash('Cet équipement est déjà dans cette salle.', 'warning')
return redirect(url_for('equipments.move_to_room', id=id))
equipment.room_id = target_room.id
if equipment.parent_id and equipment.parent and equipment.parent.parent_id:
root = equipment.parent.parent
target_group = Equipment.query.filter_by(
parent_id=root.id, room_id=target_room.id, is_group=True, is_deleted=False
).first()
if target_group:
equipment.parent_id = target_group.id
db.session.add(EquipmentRoomHistory(
equipment_id=equipment.id, old_room_id=old_room_id,
new_room_id=target_room.id, changed_by_id=current_user.id, reason=reason,
))
update_future_task_location(equipment)
record_event(equipment, 'deplacement', current_user.id, reason=reason)
db.session.commit()
flash('Équipement déplacé.', 'success')
return redirect(url_for('equipments.detail', id=equipment.id))
rooms = Room.query.join(Building).order_by(Building.name, Room.name).all()
return render_template(
'equipments/move.html', equipment=equipment, rooms=rooms,
current_room=equipment.effective_room,
max_quantity=(equipment.unindividualized_quantity if equipment.parent_id else max(
(equipment.quantity or 0) - sum(c.quantity or 0 for c in equipment.children if c.is_group), 0
)) if equipment.is_group else 1,
)
@main_bp.route('/<int:id>/repair/start', methods=['POST'])
@login_required
@permission_required('patrimoine.retire')
def start_repair(id):
equipment = Equipment.query.get_or_404(id)
quantity = request.form.get('quantity', 1, type=int) or 1
reason = _empty_to_none((request.form.get('reason') or '').strip())
try:
units, intervention = start_equipment_repair(equipment, quantity, reason, current_user.id)
for unit in units:
unit.create_scheduled_tasks_from_lot(commit=False)
db.session.commit()
except ValueError as exc:
db.session.rollback()
flash(str(exc), 'danger')
return redirect(url_for('equipments.detail', id=id))
flash(f'{len(units)} unité(s) retirée(s) pour réparation. Intervention #{intervention.id} créée.', 'warning')
return redirect(url_for('interventions.detail', id=intervention.id))
@main_bp.route('/<int:id>/repair/complete', methods=['POST'])
@login_required
@permission_required('patrimoine.edit')
def complete_repair(id):
equipment = Equipment.query.get_or_404(id)
complete_equipment_repair(equipment, request.form.get('reason'), current_user.id)
db.session.commit()
flash('Réparation terminée, équipement remis en service.', 'success')
return redirect(url_for('equipments.detail', id=id))
2026-08-14 18:02:25 +02:00
# === Gestion des Zones ===