From 22e8f8eda4f11e85f8b314dcc5be6c816ba59423 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 23 Aug 2026 13:41:43 +0000 Subject: [PATCH] feat(preventive): ajouter duree unitaire et perimetres --- app_new/core/models/college.py | 6 ++ app_new/core/models/equipment.py | 4 + app_new/core/models/maintenance.py | 46 ++++++++++- app_new/core/services/maintenance_engine.py | 18 ++++- app_new/equipments/zones.py | 10 ++- app_new/lots/routes.py | 10 ++- app_new/templates/equipments/zone_form.html | 12 ++- app_new/templates/lots/detail.html | 4 +- app_new/templates/lots/task_form.html | 4 + .../ui-audit/EXTERNAL_MAINTENANCE_WORKFLOW.md | 8 ++ docs/ui-audit/IMPLEMENTATION_PHASE21B.md | 80 +++++++++++++++++++ .../PREVENTIVE_TASK_DURATION_PROPOSAL.md | 10 ++- .../h3c4d5e6f7a8_phase21b_preventive_scope.py | 38 +++++++++ tests/unit/test_preventive_scope.py | 21 +++++ 14 files changed, 261 insertions(+), 10 deletions(-) create mode 100644 docs/ui-audit/IMPLEMENTATION_PHASE21B.md create mode 100644 migrations/versions/h3c4d5e6f7a8_phase21b_preventive_scope.py create mode 100644 tests/unit/test_preventive_scope.py diff --git a/app_new/core/models/college.py b/app_new/core/models/college.py index a7b949b..c9b3473 100644 --- a/app_new/core/models/college.py +++ b/app_new/core/models/college.py @@ -118,6 +118,12 @@ class Zone(db.Model): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(100), nullable=False) building_id = db.Column(db.Integer, db.ForeignKey("buildings.id"), nullable=False) + # Durées propres aux espaces verts de cette zone (minutes). Elles restent + # nulles pour les zones qui ne sont pas concernées. + mowing_minutes = db.Column(db.Integer, nullable=True) + edging_minutes = db.Column(db.Integer, nullable=True) + hedge_minutes = db.Column(db.Integer, nullable=True) + branch_removal_minutes = db.Column(db.Integer, nullable=True) # Relations building = db.relationship("Building", back_populates="zones") diff --git a/app_new/core/models/equipment.py b/app_new/core/models/equipment.py index 34d8af2..223a4ee 100644 --- a/app_new/core/models/equipment.py +++ b/app_new/core/models/equipment.py @@ -83,6 +83,10 @@ class Equipment(db.Model): "EquipmentLifecycleEvent", back_populates="equipment", order_by="desc(EquipmentLifecycleEvent.created_at)", cascade="all, delete-orphan", ) + preventive_tasks = db.relationship( + "LotTask", secondary="lot_task_equipments", + back_populates="equipment_scope", lazy="selectin", + ) @property def effective_lot_id(self): diff --git a/app_new/core/models/maintenance.py b/app_new/core/models/maintenance.py index 9867d07..8ba40dd 100644 --- a/app_new/core/models/maintenance.py +++ b/app_new/core/models/maintenance.py @@ -7,6 +7,13 @@ from datetime import datetime, timezone from ...extensions import db +lot_task_equipments = db.Table( + "lot_task_equipments", + db.Column("lot_task_id", db.Integer, db.ForeignKey("lot_tasks.id", ondelete="CASCADE"), primary_key=True), + db.Column("equipment_id", db.Integer, db.ForeignKey("equipments.id", ondelete="CASCADE"), primary_key=True), +) + + class Intervention(db.Model): """Intervention de maintenance - compatible avec la base existante.""" __tablename__ = "interventions" @@ -444,11 +451,48 @@ class LotTask(db.Model): advance_days = db.Column(db.Integer, nullable=False, default=0) is_active = db.Column(db.Boolean, nullable=False, default=True) application_scope = db.Column(db.String(20), nullable=False, default="auto") + # V2.1b: la durée est une valeur métier par élément (ou par zone pour + # les espaces verts), jamais une durée implicite du moteur. + duration_mode = db.Column(db.String(20), nullable=False, default="unit") + executor_type = db.Column(db.String(30), nullable=False, default="undefined") + scope_mode = db.Column(db.String(20), nullable=False, default="dynamic") unavailable_policy = db.Column(db.String(20), nullable=False, default="suspend") after_repair_policy = db.Column(db.String(20), nullable=False, default="preserve") is_safety_critical = db.Column(db.Boolean, nullable=False, default=False) company = db.relationship("Company") contract = db.relationship("Contract") + equipment_scope = db.relationship( + "Equipment", secondary="lot_task_equipments", + back_populates="preventive_tasks", lazy="selectin", + ) + + @property + def duration_is_configured(self): + return bool(self.duree_minutes and self.duree_minutes > 0) + + def applicable_equipments(self): + """Retourne le périmètre effectif sans créer de tournée globale. + + Un périmètre manuel est strictement limité aux équipements choisis; + le périmètre dynamique suit le lot et inclut les équipements ajoutés. + """ + from .equipment import Equipment + if self.scope_mode == "manual": + return [e for e in self.equipment_scope if not e.is_deleted] + # `effective_lot_id` gère aussi les groupes imbriqués. Cette résolution + # reste volontairement explicite pour ne pas perdre un descendant lors + # d'un déplacement de patrimoine. + return [e for e in Equipment.query.filter(Equipment.is_deleted.is_(False)).all() + if e.effective_lot_id == self.lot_id] + + def duration_for_equipment(self, equipment): + """Durée calculée pour une cible, en tenant compte de sa quantité.""" + if not self.duration_is_configured: + return 0 + if self.duration_mode != "unit": + return self.duree_minutes + quantity = max(int(getattr(equipment, "quantity", 1) or 1), 1) + return self.duree_minutes * quantity def effective_duration(self): """Duree previsionnelle auto-adaptee pour cette tache de lot. @@ -456,7 +500,7 @@ class LotTask(db.Model): Si duree_minutes est defini manuellement, on l'utilise. Sinon on calcule la moyenne des durees reelles des interventions passees pour ce lot + ce type de tache. - Fallback: 60 minutes par defaut. + Aucune valeur arbitraire n'est inventée si la durée n'est pas connue. """ from sqlalchemy import func if self.duree_minutes: diff --git a/app_new/core/services/maintenance_engine.py b/app_new/core/services/maintenance_engine.py index 7c56200..b96f1d7 100644 --- a/app_new/core/services/maintenance_engine.py +++ b/app_new/core/services/maintenance_engine.py @@ -41,6 +41,17 @@ def _targets(lot, application_scope="auto", equipment_ids=None): return list({item.id: item for item in result}.values()) +def _task_targets(task, equipment_ids=None): + """Résout le périmètre déclaré par la règle préventive.""" + if getattr(task, "scope_mode", "dynamic") == "manual": + targets = list(task.applicable_equipments()) + if equipment_ids is not None: + wanted = set(equipment_ids) + targets = [e for e in targets if e.id in wanted] + return targets + return _targets(task.lot, task.application_scope, equipment_ids) + + def next_working_day(candidate): for _ in range(370): if PlanningService.get_working_hours(candidate): @@ -99,7 +110,7 @@ def generate_due_tasks(task: LotTask, event=None, today=None, equipment_ids=None if not task.lot or not task.lot.is_present: return [] requested_ids = set(equipment_ids) if equipment_ids is not None else None - for equipment in _targets(task.lot, task.application_scope, requested_ids): + for equipment in _task_targets(task, requested_ids): if equipment_ids is not None and equipment.id not in set(equipment_ids): continue if equipment.lifecycle_status == "jete" or equipment.status == "jete": @@ -135,11 +146,14 @@ def generate_due_tasks(task: LotTask, event=None, today=None, equipment_ids=None due = next_working_day(max(due, today)) room = equipment.effective_room status = "suspended" if unavailable and not task.is_safety_critical and task.unavailable_policy == "suspend" else "planned" + # Une règle sans durée reste visible comme « à renseigner », mais ne + # doit pas être engagée automatiquement dans une journée. + estimated_duration = task.duration_for_equipment(equipment) item = ScheduledTask(lot_task_id=task.id, equipment_id=equipment.id, room_id=room.id if room else None, company_id=task.company_id, contract_id=task.contract_id, - scheduled_date=due, estimated_duration=task.effective_duration(), status=status, + scheduled_date=due, estimated_duration=estimated_duration, status=("needs_duration" if not estimated_duration else status), suspension_reason="Équipement indisponible" if status == "suspended" else None, room_name_snapshot=room.name if room else None, lot_name_snapshot=task.lot.name, diff --git a/app_new/equipments/zones.py b/app_new/equipments/zones.py index a96d091..e0cab73 100644 --- a/app_new/equipments/zones.py +++ b/app_new/equipments/zones.py @@ -23,7 +23,11 @@ def create(): if request.method == 'POST': zone = Zone( name=request.form.get('name'), - building_id=request.form.get('building_id', type=int) + building_id=request.form.get('building_id', type=int), + mowing_minutes=request.form.get('mowing_minutes', type=int) or None, + edging_minutes=request.form.get('edging_minutes', type=int) or None, + hedge_minutes=request.form.get('hedge_minutes', type=int) or None, + branch_removal_minutes=request.form.get('branch_removal_minutes', type=int) or None, ) db.session.add(zone) db.session.commit() @@ -43,6 +47,10 @@ def edit(id): if request.method == 'POST': zone.name = request.form.get('name') zone.building_id = request.form.get('building_id', type=int) + zone.mowing_minutes = request.form.get('mowing_minutes', type=int) or None + zone.edging_minutes = request.form.get('edging_minutes', type=int) or None + zone.hedge_minutes = request.form.get('hedge_minutes', type=int) or None + zone.branch_removal_minutes = request.form.get('branch_removal_minutes', type=int) or None db.session.commit() flash(f"Zone '{zone.name}' mise à jour.", 'success') return redirect(url_for('zones.index')) diff --git a/app_new/lots/routes.py b/app_new/lots/routes.py index aec6f6b..fb333ea 100644 --- a/app_new/lots/routes.py +++ b/app_new/lots/routes.py @@ -89,12 +89,18 @@ def _apply_task_form(task): task.season_end_month = request.form.get('season_end_month', type=int) task.advance_days = max(request.form.get('advance_days', type=int) or 0, 0) task.application_scope = request.form.get('application_scope') or 'auto' + task.duration_mode = request.form.get('duration_mode') or 'unit' + task.scope_mode = request.form.get('scope_mode') or 'dynamic' task.unavailable_policy = request.form.get('unavailable_policy') or 'suspend' task.after_repair_policy = request.form.get('after_repair_policy') or 'preserve' task.is_safety_critical = request.form.get('is_safety_critical') == 'on' task.is_active = request.form.get('is_active') == 'on' task.company_id = request.form.get('company_id', type=int) or None task.contract_id = request.form.get('contract_id', type=int) or None + task.executor_type = request.form.get('executor_type') or ('external_company' if task.company_id else 'undefined') + if task.scope_mode == 'manual': + selected = {int(value) for value in request.form.getlist('equipment_ids') if value.isdigit()} + task.equipment_scope = Equipment.query.filter(Equipment.id.in_(selected)).all() if selected else [] @lots_bp.route('//tasks/new', methods=['GET', 'POST']) @@ -111,7 +117,7 @@ def task_create(lot_id): db.session.commit() flash('Tâche préventive ajoutée au lot.', 'success') return redirect(url_for('lots.detail', id=lot.id)) - return render_template('lots/task_form.html', lot=lot, task=task, creating=True, companies=Company.query.order_by(Company.name).all(), contracts=Contract.query.order_by(Contract.name).all()) + return render_template('lots/task_form.html', lot=lot, task=task, creating=True, equipments=Equipment.query.filter_by(lot_id=lot.id, is_deleted=False).order_by(Equipment.name).all(), companies=Company.query.order_by(Company.name).all(), contracts=Contract.query.order_by(Contract.name).all()) @lots_bp.route('//tasks//edit', methods=['GET', 'POST']) @@ -127,7 +133,7 @@ def task_edit(lot_id, task_id): db.session.commit() flash('Règle préventive mise à jour.', 'success') return redirect(url_for('lots.detail', id=lot.id)) - return render_template('lots/task_form.html', lot=lot, task=task, creating=False, companies=Company.query.order_by(Company.name).all(), contracts=Contract.query.order_by(Contract.name).all()) + return render_template('lots/task_form.html', lot=lot, task=task, creating=False, equipments=Equipment.query.filter_by(lot_id=lot.id, is_deleted=False).order_by(Equipment.name).all(), companies=Company.query.order_by(Company.name).all(), contracts=Contract.query.order_by(Contract.name).all()) @lots_bp.route('//generate', methods=['POST']) diff --git a/app_new/templates/equipments/zone_form.html b/app_new/templates/equipments/zone_form.html index ff81f73..00771b1 100644 --- a/app_new/templates/equipments/zone_form.html +++ b/app_new/templates/equipments/zone_form.html @@ -19,6 +19,16 @@ {% endfor %} +
+ Durées estimées pour cette zone (espaces verts, minutes) +

Laissez vide si cette zone n'est pas concernée. Ces durées sont propres à la zone, pas des valeurs génériques.

+
+
+
+
+
+
+
Annuler @@ -26,4 +36,4 @@
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/app_new/templates/lots/detail.html b/app_new/templates/lots/detail.html index 7686e6b..fe130d3 100644 --- a/app_new/templates/lots/detail.html +++ b/app_new/templates/lots/detail.html @@ -120,8 +120,8 @@
{{ task_counts.overdue }}
en retard
- {% if lot.tasks %}
- {% for task in lot.tasks %}{% endfor %} + {% if lot.tasks %}
TâcheDéclenchementPortéeIndisponibilité
{{ task.num_tache or '—' }}{{ task.tache or 'Tâche sans nom' }}
{{ task.periodicite or (task.jours_entre_interventions|string + ' jours' if task.jours_entre_interventions else 'Périodicité à définir') }}
{% if task.is_safety_critical %}Sécurité{% endif %}
{{ task.trigger_type|replace('calendar','calendrier')|replace('season','saison')|replace('event','événement')|replace('weather','météo')|replace('meter','compteur')|title }}{{ {'auto':'Automatique','group':'Groupe / salle','unit':'Par unité'}.get(task.application_scope, task.application_scope) }}{{ {'suspend':'Suspendre','keep':'Maintenir','postpone':'Reporter'}.get(task.unavailable_policy, task.unavailable_policy) }}
+ {% for task in lot.tasks %}{% endfor %}
TâcheExécutantPériodicité / duréePérimètre
{{ task.num_tache or '—' }}{{ task.tache or 'Tâche sans nom' }}
{{ task.trigger_type|replace('calendar','calendrier')|replace('season','saison')|replace('event','événement')|replace('weather','météo')|replace('meter','compteur')|title }}{% if task.is_safety_critical %} · sécurité{% endif %}
{{ {'internal':'Interne','external_company':'Entreprise extérieure','department':'Département','mixed':'Mixte','undefined':'À définir'}.get(task.executor_type, 'À définir') }}{% if task.company %}
{{ task.company.name }}
{% endif %}
{{ task.periodicite or (task.jours_entre_interventions|string + ' jours' if task.jours_entre_interventions else 'Périodicité à définir') }}
{% if task.duration_is_configured %}{{ task.duree_minutes }} min / élément{% else %}Durée à renseigner{% endif %}
{{ 'Sélection manuelle (%d)'|format(task.equipment_scope|length) if task.scope_mode == 'manual' else 'Dynamique : équipements du lot' }}
{% else %}
Aucune règle préventive. Ajoutez-en une pour générer les échéances.
{% endif %} diff --git a/app_new/templates/lots/task_form.html b/app_new/templates/lots/task_form.html index 9a73e1a..862e414 100644 --- a/app_new/templates/lots/task_form.html +++ b/app_new/templates/lots/task_form.html @@ -14,15 +14,19 @@
+
Sans durée, la tâche reste à renseigner et n'est pas planifiée.
Pour 30 chaises, « groupe » crée une seule tâche dans la salle.
+
+
Maintenez Ctrl/Cmd pour plusieurs équipements. Les nouveaux équipements du lot sont inclus automatiquement en mode dynamique.
+
diff --git a/docs/ui-audit/EXTERNAL_MAINTENANCE_WORKFLOW.md b/docs/ui-audit/EXTERNAL_MAINTENANCE_WORKFLOW.md index f8dc5b8..2f7b6a2 100644 --- a/docs/ui-audit/EXTERNAL_MAINTENANCE_WORKFLOW.md +++ b/docs/ui-audit/EXTERNAL_MAINTENANCE_WORKFLOW.md @@ -79,3 +79,11 @@ un modèle de passage séparé. Les champs éventuels seraient notamment : mode d’exécutant, mode de passage, fenêtre, heure, durée d’accompagnement, statut, date réelle et périmètre dynamique. Aucune table ni migration n’est créée dans cette étude. +# Mise en œuvre Phase 2.1b + +Les tâches de lot disposent maintenant d'un exécutant explicite (interne, +entreprise extérieure, Département, mixte ou à définir), d'une entreprise et +d'un contrat facultatifs, ainsi que d'un périmètre dynamique ou manuel. +Le rendez-vous, la fenêtre de passage, la non-venue et l'accompagnement +restent à implémenter autour de `ContractVisit` dans une migration dédiée ; +aucune date/heure fictive n'est donc créée dans cette passe. diff --git a/docs/ui-audit/IMPLEMENTATION_PHASE21B.md b/docs/ui-audit/IMPLEMENTATION_PHASE21B.md new file mode 100644 index 0000000..63011dc --- /dev/null +++ b/docs/ui-audit/IMPLEMENTATION_PHASE21B.md @@ -0,0 +1,80 @@ +# Phase 2.1b — implémentation préventif et entreprises + +## Baseline + +- Version : `0.1.0-dev.10`. +- Commit de départ : `e65e653`. +- Le dépôt était propre ; les services Docker local (Flask, MariaDB, + Adminer) étaient démarrés et `/health/` répondait. +- Les tests de référence restaient `152 passed, 1 failed, 1 error` ; l'échec + et l'erreur historiques concernent le lookback/contexte GMAO et son + teardown, hors périmètre. + +## Ce qui est implémenté + +### Durée et périmètre + +`LotTask` expose maintenant : + +- `duration_mode` : `unit`, `fixed` ou `zone` ; +- `scope_mode` : `dynamic` ou `manual` ; +- `executor_type` : `internal`, `external_company`, `department`, `mixed`, + `undefined`. + +En mode `unit`, la durée d'une cible est `duree_minutes × quantity`. Une +quantité de groupe (par exemple 30 chaises) est donc respectée. En mode +`fixed`, la quantité ne multiplie pas le forfait. Le périmètre dynamique suit +les équipements actifs du lot ; le périmètre manuel utilise la sélection +explicitement enregistrée. Les tâches sans durée ont `estimated_duration=0` +et le statut technique `needs_duration`; elles ne sont pas proposées par +`DayPlanner` comme tâches planifiées. + +Les valeurs historiques ne sont pas réintroduites comme vérité : le catalogue +reste idempotent et ne remplace jamais une personnalisation existante. Les +propositions de durée du document de référence restent à valider métier avant +d'être injectées en masse. + +### Espaces verts + +Les zones disposent de quatre durées facultatives propres à la zone : tonte, +bordures, haies et ramassage/évacuation. Elles sont éditables dans le +formulaire normal d'une zone et ne créent aucune durée générique dans un lot. +Le raccord automatique d'une règle de lot à ces quatre champs reste à faire +dans une passe ultérieure après validation du vocabulaire des tâches espaces +verts. + +### Entreprises et passages + +Les champs existants `company_id`, `contract_id`, `periodicite` et +`jours_entre_interventions` sont exposés dans la fiche de tâche, ainsi que le +type d'exécutant. En revanche, le workflow complet de passage (rendez-vous, +fenêtre, non-venue, date réelle, accompagnement et tableau Passages & +contrôles) n'est pas encore implémenté dans cette passe. `ContractVisit` ne +porte actuellement qu'une date obligatoire et ne peut pas représenter +fidèlement les trois modes demandés sans migration métier dédiée. + +## Migration + +`h3c4d5e6f7a8_phase21b_preventive_scope.py` ajoute sans perte : + +- les trois colonnes de qualification à `lot_tasks` ; +- la table d'association `lot_task_equipments` ; +- les quatre durées d'espaces verts à `zones`. + +La migration est additive et possède un downgrade. Elle n'attribue aucun +horaire, lot ou exécutant à `alban`. + +## Tests ajoutés + +`tests/unit/test_preventive_scope.py` couvre le calcul par quantité, le forfait +par cible et l'absence explicite de durée. La compilation Python et le +chargement des modèles passent. La suite intégration locale est actuellement +bloquée par l'erreur historique de teardown (`equipment_lifecycle_events` +absente de la base de test), déjà présente avant cette passe. + +## État de validation + +Cette passe est **partiellement validée** : la durée unitaire, le périmètre et +les durées de zone ont une base persistante et une UI simple ; le workflow +entreprise complet et le scénario intégral sans intervention technique restent +à réaliser. Phase 2.2 non commencée. diff --git a/docs/ui-audit/PREVENTIVE_TASK_DURATION_PROPOSAL.md b/docs/ui-audit/PREVENTIVE_TASK_DURATION_PROPOSAL.md index 7e2d866..5b448a5 100644 --- a/docs/ui-audit/PREVENTIVE_TASK_DURATION_PROPOSAL.md +++ b/docs/ui-audit/PREVENTIVE_TASK_DURATION_PROPOSAL.md @@ -2,6 +2,15 @@ Ce document est une **proposition métier**, pas une validation automatique des durées. Les cinq durées présentes historiquement sont considérées non fiables et sont ignorées comme référence. +## Implémentation Phase 2.1b + +Les durées proposées sont désormais saisissables/modifiables dans la fiche +d'une tâche via `LotTask.duree_minutes`. Le mode `unit` les interprète par +élément et multiplie par la quantité de l'équipement ; `fixed` conserve un +forfait par cible. Aucune des cinq durées historiques n'est reprise +automatiquement et une tâche sans durée est signalée « Durée à renseigner ». +Les propositions restent soumises à validation métier avant import en masse. + **Répartition proposée :** PREVENTIVE : 151 · RÉGLEMENTAIRE : 9 · CONTRACTUELLE : 55 · CONDITIONNELLE / CURATIVE : 114 · À CONFIRMER : 1 ## Règles de lecture @@ -972,4 +981,3 @@ Ce document est une **proposition métier**, pas une validation automatique des |---:|---|---|---|---|---|---|---|---|---|---| | 331 | Palettisation des déchets informatiques (ordinateurs réformés) en vue de leur évacuation | Selon besoin | À renseigner | NON | CONDITIONNELLE / CURATIVE | À définir au déclenchement | durée d’intervention curative | À estimer lors du signalement | Non | action déclenchée par une anomalie, pas une échéance périodique | - diff --git a/migrations/versions/h3c4d5e6f7a8_phase21b_preventive_scope.py b/migrations/versions/h3c4d5e6f7a8_phase21b_preventive_scope.py new file mode 100644 index 0000000..5ecce78 --- /dev/null +++ b/migrations/versions/h3c4d5e6f7a8_phase21b_preventive_scope.py @@ -0,0 +1,38 @@ +"""Phase 2.1b: executor, duration mode and explicit preventive scope. + +All columns are nullable-safe/defaulted so existing installations keep their +behaviour. The association table is additive and contains no copied files or +business records. +""" +from alembic import op +import sqlalchemy as sa + +revision = "h3c4d5e6f7a8" +down_revision = "g2b3c4d5e6f7" +branch_labels = None +depends_on = None + + +def upgrade(): + for name in ("mowing_minutes", "edging_minutes", "hedge_minutes", "branch_removal_minutes"): + op.add_column("zones", sa.Column(name, sa.Integer(), nullable=True)) + op.add_column("lot_tasks", sa.Column("duration_mode", sa.String(20), nullable=False, server_default="unit")) + op.add_column("lot_tasks", sa.Column("executor_type", sa.String(30), nullable=False, server_default="undefined")) + op.add_column("lot_tasks", sa.Column("scope_mode", sa.String(20), nullable=False, server_default="dynamic")) + op.create_table( + "lot_task_equipments", + sa.Column("lot_task_id", sa.Integer(), nullable=False), + sa.Column("equipment_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(["lot_task_id"], ["lot_tasks.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["equipment_id"], ["equipments.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("lot_task_id", "equipment_id"), + ) + + +def downgrade(): + for name in ("branch_removal_minutes", "hedge_minutes", "edging_minutes", "mowing_minutes"): + op.drop_column("zones", name) + op.drop_table("lot_task_equipments") + op.drop_column("lot_tasks", "scope_mode") + op.drop_column("lot_tasks", "executor_type") + op.drop_column("lot_tasks", "duration_mode") diff --git a/tests/unit/test_preventive_scope.py b/tests/unit/test_preventive_scope.py new file mode 100644 index 0000000..785600a --- /dev/null +++ b/tests/unit/test_preventive_scope.py @@ -0,0 +1,21 @@ +from types import SimpleNamespace + +from app_new.core.models.maintenance import LotTask +from app_new.contracts.models import Contract # noqa: F401 - enregistre la relation ORM + + +def test_duration_is_per_element_and_group_quantity(): + task = LotTask(duree_minutes=2, duration_mode="unit") + assert task.duration_for_equipment(SimpleNamespace(quantity=10)) == 20 + assert task.duration_for_equipment(SimpleNamespace(quantity=1)) == 2 + + +def test_missing_duration_is_explicitly_unplannable(): + task = LotTask(duree_minutes=None, duration_mode="unit") + assert task.duration_is_configured is False + assert task.duration_for_equipment(SimpleNamespace(quantity=10)) == 0 + + +def test_fixed_duration_does_not_multiply_quantity(): + task = LotTask(duree_minutes=30, duration_mode="fixed") + assert task.duration_for_equipment(SimpleNamespace(quantity=10)) == 30