diff --git a/app_new/core/models/maintenance.py b/app_new/core/models/maintenance.py index 9412a5e..545ce0e 100644 --- a/app_new/core/models/maintenance.py +++ b/app_new/core/models/maintenance.py @@ -266,7 +266,7 @@ class Intervention(db.Model): ).scalar() if avg_lot and avg_lot > 0: return round(avg_lot, 1) - return 60.0 # valeur par defaut (1h) + return 0 @property def is_overdue(self): diff --git a/app_new/core/models/planning.py b/app_new/core/models/planning.py index af3c90d..27e232c 100644 --- a/app_new/core/models/planning.py +++ b/app_new/core/models/planning.py @@ -569,7 +569,7 @@ class ScheduledTask(db.Model): equipment_id=self.equipment_id, room_id=self.room_id, scheduled_date=self.scheduled_date, - estimated_duration=float(self.estimated_duration or 60), + estimated_duration=float(self.estimated_duration or 0), ) db.session.add(interv) db.session.flush() diff --git a/app_new/core/services/day_planner.py b/app_new/core/services/day_planner.py index db9c426..b002eaf 100644 --- a/app_new/core/services/day_planner.py +++ b/app_new/core/services/day_planner.py @@ -318,7 +318,8 @@ class DayPlanner: description=intervention.description or "", task_type="emergency" if intervention.priority == "urgente" else "curative", priority=1 if intervention.priority == "urgente" else 3, constraint="urgent" if intervention.priority == "urgente" else ("fixed" if intervention.scheduled_start and intervention.scheduled_end else "flexible"), - target_date=day, duration_minutes=int(intervention.estimated_duration or 30), + target_date=day, + duration_minutes=int(intervention.estimated_duration or intervention.effective_estimated_duration or 0), room_id=intervention.room_id, room_name=room.name if room else None, zone_name=room.zone.name if room and room.zone else None, building_name=room.building.name if room and room.building else None, diff --git a/app_new/core/services/equipment_creation.py b/app_new/core/services/equipment_creation.py new file mode 100644 index 0000000..ec50fb4 --- /dev/null +++ b/app_new/core/services/equipment_creation.py @@ -0,0 +1,52 @@ +"""Service métier unique pour créer des équipements. + +Les formulaires historiques (fiche avancée et wizard) délèguent ici la +construction de l'objet. Le lot reste facultatif : un inventaire incomplet est +valide et pourra être complété plus tard. +""" +from datetime import date + +from ...extensions import db +from ..models.equipment import Equipment + + +def create_equipment(*, name, category_id=None, lot_id=None, room_id=None, + quantity=1, is_group=False, parent_id=None, + tracked_individually=False, mobility="non_precise", + status="en_service", code=None, description=None, + management_mode=None, serial_number=None, + manufacturer=None, model_reference=None, + supplier_id=None, supplier_reference=None, + purchase_price=None, purchase_date=None, + recurrence_monitoring=False, position=None, + individual_number=None, create_schedule=True): + """Construit, ajoute et flush un équipement sans recherche par nom. + + Retourne l'instance persistable et son identifiant est disponible dès le + retour. La génération préventive est déclenchée uniquement si un lot est + effectivement renseigné. + """ + name = (name or "").strip() + if not name: + raise ValueError("Le nom de l'équipement est obligatoire.") + quantity = int(quantity or 1) + if quantity < 1: + raise ValueError("La quantité doit être positive.") + equipment = Equipment( + name=name, category_id=category_id or None, lot_id=lot_id or None, + room_id=room_id or None, quantity=quantity, is_group=bool(is_group), + parent_id=parent_id or None, + tracked_individually=bool(tracked_individually), mobility=mobility or "non_precise", + status=status or "en_service", code=code or None, + description=description or None, management_mode=management_mode or None, + serial_number=serial_number or None, manufacturer=manufacturer or None, + model_reference=model_reference or None, supplier_id=supplier_id or None, + supplier_reference=supplier_reference or None, purchase_price=purchase_price or None, + purchase_date=purchase_date, recurrence_monitoring=bool(recurrence_monitoring), + position=position or None, individual_number=individual_number, + ) + db.session.add(equipment) + db.session.flush() + if create_schedule and equipment.lot_id: + equipment.create_scheduled_tasks_from_lot(commit=False) + return equipment diff --git a/app_new/equipments/main.py b/app_new/equipments/main.py index af5124e..bf8bfd1 100644 --- a/app_new/equipments/main.py +++ b/app_new/equipments/main.py @@ -16,6 +16,7 @@ from ..core.services.equipment_lifecycle import ( confirm_disposal, propose_disposal, record_event, start_repair as start_equipment_repair, update_future_task_location, ) +from ..core.services.equipment_creation import create_equipment from ..core.authorization import permission_required def _empty_to_none(v): @@ -265,17 +266,14 @@ def create(): if not category_id or not lot or lot.category_id != category_id: flash('Le lot sélectionné doit appartenir à la catégorie choisie.', 'danger') return redirect(url_for('equipments.create', advanced=1)), 400 - equipment = Equipment( - name=request.form.get('name'), - category_id=category_id, - room_id=request.form.get('room_id') or None, - lot_id=lot_id, + equipment = create_equipment( + name=request.form.get('name'), category_id=category_id, + room_id=request.form.get('room_id') or None, lot_id=lot_id, code=_empty_to_none(request.form.get('code', '').strip()), description=_empty_to_none(request.form.get('description', '').strip()), 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, + quantity=int(request.form.get('quantity', 1) or 1), parent_id=parent_id, mobility=request.form.get('mobility', 'non_precise'), management_mode=request.form.get('management_mode') or None, serial_number=_empty_to_none(request.form.get('serial_number', '').strip()), @@ -287,13 +285,8 @@ def create(): 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() - # Créer les tâches planifiées si l'équipement a un lot - if equipment.lot_id: - equipment.create_scheduled_tasks_from_lot() - flash('Équipement créé avec succès.', 'success') return redirect(url_for('equipments.detail', id=equipment.id)) diff --git a/app_new/wizard/routes.py b/app_new/wizard/routes.py index 0b163fe..7ed20d4 100644 --- a/app_new/wizard/routes.py +++ b/app_new/wizard/routes.py @@ -7,6 +7,7 @@ from app_new.extensions import db from app_new.core.models.equipment import Equipment, EquipmentCategory from app_new.core.models.maintenance import Lot from app_new.core.models.college import Room, Building, Zone, RoomType +from app_new.core.services.equipment_creation import create_equipment wizard_bp = Blueprint('wizard', __name__, url_prefix='/wizard', template_folder='templates') @@ -91,42 +92,43 @@ def bulk_create_equipments(): 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)) + category_raw = raw.get('category_id') + lot_raw = raw.get('lot_id') + category = db.session.get(EquipmentCategory, int(category_raw)) if category_raw not in (None, '') else None + lot = db.session.get(Lot, int(lot_raw)) if lot_raw not in (None, '') else None 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: + if not name or quantity < 1 or quantity > 1000 or (category_raw not in (None, '') and not category) or (lot_raw not in (None, '') and not lot): raise ValueError('Une ligne contient des données invalides.') - if lot.category_id != category.id: + if lot and category 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, + root = create_equipment( + name=name, category_id=category.id if category else None, + lot_id=lot.id if lot else None, is_group=True, + quantity=quantity * len(rooms), mobility=mobility, tracked_individually=(mode == 'individuel'), status='en_service', + create_schedule=False, ) - db.session.add(root) - db.session.flush() created.append(root) for room in rooms: - group = Equipment( + group = create_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'), + create_schedule=False, ) - db.session.add(group) - db.session.flush() created.append(group) if mode == 'individuel': for number in range(1, quantity + 1): - unit = Equipment( + unit = create_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}', + create_schedule=False, ) - db.session.add(unit) created.append(unit) # Valider d'abord le patrimoine. La génération des échéances est # volontairement découplée : une anomalie dans un lot ne doit jamais @@ -219,32 +221,34 @@ def create_equipments(): # Debug current_app.logger.info(f"Création équipements: tracked_individually={tracked_individually}, rooms={rooms_data}") - if not lot_id or not category_id: - return jsonify({'error': 'Lot et catégorie requis'}), 400 + if category_id and not db.session.get(EquipmentCategory, category_id): + return jsonify({'error': 'Catégorie invalide'}), 400 + if lot_id and not db.session.get(Lot, lot_id): + return jsonify({'error': 'Lot invalide'}), 400 if not rooms_data: return jsonify({'error': 'Au moins une salle requise'}), 400 - lot = Lot.query.get(lot_id) - category = EquipmentCategory.query.get(category_id) + lot = db.session.get(Lot, lot_id) if lot_id else None + category = db.session.get(EquipmentCategory, category_id) if category_id else None # Nom de base: personnalisé ou catégorie - base_name = equipment_name if equipment_name else category.name + base_name = equipment_name if equipment_name else (category.name if category else 'Équipement') created_equipments = [] + created_instances = [] # Étape 1: Créer le parent global (si demandé) parent = None if create_parent and parent_name: - parent = Equipment( + parent = create_equipment( name=parent_name, lot_id=lot_id, category_id=category_id, is_group=True, - parent_id=None + parent_id=None, create_schedule=False, ) - db.session.add(parent) - db.session.flush() # Pour avoir l'ID + created_instances.append(parent) # Étape 2: Pour chaque salle, créer le groupe et les individuels for room_data in rooms_data: @@ -257,17 +261,16 @@ def create_equipments(): if tracked_individually: # Avec suivi individuel: créer un groupe + des individus - room_group = Equipment( + room_group = create_equipment( name=f"{base_name} {room.name}", lot_id=lot_id if not parent else None, category_id=category_id if not parent else None, is_group=True, parent_id=parent.id if parent else None, room_id=room_id, - quantity=1 + quantity=1, create_schedule=False, ) - db.session.add(room_group) - db.session.flush() + created_instances.append(room_group) created_equipments.append({ 'type': 'group', @@ -279,7 +282,7 @@ def create_equipments(): # Créer les équipements individuels if count > 1: for i in range(1, count + 1): - individual = Equipment( + individual = create_equipment( name=f"{base_name} {i}", lot_id=lot_id, # Hériter du lot category_id=category_id, # Hériter de la catégorie @@ -287,9 +290,9 @@ def create_equipments(): tracked_individually=True, parent_id=room_group.id, room_id=room_id, - position=f"{base_name} n°{i}" + position=f"{base_name} n°{i}", create_schedule=False, ) - db.session.add(individual) + created_instances.append(individual) created_equipments.append({ 'type': 'individual', @@ -298,17 +301,16 @@ def create_equipments(): }) else: # Sans suivi individuel: créer un seul équipement avec quantity - equipment = Equipment( + equipment = create_equipment( name=f"{base_name} {room.name}", lot_id=lot_id if not parent else None, category_id=category_id if not parent else None, is_group=False, parent_id=parent.id if parent else None, room_id=room_id, - quantity=count + quantity=count, create_schedule=False, ) - db.session.add(equipment) - db.session.flush() + created_instances.append(equipment) created_equipments.append({ 'type': 'equipment', @@ -318,14 +320,13 @@ def create_equipments(): }) db.session.commit() - - # Créer les tâches planifiées pour tous les équipements créés - for eq in created_equipments: - # Récupérer l'équipement depuis la DB pour avoir son ID - equipment = Equipment.query.filter_by(name=eq['name']).first() - if equipment and equipment.effective_lot_id: + for equipment in created_instances: + if equipment.effective_lot_id: equipment.create_scheduled_tasks_from_lot() + # Les instances créées sont conservées en mémoire : aucune recherche + # fragile par nom (deux équipements peuvent porter le même libellé). + return jsonify({ 'success': True, 'count': len(created_equipments), diff --git a/tests/unit/test_equipment_creation.py b/tests/unit/test_equipment_creation.py new file mode 100644 index 0000000..7ebcb35 --- /dev/null +++ b/tests/unit/test_equipment_creation.py @@ -0,0 +1,26 @@ +from app_new import db +from app_new.core.models.equipment import Equipment +from app_new.core.services.equipment_creation import create_equipment + + +def test_equipment_creation_accepts_missing_lot_and_preserves_identity(app): + with app.app_context(): + equipment = create_equipment( + name="TEST_UI_020_EQUIPMENT_NO_LOT", + quantity=12, + is_group=True, + create_schedule=False, + ) + db.session.commit() + assert equipment.id is not None + assert equipment.lot_id is None + assert equipment.quantity == 12 + assert db.session.get(Equipment, equipment.id) is equipment + + +def test_equipment_creation_returns_distinct_objects_for_duplicate_names(app): + with app.app_context(): + first = create_equipment(name="TEST_UI_020_DUPLICATE", create_schedule=False) + second = create_equipment(name="TEST_UI_020_DUPLICATE", create_schedule=False) + db.session.commit() + assert first.id != second.id