From b6c94b74d7c97f5d3479558e009c7d196833d144 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 14 Aug 2026 21:39:55 +0000 Subject: [PATCH] =?UTF-8?q?Unifie=20les=20r=C3=A8gles=20et=20=C3=A9ch?= =?UTF-8?q?=C3=A9ances=20de=20maintenance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app_new/contracts/models.py | 5 +- app_new/contracts/routes.py | 12 +++- .../contracts/templates/contracts/detail.html | 3 +- .../contracts/templates/contracts/form.html | 8 ++- app_new/core/models/equipment.py | 13 ++++- app_new/core/models/maintenance.py | 7 +++ app_new/core/services/maintenance_engine.py | 58 +++++++++++++++++++ app_new/equipments/main.py | 12 ++++ app_new/planning/schedules.py | 47 +++++++++------ .../templates/planning/edit_task.html | 11 +++- .../templates/planning/task_form.html | 25 +++----- .../planning/templates/planning/tasks.html | 3 +- app_new/scheduler/engine.py | 12 +++- .../templates/equipments/category_detail.html | 3 +- .../e6f0a1b2c3d4_unify_maintenance_rules.py | 34 +++++++++++ tests/integration/test_maintenance_engine.py | 38 ++++++++++++ 16 files changed, 245 insertions(+), 46 deletions(-) create mode 100644 app_new/core/services/maintenance_engine.py create mode 100644 migrations/versions/e6f0a1b2c3d4_unify_maintenance_rules.py create mode 100644 tests/integration/test_maintenance_engine.py diff --git a/app_new/contracts/models.py b/app_new/contracts/models.py index 6ff8000..3e19c7b 100644 --- a/app_new/contracts/models.py +++ b/app_new/contracts/models.py @@ -39,6 +39,9 @@ class Contract(db.Model): # Statut status = db.Column(db.String(20), default="actif") # actif, expire, resilie, en_attente notes = db.Column(db.Text, nullable=True) + visit_interval_days = db.Column(db.Integer, nullable=True) + last_visit_date = db.Column(db.Date, nullable=True) + next_visit_date = db.Column(db.Date, nullable=True) # Documents contract_file = db.Column(db.String(500), nullable=True) @@ -109,4 +112,4 @@ class Contract(db.Model): return "success" def __repr__(self): - return f"" \ No newline at end of file + return f"" diff --git a/app_new/contracts/routes.py b/app_new/contracts/routes.py index 463814b..8e4d1b5 100644 --- a/app_new/contracts/routes.py +++ b/app_new/contracts/routes.py @@ -11,6 +11,14 @@ from ..core.models.equipment import Equipment from ..companies.routes import Company from datetime import datetime, date +def _optional_date(value): + return datetime.strptime(value, '%Y-%m-%d').date() if value else None + +def _apply_visits(contract): + contract.visit_interval_days = request.form.get('visit_interval_days', type=int) + contract.last_visit_date = _optional_date(request.form.get('last_visit_date')) + contract.next_visit_date = _optional_date(request.form.get('next_visit_date')) + contracts_bp = Blueprint('contracts', __name__, url_prefix='/contracts', template_folder='templates') @@ -64,6 +72,7 @@ def create(): notes=request.form.get('notes', ''), ) db.session.add(contract) + _apply_visits(contract) db.session.commit() flash('Contrat cree avec succes.', 'success') return redirect(url_for('contracts.detail', id=contract.id)) @@ -112,6 +121,7 @@ def edit(id): contract.annual_amount = float(request.form.get('annual_amount')) if request.form.get('annual_amount') else None contract.status = request.form.get('status', 'actif') contract.notes = request.form.get('notes', '') + _apply_visits(contract) db.session.commit() flash('Contrat modifie.', 'success') return redirect(url_for('contracts.detail', id=contract.id)) @@ -143,4 +153,4 @@ def api_summary(): 'expiring_soon': sum(1 for c in contracts if c.is_expiring_soon), 'must_send_notice': sum(1 for c in contracts if c.must_send_notice_now), 'expired': sum(1 for c in contracts if c.end_date and c.end_date < date.today()), - }) \ No newline at end of file + }) diff --git a/app_new/contracts/templates/contracts/detail.html b/app_new/contracts/templates/contracts/detail.html index 0f93d2a..9c97115 100644 --- a/app_new/contracts/templates/contracts/detail.html +++ b/app_new/contracts/templates/contracts/detail.html @@ -69,6 +69,7 @@ {% endif %} {% else %}Duree indeterminee{% endif %} + Visites extérieures{% if contract.visit_interval_days %}Tous les {{ contract.visit_interval_days }} jours · prochaine visite {{ contract.next_visit_date.strftime('%d/%m/%Y') if contract.next_visit_date else 'à planifier' }}{% else %}—{% endif %} @@ -108,4 +109,4 @@ {% endif %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/app_new/contracts/templates/contracts/form.html b/app_new/contracts/templates/contracts/form.html index 99f269c..efc1050 100644 --- a/app_new/contracts/templates/contracts/form.html +++ b/app_new/contracts/templates/contracts/form.html @@ -54,6 +54,12 @@ +
Visites de maintenance extérieure
+
+
+
+
+
Dates et duree
@@ -135,4 +141,4 @@
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/app_new/core/models/equipment.py b/app_new/core/models/equipment.py index 54c2de1..8743190 100644 --- a/app_new/core/models/equipment.py +++ b/app_new/core/models/equipment.py @@ -14,6 +14,9 @@ class EquipmentCategory(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) + recurrence_alert_enabled = db.Column(db.Boolean, nullable=False, default=False) + recurrence_threshold = db.Column(db.Integer, nullable=False, default=3) + recurrence_window_days = db.Column(db.Integer, nullable=False, default=365) # Relations equipments = db.relationship("Equipment", back_populates="category", overlaps="category") @@ -173,7 +176,15 @@ class Equipment(db.Model): @property def needs_deep_work(self): """Nécessite une intervention profonde.""" - return bool(self.recurrence_monitoring and self.curative_count >= 3) + category = self.effective_category + enabled = self.recurrence_monitoring or bool(category and category.recurrence_alert_enabled) + threshold = category.recurrence_threshold if category else 3 + if not enabled: + return False + from datetime import timedelta + window = category.recurrence_window_days if category else 365 + cutoff = datetime.now(timezone.utc) - timedelta(days=window) + return len([i for i in self.interventions if i.type == 'curatif' and i.created_at and i.created_at >= cutoff]) >= threshold @property def is_mobile(self): diff --git a/app_new/core/models/maintenance.py b/app_new/core/models/maintenance.py index 076a5bf..61ec620 100644 --- a/app_new/core/models/maintenance.py +++ b/app_new/core/models/maintenance.py @@ -379,6 +379,13 @@ class LotTask(db.Model): jours_entre_interventions = db.Column(db.Integer, nullable=True) type_maintenance = db.Column(db.String(50), nullable=True) duree_minutes = db.Column(db.Integer, nullable=True) # Durée estimée en minutes + trigger_type = db.Column(db.String(20), nullable=False, default="calendar") + trigger_event = db.Column(db.String(80), nullable=True) + meter_threshold = db.Column(db.Float, nullable=True) + season_start_month = db.Column(db.Integer, nullable=True) + season_end_month = db.Column(db.Integer, nullable=True) + advance_days = db.Column(db.Integer, nullable=False, default=0) + is_active = db.Column(db.Boolean, nullable=False, default=True) def effective_duration(self): """Duree previsionnelle auto-adaptee pour cette tache de lot. diff --git a/app_new/core/services/maintenance_engine.py b/app_new/core/services/maintenance_engine.py new file mode 100644 index 0000000..3536ffb --- /dev/null +++ b/app_new/core/services/maintenance_engine.py @@ -0,0 +1,58 @@ +"""Moteur unique de génération des échéances depuis les tâches de lot.""" +from datetime import date, timedelta + +from app_new.extensions import db +from app_new.core.models.planning import Meter, ScheduledTask +from app_new.core.models.maintenance import LotTask +from app_new.core.services.planning_service import PlanningService + + +def _targets(lot): + result = [] + for root in lot.equipments.filter_by(is_deleted=False): + if root.tracked_individually: + result.extend([x for x in root.all_children if not x.is_group and not x.is_deleted]) + elif root.is_group and root.children.count(): + result.extend([x for x in root.children if not x.is_deleted]) + else: + result.append(root) + return list({item.id: item for item in result}.values()) + + +def _working_day(candidate): + for _ in range(370): + if PlanningService.get_working_hours(candidate): + return candidate + candidate += timedelta(days=1) + raise ValueError("Aucune journée travaillée disponible dans les 12 prochains mois.") + + +def generate_due_tasks(task: LotTask, event=None, today=None): + """Génère les échéances sans doublon et retourne les objets créés.""" + today = today or date.today() + if not task.is_active: + return [] + if task.trigger_type in ("event", "weather") and task.trigger_event != event: + return [] + if task.trigger_type == "season": + start, end = task.season_start_month or 1, task.season_end_month or 12 + inside = start <= today.month <= end if start <= end else today.month >= start or today.month <= end + if not inside: + return [] + created = [] + for equipment in _targets(task.lot): + if task.trigger_type == "meter": + meter = Meter.query.filter_by(equipment_id=equipment.id, is_active=True).order_by(Meter.current_value.desc()).first() + if not meter or task.meter_threshold is None or meter.current_value < task.meter_threshold: + continue + existing = ScheduledTask.query.filter_by(lot_task_id=task.id, equipment_id=equipment.id, status="planned").first() + if existing: + continue + due = _working_day(today + timedelta(days=max(task.advance_days or 0, 0))) + item = ScheduledTask(lot_task_id=task.id, equipment_id=equipment.id, + room_id=equipment.effective_room.id if equipment.effective_room else None, + scheduled_date=due, estimated_duration=task.effective_duration(), status="planned") + db.session.add(item) + created.append(item) + db.session.commit() + return created diff --git a/app_new/equipments/main.py b/app_new/equipments/main.py index cd07482..dd675f4 100644 --- a/app_new/equipments/main.py +++ b/app_new/equipments/main.py @@ -440,6 +440,18 @@ def category_detail(id): return render_template('equipments/category_detail.html', category=category, equipments=equipments) +@main_bp.route('/categories//recurrence-policy', methods=['POST']) +@login_required +def category_recurrence_policy(id): + category = EquipmentCategory.query.get_or_404(id) + category.recurrence_alert_enabled = request.form.get('enabled') == 'on' + category.recurrence_threshold = max(1, request.form.get('threshold', type=int) or 3) + category.recurrence_window_days = max(1, request.form.get('window_days', type=int) or 365) + db.session.commit() + flash('Politique de récurrence mise à jour.', 'success') + return redirect(url_for('equipments.category_detail', id=id)) + + @main_bp.route('/categories/new', methods=['GET', 'POST']) @login_required def create_category(): diff --git a/app_new/planning/schedules.py b/app_new/planning/schedules.py index 258c69f..28b3a22 100644 --- a/app_new/planning/schedules.py +++ b/app_new/planning/schedules.py @@ -165,6 +165,13 @@ def edit_task(id): task.periodicite = request.form.get('periodicite') or None task.jours_entre_interventions = int(request.form.get('jours_entre_interventions')) if request.form.get('jours_entre_interventions') and request.form.get('jours_entre_interventions').isdigit() else None task.type_maintenance = request.form.get('type_maintenance') or None + task.trigger_type = request.form.get('trigger_type', 'calendar') + task.trigger_event = request.form.get('trigger_event') or None + task.meter_threshold = request.form.get('meter_threshold', type=float) + task.season_start_month = request.form.get('season_start_month', type=int) + task.season_end_month = request.form.get('season_end_month', type=int) + task.advance_days = request.form.get('advance_days', type=int, default=0) + task.is_active = request.form.get('is_active') == 'on' task.contrat = request.form.get('contrat') == 'on' db.session.commit() @@ -174,28 +181,36 @@ def edit_task(id): return render_template('planning/edit_task.html', task=task) +@planning_bp.route('/tasks//generate', methods=['POST']) +@login_required +def generate_task(id): + from app_new.core.models.maintenance import LotTask + from app_new.core.services.maintenance_engine import generate_due_tasks + task = LotTask.query.get_or_404(id) + created = generate_due_tasks(task, event=(request.form.get('event') or '').strip() or None) + flash(f'{len(created)} échéance(s) créée(s), sans doublon.', 'success' if created else 'info') + return redirect(url_for('planning.tasks')) + + @planning_bp.route('/tasks/new', methods=['GET', 'POST']) @login_required def new_task(): """Créer une nouvelle tâche préventive.""" if request.method == 'POST': - task = PreventiveTask( + task = LotTask( lot_id=request.form.get('lot_id', type=int), - category_id=request.form.get('category_id', type=int) or None, - task_number=request.form.get('task_number'), - name=request.form.get('name'), - description=request.form.get('description'), - location_pattern=request.form.get('location_pattern'), - period_days=request.form.get('period_days', type=int), - duration_minutes=request.form.get('duration_minutes', type=int, default=30), - has_contract=request.form.get('has_contract') == 'on', - manager=request.form.get('manager'), - company_id=request.form.get('company_id', type=int) or None, - trigger_type=request.form.get('trigger_type', 'time'), - meter_type_required=request.form.get('meter_type_required'), + num_tache=request.form.get('task_number'), + tache=request.form.get('name'), + periodicite=request.form.get('periodicite'), + jours_entre_interventions=request.form.get('period_days', type=int), + duree_minutes=request.form.get('duration_minutes', type=int, default=30), + contrat=request.form.get('has_contract') == 'on', + gestionnaire=request.form.get('manager'), + trigger_type=request.form.get('trigger_type', 'calendar'), + trigger_event=request.form.get('trigger_event') or None, meter_threshold=request.form.get('meter_threshold', type=float), - requires_room_access=request.form.get('requires_room_access') == 'on', - allowed_time_slots=request.form.get('allowed_time_slots', 'always'), + season_start_month=request.form.get('season_start_month', type=int), + season_end_month=request.form.get('season_end_month', type=int), is_active=True ) db.session.add(task) @@ -318,5 +333,3 @@ def new_consumable(): title='Nouveau consommable', consumable=None, companies=companies) - - diff --git a/app_new/planning/templates/planning/edit_task.html b/app_new/planning/templates/planning/edit_task.html index fcc9982..c959377 100644 --- a/app_new/planning/templates/planning/edit_task.html +++ b/app_new/planning/templates/planning/edit_task.html @@ -52,6 +52,15 @@ Ou laissez vide si périodicité textuelle +
Déclenchement
+
+
+
+
+
+
+
+
@@ -129,4 +138,4 @@
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/app_new/planning/templates/planning/task_form.html b/app_new/planning/templates/planning/task_form.html index e107162..3ac3903 100644 --- a/app_new/planning/templates/planning/task_form.html +++ b/app_new/planning/templates/planning/task_form.html @@ -22,30 +22,19 @@ -
- - - Filtre par type d'équipement (ex: Fenêtres uniquement) -
-
- +
- +
- +
@@ -65,7 +54,7 @@
- + 365 = annuel, 30 = mensuel, 7 = hebdomadaire, 1 = quotidien
@@ -77,8 +66,7 @@
@@ -95,6 +83,7 @@ Déclencher la maintenance à cette valeur
+
@@ -179,4 +168,4 @@ document.querySelector('select[name="trigger_type"]').addEventListener('change', document.getElementById('meterOptions').style.display = this.value === 'meter' ? 'block' : 'none'; }); -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/app_new/planning/templates/planning/tasks.html b/app_new/planning/templates/planning/tasks.html index e41c81d..ccb5e49 100644 --- a/app_new/planning/templates/planning/tasks.html +++ b/app_new/planning/templates/planning/tasks.html @@ -115,6 +115,7 @@ +
{% else %} @@ -163,4 +164,4 @@ document.getElementById('filterCategory').addEventListener('change', filterTasks document.getElementById('filterPeriod').addEventListener('change', filterTasks); document.getElementById('filterContract').addEventListener('change', filterTasks); -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/app_new/scheduler/engine.py b/app_new/scheduler/engine.py index b33b17c..6a3e47d 100644 --- a/app_new/scheduler/engine.py +++ b/app_new/scheduler/engine.py @@ -11,7 +11,7 @@ Genere les taches planifiees en croisant : from datetime import datetime, timezone, date, time, timedelta from ..extensions import db from ..core.models.planning import ( - WorkSchedule, CollegeClosure, PersonalLeave, Training, + WorkSchedule, CollegeClosure, ClosureWorkDay, PersonalLeave, Training, PreventiveTask, PlanningDay, PlanningItem ) from ..core.models.maintenance import Intervention, LotTask, Lot @@ -32,6 +32,12 @@ def get_working_hours(check_date, user_id=1): ).first() if closure: + exceptional_day = ClosureWorkDay.query.filter_by( + closure_id=closure.id, work_date=check_date + ).first() + if exceptional_day: + return (exceptional_day.start_time, exceptional_day.end_time, + exceptional_day.lunch_start, exceptional_day.lunch_end) if closure.work_hours_type == 'none': return None # Ferme # Horaires reduits pendant les vacances @@ -132,7 +138,7 @@ def get_due_tasks(weeks_ahead=4): }) # 2. LotTask avec jours_entre_interventions - for lt in LotTask.query.all(): + for lt in LotTask.query.filter_by(is_active=True, trigger_type='calendar').all(): if not lt.jours_entre_interventions: continue lot = Lot.query.get(lt.lot_id) @@ -372,4 +378,4 @@ def run_scheduler(weeks_ahead=4, user_id=1): 'total_due': len(due_tasks), 'errors': errors, 'message': f"{planned} tache(s) planifiee(s), {skipped} non planifiee(s) sur {len(due_tasks)} dues" - } \ No newline at end of file + } diff --git a/app_new/templates/equipments/category_detail.html b/app_new/templates/equipments/category_detail.html index 1cf1be4..385920a 100644 --- a/app_new/templates/equipments/category_detail.html +++ b/app_new/templates/equipments/category_detail.html @@ -30,6 +30,7 @@ +
Alerte de pannes récurrentes
@@ -94,4 +95,4 @@
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/migrations/versions/e6f0a1b2c3d4_unify_maintenance_rules.py b/migrations/versions/e6f0a1b2c3d4_unify_maintenance_rules.py new file mode 100644 index 0000000..f7e36c2 --- /dev/null +++ b/migrations/versions/e6f0a1b2c3d4_unify_maintenance_rules.py @@ -0,0 +1,34 @@ +"""Unifie les règles de maintenance dans les tâches de lot. + +Revision ID: e6f0a1b2c3d4 +Revises: d5e9f0a1b2c3 +""" +from alembic import op +import sqlalchemy as sa + +revision = "e6f0a1b2c3d4" +down_revision = "d5e9f0a1b2c3" +branch_labels = None +depends_on = None + +def upgrade(): + for name, column in [ + ("trigger_type", sa.Column("trigger_type", sa.String(20), nullable=False, server_default="calendar")), + ("trigger_event", sa.Column("trigger_event", sa.String(80))), + ("meter_threshold", sa.Column("meter_threshold", sa.Float())), + ("season_start_month", sa.Column("season_start_month", sa.Integer())), + ("season_end_month", sa.Column("season_end_month", sa.Integer())), + ("advance_days", sa.Column("advance_days", sa.Integer(), nullable=False, server_default="0")), + ("is_active", sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true())), + ]: op.add_column("lot_tasks", column) + op.add_column("equipment_categories", sa.Column("recurrence_alert_enabled", sa.Boolean(), nullable=False, server_default=sa.false())) + op.add_column("equipment_categories", sa.Column("recurrence_threshold", sa.Integer(), nullable=False, server_default="3")) + op.add_column("equipment_categories", sa.Column("recurrence_window_days", sa.Integer(), nullable=False, server_default="365")) + op.add_column("contracts", sa.Column("visit_interval_days", sa.Integer())) + op.add_column("contracts", sa.Column("last_visit_date", sa.Date())) + op.add_column("contracts", sa.Column("next_visit_date", sa.Date())) + +def downgrade(): + for name in ("next_visit_date", "last_visit_date", "visit_interval_days"): op.drop_column("contracts", name) + for name in ("recurrence_window_days", "recurrence_threshold", "recurrence_alert_enabled"): op.drop_column("equipment_categories", name) + for name in ("is_active", "advance_days", "season_end_month", "season_start_month", "meter_threshold", "trigger_event", "trigger_type"): op.drop_column("lot_tasks", name) diff --git a/tests/integration/test_maintenance_engine.py b/tests/integration/test_maintenance_engine.py new file mode 100644 index 0000000..fb945ef --- /dev/null +++ b/tests/integration/test_maintenance_engine.py @@ -0,0 +1,38 @@ +from datetime import date, time, timedelta +from uuid import uuid4 + +from app_new.extensions import db +from app_new.core.models.college import Building, Room +from app_new.core.models.equipment import Equipment, EquipmentCategory +from app_new.core.models.maintenance import Lot, LotTask +from app_new.core.models.planning import CollegeClosure, ClosureWorkDay, ScheduledTask +from app_new.core.services.maintenance_engine import generate_due_tasks +from app_new.scheduler.engine import get_working_hours + + +def test_exceptional_vacation_day_has_its_specific_hours(app): + with app.app_context(): + worked = date.today() + timedelta(days=20) + closure = CollegeClosure(name=f"Vacances {uuid4().hex}", start_date=worked, end_date=worked, work_hours_type="none") + db.session.add(closure); db.session.flush() + db.session.add(ClosureWorkDay(closure_id=closure.id, work_date=worked, start_time=time(7, 30), end_time=time(14))) + db.session.commit() + assert get_working_hours(worked)[:2] == (time(7, 30), time(14)) + + +def test_lot_task_engine_uses_individual_units_and_prevents_duplicates(app): + with app.app_context(): + suffix = uuid4().hex[:7] + building = Building(name=f"B {suffix}"); db.session.add(building); db.session.flush() + room = Room(name="Local", building_id=building.id); category = EquipmentCategory(name=f"Cat {suffix}") + lot = Lot(name=f"Lot {suffix}", category=category); db.session.add_all([room, category, lot]); db.session.flush() + root = Equipment(name=f"Groupe {suffix}", lot=lot, category=category, is_group=True, tracked_individually=True) + db.session.add(root); db.session.flush() + unit = Equipment(name=f"Unité {suffix}", parent_id=root.id, room_id=room.id, tracked_individually=True) + task = LotTask(lot=lot, tache="Contrôle", trigger_type="event", trigger_event="gel", is_active=True) + db.session.add_all([unit, task]); db.session.commit() + assert generate_due_tasks(task, event="orage") == [] + assert len(generate_due_tasks(task, event="gel")) == 1 + assert generate_due_tasks(task, event="gel") == [] + scheduled = ScheduledTask.query.filter_by(lot_task_id=task.id).one() + assert scheduled.equipment_id == unit.id