From e15ecdf46e6980e288d4c47eead0afc5672f66b6 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 23 Aug 2026 14:52:22 +0000 Subject: [PATCH] =?UTF-8?q?feat(planning):=20pr=C3=A9parer=20patrimoine=20?= =?UTF-8?q?et=20t=C3=A2ches=20fractionnables?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app_new/contracts/routes.py | 9 ++- .../contracts/templates/contracts/detail.html | 11 +++ app_new/core/models/__init__.py | 4 +- app_new/core/models/maintenance.py | 51 +++++++++++-- app_new/core/models/planning.py | 44 ++++++++++++ app_new/core/services/day_planner.py | 3 +- app_new/core/services/maintenance_engine.py | 4 ++ app_new/core/services/task_execution.py | 71 +++++++++++++++++++ app_new/equipments/rooms.py | 32 +++++++++ .../templates/equipments/rooms_bulk.html | 5 ++ app_new/lots/routes.py | 6 ++ app_new/planning/schedules.py | 71 +++++++++++++++++++ app_new/templates/lots/task_form.html | 2 + app_new/templates/planning/my_day.html | 9 +++ docs/ui-audit/BUGS.md | 4 ++ docs/ui-audit/CREATED_TEST_DATA.md | 3 + docs/ui-audit/IMPLEMENTATION_PHASE21C.md | 26 +++++++ .../INTERRUPTIBLE_TASKS_ARCHITECTURE.md | 7 ++ docs/ui-audit/PAGES_MATRIX.md | 3 + .../PATRIMONY_PROFILE_ARCHITECTURE.md | 7 ++ docs/ui-audit/PROGRESS.md | 8 +++ docs/ui-audit/REPORT.md | 3 + .../TECHNICAL_MAPPING_ARCHITECTURE.md | 5 ++ docs/ui-audit/UX_PROPOSALS.md | 3 + .../j5e6f7a8b9c0_phase21c_execution.py | 46 ++++++++++++ 25 files changed, 428 insertions(+), 9 deletions(-) create mode 100644 app_new/core/services/task_execution.py create mode 100644 app_new/equipments/templates/equipments/rooms_bulk.html create mode 100644 docs/ui-audit/IMPLEMENTATION_PHASE21C.md create mode 100644 docs/ui-audit/INTERRUPTIBLE_TASKS_ARCHITECTURE.md create mode 100644 docs/ui-audit/PATRIMONY_PROFILE_ARCHITECTURE.md create mode 100644 docs/ui-audit/TECHNICAL_MAPPING_ARCHITECTURE.md create mode 100644 migrations/versions/j5e6f7a8b9c0_phase21c_execution.py diff --git a/app_new/contracts/routes.py b/app_new/contracts/routes.py index 54ed544..6ffc6c8 100644 --- a/app_new/contracts/routes.py +++ b/app_new/contracts/routes.py @@ -117,7 +117,14 @@ def create(): def detail(id): """Detail d'un contrat.""" contract = Contract.query.get_or_404(id) - return render_template('contracts/detail.html', contract=contract) + from app_new.core.models.maintenance import LotTask + from app_new.core.models.planning import ScheduledTask + covered_tasks = LotTask.query.filter_by(contract_id=contract.id).order_by(LotTask.tache).all() + covered_task_ids = [task.id for task in covered_tasks] + covered_occurrences = (ScheduledTask.query.filter(ScheduledTask.lot_task_id.in_(covered_task_ids)) + .order_by(ScheduledTask.scheduled_date.desc()).all()) if covered_task_ids else [] + return render_template('contracts/detail.html', contract=contract, + covered_tasks=covered_tasks, covered_occurrences=covered_occurrences) @contracts_bp.route('//edit', methods=['GET', 'POST']) diff --git a/app_new/contracts/templates/contracts/detail.html b/app_new/contracts/templates/contracts/detail.html index c328617..7684833 100644 --- a/app_new/contracts/templates/contracts/detail.html +++ b/app_new/contracts/templates/contracts/detail.html @@ -53,6 +53,17 @@ +
+
+
Maintenance couverte par ce contrat
+ {% if covered_tasks %} +
+ {% for task in covered_tasks %}{% endfor %} +
Tâche / lotPériodicitéÉquipements / périmètreÉchéances
{{ task.tache or 'Tâche sans nom' }}
{{ task.lot.name if task.lot else 'Lot non défini' }}
{{ task.periodicite or (task.jours_entre_interventions|string + ' jours' if task.jours_entre_interventions else 'À définir') }}{% if task.scope_mode == 'manual' %}Sélection manuelle ({{ task.equipment_scope|length }}){% else %}Équipements actifs du lot{% endif %}{% set occurrences = covered_occurrences|selectattr('lot_task_id','equalto',task.id)|list %}{{ occurrences|length }} occurrence(s){% if occurrences %}
Dernière : {{ occurrences[0].scheduled_date.strftime('%d/%m/%Y') if occurrences[0].scheduled_date else '—' }}
{% endif %}
+ {% else %}

Aucune tâche préventive n'est encore rattachée à ce contrat.

{% endif %} +
+
+
Visites du prestataire
diff --git a/app_new/core/models/__init__.py b/app_new/core/models/__init__.py index 7396c75..f9fa507 100644 --- a/app_new/core/models/__init__.py +++ b/app_new/core/models/__init__.py @@ -13,7 +13,7 @@ from .company import Company, Part, PartStockMovement, Alert, Service from .planning import ( WorkSchedule, WorkScheduleTemplate, AnnualTimeConfig, TimeEntry, CollegeClosure, ClosureSchedule, ClosureWorkDay, PersonalLeave, Training, TrainingParticipant, Meter, MeterReading, Consumable, ConsumableUsage, EquipmentConsumable, - PreventiveTask, PreventiveTaskConsumable, ScheduledTask, + PreventiveTask, PreventiveTaskConsumable, ScheduledTask, TaskExecutionSegment, TechnicianAvailability, AdminTask, ZoneAccessRule ) from .settings import AppSettings @@ -37,7 +37,7 @@ __all__ = [ 'Company', 'Part', 'PartStockMovement', 'InterventionPart', 'Alert', 'Service', 'WorkSchedule', 'WorkScheduleTemplate', 'AnnualTimeConfig', 'TimeEntry', 'CollegeClosure', 'ClosureSchedule', 'ClosureWorkDay', 'PersonalLeave', 'Training', 'TrainingParticipant', 'Meter', 'MeterReading', 'Consumable', 'ConsumableUsage', 'EquipmentConsumable', - 'PreventiveTask', 'PreventiveTaskConsumable', 'ScheduledTask', + 'PreventiveTask', 'PreventiveTaskConsumable', 'ScheduledTask', 'TaskExecutionSegment', 'TechnicianAvailability', 'AdminTask', 'ZoneAccessRule', 'AppSettings', 'AuditLog', 'TemplateAuditMark', 'PreventionWorkLog', 'StaffAuthorization', 'RiskAssessment', 'PreventionAction', 'SafetyRegisterEntry', diff --git a/app_new/core/models/maintenance.py b/app_new/core/models/maintenance.py index 8ba40dd..72ee12c 100644 --- a/app_new/core/models/maintenance.py +++ b/app_new/core/models/maintenance.py @@ -459,6 +459,13 @@ class LotTask(db.Model): 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) + # Phase 2.1c : nature métier et exécution fractionnable. + task_nature = db.Column(db.String(30), nullable=False, default="unknown") + interruptible = db.Column(db.Boolean, nullable=False, default=False) + splittable = db.Column(db.Boolean, nullable=False, default=False) + minimum_segment_minutes = db.Column(db.Integer, nullable=True) + allow_next_day_resume = db.Column(db.Boolean, nullable=False, default=False) + requires_safe_pause = db.Column(db.Boolean, nullable=False, default=False) company = db.relationship("Company") contract = db.relationship("Contract") equipment_scope = db.relationship( @@ -479,16 +486,50 @@ class LotTask(db.Model): 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] + # Résolution par niveaux : un équipement est rattaché directement au + # lot ou hérite du lot d'un parent. On évite ainsi le N+1 de + # ``effective_lot_id`` (qui chargeait chaque parent en Python). + result = [] + frontier = [self.lot_id] + while frontier: + query = Equipment.query.filter( + Equipment.is_deleted.is_(False), Equipment.lot_id.in_(frontier) + ) + batch = query.all() + result.extend(batch) + parent_ids = [item.id for item in batch] + if not parent_ids: + break + # Les enfants sans lot héritent du lot de leur parent. Les enfants + # ayant déjà un lot propre sont volontairement exclus de la chaîne. + inherited = Equipment.query.filter( + Equipment.is_deleted.is_(False), + Equipment.parent_id.in_(parent_ids), + Equipment.lot_id.is_(None), + ).all() + result.extend(inherited) + frontier = [item.id for item in inherited] + return result 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 == "zone": + room = getattr(equipment, "room", None) + zone = getattr(room, "zone", None) if room else None + if not zone: + return 0 + label = (self.tache or "").lower() + if "tonte" in label: + return int(zone.mowing_minutes or 0) + if "bordure" in label: + return int(zone.edging_minutes or 0) + if "haie" in label: + return int(zone.hedge_minutes or 0) + if any(word in label for word in ("ramassage", "évacuation", "branches")): + return int(zone.branch_removal_minutes or 0) + return 0 if self.duration_mode != "unit": return self.duree_minutes quantity = max(int(getattr(equipment, "quantity", 1) or 1), 1) diff --git a/app_new/core/models/planning.py b/app_new/core/models/planning.py index c644a31..af3c90d 100644 --- a/app_new/core/models/planning.py +++ b/app_new/core/models/planning.py @@ -514,6 +514,10 @@ class ScheduledTask(db.Model): company = db.relationship("Company") contract = db.relationship("Contract") intervention = db.relationship("Intervention") + execution_segments = db.relationship( + "TaskExecutionSegment", back_populates="scheduled_task", + cascade="all, delete-orphan", order_by="TaskExecutionSegment.started_at", + ) @property def is_overdue(self): @@ -523,6 +527,16 @@ class ScheduledTask(db.Model): def can_be_ignored(self): return self.status == 'planned' + + @property + def realized_minutes(self): + """Temps réellement saisi dans les segments d'exécution.""" + return sum(int(segment.duration_minutes or 0) for segment in self.execution_segments) + + @property + def remaining_minutes(self): + estimated = int(self.estimated_duration or 0) + return max(estimated - self.realized_minutes, 0) if estimated else 0 def ignore(self, reason=''): """Marque la tache comme ignoree.""" @@ -723,3 +737,33 @@ class PlanningItem(db.Model): def __repr__(self): return f"" + + +class TaskExecutionSegment(db.Model): + """Un segment de réalisation d'une tâche planifiée. + + Plusieurs segments peuvent appartenir à la même obligation. Cela permet + de mettre en pause une tonte ou un contrôle sans perdre le temps déjà + réalisé et sans créer une fausse tâche indépendante. + """ + __tablename__ = "task_execution_segments" + + id = db.Column(db.Integer, primary_key=True) + scheduled_task_id = db.Column(db.Integer, db.ForeignKey("scheduled_tasks.id", ondelete="CASCADE"), nullable=False, index=True) + technician_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + started_at = db.Column(db.DateTime, nullable=False) + ended_at = db.Column(db.DateTime, nullable=True) + duration_minutes = db.Column(db.Integer, nullable=False, default=0) + status = db.Column(db.String(20), nullable=False, default="active") + pause_reason = db.Column(db.String(80), nullable=True) + note = db.Column(db.Text, nullable=True) + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), nullable=False) + + scheduled_task = db.relationship("ScheduledTask", back_populates="execution_segments") + technician = db.relationship("User") + + def close(self, ended_at, reason=None): + self.ended_at = ended_at + self.status = "paused" if reason else "completed" + self.pause_reason = reason + self.duration_minutes = max(int((ended_at - self.started_at).total_seconds() // 60), 0) diff --git a/app_new/core/services/day_planner.py b/app_new/core/services/day_planner.py index e486bdc..db9c426 100644 --- a/app_new/core/services/day_planner.py +++ b/app_new/core/services/day_planner.py @@ -296,7 +296,8 @@ class DayPlanner: title=title, task_type=task_type, priority=2 if task.lot_task_id else 4, constraint="fixed" if task.scheduled_start and task.scheduled_end else "flexible", - target_date=day, duration_minutes=int(task.estimated_duration or 30), + target_date=day, + duration_minutes=(task.remaining_minutes if task.execution_segments else int(task.estimated_duration or 0)), room_id=task.room_id or (room.id if room else None), 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/maintenance_engine.py b/app_new/core/services/maintenance_engine.py index 42d1ef8..33e3d42 100644 --- a/app_new/core/services/maintenance_engine.py +++ b/app_new/core/services/maintenance_engine.py @@ -99,6 +99,10 @@ def generate_due_tasks(task: LotTask, event=None, today=None, equipment_ids=None today = today or date.today() if not task.is_active: return [] + # Une action conditionnelle/curative est déclenchée par un constat ou une + # intervention, jamais par une échéance calendaire silencieuse. + if getattr(task, "task_nature", "unknown") == "conditional_curative" and event is None: + return [] if task.trigger_type in ("event", "weather") and task.trigger_event != event: return [] if task.trigger_type == "season": diff --git a/app_new/core/services/task_execution.py b/app_new/core/services/task_execution.py new file mode 100644 index 0000000..7075879 --- /dev/null +++ b/app_new/core/services/task_execution.py @@ -0,0 +1,71 @@ +"""Règles minimales d'exécution des tâches interrompables/fractionnables.""" +from datetime import datetime, timezone + +from ...extensions import db +from ..models.planning import ScheduledTask, TaskExecutionSegment + + +PAUSE_REASONS = { + "eleves": "Présence d'élèves", + "urgence": "Urgence", + "local": "Local devenu occupé", + "materiel": "Attente de matériel", + "entreprise": "Entreprise/personne indisponible", + "meteo": "Météo", + "securite": "Sécurité", + "autre": "Autre", +} + + +def _now(): + return datetime.now(timezone.utc) + + +def start_task(task, technician_id=None, now=None): + if not task or task.status in ("completed", "terminée"): + raise ValueError("Cette tâche est déjà terminée ou introuvable.") + if task.execution_segments and task.execution_segments[-1].status == "active": + raise ValueError("Cette tâche est déjà en cours.") + if task.lot_task and task.lot_task.minimum_segment_minutes and task.remaining_minutes < task.lot_task.minimum_segment_minutes: + raise ValueError("Le temps restant est inférieur au segment minimum.") + segment = TaskExecutionSegment( + scheduled_task=task, technician_id=technician_id, + started_at=now or _now(), status="active", + ) + task.status = "in_progress" + db.session.add(segment) + return segment + + +def pause_task(task, reason="autre", now=None, note=None): + if not task or not task.execution_segments or task.execution_segments[-1].status != "active": + raise ValueError("Aucun segment actif à mettre en pause.") + if task.lot_task and not task.lot_task.interruptible: + raise ValueError("Cette tâche ne peut pas être interrompue.") + if reason not in PAUSE_REASONS: + raise ValueError("Motif de pause inconnu.") + segment = task.execution_segments[-1] + segment.close(now or _now(), reason=reason) + segment.note = note + task.status = "paused" + return segment + + +def resume_task(task, technician_id=None, now=None): + if not task or not task.lot_task or not task.lot_task.interruptible: + raise ValueError("Cette tâche ne peut pas être reprise après interruption.") + if task.remaining_minutes <= 0: + task.status = "completed" + return None + return start_task(task, technician_id=technician_id, now=now) + + +def complete_task(task, now=None): + if not task or not task.execution_segments or task.execution_segments[-1].status != "active": + raise ValueError("Aucun segment actif à terminer.") + segment = task.execution_segments[-1] + segment.close(now or _now()) + task.actual_duration = task.realized_minutes + task.completed_at = now or _now() + task.status = "completed" + return segment diff --git a/app_new/equipments/rooms.py b/app_new/equipments/rooms.py index 17c4000..df8eebe 100644 --- a/app_new/equipments/rooms.py +++ b/app_new/equipments/rooms.py @@ -146,6 +146,38 @@ def create(): return render_template('equipments/room_form.html', **context) +@rooms_bp.route('/bulk', methods=['GET', 'POST']) +@login_required +def bulk_create(): + """Création rapide de locaux similaires, sans imposer d'équipement.""" + context = _room_form_context() + if request.method == 'POST': + building_id = request.form.get('building_id', type=int) + zone_id = request.form.get('zone_id', type=int) + building = db.session.get(Building, building_id) if building_id else None + zone = db.session.get(Zone, zone_id) if zone_id else None + names = [line.strip() for line in (request.form.get('names') or '').splitlines() if line.strip()] + if not building or (zone and zone.building_id != building.id) or not names: + flash('Choisissez un bâtiment valide et indiquez au moins un local.', 'danger') + return render_template('equipments/rooms_bulk.html', **context), 400 + room_type_id = request.form.get('room_type_id', type=int) + created = [] + existing = {room.name for room in Room.query.filter_by(building_id=building.id).all()} + for name in names: + if name in existing: + continue + created.append(Room(name=name, code=name, building_id=building.id, + zone_id=zone.id if zone else None, + room_type_id=room_type_id, + floor=request.form.get('floor', 0, type=int))) + existing.add(name) + db.session.add_all(created) + db.session.commit() + flash(f'{len(created)} local(aux) créé(s). Aucun équipement n’a été ajouté automatiquement.', 'success') + return redirect(url_for('rooms.index')) + return render_template('equipments/rooms_bulk.html', **context) + + @rooms_bp.route('//edit', methods=['GET', 'POST']) @login_required def edit(id): diff --git a/app_new/equipments/templates/equipments/rooms_bulk.html b/app_new/equipments/templates/equipments/rooms_bulk.html new file mode 100644 index 0000000..a2de07b --- /dev/null +++ b/app_new/equipments/templates/equipments/rooms_bulk.html @@ -0,0 +1,5 @@ +{% extends "base.html" %} +{% block title %}Créer plusieurs locaux{% endblock %} +{% block content %}

Créer plusieurs locaux

Un nom par ligne. Le profil de local reste modifiable ensuite ; aucun équipement n'est imposé.

{% endblock %} diff --git a/app_new/lots/routes.py b/app_new/lots/routes.py index fb333ea..3384e4a 100644 --- a/app_new/lots/routes.py +++ b/app_new/lots/routes.py @@ -98,6 +98,12 @@ def _apply_task_form(task): 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') + task.task_nature = request.form.get('task_nature') or 'unknown' + task.interruptible = request.form.get('interruptible') == 'on' + task.splittable = request.form.get('splittable') == 'on' + task.minimum_segment_minutes = request.form.get('minimum_segment_minutes', type=int) + task.allow_next_day_resume = request.form.get('allow_next_day_resume') == 'on' + task.requires_safe_pause = request.form.get('requires_safe_pause') == 'on' 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 [] diff --git a/app_new/planning/schedules.py b/app_new/planning/schedules.py index 3cdfee4..1cc8425 100644 --- a/app_new/planning/schedules.py +++ b/app_new/planning/schedules.py @@ -39,6 +39,13 @@ def my_day(): for item in candidates if item.status in ('à replanifier', 'proposé', 'conflit') } + execution_states = {} + for task in ScheduledTask.query.filter(ScheduledTask.scheduled_date == target_date).all(): + execution_states[task.id] = { + 'status': task.status, + 'realized': task.realized_minutes, + 'remaining': task.remaining_minutes, + } return render_template( 'planning/my_day.html', target_date=target_date, @@ -48,6 +55,7 @@ def my_day(): next_date=target_date + timedelta(days=1), hours_configured=bool(windows), alternatives=alternatives, + execution_states=execution_states, ) @@ -115,6 +123,69 @@ def my_day_reschedule(): return redirect(url_for('planning.my_day', date=target_date.isoformat())) +def _execution_redirect(task): + return redirect(url_for('planning.my_day', date=(task.scheduled_date or date.today()).isoformat())) + + +@planning_bp.route('/scheduled//start', methods=['POST']) +@login_required +def scheduled_start(task_id): + task = ScheduledTask.query.get_or_404(task_id) + if task.assigned_to_id not in (None, current_user.id): + flash('Cette tâche ne vous est pas attribuée.', 'danger') + return _execution_redirect(task) + from ..core.services.task_execution import start_task + try: + start_task(task, technician_id=current_user.id) + db.session.commit() + flash('Tâche démarrée.', 'success') + except ValueError as exc: + flash(str(exc), 'warning') + return _execution_redirect(task) + + +@planning_bp.route('/scheduled//pause', methods=['POST']) +@login_required +def scheduled_pause(task_id): + task = ScheduledTask.query.get_or_404(task_id) + from ..core.services.task_execution import pause_task + try: + pause_task(task, reason=request.form.get('reason', 'autre'), note=request.form.get('note')) + db.session.commit() + flash('Tâche mise en pause ; le temps réalisé est conservé.', 'success') + except ValueError as exc: + flash(str(exc), 'warning') + return _execution_redirect(task) + + +@planning_bp.route('/scheduled//resume', methods=['POST']) +@login_required +def scheduled_resume(task_id): + task = ScheduledTask.query.get_or_404(task_id) + from ..core.services.task_execution import resume_task + try: + resume_task(task, technician_id=current_user.id) + db.session.commit() + flash('Tâche reprise.', 'success') + except ValueError as exc: + flash(str(exc), 'warning') + return _execution_redirect(task) + + +@planning_bp.route('/scheduled//complete', methods=['POST']) +@login_required +def scheduled_complete(task_id): + task = ScheduledTask.query.get_or_404(task_id) + from ..core.services.task_execution import complete_task + try: + complete_task(task) + db.session.commit() + flash('Tâche terminée.', 'success') + except ValueError as exc: + flash(str(exc), 'warning') + return _execution_redirect(task) + + @planning_bp.route('/rooms') @login_required def room_schedules(): diff --git a/app_new/templates/lots/task_form.html b/app_new/templates/lots/task_form.html index a7281a1..5c64008 100644 --- a/app_new/templates/lots/task_form.html +++ b/app_new/templates/lots/task_form.html @@ -27,6 +27,8 @@
+
+
Exécution interrompable
diff --git a/app_new/templates/planning/my_day.html b/app_new/templates/planning/my_day.html index e1756e0..ada3a12 100644 --- a/app_new/templates/planning/my_day.html +++ b/app_new/templates/planning/my_day.html @@ -52,6 +52,15 @@
{% if item.explanation %}

{{ item.explanation }}

{% endif %} + {% if item.source_type == 'scheduled_task' and execution_states.get(item.source_id) %} + {% set execution = execution_states.get(item.source_id) %} +
Temps réalisé : {{ execution.realized }} min · restant : {{ execution.remaining }} min
+
+ {% if execution.status in ('planned', 'suspended', 'postponed') %}
{% endif %} + {% if execution.status == 'in_progress' %}
{% endif %} + {% if execution.status == 'paused' %}
{% endif %} +
+ {% endif %} {% set item_alternatives = alternatives.get((item.source_type, item.source_id), []) %} {% if item_alternatives and item.status != 'conflit' %}
diff --git a/docs/ui-audit/BUGS.md b/docs/ui-audit/BUGS.md index 83d9921..fb4fdb9 100644 --- a/docs/ui-audit/BUGS.md +++ b/docs/ui-audit/BUGS.md @@ -571,3 +571,7 @@ d'intégration n'a été modifié. Nouveau total : 76/76. doit pas être comblé par une valeur arbitraire. - La sélection en masse filtrée des équipements et le raccord automatique des durées espaces verts aux tâches de lot restent à faire. +### Phase 2.1c + +- La configuration des profils d'équipements et leur proposition en masse reste progressive : la création de locaux ne crée volontairement aucun équipement sans validation. +- Le moteur de fractionnement propose encore des segments au niveau de l'exécution ; l'algorithme de placement multi-segments est documenté mais non généralisé à toutes les tâches. diff --git a/docs/ui-audit/CREATED_TEST_DATA.md b/docs/ui-audit/CREATED_TEST_DATA.md index 72c9c23..871a227 100644 --- a/docs/ui-audit/CREATED_TEST_DATA.md +++ b/docs/ui-audit/CREATED_TEST_DATA.md @@ -177,3 +177,6 @@ dans ce dépôt. Aucune donnée `TEST_UI_PHASE21B_*` n'a été créée dans cette passe : les scénarios entreprise doivent être rejoués depuis l'interface avant validation. +### Phase 2.1c + +Le scénario `TEST_UI_PHASE21C_*` est prévu pour la validation locale : locaux standards, sanitaires avec flash SSI, salle musique, couloir/PCF, espace d'attente sécurisé, VDI, VMC, local électrique et espaces verts. Aucune donnée n'est supprimée par cette passe ; la validation UI de création en masse doit être rejouée par un administrateur de test. diff --git a/docs/ui-audit/IMPLEMENTATION_PHASE21C.md b/docs/ui-audit/IMPLEMENTATION_PHASE21C.md new file mode 100644 index 0000000..5fe8c4a --- /dev/null +++ b/docs/ui-audit/IMPLEMENTATION_PHASE21C.md @@ -0,0 +1,26 @@ +# Implémentation Phase 2.1c — patrimoine et exécution progressive + +## Baseline + +- Version : 0.1.0-dev.11 +- Migration de départ : i4d5e6f7a8b9 +- Phase 2.1b : partiellement validée ; suite pytest connue : 159 pass, 1 échec et 1 erreur historiques (lookback/configuration GMAO). +- Les intégrations externes et l'ancien menu (76/76, administrateurs) sont hors modification. + +## Clôture technique 2.1b + +Le périmètre dynamique ne résout plus `effective_lot_id` équipement par équipement : il parcourt les rattachements directs et les descendants par niveaux SQL. Les groupes dont le lot est hérité restent pris en compte. Le scénario de charge 100 équipements doit être mesuré sur l'installation locale ; la résolution est désormais bornée par le nombre de niveaux de parenté et non par le nombre d'équipements. + +Les tâches en mode `zone` utilisent les durées explicites de la zone (tonte, bordures, haies, branches). Une valeur absente donne 0 et produit `needs_duration`, sans durée générique. + +La fiche contrat expose maintenant les LotTask couvertes, leurs périmètres et leurs occurrences planifiées, en complément de ContractVisit. + +## Patrimoine progressif + +`RoomType` est conservé comme profil de local extensible. La route `/equipments/rooms/bulk` permet de créer plusieurs locaux à partir d'une liste, avec bâtiment, zone, étage et profil facultatif. Aucun équipement n'est créé implicitement : l'administrateur valide ensuite les équipements proposés. La cartographie technique avancée reste facultative. + +## Tâches interrompables + +LotTask porte désormais la nature métier (`preventive`, `conditional_curative`, `regulatory`, `contractual`, `unknown`) séparément de l'exécutant, ainsi que les indicateurs d'interruption/fractionnement et le segment minimum. Les `TaskExecutionSegment` conservent le temps réalisé, les pauses et leurs motifs. Une tâche conditionnelle n'est pas générée périodiquement par le moteur. + +La migration est non destructive et conserve les valeurs par défaut sûres. Les actions d'exécution complète et l'édition avancée des profils d'équipements restent à finaliser après validation UI. diff --git a/docs/ui-audit/INTERRUPTIBLE_TASKS_ARCHITECTURE.md b/docs/ui-audit/INTERRUPTIBLE_TASKS_ARCHITECTURE.md new file mode 100644 index 0000000..420ba05 --- /dev/null +++ b/docs/ui-audit/INTERRUPTIBLE_TASKS_ARCHITECTURE.md @@ -0,0 +1,7 @@ +# Tâches interrompables et fractionnables + +LotTask distingue `interruptible`, `splittable`, `minimum_segment_minutes`, `allow_next_day_resume` et `requires_safe_pause`. Une obligation ScheduledTask possède plusieurs `TaskExecutionSegment` au lieu d'un seul compteur opaque. + +Le temps réalisé est la somme des segments fermés ; le temps restant est la durée estimée moins cette somme. Une pause conserve son motif (élèves, urgence, sécurité, météo, etc.) et ne termine pas la tâche. La réalisation complète passe seulement à `completed` lorsque le dernier segment est terminé. + +Le moteur de proposition doit chercher un bloc continu pour une tâche non fractionnable. Pour une tâche fractionnable, il pourra proposer plusieurs segments compatibles ; la validation reste humaine. Les passages entreprise fixes restent des contraintes indépendantes. diff --git a/docs/ui-audit/PAGES_MATRIX.md b/docs/ui-audit/PAGES_MATRIX.md index 0549de4..1bd1341 100644 --- a/docs/ui-audit/PAGES_MATRIX.md +++ b/docs/ui-audit/PAGES_MATRIX.md @@ -150,3 +150,6 @@ navigateur instrumenté sera disponible. | `/lots//tasks/new` | durée et périodicité d'une action préventive | route existante | | assistant équipement | créer un équipement et choisir/créer son lot | route UI existante | | `/planning/my-day` | vérifier les prérequis et la journée | aide horaires/planning ajoutée | +| `/equipments/rooms/bulk` | Créer plusieurs locaux similaires | Utilisateur connecté | Implémenté Phase 2.1c | +| `/contracts/` | Maintenance couverte par contrat | Utilisateur connecté | Enrichi Phase 2.1c | +| `/planning/scheduled//{start,pause,resume,complete}` | Exécuter une tâche par segments | Utilisateur connecté | Implémenté Phase 2.1c | diff --git a/docs/ui-audit/PATRIMONY_PROFILE_ARCHITECTURE.md b/docs/ui-audit/PATRIMONY_PROFILE_ARCHITECTURE.md new file mode 100644 index 0000000..22ffd2b --- /dev/null +++ b/docs/ui-audit/PATRIMONY_PROFILE_ARCHITECTURE.md @@ -0,0 +1,7 @@ +# Architecture patrimoine et profils de locaux + +`Building → Zone → Room (Local / emplacement) → Equipment` reste la chaîne de base. RoomType sert de profil métier extensible : salle de classe, circulation, sanitaires, local technique, espaces verts, etc. Un profil est une proposition ; il ne crée pas automatiquement un inventaire sans validation. + +La création en masse `/equipments/rooms/bulk` couvre les locaux similaires et évite la saisie répétitive. Les ouvrages (murs, sol, plafond) et les quantités d'équipements pourront être enrichis progressivement sans rendre la cartographie obligatoire. + +Les équipements groupés conservent leur quantité et leur mode de gestion. Les associations aux lots restent modifiables depuis les workflows existants. diff --git a/docs/ui-audit/PROGRESS.md b/docs/ui-audit/PROGRESS.md index e2fba65..19659cc 100644 --- a/docs/ui-audit/PROGRESS.md +++ b/docs/ui-audit/PROGRESS.md @@ -515,3 +515,11 @@ aux administrateurs. non-venue, date réelle et accompagnement ; page `Passages & contrôles`. - Phase partiellement validée : sélection en masse avancée et scénario complet UI restent à compléter. +### Phase 2.1c — patrimoine et exécution progressive (en cours) + +- migration `j5e6f7a8b9c0` appliquée localement ; +- résolution du périmètre dynamique par niveaux SQL ; +- durées espaces verts raccordées au calcul d'une cible ; +- fiche contrat enrichie avec la maintenance couverte ; +- création en masse de locaux disponible via `/equipments/rooms/bulk` ; +- nature métier et segments d'exécution ajoutés ; validation UI complète encore à rejouer. diff --git a/docs/ui-audit/REPORT.md b/docs/ui-audit/REPORT.md index 5e2bbca..51c6423 100644 --- a/docs/ui-audit/REPORT.md +++ b/docs/ui-audit/REPORT.md @@ -384,3 +384,6 @@ modifiables. Les passages entreprise disposent de trois modes et d'un suivi réalisé/non-venue. La phase reste **PARTIELLEMENT VALIDÉE** : la sélection en masse et le scénario de bout en bout n'ont pas encore été rejoués entièrement depuis l'interface. +### Phase 2.1c (état intermédiaire) + +La clôture technique ajoute la migration `j5e6f7a8b9c0` : nature de tâche, propriétés interruptible/fractionnable et segments d'exécution. La création en masse de locaux et l'affichage de la maintenance couverte par contrat sont disponibles. La validation visuelle et le scénario UI complet restent à effectuer avant toute qualification définitive. diff --git a/docs/ui-audit/TECHNICAL_MAPPING_ARCHITECTURE.md b/docs/ui-audit/TECHNICAL_MAPPING_ARCHITECTURE.md new file mode 100644 index 0000000..e975c5d --- /dev/null +++ b/docs/ui-audit/TECHNICAL_MAPPING_ARCHITECTURE.md @@ -0,0 +1,5 @@ +# Préparation de la cartographie technique + +La Phase 2.1c ne construit pas la cartographie complète. Le patrimoine garde toutefois un point d'extension pour un graphe de relations génériques : `alimente`, `protège`, `commande`, `dessert`, `connecté à`, `report de`, `asservi à`. + +Cette future relation pourra relier un TGBT à un tableau puis à un sous-tableau, ainsi que les réseaux eau, gaz, chauffage, CVC, SSI et informatique. Les relations partielles et les points de coupure inconnus devront être signalés comme aide, jamais bloquer l'utilisation de Ma journée. diff --git a/docs/ui-audit/UX_PROPOSALS.md b/docs/ui-audit/UX_PROPOSALS.md index 39bd3eb..0f48e04 100644 --- a/docs/ui-audit/UX_PROPOSALS.md +++ b/docs/ui-audit/UX_PROPOSALS.md @@ -378,3 +378,6 @@ de bord. La timeline affiche heure, durée, localisation, type, contrainte, statut et une explication en français. Une alerte indique explicitement quand les horaires du technicien ne sont pas configurés ; aucune plage par défaut n'est inventée. +### Phase 2.1c + +Présenter les locaux comme « Local / emplacement », proposer un profil sans imposer les équipements, et privilégier une création multi-locaux. Sur Ma journée, afficher le temps réalisé/restant et les actions Démarrer, Mettre en pause, Reprendre, Terminer uniquement lorsqu'elles sont supportées. diff --git a/migrations/versions/j5e6f7a8b9c0_phase21c_execution.py b/migrations/versions/j5e6f7a8b9c0_phase21c_execution.py new file mode 100644 index 0000000..f1f7248 --- /dev/null +++ b/migrations/versions/j5e6f7a8b9c0_phase21c_execution.py @@ -0,0 +1,46 @@ +"""Phase 2.1c: nature métier et segments d'exécution.""" +from alembic import op +import sqlalchemy as sa + +revision = "j5e6f7a8b9c0" +down_revision = "i4d5e6f7a8b9" +branch_labels = None +depends_on = None + + +def upgrade(): + columns = [ + sa.Column("task_nature", sa.String(30), nullable=False, server_default="unknown"), + sa.Column("interruptible", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("splittable", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("minimum_segment_minutes", sa.Integer(), nullable=True), + sa.Column("allow_next_day_resume", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("requires_safe_pause", sa.Boolean(), nullable=False, server_default=sa.false()), + ] + for column in columns: + op.add_column("lot_tasks", column) + op.create_table( + "task_execution_segments", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("scheduled_task_id", sa.Integer(), nullable=False), + sa.Column("technician_id", sa.Integer(), nullable=True), + sa.Column("started_at", sa.DateTime(), nullable=False), + sa.Column("ended_at", sa.DateTime(), nullable=True), + sa.Column("duration_minutes", sa.Integer(), nullable=False, server_default="0"), + sa.Column("status", sa.String(20), nullable=False, server_default="active"), + sa.Column("pause_reason", sa.String(80), nullable=True), + sa.Column("note", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["scheduled_task_id"], ["scheduled_tasks.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["technician_id"], ["users.id"], ondelete="SET NULL"), + ) + op.create_index("ix_task_execution_segments_scheduled_task_id", "task_execution_segments", ["scheduled_task_id"]) + op.create_index("ix_task_execution_segments_technician_id", "task_execution_segments", ["technician_id"]) + + +def downgrade(): + op.drop_index("ix_task_execution_segments_technician_id", table_name="task_execution_segments") + op.drop_index("ix_task_execution_segments_scheduled_task_id", table_name="task_execution_segments") + op.drop_table("task_execution_segments") + for name in ("requires_safe_pause", "allow_next_day_resume", "minimum_segment_minutes", "splittable", "interruptible", "task_nature"): + op.drop_column("lot_tasks", name)