From ecc76ddc355f69a851f12febfe15245d327f08c5 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 14 Aug 2026 21:24:42 +0000 Subject: [PATCH] =?UTF-8?q?Simplifie=20la=20cr=C3=A9ation=20massive=20des?= =?UTF-8?q?=20=C3=A9quipements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app_new/core/models/equipment.py | 4 +- app_new/equipments/main.py | 2 + app_new/templates/equipments/index.html | 6 +- app_new/wizard/routes.py | 81 +++- .../templates/wizard/equipment_wizard.html | 394 ++---------------- .../test_housing_and_bulk_inventory.py | 90 ++++ 6 files changed, 212 insertions(+), 365 deletions(-) create mode 100644 tests/integration/test_housing_and_bulk_inventory.py diff --git a/app_new/core/models/equipment.py b/app_new/core/models/equipment.py index df0eb74..54c2de1 100644 --- a/app_new/core/models/equipment.py +++ b/app_new/core/models/equipment.py @@ -231,7 +231,7 @@ class Equipment(db.Model): return self.parent.effective_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.""" from .maintenance import LotTask from .planning import ScheduledTask @@ -272,7 +272,7 @@ class Equipment(db.Model): db.session.add(scheduled_task) created_tasks.append(scheduled_task) - if created_tasks: + if created_tasks and commit: db.session.commit() return created_tasks diff --git a/app_new/equipments/main.py b/app_new/equipments/main.py index 6cf9fdf..cd07482 100644 --- a/app_new/equipments/main.py +++ b/app_new/equipments/main.py @@ -205,6 +205,8 @@ def index(): @login_required def create(): """Créer un équipement.""" + if request.method == 'GET' and request.args.get('advanced') != '1' and not request.args.get('parent_id'): + return redirect(url_for('wizard.equipment_wizard')) # Récupérer le parent_id depuis query string ou POST preselected_parent_id = request.args.get('parent_id') diff --git a/app_new/templates/equipments/index.html b/app_new/templates/equipments/index.html index 569ac2c..150b72f 100644 --- a/app_new/templates/equipments/index.html +++ b/app_new/templates/equipments/index.html @@ -6,10 +6,10 @@

Équipements par zone

- Création par lot + Inventaire guidé - - Nouvel+ + + Fiche unitaire avancée+
diff --git a/app_new/wizard/routes.py b/app_new/wizard/routes.py index 8708ed7..2bf9c5c 100644 --- a/app_new/wizard/routes.py +++ b/app_new/wizard/routes.py @@ -27,12 +27,89 @@ def equipment_wizard(): # Tous les lots pour fallback 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', lots=lots, categories=categories, buildings=buildings, 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/') @@ -278,4 +355,4 @@ def preview_equipments(): return jsonify({ 'preview': preview, 'total': len(preview) - }) \ No newline at end of file + }) diff --git a/app_new/wizard/templates/wizard/equipment_wizard.html b/app_new/wizard/templates/wizard/equipment_wizard.html index 515e2d8..b9f7982 100644 --- a/app_new/wizard/templates/wizard/equipment_wizard.html +++ b/app_new/wizard/templates/wizard/equipment_wizard.html @@ -1,363 +1,41 @@ {% extends "base.html" %} -{% block title %}Wizard Création Équipements — GMAO Collège{% endblock %} - +{% block title %}Inventaire guidé — GMAO{% endblock %} {% block content %} -
-
-
-

Création d'équipements par lot

-
-
- -
-
-
-
-
Étape 1 : Catégorie et Lot
-
-
-
-
- - - Optionnel. Si vide, le nom de la catégorie sera utilisé. -
-
-
-
- - - Type d'équipement (Fenêtre, Porte, etc.) -
-
- - - - Sélectionnez d'abord une catégorie - - - -
-
-
-
- -
-
-
Étape 2 : Groupe parent (optionnel)
-
-
-
- - -
- -
-
- -
-
-
Étape 3 : Salles et quantités
-
-
-
-
- - -
-
- - -
-
- - -
-
- -
- -
- - -
-
- -
Salles sélectionnées
-
-
- Sélectionnez des salles dans le panneau de droite -
-
-
-
- -
-
-
Étape 4 : Prévisualisation
-
-
- - -
-
- -
- - Annuler - - -
-
- -
-
-
-
Salles disponibles
-
-
- -
- {% for building in buildings %} -
-
{{ building.name }}
-
- {% for room in building.rooms|sort(attribute='name') %} - - {% endfor %} -
-
- {% endfor %} -
-
-
-
-
+
+

Inventaire guidé

Sélectionnez les pièces, ajoutez plusieurs familles, vérifiez puis créez tout en une fois.

+ Fiche unitaire avancée
- +
1. Où se trouvent les équipements ?
+
+
{% for building in buildings %}{% for room in building.rooms|sort(attribute='name') %}{% endfor %}{% endfor %}
+
0 pièce sélectionnée
+
+ +
2. Quoi inventorier ?
+
Quantitatif : chaises ou prises comptées en nombre. Individualisé : chaque luminaire, fenêtre ou appareil possède sa propre fiche et son historique.
+
NomCatégorie / lot de maintenanceQté par pièceSuiviMobilité
+
+ +
3. Vérification
Sélectionnez une pièce et ajoutez un équipement.
+
Annuler
+ + {% endblock %} - -{% block extra_scripts %} - -{% endblock %} \ No newline at end of file +{% block extra_scripts %}{% endblock %} diff --git a/tests/integration/test_housing_and_bulk_inventory.py b/tests/integration/test_housing_and_bulk_inventory.py new file mode 100644 index 0000000..821477d --- /dev/null +++ b/tests/integration/test_housing_and_bulk_inventory.py @@ -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