gmao/app_new/equipments/main.py
root 20362fb31e
Some checks are pending
CI - Tests et Syntax / lint-and-test (push) Waiting to run
Renforcer le RBAC et séparer les administrateurs
2026-08-21 18:10:29 +00:00

910 lines
41 KiB
Python

"""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 app_new.extensions import db
from ..core.models.equipment import Equipment, EquipmentCategory, EquipmentDocument, EquipmentRoomHistory, EquipmentRestriction, EquipmentQuantityMovement
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
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
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
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})
# 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.
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)
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)
@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()
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})
@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'))
# 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
equipment = Equipment(
name=request.form.get('name'),
category_id=category_id,
room_id=request.form.get('room_id') or None,
lot_id=lot_id,
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',
)
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()
return render_template('equipments/form.html',
categories=categories,
rooms=rooms,
buildings=buildings,
parent_equipments=parent_equipments,
lots=lots,
statuses=EQUIPMENT_STATUSES,
companies=companies,
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)
@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
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
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'))
# 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()
# 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,
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))
@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'])
@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'))
@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)
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)
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)
@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)
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
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,
parent_id=equipment.id,
individual_number=first_number + i,
status='en_service',
room_id=equipment.room_id,
quantity=1,
is_group=False,
tracked_individually=True,
)
db.session.add(new_element)
# L'ajout augmente le patrimoine physique du groupe.
equipment.quantity = (equipment.quantity or 0) + count
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."""
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')
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)
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,
)
@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')
def move_to_room(id):
"""Déplace une unité ou une quantité, sans casser les totaux des groupes."""
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))
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,
)
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}")
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))
# === Gestion des Zones ===