From 82c2683c4add07df8b78dd2bc2cfa5318cd5a229 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 14 Aug 2026 19:16:01 +0000 Subject: [PATCH] =?UTF-8?q?G=C3=A8re=20le=20patrimoine=20mobile=20et=20son?= =?UTF-8?q?=20cycle=20de=20vie?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app_new/core/models/__init__.py | 8 +- app_new/core/models/college.py | 15 ++ app_new/core/models/equipment.py | 40 +++- app_new/equipments/buildings.py | 30 ++- app_new/equipments/main.py | 180 +++++++++++++++--- .../templates/equipments/building_form.html | 4 + app_new/templates/equipments/buildings.html | 13 +- app_new/templates/equipments/detail.html | 23 +++ app_new/templates/equipments/edit.html | 14 +- app_new/templates/equipments/form.html | 17 +- app_new/templates/equipments/move.html | 137 +++---------- .../c4d8e9f0a1b2_add_phase2_assets.py | 89 +++++++++ .../test_equipment_lifecycle_and_transfers.py | 93 +++++++++ 13 files changed, 515 insertions(+), 148 deletions(-) create mode 100644 migrations/versions/c4d8e9f0a1b2_add_phase2_assets.py create mode 100644 tests/integration/test_equipment_lifecycle_and_transfers.py diff --git a/app_new/core/models/__init__.py b/app_new/core/models/__init__.py index ebee400..6453193 100644 --- a/app_new/core/models/__init__.py +++ b/app_new/core/models/__init__.py @@ -3,8 +3,8 @@ Core Models - GMAO Collège Import tous les modèles du core """ from .user import User, Staff -from .college import College, Building, Room, RoomType, RoomSchedule, Zone -from .equipment import EquipmentCategory, Equipment, EquipmentDocument, EquipmentRoomHistory +from .college import College, Site, Building, Room, RoomType, RoomSchedule, Zone +from .equipment import EquipmentCategory, Equipment, EquipmentDocument, EquipmentRoomHistory, EquipmentQuantityMovement from .maintenance import ( Intervention, StatusChange, InterventionComment, InterventionDocument, InterventionPart, Lot, LotTask, LotService @@ -21,8 +21,8 @@ from .audit import AuditLog __all__ = [ 'User', 'Staff', - 'College', 'Building', 'Room', 'RoomType', 'RoomSchedule', 'Zone', - 'EquipmentCategory', 'Equipment', 'EquipmentDocument', 'EquipmentRoomHistory', + 'College', 'Site', 'Building', 'Room', 'RoomType', 'RoomSchedule', 'Zone', + 'EquipmentCategory', 'Equipment', 'EquipmentDocument', 'EquipmentRoomHistory', 'EquipmentQuantityMovement', 'Intervention', 'StatusChange', 'InterventionComment', 'InterventionDocument', 'Lot', 'LotTask', 'LotService', 'Company', 'Part', 'InterventionPart', 'Alert', 'Service', diff --git a/app_new/core/models/college.py b/app_new/core/models/college.py index cdb5877..575ac50 100644 --- a/app_new/core/models/college.py +++ b/app_new/core/models/college.py @@ -30,6 +30,19 @@ class College(db.Model): return f"" +class Site(db.Model): + """Site physique : collège, logements de fonction, annexe, etc.""" + __tablename__ = "sites" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(120), nullable=False, unique=True) + site_type = db.Column(db.String(40), nullable=False, default="college") + address = db.Column(db.Text, nullable=True) + is_active = db.Column(db.Boolean, nullable=False, default=True) + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) + buildings = db.relationship("Building", back_populates="site", lazy="dynamic") + + class Building(db.Model): """Bâtiment du collège.""" __tablename__ = "buildings" @@ -37,10 +50,12 @@ class Building(db.Model): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(100), nullable=False) description = db.Column(db.Text, nullable=True) + site_id = db.Column(db.Integer, db.ForeignKey("sites.id"), nullable=True, index=True) # Relations - utiliser overlaps pour éviter warning SQLAlchemy rooms = db.relationship("Room", overlaps="building", lazy="dynamic") zones = db.relationship("Zone", back_populates="building", lazy="dynamic") + site = db.relationship("Site", back_populates="buildings") @property def room_count(self): diff --git a/app_new/core/models/equipment.py b/app_new/core/models/equipment.py index 055c96e..df0eb74 100644 --- a/app_new/core/models/equipment.py +++ b/app_new/core/models/equipment.py @@ -45,6 +45,20 @@ class Equipment(db.Model): position_y = db.Column(db.Float, nullable=True) install_date = db.Column(db.Date, nullable=True) warranty_end = db.Column(db.Date, nullable=True) + mobility = db.Column(db.String(20), nullable=False, default="non_precise") + serial_number = db.Column(db.String(120), nullable=True, index=True) + manufacturer = db.Column(db.String(120), nullable=True) + model_reference = db.Column(db.String(120), nullable=True) + supplier_id = db.Column(db.Integer, db.ForeignKey("companies.id"), nullable=True) + supplier_reference = db.Column(db.String(120), nullable=True) + purchase_price = db.Column(db.Numeric(12, 2), nullable=True) + purchase_date = db.Column(db.Date, nullable=True) + lifecycle_status = db.Column(db.String(30), nullable=False, default="en_service") + unavailable_since = db.Column(db.DateTime, nullable=True) + planned_disposal_date = db.Column(db.Date, nullable=True) + disposal_date = db.Column(db.Date, nullable=True) + disposal_reason = db.Column(db.String(500), nullable=True) + recurrence_monitoring = db.Column(db.Boolean, nullable=False, default=False) is_deleted = db.Column(db.Boolean, default=False, nullable=False) deleted_at = db.Column(db.DateTime, nullable=True) created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) @@ -59,6 +73,7 @@ class Equipment(db.Model): documents = db.relationship("EquipmentDocument", back_populates="equipment") consumables = db.relationship("EquipmentConsumable", back_populates="equipment", lazy="dynamic") room_history = db.relationship("EquipmentRoomHistory", backref="equipment", order_by="desc(EquipmentRoomHistory.changed_at)") + supplier = db.relationship("Company", foreign_keys=[supplier_id]) @property def effective_lot_id(self): @@ -158,7 +173,11 @@ class Equipment(db.Model): @property def needs_deep_work(self): """Nécessite une intervention profonde.""" - return False + return bool(self.recurrence_monitoring and self.curative_count >= 3) + + @property + def is_mobile(self): + return self.mobility == "mobile" @property def children_count(self): @@ -301,6 +320,25 @@ class EquipmentRoomHistory(db.Model): return f"" +class EquipmentQuantityMovement(db.Model): + """Mouvement d'une quantité entre deux salles ou groupes.""" + __tablename__ = "equipment_quantity_movements" + + id = db.Column(db.BigInteger, primary_key=True, autoincrement=True) + root_equipment_id = db.Column(db.Integer, db.ForeignKey("equipments.id"), nullable=False, index=True) + source_equipment_id = db.Column(db.Integer, db.ForeignKey("equipments.id"), nullable=True) + target_equipment_id = db.Column(db.Integer, db.ForeignKey("equipments.id"), nullable=True) + source_room_id = db.Column(db.Integer, db.ForeignKey("rooms.id"), nullable=True) + target_room_id = db.Column(db.Integer, db.ForeignKey("rooms.id"), nullable=False) + quantity = db.Column(db.Integer, nullable=False) + reason = db.Column(db.String(255), nullable=True) + moved_by_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + moved_at = db.Column(db.DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) + + source_room = db.relationship("Room", foreign_keys=[source_room_id]) + target_room = db.relationship("Room", foreign_keys=[target_room_id]) + + class RoomConstraint(db.Model): """Contrainte d'indisponibilite d'une salle (cours PRONOTE, horaire special, etc.).""" __tablename__ = "room_constraints" diff --git a/app_new/equipments/buildings.py b/app_new/equipments/buildings.py index b3b6f21..16ff768 100644 --- a/app_new/equipments/buildings.py +++ b/app_new/equipments/buildings.py @@ -3,7 +3,7 @@ from flask import Blueprint, render_template, redirect, url_for, request, flash, from flask_login import login_required from app_new.extensions import db -from app_new.core.models.college import Building, Zone, Room +from app_new.core.models.college import Site, Building, Zone, Room buildings_bp = Blueprint('buildings', __name__, template_folder='templates') @@ -13,7 +13,22 @@ buildings_bp = Blueprint('buildings', __name__, template_folder='templates') def index(): """Liste des bâtiments.""" buildings = Building.query.order_by(Building.name).all() - return render_template('equipments/buildings.html', buildings=buildings) + return render_template('equipments/buildings.html', buildings=buildings, sites=Site.query.order_by(Site.name).all()) + + +@buildings_bp.route('/sites', methods=['POST']) +@login_required +def create_site(): + name = (request.form.get('name') or '').strip() + if not name: + flash('Le nom du site est obligatoire.', 'danger') + elif Site.query.filter_by(name=name).first(): + flash('Ce site existe déjà.', 'warning') + else: + db.session.add(Site(name=name, site_type=request.form.get('site_type', 'college'), address=(request.form.get('address') or '').strip() or None)) + db.session.commit() + flash(f"Site '{name}' créé.", 'success') + return redirect(url_for('buildings.index')) @buildings_bp.route('/new', methods=['GET', 'POST']) @@ -25,14 +40,14 @@ def create(): description = (request.form.get('description') or '').strip() if not name: flash('Le nom du bâtiment est requis.', 'danger') - return render_template('equipments/building_form.html', title='Nouveau bâtiment'), 400 - building = Building(name=name, description=description) + return render_template('equipments/building_form.html', title='Nouveau bâtiment', sites=Site.query.order_by(Site.name).all()), 400 + building = Building(name=name, description=description, site_id=request.form.get('site_id', type=int)) db.session.add(building) db.session.commit() flash(f"Bâtiment '{building.name}' créé.", 'success') return redirect(url_for('buildings.index'), code=303) - return render_template('equipments/building_form.html', title='Nouveau bâtiment') + return render_template('equipments/building_form.html', title='Nouveau bâtiment', sites=Site.query.order_by(Site.name).all()) @buildings_bp.route('//edit', methods=['GET', 'POST']) @@ -50,9 +65,10 @@ def edit(id): return render_template('equipments/building_form.html', title='Modifier le bâtiment', building=building, - zones=zones), 400 + zones=zones, sites=Site.query.order_by(Site.name).all()), 400 building.name = name building.description = description + building.site_id = request.form.get('site_id', type=int) db.session.commit() flash(f"Bâtiment '{building.name}' mis à jour.", 'success') return redirect(url_for('buildings.index'), code=303) @@ -61,7 +77,7 @@ def edit(id): return render_template('equipments/building_form.html', title='Modifier le bâtiment', building=building, - zones=zones) + zones=zones, sites=Site.query.order_by(Site.name).all()) @buildings_bp.route('//zones', methods=['POST']) diff --git a/app_new/equipments/main.py b/app_new/equipments/main.py index 7fa49d5..6cf9fdf 100644 --- a/app_new/equipments/main.py +++ b/app_new/equipments/main.py @@ -3,7 +3,7 @@ from flask import Blueprint, render_template, redirect, url_for, request, flash, from flask_login import login_required, current_user from datetime import datetime from app_new.extensions import db -from ..core.models.equipment import Equipment, EquipmentCategory, EquipmentDocument, EquipmentRoomHistory, EquipmentRestriction +from ..core.models.equipment import Equipment, EquipmentCategory, EquipmentDocument, EquipmentRoomHistory, EquipmentRestriction, EquipmentQuantityMovement from ..core.models.college import Room, Building, RoomType, Zone from ..core.models.maintenance import Lot, Intervention from ..core.models.planning import Meter, MeterReading, Consumable, EquipmentConsumable, ScheduledTask @@ -13,6 +13,10 @@ def _empty_to_none(v): """Convertit une chaîne vide en None.""" return None if v == '' else v + +def _date_or_none(value): + return datetime.strptime(value, '%Y-%m-%d').date() if value else None + main_bp = Blueprint("equipments", __name__, template_folder='templates') """ @@ -23,7 +27,7 @@ from flask import Blueprint, render_template, redirect, url_for, request, flash, from flask_login import login_required, current_user from datetime import datetime from app_new.extensions import db -from ..core.models.equipment import Equipment, EquipmentCategory, EquipmentDocument, EquipmentRoomHistory, EquipmentRestriction +from ..core.models.equipment import Equipment, EquipmentCategory, EquipmentDocument, EquipmentRoomHistory, EquipmentRestriction, EquipmentQuantityMovement from ..core.models.college import Room, Building, RoomType, Zone from ..core.models.maintenance import Lot, Intervention from ..core.models.planning import Meter, MeterReading, Consumable, EquipmentConsumable @@ -216,7 +220,16 @@ def create(): status=request.form.get('status', 'en_service'), is_group=request.form.get('is_group') == '1', quantity=int(request.form.get('quantity', 1) or 1), - parent_id=parent_id + parent_id=parent_id, + mobility=request.form.get('mobility', 'non_precise'), + serial_number=_empty_to_none(request.form.get('serial_number', '').strip()), + manufacturer=_empty_to_none(request.form.get('manufacturer', '').strip()), + model_reference=_empty_to_none(request.form.get('model_reference', '').strip()), + supplier_id=request.form.get('supplier_id') or None, + supplier_reference=_empty_to_none(request.form.get('supplier_reference', '').strip()), + purchase_price=request.form.get('purchase_price') or None, + purchase_date=_date_or_none(request.form.get('purchase_date')), + recurrence_monitoring=request.form.get('recurrence_monitoring') == '1', ) db.session.add(equipment) db.session.commit() @@ -233,6 +246,8 @@ def create(): buildings = Building.query.order_by(Building.name).all() parent_equipments = Equipment.query.filter(Equipment.is_group == True, Equipment.parent_id == None).order_by(Equipment.name).all() lots = Lot.query.order_by(Lot.name).all() + from ..core.models.company import Company + companies = Company.query.order_by(Company.name).all() return render_template('equipments/form.html', categories=categories, @@ -241,6 +256,7 @@ def create(): parent_equipments=parent_equipments, lots=lots, statuses=EQUIPMENT_STATUSES, + companies=companies, preselected_parent_id=preselected_parent_id) @@ -249,7 +265,13 @@ def create(): def detail(id): """Détail d'un équipement.""" equipment = Equipment.query.get_or_404(id) - return render_template('equipments/detail.html', equipment=equipment) + root_id = equipment.parent_id or equipment.id + if equipment.parent and equipment.parent.parent_id: + root_id = equipment.parent.parent_id + quantity_movements = EquipmentQuantityMovement.query.filter_by( + root_equipment_id=root_id + ).order_by(EquipmentQuantityMovement.moved_at.desc()).limit(50).all() + return render_template('equipments/detail.html', equipment=equipment, quantity_movements=quantity_movements) @main_bp.route('//edit', methods=['GET', 'POST']) @@ -270,6 +292,15 @@ def edit(id): equipment.is_group = bool(request.form.get('is_group')) equipment.tracked_individually = bool(request.form.get('tracked_individually')) equipment.position = _empty_to_none(request.form.get('position')) + equipment.mobility = request.form.get('mobility', 'non_precise') + equipment.serial_number = _empty_to_none(request.form.get('serial_number', '').strip()) + equipment.manufacturer = _empty_to_none(request.form.get('manufacturer', '').strip()) + equipment.model_reference = _empty_to_none(request.form.get('model_reference', '').strip()) + equipment.supplier_id = request.form.get('supplier_id') or None + equipment.supplier_reference = _empty_to_none(request.form.get('supplier_reference', '').strip()) + equipment.purchase_price = request.form.get('purchase_price') or None + equipment.purchase_date = _date_or_none(request.form.get('purchase_date')) + equipment.recurrence_monitoring = bool(request.form.get('recurrence_monitoring')) # Position X/Y pos_x = request.form.get('position_x') @@ -302,6 +333,8 @@ def edit(id): buildings = Building.query.order_by(Building.name).all() parent_equipments = Equipment.query.filter(Equipment.id != id).order_by(Equipment.name).all() lots = Lot.query.order_by(Lot.name).all() + from ..core.models.company import Company + companies = Company.query.order_by(Company.name).all() # Charger les relations meters = Meter.query.filter_by(equipment_id=id).all() @@ -316,6 +349,7 @@ def edit(id): buildings=buildings, parent_equipments=parent_equipments, lots=lots, + companies=companies, meters=meters, equipment_consumables=equipment_consumables, all_consumables=all_consumables, @@ -443,6 +477,9 @@ def mark_as_trash(id): """Marquer un équipement comme à jeter.""" equipment = Equipment.query.get_or_404(id) equipment.status = 'a_jeter' + equipment.lifecycle_status = 'mise_au_rebut_planifiee' + equipment.planned_disposal_date = _date_or_none(request.form.get('planned_disposal_date')) + equipment.disposal_reason = _empty_to_none(request.form.get('reason', '').strip()) db.session.commit() flash(f"Équipement '{equipment.name}' marqué comme à jeter.", 'warning') return redirect(url_for('equipments.detail', id=equipment.id)) @@ -458,6 +495,8 @@ def cancel_trash(id): return redirect(url_for('equipments.detail', id=equipment.id)) equipment.status = 'en_service' + equipment.lifecycle_status = 'en_service' + equipment.planned_disposal_date = None db.session.commit() flash(f"Équipement '{equipment.name}' remis en service.", 'success') return redirect(url_for('equipments.detail', id=equipment.id)) @@ -469,6 +508,8 @@ def confirm_trashed(id): """Confirmer qu'un équipement a été jeté.""" equipment = Equipment.query.get_or_404(id) equipment.status = 'jete' + equipment.lifecycle_status = 'jete' + equipment.disposal_date = datetime.now().date() equipment.room_id = None db.session.commit() flash(f"Équipement '{equipment.name}' marqué comme jeté.", 'success') @@ -659,33 +700,118 @@ def init_scheduled_tasks(id): @main_bp.route('//move', methods=['GET', 'POST']) @login_required def move_to_room(id): - """Déplacer un équipement vers une autre salle.""" - from ..core.models.college import Building, Room - from ..core.models.equipment import EquipmentRoomHistory - + """Déplace une unité ou une quantité, sans casser les totaux des groupes.""" equipment = Equipment.query.get_or_404(id) - + + if equipment.mobility == 'fixe': + flash("Cet équipement est fixe. Sa relocalisation nécessite une modification de sa fiche.", 'danger') + return redirect(url_for('equipments.detail', id=id)) + if request.method == 'POST': - new_room_id = request.form.get('room_id') - if new_room_id: - old_room_id = equipment.room_id - - # Créer l'historique du déplacement - if old_room_id != int(new_room_id): - history = EquipmentRoomHistory( - equipment_id=equipment.id, - old_room_id=old_room_id, - new_room_id=new_room_id + target_room_id = request.form.get('room_id', type=int) + target_room = db.session.get(Room, target_room_id) if target_room_id else None + if target_room is None: + flash('La salle cible est invalide.', 'danger') + return redirect(url_for('equipments.move_to_room', id=id)) + reason = _empty_to_none((request.form.get('reason') or '').strip()) + + if equipment.is_group: + quantity = request.form.get('quantity', 1, type=int) + if quantity < 1: + flash('La quantité doit être positive.', 'danger') + return redirect(url_for('equipments.move_to_room', id=id)) + + root = equipment.parent if equipment.parent_id else equipment + if equipment.parent_id: + available = equipment.unindividualized_quantity + else: + allocated = sum(child.quantity or 0 for child in equipment.children if child.is_group) + available = max((equipment.quantity or 0) - allocated, 0) + if quantity > available: + flash(f'Seulement {available} unité(s) non individualisée(s) sont déplaçables.', 'danger') + return redirect(url_for('equipments.move_to_room', id=id)) + + target = Equipment.query.filter_by( + parent_id=root.id, room_id=target_room.id, is_group=True, is_deleted=False + ).first() + if target is None: + target = Equipment( + name=equipment.name if equipment.parent_id else root.name, + parent_id=root.id, room_id=target_room.id, is_group=True, + quantity=0, status='en_service', mobility=root.mobility, ) - db.session.add(history) - - equipment.room_id = new_room_id + db.session.add(target) + db.session.flush() + if equipment.parent_id and target.id == equipment.id: + flash('La salle cible est déjà la salle source.', 'warning') + return redirect(url_for('equipments.move_to_room', id=id)) + + source_room_id = equipment.room_id if equipment.parent_id else None + if equipment.parent_id: + equipment.quantity -= quantity + target.quantity = (target.quantity or 0) + quantity + db.session.add(EquipmentQuantityMovement( + root_equipment_id=root.id, source_equipment_id=equipment.id, + target_equipment_id=target.id, source_room_id=source_room_id, + target_room_id=target_room.id, quantity=quantity, reason=reason, + moved_by_id=current_user.id, + )) db.session.commit() - flash(f"Équipement déplacé.", 'success') - return redirect(url_for('equipments.detail', id=equipment.id)) - - buildings = Building.query.order_by(Building.name).all() - return render_template('equipments/move.html', equipment=equipment, buildings=buildings) + flash(f'{quantity} unité(s) déplacée(s) vers {target_room.name}.', 'success') + return redirect(url_for('equipments.detail', id=root.id)) + + old_room_id = equipment.effective_room.id if equipment.effective_room else None + if old_room_id == target_room.id: + flash('Cet équipement est déjà dans cette salle.', 'warning') + return redirect(url_for('equipments.move_to_room', id=id)) + equipment.room_id = target_room.id + if equipment.parent_id and equipment.parent and equipment.parent.parent_id: + root = equipment.parent.parent + target_group = Equipment.query.filter_by( + parent_id=root.id, room_id=target_room.id, is_group=True, is_deleted=False + ).first() + if target_group: + equipment.parent_id = target_group.id + db.session.add(EquipmentRoomHistory( + equipment_id=equipment.id, old_room_id=old_room_id, + new_room_id=target_room.id, changed_by_id=current_user.id, reason=reason, + )) + db.session.commit() + flash('Équipement déplacé.', 'success') + return redirect(url_for('equipments.detail', id=equipment.id)) + + rooms = Room.query.join(Building).order_by(Building.name, Room.name).all() + return render_template( + 'equipments/move.html', equipment=equipment, rooms=rooms, + current_room=equipment.effective_room, + max_quantity=(equipment.unindividualized_quantity if equipment.parent_id else max( + (equipment.quantity or 0) - sum(c.quantity or 0 for c in equipment.children if c.is_group), 0 + )) if equipment.is_group else 1, + ) + + +@main_bp.route('//repair/start', methods=['POST']) +@login_required +def start_repair(id): + equipment = Equipment.query.get_or_404(id) + equipment.lifecycle_status = 'en_reparation' + equipment.status = 'en_maintenance' + equipment.unavailable_since = datetime.now() + db.session.commit() + flash('Équipement retiré provisoirement pour réparation.', 'warning') + return redirect(url_for('equipments.detail', id=id)) + + +@main_bp.route('//repair/complete', methods=['POST']) +@login_required +def complete_repair(id): + equipment = Equipment.query.get_or_404(id) + equipment.lifecycle_status = 'en_service' + equipment.status = 'en_service' + equipment.unavailable_since = None + db.session.commit() + flash('Réparation terminée, équipement remis en service.', 'success') + return redirect(url_for('equipments.detail', id=id)) # === Gestion des Zones === diff --git a/app_new/templates/equipments/building_form.html b/app_new/templates/equipments/building_form.html index 1dc92fa..6108d6d 100644 --- a/app_new/templates/equipments/building_form.html +++ b/app_new/templates/equipments/building_form.html @@ -11,6 +11,10 @@ +
+ + +
diff --git a/app_new/templates/equipments/buildings.html b/app_new/templates/equipments/buildings.html index 9614162..d7431cf 100644 --- a/app_new/templates/equipments/buildings.html +++ b/app_new/templates/equipments/buildings.html @@ -7,12 +7,23 @@ Nouveau bâtiment
+
Sites
+
{% for site in sites %}{{ site.name }} · {{ site.site_type|replace('_',' ') }}{% else %}Aucun site défini.{% endfor %}
+
+
+
+
+
+
+
+
{% for building in buildings %}
{{ building.name }}
+
{{ building.site.name if building.site else 'Site non renseigné' }}

{{ building.description or '' }}

{{ building.rooms.count() }} salle(s)

@@ -24,4 +35,4 @@
{% endfor %}
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/app_new/templates/equipments/detail.html b/app_new/templates/equipments/detail.html index 715ebce..4b5af39 100644 --- a/app_new/templates/equipments/detail.html +++ b/app_new/templates/equipments/detail.html @@ -48,16 +48,31 @@ Intervention + {% if equipment.mobility != 'fixe' %} Déplacer{% endif %} {% endif %}
+ {% if equipment.lifecycle_status == 'en_reparation' %} +
Retiré provisoirement pour réparation depuis {{ equipment.unavailable_since|datetime_fmt }}.
+ {% elif equipment.status != 'jete' %} +
+
+
+
+
+
+
+
+ {% endif %} {% if equipment.status == 'a_jeter' %}
Cet équipement est marqué comme à jeter. + {% if equipment.planned_disposal_date %}Date prévue : {{ equipment.planned_disposal_date|date_fmt }}.{% endif %} + {% if equipment.disposal_reason %}Motif : {{ equipment.disposal_reason }}.{% endif %}
@@ -99,6 +114,10 @@ Statut {{ equipment.status|format_status }} + Mobilité{{ equipment.mobility|replace('_', ' ')|title }} + {% if equipment.serial_number %}Numéro de série{{ equipment.serial_number }}{% endif %} + {% if equipment.manufacturer or equipment.model_reference %}Fabricant / modèle{{ equipment.manufacturer or '—' }} / {{ equipment.model_reference or '—' }}{% endif %} + {% if equipment.supplier %}Fournisseur{{ equipment.supplier.name }}{% endif %} {% if equipment.is_group %} @@ -202,6 +221,10 @@
+ + {% if quantity_movements %} +
Mouvements quantitatifs
{% for movement in quantity_movements %}{% endfor %}
DateOrigineDestinationQuantitéMotif
{{ movement.moved_at|datetime_fmt }}{{ movement.source_room.full_name if movement.source_room else 'Stock non localisé' }}{{ movement.target_room.full_name }}{{ movement.quantity }}{{ movement.reason or '—' }}
+ {% endif %} {% set meters_list = equipment.meters.all() %} diff --git a/app_new/templates/equipments/edit.html b/app_new/templates/equipments/edit.html index 5fe5471..66ff8c6 100644 --- a/app_new/templates/equipments/edit.html +++ b/app_new/templates/equipments/edit.html @@ -176,6 +176,18 @@
+
Données techniques
+
+
+
+
+
+
+
+
+
+
+
Position @@ -736,4 +748,4 @@ function deleteDocument(docId) { } } -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/app_new/templates/equipments/form.html b/app_new/templates/equipments/form.html index 2fd48ff..23c2355 100644 --- a/app_new/templates/equipments/form.html +++ b/app_new/templates/equipments/form.html @@ -155,6 +155,21 @@
+
+
Suivi technique et mobilité
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -336,4 +351,4 @@ document.addEventListener('DOMContentLoaded', function() { {% endif %} }); -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/app_new/templates/equipments/move.html b/app_new/templates/equipments/move.html index 9e0624f..b7c8ddc 100644 --- a/app_new/templates/equipments/move.html +++ b/app_new/templates/equipments/move.html @@ -1,109 +1,34 @@ {% extends "base.html" %} -{% block title %}Déplacer l'équipement — GMAO Collège{% endblock %} - +{% block title %}Déplacer {{ equipment.name }} — GMAO{% endblock %} {% block content %} -

- Déplacer l'équipement -

- -
-
-
- {{ equipment.name }} - {% if equipment.code %}({{ equipment.code }}){% endif %} - - {% if equipment.is_individual and equipment.parent %} -
Élément n°{{ equipment.individual_number }} du groupe « {{ equipment.parent.name }} » - {% endif %} -
- - - {{ csrf_token() if csrf_token is defined }} - - {% if equipment.is_individual and equipment.parent %} - {# Élément individuel : doit choisir un groupe cible #} -
- - Les éléments individuels doivent être déplacés vers un groupe du même type dans une autre salle. -
- -
- - -
- -
- - - {% if not target_groups %} - Aucun groupe du même type ({{ equipment.parent.name }}) disponible dans d'autres salles. - {% endif %} -
- - - - {% else %} - {# Équipement standard ou groupe #} - -
- - -
- -
- - -
- {% endif %} - -
- - - Annuler - -
- -
+
+

Déplacer {{ equipment.name }}

+
+
+
Salle actuelle
{{ current_room.full_name if current_room else 'Stock non localisé' }}
+
Mobilité
{{ equipment.mobility|replace('_', ' ')|title }}
+ {% if equipment.is_group %}
Quantité disponible
{{ max_quantity }} non individualisée(s)
{% endif %} +
+
+ + {% if equipment.is_group %} +
+ + +
Les unités déjà individualisées doivent être déplacées depuis leur propre fiche.
+
+ {% endif %} +
+ + +
+
+ + Annuler +
+
- - -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/migrations/versions/c4d8e9f0a1b2_add_phase2_assets.py b/migrations/versions/c4d8e9f0a1b2_add_phase2_assets.py new file mode 100644 index 0000000..d88345d --- /dev/null +++ b/migrations/versions/c4d8e9f0a1b2_add_phase2_assets.py @@ -0,0 +1,89 @@ +"""Ajoute sites, données techniques et mouvements quantitatifs. + +Revision ID: c4d8e9f0a1b2 +Revises: b3c7d8e9f0a1 +Create Date: 2026-08-14 +""" +from alembic import op +import sqlalchemy as sa + +revision = "c4d8e9f0a1b2" +down_revision = "b3c7d8e9f0a1" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "sites", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=120), nullable=False), + sa.Column("site_type", sa.String(length=40), nullable=False), + sa.Column("address", sa.Text(), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint("name"), + ) + op.add_column("buildings", sa.Column("site_id", sa.Integer(), nullable=True)) + op.create_foreign_key("fk_buildings_site", "buildings", "sites", ["site_id"], ["id"]) + op.create_index("ix_buildings_site_id", "buildings", ["site_id"]) + + equipment_columns = ( + sa.Column("mobility", sa.String(length=20), nullable=False, server_default="non_precise"), + sa.Column("serial_number", sa.String(length=120), nullable=True), + sa.Column("manufacturer", sa.String(length=120), nullable=True), + sa.Column("model_reference", sa.String(length=120), nullable=True), + sa.Column("supplier_id", sa.Integer(), nullable=True), + sa.Column("supplier_reference", sa.String(length=120), nullable=True), + sa.Column("purchase_price", sa.Numeric(12, 2), nullable=True), + sa.Column("purchase_date", sa.Date(), nullable=True), + sa.Column("lifecycle_status", sa.String(length=30), nullable=False, server_default="en_service"), + sa.Column("unavailable_since", sa.DateTime(), nullable=True), + sa.Column("planned_disposal_date", sa.Date(), nullable=True), + sa.Column("disposal_date", sa.Date(), nullable=True), + sa.Column("disposal_reason", sa.String(length=500), nullable=True), + sa.Column("recurrence_monitoring", sa.Boolean(), nullable=False, server_default=sa.false()), + ) + for column in equipment_columns: + op.add_column("equipments", column) + op.create_foreign_key("fk_equipments_supplier", "equipments", "companies", ["supplier_id"], ["id"]) + op.create_index("ix_equipments_serial_number", "equipments", ["serial_number"]) + + op.create_table( + "equipment_quantity_movements", + sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("root_equipment_id", sa.Integer(), nullable=False), + sa.Column("source_equipment_id", sa.Integer(), nullable=True), + sa.Column("target_equipment_id", sa.Integer(), nullable=True), + sa.Column("source_room_id", sa.Integer(), nullable=True), + sa.Column("target_room_id", sa.Integer(), nullable=False), + sa.Column("quantity", sa.Integer(), nullable=False), + sa.Column("reason", sa.String(length=255), nullable=True), + sa.Column("moved_by_id", sa.Integer(), nullable=True), + sa.Column("moved_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["root_equipment_id"], ["equipments.id"]), + sa.ForeignKeyConstraint(["source_equipment_id"], ["equipments.id"]), + sa.ForeignKeyConstraint(["target_equipment_id"], ["equipments.id"]), + sa.ForeignKeyConstraint(["source_room_id"], ["rooms.id"]), + sa.ForeignKeyConstraint(["target_room_id"], ["rooms.id"]), + sa.ForeignKeyConstraint(["moved_by_id"], ["users.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + sa.CheckConstraint("quantity > 0", name="ck_equipment_quantity_movement_positive"), + ) + op.create_index("ix_equipment_quantity_movements_root_equipment_id", "equipment_quantity_movements", ["root_equipment_id"]) + + +def downgrade(): + op.drop_table("equipment_quantity_movements") + op.drop_index("ix_equipments_serial_number", table_name="equipments") + op.drop_constraint("fk_equipments_supplier", "equipments", type_="foreignkey") + for name in ( + "recurrence_monitoring", "disposal_reason", "disposal_date", "planned_disposal_date", + "unavailable_since", "lifecycle_status", "purchase_date", "purchase_price", + "supplier_reference", "supplier_id", "model_reference", "manufacturer", "serial_number", "mobility", + ): + op.drop_column("equipments", name) + op.drop_index("ix_buildings_site_id", table_name="buildings") + op.drop_constraint("fk_buildings_site", "buildings", type_="foreignkey") + op.drop_column("buildings", "site_id") + op.drop_table("sites") diff --git a/tests/integration/test_equipment_lifecycle_and_transfers.py b/tests/integration/test_equipment_lifecycle_and_transfers.py new file mode 100644 index 0000000..9338074 --- /dev/null +++ b/tests/integration/test_equipment_lifecycle_and_transfers.py @@ -0,0 +1,93 @@ +from app_new import db +from app_new.core.models.college import Building, Room, Site +from app_new.core.models.equipment import Equipment, EquipmentQuantityMovement +from app_new.core.models.user import User +from app_new.equipments.main import complete_repair, move_to_room, start_repair +from flask_login import login_user + + +def _rooms(app): + with app.app_context(): + building = Building(name='Bâtiment transfert') + db.session.add(building) + db.session.flush() + source = Room(name='Salle source', building_id=building.id) + target = Room(name='Salle cible', building_id=building.id) + db.session.add_all([source, target]) + db.session.commit() + return source.id, target.id + + +def test_partial_group_transfer_updates_both_rooms(authenticated_client, app): + source_room_id, target_room_id = _rooms(app) + with app.app_context(): + root = Equipment(name='Chaises', is_group=True, quantity=30, mobility='mobile') + db.session.add(root) + db.session.flush() + source = Equipment( + name='Chaises', parent_id=root.id, room_id=source_room_id, + is_group=True, quantity=10, mobility='mobile', + ) + db.session.add(source) + db.session.commit() + source_id, root_id = source.id, root.id + assert db.session.get(Room, target_room_id) is not None + assert db.session.get(Equipment, source_id) is not None + + admin = User.query.filter_by(role='admin').first() + with app.test_request_context(method='POST', data={'room_id': target_room_id, 'quantity': 4, 'reason': 'Réorganisation'}): + login_user(admin) + response = move_to_room.__wrapped__(source_id) + assert response.status_code == 302 + + with app.app_context(): + source = db.session.get(Equipment, source_id) + target = Equipment.query.filter_by(parent_id=root_id, room_id=target_room_id).one() + movement = EquipmentQuantityMovement.query.filter_by(root_equipment_id=root_id).one() + assert source.quantity == 6 + assert target.quantity == 4 + assert movement.quantity == 4 + + +def test_fixed_equipment_cannot_be_moved(authenticated_client, app): + source_room_id, target_room_id = _rooms(app) + with app.app_context(): + equipment = Equipment(name='Luminaire fixe', room_id=source_room_id, mobility='fixe') + db.session.add(equipment) + db.session.commit() + equipment_id = equipment.id + admin = User.query.filter_by(role='admin').first() + with app.test_request_context(method='POST', data={'room_id': target_room_id}): + login_user(admin) + response = move_to_room.__wrapped__(equipment_id) + assert response.status_code == 302 + with app.app_context(): + assert db.session.get(Equipment, equipment_id).room_id == source_room_id + + +def test_repair_workflow(authenticated_client, app): + with app.app_context(): + equipment = Equipment(name='Photocopieur', mobility='mobile') + db.session.add(equipment) + db.session.commit() + equipment_id = equipment.id + admin = User.query.filter_by(role='admin').first() + with app.test_request_context(method='POST'): + login_user(admin) + assert start_repair.__wrapped__(equipment_id).status_code == 302 + assert db.session.get(Equipment, equipment_id).lifecycle_status == 'en_reparation' + with app.test_request_context(method='POST'): + login_user(admin) + assert complete_repair.__wrapped__(equipment_id).status_code == 302 + equipment = db.session.get(Equipment, equipment_id) + assert equipment.lifecycle_status == 'en_service' + assert equipment.unavailable_since is None + + +def test_site_can_represent_staff_housing(authenticated_client, app): + response = authenticated_client.post('/equipments/buildings/sites', data={ + 'name': 'Logements de fonction', 'site_type': 'logements', + }) + assert response.status_code == 302 + with app.app_context(): + assert Site.query.filter_by(name='Logements de fonction', site_type='logements').one()