Simplifie la création massive des équipements
Some checks are pending
CI - Tests et Syntax / lint-and-test (push) Waiting to run
Some checks are pending
CI - Tests et Syntax / lint-and-test (push) Waiting to run
This commit is contained in:
parent
a296b9e013
commit
ecc76ddc35
6 changed files with 212 additions and 365 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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} n°{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>')
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
<div class="row">
|
<div class="card mb-3"><div class="card-header fw-semibold">1. Où se trouvent les équipements ?</div><div class="card-body">
|
||||||
<div class="col-lg-8">
|
<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>
|
||||||
<div class="card">
|
<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>
|
||||||
<div class="card-header">
|
<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>
|
||||||
<h5><i class="bi bi-1-circle"></i> Étape 1 : Catégorie et Lot</h5>
|
</div></div>
|
||||||
</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;">
|
<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>
|
||||||
<h6><i class="bi bi-clipboard-check"></i> Maintenances préventives du lot</h6>
|
<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>
|
||||||
<div id="lot_tasks_list" class="table-responsive"></div>
|
<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>
|
||||||
</div>
|
</div></div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card mt-3">
|
<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>
|
||||||
<div class="card-header">
|
<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 l’inventaire</button></div>
|
||||||
<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">
|
<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>
|
||||||
<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>
|
|
||||||
|
|
||||||
<select id="all_lots" style="display: none;">
|
|
||||||
{% for lot in lots %}
|
|
||||||
<option value="{{ lot.id }}">{{ lot.name }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_scripts %}
|
|
||||||
<script>
|
|
||||||
let selectedRooms = [];
|
|
||||||
let lotsByCategory = {{ lots_by_category | tojson | safe }};
|
|
||||||
|
|
||||||
document.getElementById('create_parent').addEventListener('change', function() {
|
|
||||||
document.getElementById('parent_options').style.display = this.checked ? 'block' : 'none';
|
|
||||||
});
|
|
||||||
|
|
||||||
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>N°</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 %}
|
||||||
|
|
|
||||||
90
tests/integration/test_housing_and_bulk_inventory.py
Normal file
90
tests/integration/test_housing_and_bulk_inventory.py
Normal 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
|
||||||
Loading…
Reference in a new issue