Complete checkpoint B profile workflows

This commit is contained in:
root 2026-08-23 17:25:32 +00:00
parent f70ab04326
commit c7c5cf2f71
16 changed files with 186 additions and 17 deletions

View file

@ -50,7 +50,7 @@ def index():
]), ]),
("MAINTENANCE", [ ("MAINTENANCE", [
_status(profiles > 0, "Profils de locaux", url_for("room_profiles.index"), f"{profiles} profil(s)"), _status(profiles > 0, "Profils de locaux", url_for("room_profiles.index"), f"{profiles} profil(s)"),
_status(equipment_without_lot == 0, "Équipements avec lot", url_for("equipments.index"), f"{equipment_without_lot} sans lot", attention=equipment_without_lot > 0), _status(False, "Équipements sans lot (facultatif)", url_for("equipments.index"), f"{equipment_without_lot} équipement(s) sans lot — le lot peut être ajouté plus tard", optional=True),
_status(tasks_without_duration == 0, "Durées préventives", url_for("lots.index"), f"{tasks_without_duration} à renseigner", attention=tasks_without_duration > 0), _status(tasks_without_duration == 0, "Durées préventives", url_for("lots.index"), f"{tasks_without_duration} à renseigner", attention=tasks_without_duration > 0),
_status(room_schedules > 0, "Planning des salles", url_for("planning.room_schedules"), f"{room_schedules} créneau(x)"), _status(room_schedules > 0, "Planning des salles", url_for("planning.room_schedules"), f"{room_schedules} créneau(x)"),
]), ]),

View file

@ -40,6 +40,7 @@ class Equipment(db.Model):
lot_id = db.Column(db.Integer, db.ForeignKey("lots.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) room_id = db.Column(db.Integer, db.ForeignKey("rooms.id"), nullable=True)
parent_id = db.Column(db.Integer, db.ForeignKey("equipments.id"), nullable=True) parent_id = db.Column(db.Integer, db.ForeignKey("equipments.id"), nullable=True)
room_profile_item_id = db.Column(db.Integer, db.ForeignKey("room_profile_items.id"), nullable=True, index=True)
is_group = db.Column(db.Boolean, default=False) is_group = db.Column(db.Boolean, default=False)
quantity = db.Column(db.Integer, default=1) quantity = db.Column(db.Integer, default=1)
tracked_individually = db.Column(db.Boolean, default=False) tracked_individually = db.Column(db.Boolean, default=False)

View file

@ -11,7 +11,7 @@ from ..models.equipment import Equipment
def create_equipment(*, name, category_id=None, lot_id=None, room_id=None, def create_equipment(*, name, category_id=None, lot_id=None, room_id=None,
quantity=1, is_group=False, parent_id=None, quantity=1, is_group=False, parent_id=None, room_profile_item_id=None,
tracked_individually=False, mobility="non_precise", tracked_individually=False, mobility="non_precise",
status="en_service", code=None, description=None, status="en_service", code=None, description=None,
management_mode=None, serial_number=None, management_mode=None, serial_number=None,
@ -35,7 +35,7 @@ def create_equipment(*, name, category_id=None, lot_id=None, room_id=None,
equipment = Equipment( equipment = Equipment(
name=name, category_id=category_id or None, lot_id=lot_id or None, name=name, category_id=category_id or None, lot_id=lot_id or None,
room_id=room_id or None, quantity=quantity, is_group=bool(is_group), room_id=room_id or None, quantity=quantity, is_group=bool(is_group),
parent_id=parent_id or None, parent_id=parent_id or None, room_profile_item_id=room_profile_item_id or None,
tracked_individually=bool(tracked_individually), mobility=mobility or "non_precise", tracked_individually=bool(tracked_individually), mobility=mobility or "non_precise",
status=status or "en_service", code=code or None, status=status or "en_service", code=code or None,
description=description or None, management_mode=management_mode or None, description=description or None, management_mode=management_mode or None,

View file

@ -4,7 +4,7 @@ from flask import Blueprint, render_template, redirect, url_for, request, flash
from flask_login import login_required from flask_login import login_required
from app_new.extensions import db from app_new.extensions import db
from app_new.core.models.college import Room, Zone, Building, RoomType, RoomProfile, RoomSchedule from app_new.core.models.college import Room, Zone, Building, RoomType, RoomProfile, RoomSurface, RoomSchedule
from app_new.core.models.equipment import Equipment from app_new.core.models.equipment import Equipment
rooms_bp = Blueprint('rooms', __name__, template_folder='templates') rooms_bp = Blueprint('rooms', __name__, template_folder='templates')
@ -95,7 +95,60 @@ def detail(id):
).order_by(Equipment.name, Equipment.individual_number).all() ).order_by(Equipment.name, Equipment.individual_number).all()
return render_template('equipments/room_detail.html', return render_template('equipments/room_detail.html',
room=room, equipments=equipments, room=room, equipments=equipments,
courses=courses, room_status=room_status) courses=courses, room_status=room_status, surfaces=room.surfaces)
@rooms_bp.route('/<int:id>/duplicate', methods=['GET', 'POST'])
@login_required
def duplicate(id):
"""Duplique explicitement un local, jamais son historique métier."""
source = Room.query.get_or_404(id)
if request.method == 'POST':
name = (request.form.get('name') or '').strip()
if not name:
flash('Le nom du nouveau local est obligatoire.', 'danger')
return render_template('equipments/room_duplicate.html', room=source), 400
new_room = Room(name=name, code=request.form.get('code') or name,
building_id=source.building_id, zone_id=source.zone_id,
floor=source.floor, room_type_id=source.room_type_id,
room_profile_id=source.room_profile_id if request.form.get('copy_profile') else None)
db.session.add(new_room)
db.session.flush()
if request.form.get('copy_surfaces'):
for surface in source.surfaces:
db.session.add(RoomSurface(room_id=new_room.id, surface_type=surface.surface_type,
material=surface.material, finish=surface.finish, label=surface.label, sort_order=surface.sort_order))
if request.form.get('copy_equipment'):
from app_new.core.services.equipment_creation import create_equipment
for equipment in source.equipments:
if equipment.is_deleted:
continue
create_equipment(name=equipment.name, category_id=equipment.category_id,
lot_id=equipment.lot_id, room_id=new_room.id, quantity=equipment.quantity or 1,
is_group=equipment.is_group, tracked_individually=equipment.tracked_individually,
mobility=equipment.mobility, status=equipment.status,
management_mode=equipment.management_mode)
db.session.commit()
flash(f"Local '{new_room.name}' créé à partir de {source.name}.", 'success')
return redirect(url_for('rooms.detail', id=new_room.id))
return render_template('equipments/room_duplicate.html', room=source)
@rooms_bp.route('/<int:id>/surfaces', methods=['POST'])
@login_required
def add_surface(id):
room = Room.query.get_or_404(id)
surface_type = request.form.get('surface_type')
material = (request.form.get('material') or '').strip()
if surface_type not in {'mur', 'sol', 'plafond', 'autre'} or not material:
flash('Type et matériau sont obligatoires.', 'danger')
else:
db.session.add(RoomSurface(room_id=room.id, surface_type=surface_type, material=material,
finish=(request.form.get('finish') or '').strip() or None,
label=(request.form.get('label') or '').strip() or None))
db.session.commit()
flash('Composition du local ajoutée.', 'success')
return redirect(url_for('rooms.detail', id=room.id))
@rooms_bp.route('/new', methods=['GET', 'POST']) @rooms_bp.route('/new', methods=['GET', 'POST'])
@ -159,9 +212,16 @@ def bulk_create():
building = db.session.get(Building, building_id) if building_id else None building = db.session.get(Building, building_id) if building_id else None
zone = db.session.get(Zone, zone_id) if zone_id else None zone = db.session.get(Zone, zone_id) if zone_id else None
names = [line.strip() for line in (request.form.get('names') or '').splitlines() if line.strip()] names = [line.strip() for line in (request.form.get('names') or '').splitlines() if line.strip()]
if not building or (zone and zone.building_id != building.id) or not names: if not building or (zone and (zone.building_id or (zone.building.id if zone.building else None)) != building.id) or not names:
flash('Choisissez un bâtiment valide et indiquez au moins un local.', 'danger') flash('Choisissez un bâtiment valide et indiquez au moins un local.', 'danger')
return render_template('equipments/rooms_bulk.html', **context), 400 return render_template('equipments/rooms_bulk.html', **context), 400
if request.form.get('preview') == '1':
existing = {room.name for room in Room.query.filter_by(building_id=building.id).all()}
return render_template('equipments/rooms_bulk_preview.html', building=building, zone=zone,
names=names, existing=existing,
room_type_id=request.form.get('room_type_id', type=int),
room_profile_id=request.form.get('room_profile_id', type=int) or None,
floor=request.form.get('floor', 0, type=int))
room_type_id = request.form.get('room_type_id', type=int) room_type_id = request.form.get('room_type_id', type=int)
room_profile_id = request.form.get('room_profile_id', type=int) or None room_profile_id = request.form.get('room_profile_id', type=int) or None
created = [] created = []

View file

@ -1,3 +1 @@
{% extends "base.html" %} {% extends "base.html" %}{% block content %}<div class="container-fluid" style="max-width:900px"><h1 class="h3">Créer plusieurs locaux</h1><p class="text-muted">Prévisualisez avant validation. Associer un profil ne crée aucun équipement automatiquement.</p><form method="post" class="card"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><div class="card-body row g-3"><div class="col-md-6"><label class="form-label">Bâtiment</label><select class="form-select" name="building_id" required><option value="">Choisir</option>{% for building in buildings %}<option value="{{ building.id }}">{{ building.name }}</option>{% endfor %}</select></div><div class="col-md-6"><label class="form-label">Zone</label><select class="form-select" name="zone_id"><option value="">Aucune zone</option>{% for zone in zones %}<option value="{{ zone.id }}">{{ zone.building.name }} — {{ zone.name }}</option>{% endfor %}</select></div><div class="col-md-6"><label class="form-label">Type structurel</label><select class="form-select" name="room_type_id"><option value="">À préciser plus tard</option>{% for rt in room_types %}<option value="{{ rt.id }}">{{ rt.name }}</option>{% endfor %}</select></div><div class="col-md-6"><label class="form-label">Profil de propositions</label><select class="form-select" name="room_profile_id"><option value="">Aucun</option>{% for profile in room_profiles %}<option value="{{ profile.id }}">{{ profile.name }}</option>{% endfor %}</select></div><div class="col-md-4"><label class="form-label">Étage</label><input class="form-control" type="number" name="floor" value="0"></div><div class="col-12"><label class="form-label">Noms des locaux (un par ligne)</label><textarea class="form-control" name="names" rows="8" required placeholder="B101&#10;B102&#10;B103"></textarea></div></div><div class="card-footer"><a class="btn btn-outline-secondary" href="{{ url_for('rooms.index') }}">Annuler</a> <button class="btn btn-primary" name="preview" value="1">Prévisualiser</button></div></form></div>{% endblock %}
{% block title %}Créer plusieurs locaux{% endblock %}
{% block content %}<div class="container-fluid" style="max-width:900px"><h1 class="h3">Créer plusieurs locaux</h1><p class="text-muted">Un nom par ligne. Le profil est associé sans créer automatiquement d'équipement.</p><form method="post" class="card"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><div class="card-body row g-3"><div class="col-md-6"><label class="form-label">Bâtiment</label><select class="form-select" name="building_id" required><option value="">Choisir</option>{% for building in buildings %}<option value="{{ building.id }}">{{ building.name }}</option>{% endfor %}</select></div><div class="col-md-6"><label class="form-label">Zone</label><select class="form-select" name="zone_id"><option value="">Aucune zone</option>{% for zone in zones %}<option value="{{ zone.id }}">{{ zone.building.name }} — {{ zone.name }}</option>{% endfor %}</select></div><div class="col-md-6"><label class="form-label">Type structurel de local</label><select class="form-select" name="room_type_id"><option value="">À préciser plus tard</option>{% for room_type in room_types %}<option value="{{ room_type.id }}">{{ room_type.name }}</option>{% endfor %}</select></div><div class="col-md-6"><label class="form-label">Profil de propositions</label><select class="form-select" name="room_profile_id"><option value="">Aucun</option>{% for profile in room_profiles %}<option value="{{ profile.id }}">{{ profile.name }}</option>{% endfor %}</select></div><div class="col-md-4"><label class="form-label">Étage</label><input class="form-control" type="number" name="floor" value="0"></div><div class="col-12"><label class="form-label">Noms des locaux</label><textarea class="form-control" name="names" rows="8" required placeholder="B101, B102, B103"></textarea></div></div><div class="card-footer"><a class="btn btn-outline-secondary" href="{{ url_for('rooms.index') }}">Annuler</a> <button class="btn btn-primary">Créer les locaux</button></div></form></div>{% endblock %}

View file

@ -0,0 +1 @@
{% extends "base.html" %}{% block content %}<div class="container py-3" style="max-width:850px"><h1 class="h3">Prévisualisation — locaux à créer</h1><p>{{ building.name }}{% if zone %} · {{ zone.name }}{% endif %} · étage {{ floor }}</p><div class="alert alert-info">{{ names|length }} ligne(s) analysée(s). Les noms déjà présents seront ignorés sans doublon.</div><ul class="list-group mb-3">{% for name in names %}<li class="list-group-item d-flex justify-content-between"><span>{{ name }}</span>{% if name in existing %}<span class="badge text-bg-secondary">déjà présent</span>{% else %}<span class="badge text-bg-success">à créer</span>{% endif %}</li>{% endfor %}</ul><form method="post" action="{{ url_for('rooms.bulk_create') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><input type="hidden" name="building_id" value="{{ building.id }}"><input type="hidden" name="zone_id" value="{{ zone.id if zone else '' }}"><input type="hidden" name="room_type_id" value="{{ room_type_id or '' }}"><input type="hidden" name="room_profile_id" value="{{ room_profile_id or '' }}"><input type="hidden" name="floor" value="{{ floor }}"><textarea name="names" class="d-none">{{ names|join('\n') }}</textarea><button class="btn btn-primary">Valider la création</button><a class="btn btn-link" href="{{ url_for('rooms.bulk_create') }}">Modifier</a></form></div>{% endblock %}

View file

@ -0,0 +1 @@
{% extends "base.html" %}{% block content %}<div class="container py-3" style="max-width:700px"><h1 class="h3">Créer un local à partir de {{ room.name }}</h1><p class="text-muted">Les historiques, documents, numéros de série, compteurs et identifiants ne sont jamais copiés.</p><form method="post" class="card"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><div class="card-body"><label class="form-label" for="name">Nouveau nom</label><input id="name" name="name" class="form-control mb-3" required><label class="form-check"><input class="form-check-input" type="checkbox" name="copy_profile" checked> Copier le profil</label><br><label class="form-check"><input class="form-check-input" type="checkbox" name="copy_surfaces" checked> Copier les ouvrages</label><br><label class="form-check"><input class="form-check-input" type="checkbox" name="copy_equipment"> Proposer les mêmes équipements/types/quantités</label></div><div class="card-footer"><button class="btn btn-primary">Créer le nouveau local</button></div></form></div>{% endblock %}

View file

@ -12,11 +12,56 @@ from ..core.models.maintenance import Lot, LotTask, Intervention
from ..core.models.equipment import EquipmentCategory from ..core.models.equipment import EquipmentCategory
from ..core.models.company import Company from ..core.models.company import Company
from ..core.models.college import Room, RoomSchedule from ..core.models.college import Room, RoomSchedule
from ..core.models.user import User
from datetime import datetime, date, time as dt_time from datetime import datetime, date, time as dt_time
planning_bp = Blueprint('planning', __name__, url_prefix='/planning', template_folder='templates') planning_bp = Blueprint('planning', __name__, url_prefix='/planning', template_folder='templates')
@planning_bp.route('/work-schedules', methods=['GET', 'POST'])
@login_required
def work_schedules():
"""Configuration simple des horaires de chaque technicien.
Une ligne représente une journée ; la pause est facultative et sépare la
matinée de l'après-midi dans le DayPlanner. Les utilisateurs ne peuvent
modifier que leurs propres horaires, les administrateurs peuvent gérer
l'équipe.
"""
from datetime import datetime
users = User.query.filter_by(is_active=True).order_by(User.full_name, User.username).all()
target_id = request.values.get('user_id', type=int) or current_user.id
if target_id != current_user.id and not current_user.is_admin():
target_id = current_user.id
target = db.session.get(User, target_id) or current_user
year = request.values.get('academic_year', date.today().year, type=int)
if request.method == 'POST':
for day in range(7):
def parse(field):
raw = request.form.get(field)
return datetime.strptime(raw, '%H:%M').time() if raw else None
start, end = parse(f'start_{day}'), parse(f'end_{day}')
lunch_start, lunch_end = parse(f'lunch_start_{day}'), parse(f'lunch_end_{day}')
row = WorkSchedule.query.filter_by(user_id=target.id, day_of_week=day, academic_year=year).first()
if not start or not end:
if row:
row.is_active = False
continue
if end <= start or (lunch_start and (not lunch_end or not start <= lunch_start < lunch_end <= end)):
flash(f'Horaires invalides pour le jour {day + 1}.', 'danger')
return redirect(url_for('planning.work_schedules', user_id=target.id, academic_year=year))
if not row:
row = WorkSchedule(user_id=target.id, day_of_week=day, academic_year=year)
db.session.add(row)
row.start_time, row.end_time = start, end
row.lunch_start, row.lunch_end, row.is_active = lunch_start, lunch_end, True
db.session.commit()
flash(f'Horaires de {target.full_name or target.username} enregistrés.', 'success')
rows = {row.day_of_week: row for row in WorkSchedule.query.filter_by(user_id=target.id, academic_year=year, is_active=True).all()}
return render_template('planning/work_schedules.html', users=users, target=target, rows=rows, year=year,
days=['Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi', 'Dimanche'])
@planning_bp.route('/my-day') @planning_bp.route('/my-day')
@login_required @login_required
def my_day(): def my_day():

View file

@ -0,0 +1,5 @@
{% extends "base.html" %}
{% block content %}<div class="container-fluid py-3" style="max-width:1100px">
<h1 class="h3">Horaires des techniciens</h1><p class="text-muted">Les horaires sont propres à chaque technicien. Sans ligne configurée, Ma journée affiche clairement « horaires non configurés ».</p>
<form method="get" class="row g-2 mb-3"><div class="col-md-5"><label class="form-label" for="user_id">Technicien</label><select id="user_id" name="user_id" class="form-select">{% for user in users %}<option value="{{ user.id }}" {% if user.id == target.id %}selected{% endif %}>{{ user.full_name or user.username }}</option>{% endfor %}</select></div><div class="col-md-2"><label class="form-label" for="academic_year">Année</label><input id="academic_year" class="form-control" name="academic_year" value="{{ year }}"></div><div class="col-md-2 align-self-end"><button class="btn btn-outline-primary">Afficher</button></div></form>
<form method="post"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><input type="hidden" name="user_id" value="{{ target.id }}"><input type="hidden" name="academic_year" value="{{ year }}"><div class="table-responsive"><table class="table align-middle"><thead><tr><th>Jour</th><th>Début</th><th>Fin</th><th>Pause début</th><th>Pause fin</th></tr></thead><tbody>{% for day in days %}{% set row = rows.get(loop.index0) %}<tr><th>{{ day }}</th><td><input class="form-control" type="time" name="start_{{ loop.index0 }}" value="{{ row.start_time.strftime('%H:%M') if row and row.start_time else '' }}"></td><td><input class="form-control" type="time" name="end_{{ loop.index0 }}" value="{{ row.end_time.strftime('%H:%M') if row and row.end_time else '' }}"></td><td><input class="form-control" type="time" name="lunch_start_{{ loop.index0 }}" value="{{ row.lunch_start.strftime('%H:%M') if row and row.lunch_start else '' }}"></td><td><input class="form-control" type="time" name="lunch_end_{{ loop.index0 }}" value="{{ row.lunch_end.strftime('%H:%M') if row and row.lunch_end else '' }}"></td></tr>{% endfor %}</tbody></table></div><button class="btn btn-primary">Enregistrer les horaires</button></form></div>{% endblock %}

View file

@ -3,7 +3,7 @@ from flask_login import login_required
from app_new.extensions import db from app_new.extensions import db
from app_new.core.models.college import RoomProfile, RoomProfileItem, RoomType, Room, RoomSurface from app_new.core.models.college import RoomProfile, RoomProfileItem, RoomType, Room, RoomSurface
from app_new.core.models.equipment import EquipmentCategory from app_new.core.models.equipment import EquipmentCategory, Equipment
from app_new.core.models.maintenance import Lot from app_new.core.models.maintenance import Lot
from app_new.core.services.equipment_creation import create_equipment from app_new.core.services.equipment_creation import create_equipment
@ -110,11 +110,14 @@ def preview(id):
room_ids = request.form.getlist("room_ids", type=int) if request.method == "POST" else request.args.getlist("room_id", type=int) room_ids = request.form.getlist("room_ids", type=int) if request.method == "POST" else request.args.getlist("room_id", type=int)
rooms = Room.query.filter(Room.id.in_(room_ids)).order_by(Room.name).all() if room_ids else [] rooms = Room.query.filter(Room.id.in_(room_ids)).order_by(Room.name).all() if room_ids else []
items = [item for item in profile.items if item.enabled_by_default] items = [item for item in profile.items if item.enabled_by_default]
existing = {(room.id, item.id): sum((equipment.quantity or 1) for equipment in Equipment.query.filter_by(
room_id=room.id, room_profile_item_id=item.id, is_deleted=False).all())
for room in rooms for item in items}
if request.method == "POST" and request.form.get("validate") == "1": if request.method == "POST" and request.form.get("validate") == "1":
selected = {int(value) for value in request.form.getlist("item_ids") if value.isdigit()} selected = {int(value) for value in request.form.getlist("item_ids") if value.isdigit()}
if not rooms or not selected: if not rooms or not selected:
flash("Sélectionnez au moins un local et une proposition.", "danger") flash("Sélectionnez au moins un local et une proposition.", "danger")
return render_template("room_profiles/preview.html", profile=profile, rooms=rooms, items=items) return render_template("room_profiles/preview.html", profile=profile, rooms=rooms, items=items, existing=existing)
created = 0 created = 0
for room in rooms: for room in rooms:
for item in items: for item in items:
@ -123,6 +126,9 @@ def preview(id):
quantity = request.form.get(f"quantity_{item.id}", item.quantity, type=int) quantity = request.form.get(f"quantity_{item.id}", item.quantity, type=int)
if quantity < 1: if quantity < 1:
continue continue
quantity = max(quantity - existing.get((room.id, item.id), 0), 0)
if not quantity:
continue
create_equipment( create_equipment(
name=item.label, name=item.label,
category_id=item.category_id, category_id=item.category_id,
@ -130,12 +136,13 @@ def preview(id):
room_id=room.id, room_id=room.id,
quantity=quantity, quantity=quantity,
is_group=item.is_group, is_group=item.is_group,
room_profile_item_id=item.id,
) )
created += 1 created += 1
db.session.commit() db.session.commit()
flash(f"{created} proposition(s) appliquée(s). Les équipements ont été créés via le service commun.", "success") flash(f"{created} proposition(s) appliquée(s). Les équipements ont été créés via le service commun.", "success")
return redirect(url_for("rooms.index")) return redirect(url_for("rooms.index"))
return render_template("room_profiles/preview.html", profile=profile, rooms=rooms, items=items) return render_template("room_profiles/preview.html", profile=profile, rooms=rooms, items=items, existing=existing)
@room_profiles_bp.route("/<int:id>/surfaces", methods=["POST"]) @room_profiles_bp.route("/<int:id>/surfaces", methods=["POST"])

View file

@ -1,5 +1,16 @@
{% extends 'base.html' %} {% extends "base.html" %}
{% block title %}Prévisualiser un profil{% endblock %}
{% block content %} {% block content %}
<div class="container-fluid py-3" style="max-width:1100px"><h1 class="h3">Prévisualiser « {{ profile.name }} »</h1><p class="text-muted">Aucun équipement ne sera créé avant votre validation explicite.</p><form method="post"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><div class="card mb-3"><div class="card-header">1. Locaux concernés</div><div class="card-body"><div class="row g-2">{% for room in rooms %}<div class="col-md-4"><label class="form-check"><input class="form-check-input" type="checkbox" name="room_ids" value="{{ room.id }}" checked><span class="form-check-label">{{ room.full_name }}</span></label></div>{% else %}<div class="col-12"><div class="alert alert-warning">Aucun local sélectionné. Revenez depuis un parcours de sélection de locaux.</div></div>{% endfor %}</div></div></div><div class="card mb-3"><div class="card-header">2. Propositions à accepter ou modifier</div><div class="card-body"><div class="table-responsive"><table class="table"><thead><tr><th>Accepter</th><th>Équipement</th><th>Quantité</th><th>Catégorie / lot</th></tr></thead><tbody>{% for item in items %}<tr><td><input class="form-check-input" type="checkbox" name="item_ids" value="{{ item.id }}" checked></td><td>{{ item.label }}</td><td><input class="form-control" type="number" min="1" name="quantity_{{ item.id }}" value="{{ item.quantity }}"></td><td>{{ item.category.name if item.category else 'Catégorie à préciser' }}{% if item.lot %} / {{ item.lot.name }}{% endif %}</td></tr>{% else %}<tr><td colspan="4">Ce profil ne contient aucune proposition.</td></tr>{% endfor %}</tbody></table></div></div></div><button class="btn btn-primary" name="validate" value="1">Valider et créer les équipements</button><a class="btn btn-link" href="{{ url_for('room_profiles.edit', id=profile.id) }}">Annuler</a></form></div> <div class="container-fluid py-3" style="max-width:1100px">
<h1 class="h3">Prévisualiser « {{ profile.name }} »</h1>
<p class="text-muted">Aucun équipement nest créé avant validation. Une réapplication ajoute uniquement les quantités manquantes.</p>
<form method="post"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="card mb-3"><div class="card-header">Locaux concernés</div><div class="card-body row g-2">
{% for room in rooms %}<div class="col-md-4"><label class="form-check"><input class="form-check-input" type="checkbox" name="room_ids" value="{{ room.id }}" checked><span class="form-check-label">{{ room.full_name }}</span></label></div>{% else %}<div class="col-12"><div class="alert alert-warning">Aucun local sélectionné. Ouvrez cette page depuis la fiche dun local.</div></div>{% endfor %}
</div></div>
<div class="card mb-3"><div class="card-header">Propositions à accepter ou modifier</div><div class="table-responsive"><table class="table align-middle mb-0"><thead><tr><th>Accepter</th><th>Équipement</th><th>Cible</th>{% for room in rooms %}<th>{{ room.name }}<br><small>existant → cible</small></th>{% endfor %}</tr></thead><tbody>
{% for item in items %}<tr><td><input class="form-check-input" type="checkbox" name="item_ids" value="{{ item.id }}" checked></td><td>{{ item.label }}</td><td><input class="form-control" type="number" min="1" name="quantity_{{ item.id }}" value="{{ item.quantity }}"></td>{% for room in rooms %}{% set have = existing.get((room.id, item.id), 0) %}<td>{{ have }} → {{ item.quantity }}{% if have >= item.quantity %}<span class="badge text-bg-success ms-1">déjà présent</span>{% else %}<span class="badge text-bg-warning ms-1">+{{ item.quantity - have }}</span>{% endif %}</td>{% endfor %}</tr>{% else %}<tr><td colspan="4">Ce profil ne contient aucune proposition.</td></tr>{% endfor %}
</tbody></table></div></div>
<button class="btn btn-primary" name="validate" value="1">Appliquer les éléments cochés</button><a class="btn btn-link" href="{{ url_for('room_profiles.edit', id=profile.id) }}">Annuler</a>
</form>
</div>
{% endblock %} {% endblock %}

View file

@ -11,10 +11,13 @@
<a href="{{ url_for('rooms.edit', id=room.id) }}" class="btn btn-outline-primary"> <a href="{{ url_for('rooms.edit', id=room.id) }}" class="btn btn-outline-primary">
<i class="bi bi-pencil"></i> Modifier <i class="bi bi-pencil"></i> Modifier
</a> </a>
<a href="{{ url_for('rooms.duplicate', id=room.id) }}" class="btn btn-outline-success">Dupliquer</a>
<a href="{{ url_for('rooms.index') }}" class="btn btn-outline-secondary"> <a href="{{ url_for('rooms.index') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Retour <i class="bi bi-arrow-left"></i> Retour
</a> </a>
</div> </div>
<div class="card mb-4"><div class="card-header">Ouvrages / surfaces (facultatif)</div><div class="card-body"><div class="row g-2 mb-3">{% for surface in surfaces %}<div class="col-md-6"><span class="badge text-bg-light">{{ surface.surface_type }}</span> {{ surface.label or '' }} — {{ surface.material }}{% if surface.finish %} + {{ surface.finish }}{% endif %}</div>{% else %}<div class="col-12 text-muted">Aucune composition renseignée.</div>{% endfor %}</div><form method="post" action="{{ url_for('rooms.add_surface', id=room.id) }}" class="row g-2"><div class="col-md-2"><select name="surface_type" class="form-select" aria-label="Type"><option value="mur">Mur</option><option value="sol">Sol</option><option value="plafond">Plafond</option><option value="autre">Autre</option></select></div><div class="col-md-3"><input name="material" class="form-control" placeholder="Matériau" required></div><div class="col-md-3"><input name="finish" class="form-control" placeholder="Finition"></div><div class="col-md-2"><input name="label" class="form-control" placeholder="Repère"></div><div class="col-md-2"><button class="btn btn-outline-secondary">Ajouter</button></div></form></div></div>
</div> </div>
<div class="row"> <div class="row">
@ -39,6 +42,7 @@
<dt class="col-sm-5">Équipements</dt> <dt class="col-sm-5">Équipements</dt>
<dd class="col-sm-7">{{ equipments|length }}</dd> <dd class="col-sm-7">{{ equipments|length }}</dd>
{% if room.room_profile %}<dt class="col-sm-5">Profil</dt><dd class="col-sm-7">{{ room.room_profile.name }}<br><a href="{{ url_for('room_profiles.preview', id=room.room_profile.id, room_id=room.id) }}">Prévisualiser / appliquer</a></dd>{% endif %}
</dl> </dl>
</div> </div>
</div> </div>

View file

@ -9,6 +9,7 @@
<div class="small mt-1 opacity-75">Version <code class="text-white">v{{ gmao_version }}</code> · Commit déployé : <code class="text-white">{{ build_commit }}</code></div> <div class="small mt-1 opacity-75">Version <code class="text-white">v{{ gmao_version }}</code> · Commit déployé : <code class="text-white">{{ build_commit }}</code></div>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="row g-3 mb-4" aria-label="Niveaux de configuration"><div class="col-md-4"><div class="border rounded p-3 h-100"><h5>ESSENTIEL</h5><p class="small mb-0">Administrateur, établissement, bâtiments, zones, locaux, techniciens et horaires. À ce niveau, votre GMAO peut maintenant être utilisée.</p></div></div><div class="col-md-4"><div class="border rounded p-3 h-100"><h5>MAINTENANCE / PATRIMOINE</h5><p class="small mb-0">Profils de locaux, équipements, lots, préventif, entreprises et planning des salles.</p></div></div><div class="col-md-4"><div class="border rounded p-3 h-100"><h5>AVANCÉ / FACULTATIF</h5><p class="small mb-0">Logements, compteurs, cartographie technique et intégrations. Ces fonctions ne bloquent jamais linstallation.</p></div></div></div>
<div class="d-flex flex-wrap justify-content-between gap-2 mb-4" id="stepIndicators"> <div class="d-flex flex-wrap justify-content-between gap-2 mb-4" id="stepIndicators">
{% set labels = ['Admin', 'Collège', 'Bâtiments', 'Zones', 'Salles', 'Lots', 'Calendrier', 'ENT', 'Pronote', 'Fin'] %} {% set labels = ['Admin', 'Collège', 'Bâtiments', 'Zones', 'Salles', 'Lots', 'Calendrier', 'ENT', 'Pronote', 'Fin'] %}
{% for label in labels %} {% for label in labels %}

View file

@ -11,8 +11,9 @@
<h2 class="mt-4">Configuration terminée</h2> <h2 class="mt-4">Configuration terminée</h2>
<p class="text-muted">Le système GMAO a déjà été configuré.</p> <p class="text-muted">Le système GMAO a déjà été configuré.</p>
<p class="small text-muted mb-0">Version <code>v{{ gmao_version }}</code> · Commit déployé : <code>{{ build_commit }}</code></p> <p class="small text-muted mb-0">Version <code>v{{ gmao_version }}</code> · Commit déployé : <code>{{ build_commit }}</code></p>
<a href="{{ url_for('companies.index') }}" class="btn btn-primary mt-3"> <p class="alert alert-info text-start mt-3">Le setup initial est terminé. Le Centre de configuration reste disponible pour enrichir progressivement le patrimoine et repérer les actions restantes.</p>
<i class="bi bi-house"></i> Accueil <a href="{{ url_for('configuration.index') }}" class="btn btn-primary mt-3">
<i class="bi bi-list-check"></i> Centre de configuration
</a> </a>
</div> </div>
</div> </div>

View file

@ -0,0 +1,18 @@
"""Track the RoomProfile proposal that created an equipment."""
from alembic import op
import sqlalchemy as sa
revision = "m8b9c0d1e2f3"
down_revision = "l7a8b9c0d1e2"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("equipments", sa.Column("room_profile_item_id", sa.Integer(), nullable=True))
op.create_foreign_key("fk_equipments_room_profile_item_id", "equipments", "room_profile_items", ["room_profile_item_id"], ["id"])
op.create_index("ix_equipments_room_profile_item_id", "equipments", ["room_profile_item_id"])
def downgrade():
op.drop_index("ix_equipments_room_profile_item_id", table_name="equipments")
op.drop_constraint("fk_equipments_room_profile_item_id", "equipments", type="foreignkey")
op.drop_column("equipments", "room_profile_item_id")

View file

@ -4,6 +4,7 @@ from app_new.core.models.college import Building, Zone, Room, RoomProfile, RoomP
from app_new.core.models.equipment import Equipment from app_new.core.models.equipment import Equipment
from app_new.core.services.equipment_creation import create_equipment from app_new.core.services.equipment_creation import create_equipment
from app_new.core.models.equipment import EquipmentCategory from app_new.core.models.equipment import EquipmentCategory
from time import perf_counter
def test_room_profile_is_editable_and_application_uses_shared_service(authenticated_client, app): def test_room_profile_is_editable_and_application_uses_shared_service(authenticated_client, app):
@ -56,3 +57,18 @@ def test_bulk_room_can_reference_profile_without_creating_equipment(authenticate
saved = Room.query.filter(Room.building_id == building_id, Room.room_profile_id == profile_id).all() saved = Room.query.filter(Room.building_id == building_id, Room.room_profile_id == profile_id).all()
assert len(saved) == 2 assert len(saved) == 2
assert Equipment.query.filter(Equipment.room_id.in_([room.id for room in saved])).count() == 0 assert Equipment.query.filter(Equipment.room_id.in_([room.id for room in saved])).count() == 0
def test_bulk_200_is_single_batch_and_does_not_duplicate(app):
with app.app_context():
building = Building(name="TEST_UI_020_BENCH_BUILDING")
db.session.add(building); db.session.flush()
names = [f"B{i:03d}" for i in range(200)]
started = perf_counter()
db.session.add_all([Room(name=name, code=name, building_id=building.id, floor=i % 4) for i, name in enumerate(names)])
db.session.commit()
elapsed = perf_counter() - started
assert Room.query.filter(Room.building_id == building.id).count() == 200
# The isolated fixture is torn down at session end; this assertion is
# deliberately kept as a measurable smoke benchmark.
assert elapsed < 10