Simplifie la création massive des équipements
Some checks are pending
CI - Tests et Syntax / lint-and-test (push) Waiting to run

This commit is contained in:
root 2026-08-14 21:24:42 +00:00
parent a296b9e013
commit ecc76ddc35
6 changed files with 212 additions and 365 deletions

View file

@ -231,7 +231,7 @@ class Equipment(db.Model):
return self.parent.effective_room return self.parent.effective_room
return self.room return self.room
def create_scheduled_tasks_from_lot(self): def create_scheduled_tasks_from_lot(self, commit=True):
"""Crée les tâches planifiées à partir des tâches du lot héritées.""" """Crée les tâches planifiées à partir des tâches du lot héritées."""
from .maintenance import LotTask from .maintenance import LotTask
from .planning import ScheduledTask from .planning import ScheduledTask
@ -272,7 +272,7 @@ class Equipment(db.Model):
db.session.add(scheduled_task) db.session.add(scheduled_task)
created_tasks.append(scheduled_task) created_tasks.append(scheduled_task)
if created_tasks: if created_tasks and commit:
db.session.commit() db.session.commit()
return created_tasks return created_tasks

View file

@ -205,6 +205,8 @@ def index():
@login_required @login_required
def create(): def create():
"""Créer un équipement.""" """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 # Récupérer le parent_id depuis query string ou POST
preselected_parent_id = request.args.get('parent_id') preselected_parent_id = request.args.get('parent_id')

View file

@ -6,10 +6,10 @@
<h2 class="mb-0 h5"><i class="bi bi-pc-display"></i> Équipements par zone</h2> <h2 class="mb-0 h5"><i class="bi bi-pc-display"></i> Équipements par zone</h2>
<div> <div>
<a href="{{ url_for('wizard.equipment_wizard') }}" class="btn btn-success btn-sm me-2"> <a href="{{ url_for('wizard.equipment_wizard') }}" class="btn btn-success btn-sm me-2">
<i class="bi bi-magic"></i> <span class="d-none d-sm-inline">Création par lot</span> <i class="bi bi-magic"></i> <span class="d-none d-sm-inline">Inventaire guidé</span>
</a> </a>
<a href="{{ url_for('equipments.create') }}" class="btn btn-primary btn-sm"> <a href="{{ url_for('equipments.create', advanced=1) }}" class="btn btn-outline-primary btn-sm">
<i class="bi bi-plus-lg"></i> <span class="d-none d-sm-inline">Nouvel</span><span class="d-sm-none">+</span> <i class="bi bi-plus-lg"></i> <span class="d-none d-sm-inline">Fiche unitaire avancée</span><span class="d-sm-none">+</span>
</a> </a>
</div> </div>
</div> </div>

View file

@ -27,12 +27,89 @@ def equipment_wizard():
# Tous les lots pour fallback # Tous les lots pour fallback
lots = Lot.query.order_by(Lot.name).all() lots = Lot.query.order_by(Lot.name).all()
housing_id = request.args.get('housing_id', type=int)
selected_room_ids = []
if housing_id:
from app_new.core.models.college import HousingUnit
housing = HousingUnit.query.get_or_404(housing_id)
selected_room_ids = [room.id for room in housing.rooms]
return render_template('wizard/equipment_wizard.html', return render_template('wizard/equipment_wizard.html',
lots=lots, lots=lots,
categories=categories, categories=categories,
buildings=buildings, buildings=buildings,
room_types=room_types, room_types=room_types,
lots_by_category=lots_by_category) lots_by_category=lots_by_category,
selected_room_ids=selected_room_ids)
@wizard_bp.route('/equipments/bulk-create', methods=['POST'])
@login_required
def bulk_create_equipments():
"""Crée plusieurs familles d'équipements en une transaction."""
data = request.get_json(silent=True) or {}
room_ids = list(dict.fromkeys(data.get('room_ids') or []))
items = data.get('items') or []
rooms = Room.query.filter(Room.id.in_(room_ids)).all() if room_ids else []
if len(rooms) != len(room_ids):
return jsonify({'error': 'Une ou plusieurs pièces sont invalides.'}), 400
if not rooms or not items:
return jsonify({'error': 'Sélectionnez au moins une pièce et un équipement.'}), 400
created = []
try:
for raw in items:
name = (raw.get('name') or '').strip()
category = db.session.get(EquipmentCategory, int(raw.get('category_id') or 0))
lot = db.session.get(Lot, int(raw.get('lot_id') or 0))
quantity = int(raw.get('quantity') or 1)
mode = raw.get('mode', 'quantitatif')
mobility = raw.get('mobility', 'non_precise')
if not name or not category or not lot or quantity < 1 or quantity > 1000:
raise ValueError('Une ligne contient des données invalides.')
if lot.category_id and lot.category_id != category.id:
raise ValueError(f'Le lot « {lot.name} » ne correspond pas à la catégorie « {category.name} ».')
root = Equipment(
name=name, category_id=category.id, lot_id=lot.id,
is_group=True, quantity=quantity * len(rooms), mobility=mobility,
tracked_individually=(mode == 'individuel'), status='en_service',
)
db.session.add(root)
db.session.flush()
created.append(root)
for room in rooms:
group = Equipment(
name=name, parent_id=root.id, room_id=room.id, is_group=True,
quantity=quantity, mobility=mobility, status='en_service',
tracked_individually=(mode == 'individuel'),
)
db.session.add(group)
db.session.flush()
created.append(group)
if mode == 'individuel':
for number in range(1, quantity + 1):
unit = Equipment(
name=f'{name} #{number}', parent_id=group.id, room_id=room.id,
is_group=False, quantity=1, mobility=mobility,
tracked_individually=True, individual_number=number,
status='en_service', position=f'{name}{number}',
)
db.session.add(unit)
created.append(unit)
# Les tâches sont générées sur les unités individualisées, sinon sur
# les groupes de pièce, jamais sur le parent de synthèse.
task_targets = [eq for eq in created if (not eq.is_group or (eq.parent_id and not eq.tracked_individually))]
for equipment in task_targets:
equipment.create_scheduled_tasks_from_lot(commit=False)
db.session.commit()
return jsonify({'success': True, 'created_count': len(created), 'families': len(items), 'rooms': len(rooms)})
except (TypeError, ValueError) as exc:
db.session.rollback()
return jsonify({'error': str(exc)}), 400
except Exception:
db.session.rollback()
current_app.logger.exception('Échec de la création massive du patrimoine')
return jsonify({'error': 'La création a été annulée intégralement.'}), 500
@wizard_bp.route('/api/rooms/<int:building_id>') @wizard_bp.route('/api/rooms/<int:building_id>')

View file

@ -1,363 +1,41 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Wizard Création Équipements — GMAO Collège{% endblock %} {% block title %}Inventaire guidé — GMAO{% endblock %}
{% block content %} {% block content %}
<div class="container-fluid"> <div class="d-flex justify-content-between align-items-start mb-3">
<div class="row mb-4"> <div><h1 class="h3"><i class="bi bi-magic"></i> Inventaire guidé</h1><p class="text-muted mb-0">Sélectionnez les pièces, ajoutez plusieurs familles, vérifiez puis créez tout en une fois.</p></div>
<div class="col-12"> <a href="{{ url_for('equipments.create', advanced=1) }}" class="btn btn-outline-secondary btn-sm">Fiche unitaire avancée</a>
<h1><i class="bi bi-magic"></i> Création d'équipements par lot</h1>
</div>
</div>
<div class="row">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<h5><i class="bi bi-1-circle"></i> Étape 1 : Catégorie et Lot</h5>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-12 mb-3">
<label class="form-label">Nom de l'équipement</label>
<input type="text" id="equipment_name" class="form-control" placeholder="Laisser vide pour utiliser le nom de la catégorie">
<small class="text-muted">Optionnel. Si vide, le nom de la catégorie sera utilisé.</small>
</div>
</div>
<div class="row mb-3">
<div class="col-md-6 mb-3">
<label class="form-label">Catégorie *</label>
<select id="category_id" class="form-select">
<option value="">-- Sélectionner une catégorie --</option>
{% for cat in categories %}
<option value="{{ cat.id }}">{{ cat.name }}</option>
{% endfor %}
</select>
<small class="text-muted">Type d'équipement (Fenêtre, Porte, etc.)</small>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Lot *</label>
<select id="lot_id" class="form-select" disabled>
<option value="">-- Sélectionner d'abord une catégorie --</option>
</select>
<small class="text-muted" id="lot_hint">
<i class="bi bi-info-circle"></i> Sélectionnez d'abord une catégorie
</small>
<div id="lot_tasks_container" class="mt-3" style="display: none;">
<h6><i class="bi bi-clipboard-check"></i> Maintenances préventives du lot</h6>
<div id="lot_tasks_list" class="table-responsive"></div>
</div>
</div>
</div>
</div>
</div>
<div class="card mt-3">
<div class="card-header">
<h5><i class="bi bi-2-circle"></i> Étape 2 : Groupe parent (optionnel)</h5>
</div>
<div class="card-body">
<div class="form-check mb-3">
<input type="checkbox" class="form-check-input" id="create_parent">
<label class="form-check-label" for="create_parent">Créer un groupe parent global</label>
</div>
<div id="parent_options" style="display: none;">
<div class="mb-3">
<label class="form-label">Nom du groupe parent</label>
<input type="text" id="parent_name" class="form-control" placeholder="Ex: Fenêtres du collège">
</div>
</div>
</div>
</div>
<div class="card mt-3">
<div class="card-header">
<h5><i class="bi bi-3-circle"></i> Étape 3 : Salles et quantités</h5>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-4">
<label class="form-label">Bâtiment</label>
<select id="building_id" class="form-select">
<option value="">-- Tous les bâtiments --</option>
{% for b in buildings %}
<option value="{{ b.id }}">{{ b.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-4">
<label class="form-label">Zone (optionnel)</label>
<select id="zone_id" class="form-select">
<option value="">-- Toutes les zones --</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label">Type de salle</label>
<select id="room_type_id" class="form-select">
<option value="">-- Tous les types --</option>
{% for rt in room_types %}
<option value="{{ rt.id }}">{{ rt.name }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="mb-3">
<label class="form-label">Suivi individuel</label>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="tracked_individually">
<label class="form-check-label" for="tracked_individually">
Créer des équipements individuels (chaque fenêtre sera suivie séparément)
</label>
</div>
</div>
<h6>Salles sélectionnées</h6>
<div id="rooms_list" class="list-group mb-3">
<div class="list-group-item text-muted">
<i class="bi bi-info-circle"></i> Sélectionnez des salles dans le panneau de droite
</div>
</div>
</div>
</div>
<div class="card mt-3">
<div class="card-header">
<h5><i class="bi bi-4-circle"></i> Étape 4 : Prévisualisation</h5>
</div>
<div class="card-body">
<button type="button" id="btn_preview" class="btn btn-outline-primary mb-3">
<i class="bi bi-eye"></i> Prévisualiser les équipements
</button>
<div id="preview_result" style="display: none;">
<table class="table table-sm">
<thead><tr><th>Équipement</th><th>Salle</th><th>Catégorie</th><th>Lot</th></tr></thead>
<tbody id="preview_body"></tbody>
</table>
</div>
</div>
</div>
<div class="mt-3">
<a href="{{ url_for('equipments.index') }}" class="btn btn-secondary">
<i class="bi bi-x-lg"></i> Annuler
</a>
<button type="button" id="btn_submit" class="btn btn-primary" disabled>
<i class="bi bi-check-lg"></i> Créer les équipements
</button>
</div>
</div>
<div class="col-lg-4">
<div class="card">
<div class="card-header">
<h5><i class="bi bi-door-open"></i> Salles disponibles</h5>
</div>
<div class="card-body">
<input type="text" id="room_search" class="form-control mb-3" placeholder="Rechercher une salle...">
<div id="rooms_by_building">
{% for building in buildings %}
<div class="building-group" data-building-id="{{ building.id }}">
<h6>{{ building.name }}</h6>
<div class="room-list">
{% for room in building.rooms|sort(attribute='name') %}
<button type="button" class="btn btn-outline-secondary btn-sm mb-1 me-1 room-item"
data-room-id="{{ room.id }}" data-room-name="{{ room.name }}"
data-building-id="{{ building.id }}"
data-zone-id="{{ room.zone_id if room.zone_id else '' }}"
data-zone-name="{{ room.zone.name if room.zone else '' }}"
data-room-type-id="{{ room.room_type_id if room.room_type_id else '' }}">
{{ room.name }}
{% if room.zone %}<small class="text-muted">({{ room.zone.name }})</small>{% endif %}
</button>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
</div> </div>
<select id="all_lots" style="display: none;"> <div class="card mb-3"><div class="card-header fw-semibold">1. Où se trouvent les équipements ?</div><div class="card-body">
{% for lot in lots %} <div class="row g-2 mb-3"><div class="col-md-4"><select id="building" class="form-select"><option value="">Tous les bâtiments</option>{% for b in buildings %}<option value="{{ b.id }}">{{ b.name }}</option>{% endfor %}</select></div><div class="col-md-4"><select id="zone" class="form-select"><option value="">Toutes les zones</option></select></div><div class="col-md-4"><input id="room-search" class="form-control" placeholder="Rechercher une pièce"></div></div>
<option value="{{ lot.id }}">{{ lot.name }}</option> <div id="rooms" class="d-flex flex-wrap gap-2">{% for building in buildings %}{% for room in building.rooms|sort(attribute='name') %}<button type="button" class="btn btn-outline-secondary btn-sm room" data-id="{{ room.id }}" data-building="{{ building.id }}" data-zone="{{ room.zone_id or '' }}" data-search="{{ (building.name ~ ' ' ~ room.name ~ ' ' ~ (room.zone.name if room.zone else ''))|lower }}">{{ building.name }} · {{ room.name }}</button>{% endfor %}{% endfor %}</div>
{% endfor %} <div class="mt-3"><strong id="room-count">0 pièce sélectionnée</strong> <button type="button" id="select-visible" class="btn btn-link btn-sm">Tout sélectionner dans le filtre</button> <button type="button" id="clear-rooms" class="btn btn-link btn-sm text-danger">Effacer</button></div>
</select> </div></div>
{% endblock %}
<div class="card mb-3"><div class="card-header d-flex justify-content-between align-items-center"><span class="fw-semibold">2. Quoi inventorier ?</span><div><button type="button" class="btn btn-outline-success btn-sm preset" data-preset="classe">Salle de classe standard</button> <button type="button" class="btn btn-outline-success btn-sm preset" data-preset="logement">Logement standard</button> <button type="button" id="add-row" class="btn btn-primary btn-sm"><i class="bi bi-plus"></i> Ajouter une ligne</button></div></div>
{% block extra_scripts %} <div class="card-body"><div class="alert alert-info py-2">Quantitatif : chaises ou prises comptées en nombre. Individualisé : chaque luminaire, fenêtre ou appareil possède sa propre fiche et son historique.</div>
<script> <div class="table-responsive"><table class="table align-middle"><thead><tr><th>Nom</th><th>Catégorie / lot de maintenance</th><th>Qté par pièce</th><th>Suivi</th><th>Mobilité</th><th></th></tr></thead><tbody id="items"></tbody></table></div>
let selectedRooms = []; </div></div>
let lotsByCategory = {{ lots_by_category | tojson | safe }};
<div class="card mb-3"><div class="card-header fw-semibold">3. Vérification</div><div class="card-body" id="summary">Sélectionnez une pièce et ajoutez un équipement.</div></div>
document.getElementById('create_parent').addEventListener('change', function() { <div class="d-flex justify-content-between"><a href="{{ url_for('equipments.index') }}" class="btn btn-outline-secondary">Annuler</a><button id="submit" class="btn btn-success btn-lg" disabled><i class="bi bi-check-lg"></i> Créer linventaire</button></div>
document.getElementById('parent_options').style.display = this.checked ? 'block' : 'none';
}); <template id="row-template"><tr class="item"><td><input class="form-control name" placeholder="Ex. Luminaire" required></td><td><select class="form-select category"><option value="">Catégorie…</option>{% for c in categories %}<option value="{{ c.id }}">{{ c.name }}</option>{% endfor %}</select><select class="form-select lot mt-1" disabled><option value="">Lot…</option></select></td><td><input type="number" min="1" max="1000" value="1" class="form-control quantity" style="min-width:6rem"></td><td><select class="form-select mode"><option value="quantitatif">Quantitatif</option><option value="individuel">Individualisé</option></select></td><td><select class="form-select mobility"><option value="non_precise">À préciser</option><option value="mobile">Mobile</option><option value="fixe">Fixe</option></select></td><td><button type="button" class="btn btn-outline-danger remove" title="Supprimer"><i class="bi bi-trash"></i></button></td></tr></template>
document.getElementById('category_id').addEventListener('change', function() {
const categoryId = this.value;
const lotSelect = document.getElementById('lot_id');
lotSelect.innerHTML = '<option value="">-- Sélectionner un lot --</option>';
if (!categoryId) { lotSelect.disabled = true; return; }
const lots = lotsByCategory[categoryId] || [];
if (lots.length === 0) {
const allLots = document.getElementById('all_lots').querySelectorAll('option');
allLots.forEach(opt => { if (opt.value) lotSelect.appendChild(opt.cloneNode(true)); });
} else {
lots.forEach(lot => { lotSelect.appendChild(new Option(lot.name, lot.id)); });
}
lotSelect.disabled = false;
});
document.getElementById('lot_id').addEventListener('change', async function() {
const lotId = this.value;
const container = document.getElementById('lot_tasks_container');
const list = document.getElementById('lot_tasks_list');
if (!lotId) { container.style.display = 'none'; return; }
try {
const response = await fetch('/wizard/api/lot_tasks/' + lotId);
const tasks = await response.json();
if (tasks.length === 0) {
container.style.display = 'block';
list.innerHTML = '<p class="text-muted small">Aucune maintenance préventive définie pour ce lot.</p>';
return;
}
let html = '<table class="table table-sm table-hover"><thead><tr>' +
'<th></th><th>Tâche</th><th>Périodicité</th><th>Contrat</th><th>Gestionnaire</th></tr></thead><tbody>';
tasks.forEach(task => {
html += '<tr><td>' + (task.num_tache || '-') + '</td><td>' + (task.tache || '-') + '</td>' +
'<td>' + (task.periodicite || '-') + '</td>' +
'<td>' + (task.contrat ? '<i class="bi bi-check text-success"></i>' : '-') + '</td>' +
'<td>' + (task.gestionnaire || '-') + '</td></tr>';
});
html += '</tbody></table>';
list.innerHTML = html;
container.style.display = 'block';
} catch (e) { container.style.display = 'none'; }
});
function filterRooms() {
const buildingId = document.getElementById('building_id').value;
const zoneId = document.getElementById('zone_id').value;
const roomTypeId = document.getElementById('room_type_id').value;
document.querySelectorAll('.room-item').forEach(item => {
const show = (!buildingId || item.dataset.buildingId === buildingId) &&
(!zoneId || item.dataset.zoneId === zoneId) &&
(!roomTypeId || item.dataset.roomTypeId === roomTypeId);
item.style.display = show ? '' : 'none';
});
document.querySelectorAll('.building-group').forEach(g => {
const gid = g.dataset.buildingId;
g.style.display = (buildingId && gid !== buildingId) ? 'none' : '';
});
}
document.getElementById('building_id').addEventListener('change', async function() {
const buildingId = this.value;
const zoneSelect = document.getElementById('zone_id');
zoneSelect.innerHTML = '<option value="">-- Toutes les zones --</option>';
if (buildingId) {
const zones = await (await fetch('/wizard/api/zones/' + buildingId)).json();
zones.forEach(z => zoneSelect.appendChild(new Option(z.name, z.id)));
}
filterRooms();
});
document.getElementById('zone_id').addEventListener('change', filterRooms);
document.getElementById('room_type_id').addEventListener('change', filterRooms);
document.getElementById('room_search').addEventListener('input', function() {
const search = this.value.toLowerCase();
document.querySelectorAll('.room-item').forEach(item => {
item.style.display = item.dataset.roomName.toLowerCase().includes(search) ? '' : 'none';
});
});
document.querySelectorAll('.room-item').forEach(btn => {
btn.addEventListener('click', function() {
const roomId = this.dataset.roomId;
const roomName = this.dataset.roomName;
const zoneName = this.dataset.zoneName || '';
const idx = selectedRooms.findIndex(r => r.id == roomId);
if (idx > -1) {
selectedRooms.splice(idx, 1);
this.classList.replace('btn-primary', 'btn-outline-secondary');
} else {
selectedRooms.push({id: roomId, name: roomName, zone: zoneName, count: 1});
this.classList.replace('btn-outline-secondary', 'btn-primary');
}
updateRoomsList();
});
});
function updateRoomsList() {
const list = document.getElementById('rooms_list');
if (selectedRooms.length === 0) {
list.innerHTML = '<div class="list-group-item text-muted"><i class="bi bi-info-circle"></i> Sélectionnez des salles</div>';
document.getElementById('btn_submit').disabled = true;
return;
}
list.innerHTML = selectedRooms.map(r => '<div class="list-group-item d-flex justify-content-between">' +
'<span>' + r.name + (r.zone ? ' <small class="text-muted">(' + r.zone + ')</small>' : '') + '</span>' +
'<input type="number" class="form-control form-control-sm room-count" style="width:80px" data-room-id="' + r.id + '" value="' + (r.count || 1) + '" min="1">' +
'</div>').join('');
document.querySelectorAll('.room-count').forEach(i => i.addEventListener('change', function() {
const room = selectedRooms.find(r => r.id == this.dataset.roomId);
if (room) room.count = parseInt(this.value) || 1;
}));
document.getElementById('btn_submit').disabled = false;
}
document.getElementById('btn_preview').addEventListener('click', function() {
const cat = document.getElementById('category_id');
const lot = document.getElementById('lot_id');
const nameInput = document.getElementById('equipment_name');
if (!cat.value || !lot.value || selectedRooms.length === 0) {
alert('Sélectionnez une catégorie, un lot et des salles.'); return;
}
const equipmentName = nameInput.value.trim() || cat.options[cat.selectedIndex].text;
const catName = cat.options[cat.selectedIndex].text;
const lotName = lot.options[lot.selectedIndex].text;
document.getElementById('preview_body').innerHTML = selectedRooms.flatMap(r => {
const count = r.count || 1;
return Array.from({length: count}, (_, i) => '<tr><td>' + equipmentName + (count > 1 ? ' - ' + (i+1) : '') + '</td><td>' + r.name + '</td><td>' + catName + '</td><td>' + lotName + '</td></tr>');
}).join('');
document.getElementById('preview_result').style.display = 'block';
});
document.getElementById('btn_submit').addEventListener('click', async function() {
const data = {
category_id: document.getElementById('category_id').value,
lot_id: document.getElementById('lot_id').value,
name: document.getElementById('equipment_name').value.trim(),
create_parent: document.getElementById('create_parent').checked,
parent_name: document.getElementById('parent_name').value,
building_id: document.getElementById('building_id').value,
zone_id: document.getElementById('zone_id').value,
rooms: selectedRooms.map(r => ({room_id: r.id, count: r.count || 1})),
tracked_individually: document.getElementById('tracked_individually').checked
};
try {
const resp = await fetch('/wizard/equipments/create', {
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(data)
});
const result = await resp.json();
if (result.success) {
alert(result.count + ' équipement(s) créé(s) avec succès !');
window.location.href = '/equipments/';
}
else alert('Erreur: ' + (result.error || 'Erreur inconnue'));
} catch (e) { alert('Erreur lors de la création.'); }
});
</script>
{% endblock %} {% endblock %}
{% block extra_scripts %}<script>
const lots={{ lots_by_category|tojson }}, selected=new Set({{ selected_room_ids|tojson }}), items=document.getElementById('items');
const norm=s=>(s||'').normalize('NFD').replace(/[\u0300-\u036f]/g,'').toLowerCase();
function refreshRooms(){const b=document.getElementById('building').value,z=document.getElementById('zone').value,q=norm(document.getElementById('room-search').value);document.querySelectorAll('.room').forEach(x=>{x.hidden=!!((b&&x.dataset.building!==b)||(z&&x.dataset.zone!==z)||(q&&!x.dataset.search.includes(q)));x.classList.toggle('btn-success',selected.has(+x.dataset.id));x.classList.toggle('btn-outline-secondary',!selected.has(+x.dataset.id));});document.getElementById('room-count').textContent=selected.size+' pièce'+(selected.size>1?'s':'')+' sélectionnée'+(selected.size>1?'s':'');refreshSummary()}
async function zones(){const s=document.getElementById('zone'),b=document.getElementById('building').value;s.innerHTML='<option value="">Toutes les zones</option>';if(b){for(const z of await (await fetch('/wizard/api/zones/'+b)).json())s.add(new Option(z.name,z.id))}refreshRooms()}
document.getElementById('rooms').onclick=e=>{const x=e.target.closest('.room');if(!x)return;const id=+x.dataset.id;selected.has(id)?selected.delete(id):selected.add(id);refreshRooms()};
document.getElementById('building').onchange=zones;document.getElementById('zone').onchange=refreshRooms;document.getElementById('room-search').oninput=refreshRooms;
document.getElementById('select-visible').onclick=()=>{document.querySelectorAll('.room:not([hidden])').forEach(x=>selected.add(+x.dataset.id));refreshRooms()};document.getElementById('clear-rooms').onclick=()=>{selected.clear();refreshRooms()};
function addRow(data={}){const row=document.getElementById('row-template').content.firstElementChild.cloneNode(true);items.append(row);row.querySelector('.name').value=data.name||'';const cat=row.querySelector('.category'),lot=row.querySelector('.lot');if(data.category){const option=[...cat.options].find(o=>norm(o.text).includes(norm(data.category)));if(option)cat.value=option.value}function fill(){lot.innerHTML='<option value="">Lot…</option>';for(const x of (lots[cat.value]||[]))lot.add(new Option(x.name,x.id));lot.disabled=!cat.value;if(data.lot&&lot.options.length>1)lot.selectedIndex=1;refreshSummary()}cat.onchange=fill;fill();row.querySelector('.quantity').value=data.quantity||1;row.querySelector('.mode').value=data.mode||'quantitatif';row.querySelector('.mobility').value=data.mobility||'non_precise';row.querySelectorAll('input,select').forEach(x=>x.addEventListener('input',refreshSummary));row.querySelector('.remove').onclick=()=>{row.remove();refreshSummary()};refreshSummary()}
document.getElementById('add-row').onclick=()=>addRow();
const presets={classe:[['Chaises','chaise',30,'quantitatif','mobile'],['Prises','prise',10,'individuel','fixe'],['Luminaires','luminaire',10,'individuel','fixe'],['Portes','porte',2,'individuel','fixe'],['Murs','mur',4,'quantitatif','fixe']],logement:[['Prises','prise',8,'individuel','fixe'],['Luminaires','luminaire',2,'individuel','fixe'],['Portes','porte',1,'individuel','fixe'],['Fenêtres','fenetre',1,'individuel','fixe']]};
document.querySelectorAll('.preset').forEach(b=>b.onclick=()=>presets[b.dataset.preset].forEach(x=>addRow({name:x[0],category:x[1],quantity:x[2],mode:x[3],mobility:x[4],lot:true})));
function payload(){return [...document.querySelectorAll('.item')].map(r=>({name:r.querySelector('.name').value.trim(),category_id:r.querySelector('.category').value,lot_id:r.querySelector('.lot').value,quantity:+r.querySelector('.quantity').value,mode:r.querySelector('.mode').value,mobility:r.querySelector('.mobility').value}))}
function refreshSummary(){const data=payload(),valid=selected.size&&data.length&&data.every(x=>x.name&&x.category_id&&x.lot_id&&x.quantity>0),units=data.reduce((n,x)=>n+x.quantity*selected.size,0);document.getElementById('summary').textContent=data.length?`${data.length} famille(s), ${selected.size} pièce(s), ${units} unité(s) au total. Les lots et maintenances seront hérités par chaque équipement.`:'Ajoutez au moins une ligne déquipement.';document.getElementById('submit').disabled=!valid}
document.getElementById('submit').onclick=async()=>{const b=document.getElementById('submit');b.disabled=true;const r=await fetch('{{ url_for("wizard.bulk_create_equipments") }}',{method:'POST',headers:{'Content-Type':'application/json','X-CSRFToken':'{{ csrf_token() }}'},body:JSON.stringify({room_ids:[...selected],items:payload()})});const d=await r.json();if(!r.ok){alert(d.error||'Création impossible');refreshSummary();return}location.href='{{ url_for("equipments.index") }}'};
addRow();refreshRooms();
</script>{% endblock %}

View file

@ -0,0 +1,90 @@
from uuid import uuid4
from app_new.extensions import db
from app_new.core.models.college import Building, HousingUnit, Room, Zone
from app_new.core.models.equipment import Equipment, EquipmentCategory
from app_new.core.models.maintenance import Lot
from app_new.wizard.routes import bulk_create_equipments, equipment_wizard
from app_new.housing.routes import detail as housing_detail
def _location():
suffix = uuid4().hex[:8]
building = Building(name=f"Logements {suffix}")
db.session.add(building)
db.session.flush()
zone = Zone(name="Habitation", building_id=building.id)
db.session.add(zone)
db.session.flush()
rooms = [Room(name=name, building_id=building.id, zone_id=zone.id) for name in ("Séjour", "Cuisine")]
db.session.add_all(rooms)
db.session.flush()
return building, rooms
def test_housing_groups_rooms_and_is_available_in_navigation(app):
with app.app_context():
building, rooms = _location()
unit = HousingUnit(name="Logement de fonction", building_id=building.id)
db.session.add(unit)
db.session.flush()
for room in rooms:
room.housing_unit_id = unit.id
db.session.commit()
with app.test_request_context(f"/housing/{unit.id}"):
response = housing_detail.__wrapped__(unit.id)
assert "Séjour" in response
assert "Inventorier" in response
def test_bulk_inventory_creates_room_groups_and_individual_units(app):
with app.app_context():
_, rooms = _location()
category = EquipmentCategory(name=f"Luminaire {uuid4().hex[:6]}")
lot = Lot(name=f"Lot éclairage {uuid4().hex[:6]}", category=category)
db.session.add_all([category, lot])
db.session.commit()
payload = {"room_ids": [r.id for r in rooms], "items": [{
"name": "Luminaire", "category_id": category.id, "lot_id": lot.id,
"quantity": 2, "mode": "individuel", "mobility": "fixe",
}]}
with app.test_request_context(json=payload):
response = bulk_create_equipments.__wrapped__()
assert response.status_code == 200
root = Equipment.query.filter_by(name="Luminaire", parent_id=None).order_by(Equipment.id.desc()).first()
assert root.quantity == 4
assert root.children.count() == 2
assert all(group.children.count() == 2 for group in root.children)
assert all(unit.effective_lot_id == lot.id for group in root.children for unit in group.children)
def test_bulk_inventory_rolls_back_everything_when_one_line_is_invalid(app):
with app.app_context():
_, rooms = _location()
category = EquipmentCategory(name=f"Test {uuid4().hex[:6]}")
lot = Lot(name=f"Lot test {uuid4().hex[:6]}", category=category)
db.session.add_all([category, lot])
db.session.commit()
before = Equipment.query.count()
payload = {"room_ids": [rooms[0].id], "items": [
{"name": "Valide", "category_id": category.id, "lot_id": lot.id, "quantity": 1},
{"name": "Invalide", "category_id": 0, "lot_id": lot.id, "quantity": 1},
]}
with app.test_request_context(json=payload):
_, status = bulk_create_equipments.__wrapped__()
assert status == 400
assert Equipment.query.count() == before
def test_guided_inventory_page_renders_with_housing_preselection(app):
with app.app_context():
building, rooms = _location()
unit = HousingUnit(name=f"Logement {uuid4().hex[:6]}", building_id=building.id)
db.session.add(unit)
db.session.flush()
rooms[0].housing_unit_id = unit.id
db.session.commit()
with app.test_request_context(f"/wizard/equipments?housing_id={unit.id}"):
html = equipment_wizard.__wrapped__()
assert "Inventaire guidé" in html
assert f"[{rooms[0].id}]" in html