feat(planning): préparer patrimoine et tâches fractionnables

This commit is contained in:
root 2026-08-23 14:52:22 +00:00
parent 7378991f2e
commit e15ecdf46e
25 changed files with 428 additions and 9 deletions

View file

@ -117,7 +117,14 @@ def create():
def detail(id): def detail(id):
"""Detail d'un contrat.""" """Detail d'un contrat."""
contract = Contract.query.get_or_404(id) 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('/<int:id>/edit', methods=['GET', 'POST']) @contracts_bp.route('/<int:id>/edit', methods=['GET', 'POST'])

View file

@ -53,6 +53,17 @@
</div> </div>
</div> </div>
<div class="card border-0 shadow-sm mt-3">
<div class="card-body">
<h6><i class="bi bi-shield-check text-success"></i> Maintenance couverte par ce contrat</h6>
{% if covered_tasks %}
<div class="table-responsive"><table class="table table-sm align-middle mb-0"><thead><tr><th>Tâche / lot</th><th>Périodicité</th><th>Équipements / périmètre</th><th>Échéances</th></tr></thead><tbody>
{% for task in covered_tasks %}<tr><td><strong>{{ task.tache or 'Tâche sans nom' }}</strong><div class="small text-muted">{{ task.lot.name if task.lot else 'Lot non défini' }}</div></td><td>{{ task.periodicite or (task.jours_entre_interventions|string + ' jours' if task.jours_entre_interventions else 'À définir') }}</td><td>{% if task.scope_mode == 'manual' %}Sélection manuelle ({{ task.equipment_scope|length }}){% else %}Équipements actifs du lot{% endif %}</td><td>{% set occurrences = covered_occurrences|selectattr('lot_task_id','equalto',task.id)|list %}{{ occurrences|length }} occurrence(s){% if occurrences %}<div class="small text-muted">Dernière : {{ occurrences[0].scheduled_date.strftime('%d/%m/%Y') if occurrences[0].scheduled_date else '—' }}</div>{% endif %}</td></tr>{% endfor %}
</tbody></table></div>
{% else %}<p class="text-muted mb-0">Aucune tâche préventive n'est encore rattachée à ce contrat.</p>{% endif %}
</div>
</div>
<div class="card border-0 shadow-sm mt-3"> <div class="card border-0 shadow-sm mt-3">
<div class="card-header d-flex justify-content-between"><span><i class="bi bi-calendar-check"></i> Visites du prestataire</span> <div class="card-header d-flex justify-content-between"><span><i class="bi bi-calendar-check"></i> Visites du prestataire</span>
<form method="post" action="{{ url_for('contracts.generate_visits', id=contract.id) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="btn btn-sm btn-outline-primary">Générer sur 12 mois</button></form> <form method="post" action="{{ url_for('contracts.generate_visits', id=contract.id) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="btn btn-sm btn-outline-primary">Générer sur 12 mois</button></form>

View file

@ -13,7 +13,7 @@ from .company import Company, Part, PartStockMovement, Alert, Service
from .planning import ( from .planning import (
WorkSchedule, WorkScheduleTemplate, AnnualTimeConfig, TimeEntry, CollegeClosure, ClosureSchedule, ClosureWorkDay, PersonalLeave, Training, TrainingParticipant, WorkSchedule, WorkScheduleTemplate, AnnualTimeConfig, TimeEntry, CollegeClosure, ClosureSchedule, ClosureWorkDay, PersonalLeave, Training, TrainingParticipant,
Meter, MeterReading, Consumable, ConsumableUsage, EquipmentConsumable, Meter, MeterReading, Consumable, ConsumableUsage, EquipmentConsumable,
PreventiveTask, PreventiveTaskConsumable, ScheduledTask, PreventiveTask, PreventiveTaskConsumable, ScheduledTask, TaskExecutionSegment,
TechnicianAvailability, AdminTask, ZoneAccessRule TechnicianAvailability, AdminTask, ZoneAccessRule
) )
from .settings import AppSettings from .settings import AppSettings
@ -37,7 +37,7 @@ __all__ = [
'Company', 'Part', 'PartStockMovement', 'InterventionPart', 'Alert', 'Service', 'Company', 'Part', 'PartStockMovement', 'InterventionPart', 'Alert', 'Service',
'WorkSchedule', 'WorkScheduleTemplate', 'AnnualTimeConfig', 'TimeEntry', 'CollegeClosure', 'ClosureSchedule', 'ClosureWorkDay', 'PersonalLeave', 'Training', 'TrainingParticipant', 'WorkSchedule', 'WorkScheduleTemplate', 'AnnualTimeConfig', 'TimeEntry', 'CollegeClosure', 'ClosureSchedule', 'ClosureWorkDay', 'PersonalLeave', 'Training', 'TrainingParticipant',
'Meter', 'MeterReading', 'Consumable', 'ConsumableUsage', 'EquipmentConsumable', 'Meter', 'MeterReading', 'Consumable', 'ConsumableUsage', 'EquipmentConsumable',
'PreventiveTask', 'PreventiveTaskConsumable', 'ScheduledTask', 'PreventiveTask', 'PreventiveTaskConsumable', 'ScheduledTask', 'TaskExecutionSegment',
'TechnicianAvailability', 'AdminTask', 'ZoneAccessRule', 'TechnicianAvailability', 'AdminTask', 'ZoneAccessRule',
'AppSettings', 'AuditLog', 'TemplateAuditMark', 'AppSettings', 'AuditLog', 'TemplateAuditMark',
'PreventionWorkLog', 'StaffAuthorization', 'RiskAssessment', 'PreventionAction', 'SafetyRegisterEntry', 'PreventionWorkLog', 'StaffAuthorization', 'RiskAssessment', 'PreventionAction', 'SafetyRegisterEntry',

View file

@ -459,6 +459,13 @@ class LotTask(db.Model):
unavailable_policy = db.Column(db.String(20), nullable=False, default="suspend") unavailable_policy = db.Column(db.String(20), nullable=False, default="suspend")
after_repair_policy = db.Column(db.String(20), nullable=False, default="preserve") after_repair_policy = db.Column(db.String(20), nullable=False, default="preserve")
is_safety_critical = db.Column(db.Boolean, nullable=False, default=False) 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") company = db.relationship("Company")
contract = db.relationship("Contract") contract = db.relationship("Contract")
equipment_scope = db.relationship( equipment_scope = db.relationship(
@ -479,16 +486,50 @@ class LotTask(db.Model):
from .equipment import Equipment from .equipment import Equipment
if self.scope_mode == "manual": if self.scope_mode == "manual":
return [e for e in self.equipment_scope if not e.is_deleted] 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 # Résolution par niveaux : un équipement est rattaché directement au
# reste volontairement explicite pour ne pas perdre un descendant lors # lot ou hérite du lot d'un parent. On évite ainsi le N+1 de
# d'un déplacement de patrimoine. # ``effective_lot_id`` (qui chargeait chaque parent en Python).
return [e for e in Equipment.query.filter(Equipment.is_deleted.is_(False)).all() result = []
if e.effective_lot_id == self.lot_id] 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): def duration_for_equipment(self, equipment):
"""Durée calculée pour une cible, en tenant compte de sa quantité.""" """Durée calculée pour une cible, en tenant compte de sa quantité."""
if not self.duration_is_configured: if not self.duration_is_configured:
return 0 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": if self.duration_mode != "unit":
return self.duree_minutes return self.duree_minutes
quantity = max(int(getattr(equipment, "quantity", 1) or 1), 1) quantity = max(int(getattr(equipment, "quantity", 1) or 1), 1)

View file

@ -514,6 +514,10 @@ class ScheduledTask(db.Model):
company = db.relationship("Company") company = db.relationship("Company")
contract = db.relationship("Contract") contract = db.relationship("Contract")
intervention = db.relationship("Intervention") intervention = db.relationship("Intervention")
execution_segments = db.relationship(
"TaskExecutionSegment", back_populates="scheduled_task",
cascade="all, delete-orphan", order_by="TaskExecutionSegment.started_at",
)
@property @property
def is_overdue(self): def is_overdue(self):
@ -523,6 +527,16 @@ class ScheduledTask(db.Model):
def can_be_ignored(self): def can_be_ignored(self):
return self.status == 'planned' 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=''): def ignore(self, reason=''):
"""Marque la tache comme ignoree.""" """Marque la tache comme ignoree."""
@ -723,3 +737,33 @@ class PlanningItem(db.Model):
def __repr__(self): def __repr__(self):
return f"<PlanningItem {self.item_type}: {self.title}>" return f"<PlanningItem {self.item_type}: {self.title}>"
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)

View file

@ -296,7 +296,8 @@ class DayPlanner:
title=title, title=title,
task_type=task_type, priority=2 if task.lot_task_id else 4, 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", 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, 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, zone_name=room.zone.name if room and room.zone else None,
building_name=room.building.name if room and room.building else None, building_name=room.building.name if room and room.building else None,

View file

@ -99,6 +99,10 @@ def generate_due_tasks(task: LotTask, event=None, today=None, equipment_ids=None
today = today or date.today() today = today or date.today()
if not task.is_active: if not task.is_active:
return [] 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: if task.trigger_type in ("event", "weather") and task.trigger_event != event:
return [] return []
if task.trigger_type == "season": if task.trigger_type == "season":

View file

@ -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

View file

@ -146,6 +146,38 @@ def create():
return render_template('equipments/room_form.html', **context) 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 na été ajouté automatiquement.', 'success')
return redirect(url_for('rooms.index'))
return render_template('equipments/rooms_bulk.html', **context)
@rooms_bp.route('/<int:id>/edit', methods=['GET', 'POST']) @rooms_bp.route('/<int:id>/edit', methods=['GET', 'POST'])
@login_required @login_required
def edit(id): def edit(id):

View file

@ -0,0 +1,5 @@
{% extends "base.html" %}
{% block title %}Créer plusieurs locaux{% endblock %}
{% block content %}<div class="container-fluid" style="max-width:900px"><h1 class="h3">Créer plusieurs locaux</h1><p class="text-muted">Un nom par ligne. Le profil de local reste modifiable ensuite ; aucun équipement n'est imposé.</p><form method="post" class="card"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><div class="card-body row g-3"><div class="col-md-6"><label class="form-label">Bâtiment</label><select class="form-select" name="building_id" required><option value="">Choisir</option>{% for building in buildings %}<option value="{{ building.id }}">{{ building.name }}</option>{% endfor %}</select></div><div class="col-md-6"><label class="form-label">Zone</label><select class="form-select" name="zone_id"><option value="">Aucune zone</option>{% for zone in zones %}<option value="{{ zone.id }}">{{ zone.building.name }} — {{ zone.name }}</option>{% endfor %}</select></div><div class="col-md-6"><label class="form-label">Profil / type de local</label><select class="form-select" name="room_type_id"><option value="">À préciser plus tard</option>{% for room_type in room_types %}<option value="{{ room_type.id }}">{{ room_type.name }}</option>{% endfor %}</select></div><div class="col-md-6"><label class="form-label">Étage</label><input class="form-control" type="number" name="floor" value="0"></div><div class="col-12"><label class="form-label">Noms des locaux</label><textarea class="form-control" name="names" rows="8" required placeholder="B101
B102
B103"></textarea></div></div><div class="card-footer"><a class="btn btn-outline-secondary" href="{{ url_for('rooms.index') }}">Annuler</a> <button class="btn btn-primary">Créer les locaux</button></div></form></div>{% endblock %}

View file

@ -98,6 +98,12 @@ def _apply_task_form(task):
task.company_id = request.form.get('company_id', type=int) or None 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.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.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': if task.scope_mode == 'manual':
selected = {int(value) for value in request.form.getlist('equipment_ids') if value.isdigit()} 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 [] task.equipment_scope = Equipment.query.filter(Equipment.id.in_(selected)).all() if selected else []

View file

@ -39,6 +39,13 @@ def my_day():
for item in candidates for item in candidates
if item.status in ('à replanifier', 'proposé', 'conflit') 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( return render_template(
'planning/my_day.html', 'planning/my_day.html',
target_date=target_date, target_date=target_date,
@ -48,6 +55,7 @@ def my_day():
next_date=target_date + timedelta(days=1), next_date=target_date + timedelta(days=1),
hours_configured=bool(windows), hours_configured=bool(windows),
alternatives=alternatives, 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())) 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/<int:task_id>/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/<int:task_id>/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/<int:task_id>/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/<int:task_id>/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') @planning_bp.route('/rooms')
@login_required @login_required
def room_schedules(): def room_schedules():

View file

@ -27,6 +27,8 @@
<div class="col-md-6"><label class="form-label">Entreprise exécutante</label><select class="form-select" name="company_id"><option value="">Interne / non définie</option>{% for company in companies %}<option value="{{ company.id }}" {% if task.company_id == company.id %}selected{% endif %}>{{ company.name }}</option>{% endfor %}</select></div> <div class="col-md-6"><label class="form-label">Entreprise exécutante</label><select class="form-select" name="company_id"><option value="">Interne / non définie</option>{% for company in companies %}<option value="{{ company.id }}" {% if task.company_id == company.id %}selected{% endif %}>{{ company.name }}</option>{% endfor %}</select></div>
<div class="col-md-6"><label class="form-label">Contrat associé</label><select class="form-select" name="contract_id"><option value="">Aucun contrat</option>{% for contract in contracts %}<option value="{{ contract.id }}" {% if task.contract_id == contract.id %}selected{% endif %}>{{ contract.name }}{% if contract.contract_number %} — {{ contract.contract_number }}{% endif %}</option>{% endfor %}</select></div> <div class="col-md-6"><label class="form-label">Contrat associé</label><select class="form-select" name="contract_id"><option value="">Aucun contrat</option>{% for contract in contracts %}<option value="{{ contract.id }}" {% if task.contract_id == contract.id %}selected{% endif %}>{{ contract.name }}{% if contract.contract_number %} — {{ contract.contract_number }}{% endif %}</option>{% endfor %}</select></div>
<div class="col-md-6"><label class="form-label">Exécutant</label><select class="form-select" name="executor_type"><option value="undefined" {% if task.executor_type == 'undefined' %}selected{% endif %}>Non défini</option><option value="internal" {% if task.executor_type == 'internal' %}selected{% endif %}>Équipe du collège</option><option value="external_company" {% if task.executor_type == 'external_company' %}selected{% endif %}>Entreprise extérieure</option><option value="department" {% if task.executor_type == 'department' %}selected{% endif %}>Département / autre service</option><option value="mixed" {% if task.executor_type == 'mixed' %}selected{% endif %}>Mixte</option></select></div> <div class="col-md-6"><label class="form-label">Exécutant</label><select class="form-select" name="executor_type"><option value="undefined" {% if task.executor_type == 'undefined' %}selected{% endif %}>Non défini</option><option value="internal" {% if task.executor_type == 'internal' %}selected{% endif %}>Équipe du collège</option><option value="external_company" {% if task.executor_type == 'external_company' %}selected{% endif %}>Entreprise extérieure</option><option value="department" {% if task.executor_type == 'department' %}selected{% endif %}>Département / autre service</option><option value="mixed" {% if task.executor_type == 'mixed' %}selected{% endif %}>Mixte</option></select></div>
<div class="col-md-6"><label class="form-label">Nature de la tâche</label><select class="form-select" name="task_nature"><option value="unknown" {% if task.task_nature == 'unknown' %}selected{% endif %}>À confirmer</option><option value="preventive" {% if task.task_nature == 'preventive' %}selected{% endif %}>Préventive</option><option value="conditional_curative" {% if task.task_nature == 'conditional_curative' %}selected{% endif %}>Conditionnelle / curative</option><option value="regulatory" {% if task.task_nature == 'regulatory' %}selected{% endif %}>Réglementaire</option><option value="contractual" {% if task.task_nature == 'contractual' %}selected{% endif %}>Contractuelle</option></select></div>
<div class="col-12"><fieldset class="border rounded p-3"><legend class="float-none w-auto px-2 fs-6">Exécution interrompable</legend><div class="row g-2"><div class="col-md-3 form-check"><input class="form-check-input" type="checkbox" name="interruptible" id="interruptible" {% if task.interruptible %}checked{% endif %}><label class="form-check-label" for="interruptible">Pause autorisée</label></div><div class="col-md-3 form-check"><input class="form-check-input" type="checkbox" name="splittable" id="splittable" {% if task.splittable %}checked{% endif %}><label class="form-check-label" for="splittable">Fractionnable</label></div><div class="col-md-3"><label class="form-label" for="minimum_segment_minutes">Segment minimum (min)</label><input class="form-control" type="number" min="1" name="minimum_segment_minutes" id="minimum_segment_minutes" value="{{ task.minimum_segment_minutes or '' }}"></div><div class="col-md-3 form-check"><input class="form-check-input" type="checkbox" name="allow_next_day_resume" id="allow_next_day_resume" {% if task.allow_next_day_resume %}checked{% endif %}><label class="form-check-label" for="allow_next_day_resume">Reprise le lendemain</label></div><div class="col-md-4 form-check"><input class="form-check-input" type="checkbox" name="requires_safe_pause" id="requires_safe_pause" {% if task.requires_safe_pause %}checked{% endif %}><label class="form-check-label" for="requires_safe_pause">Remise en sécurité avant pause</label></div></div></fieldset></div>
<div class="col-md-3 form-check ms-2 mt-5"><input class="form-check-input" type="checkbox" name="is_safety_critical" id="safety" {% if task.is_safety_critical %}checked{% endif %}><label class="form-check-label" for="safety">Tâche de sécurité</label></div> <div class="col-md-3 form-check ms-2 mt-5"><input class="form-check-input" type="checkbox" name="is_safety_critical" id="safety" {% if task.is_safety_critical %}checked{% endif %}><label class="form-check-label" for="safety">Tâche de sécurité</label></div>
<div class="col-md-2 form-check ms-2 mt-5"><input class="form-check-input" type="checkbox" name="is_active" id="active" {% if task.is_active %}checked{% endif %}><label class="form-check-label" for="active">Active</label></div> <div class="col-md-2 form-check ms-2 mt-5"><input class="form-check-input" type="checkbox" name="is_active" id="active" {% if task.is_active %}checked{% endif %}><label class="form-check-label" for="active">Active</label></div>
</div> </div>

View file

@ -52,6 +52,15 @@
</div> </div>
</div> </div>
{% if item.explanation %}<p class="mb-0 mt-2 small"><i class="bi bi-info-circle"></i> {{ item.explanation }}</p>{% endif %} {% if item.explanation %}<p class="mb-0 mt-2 small"><i class="bi bi-info-circle"></i> {{ item.explanation }}</p>{% endif %}
{% if item.source_type == 'scheduled_task' and execution_states.get(item.source_id) %}
{% set execution = execution_states.get(item.source_id) %}
<div class="small mt-2">Temps réalisé : {{ execution.realized }} min · restant : {{ execution.remaining }} min</div>
<div class="d-flex flex-wrap gap-1 mt-2">
{% if execution.status in ('planned', 'suspended', 'postponed') %}<form method="post" action="{{ url_for('planning.scheduled_start', task_id=item.source_id) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="btn btn-sm btn-outline-success">Démarrer</button></form>{% endif %}
{% if execution.status == 'in_progress' %}<form method="post" action="{{ url_for('planning.scheduled_pause', task_id=item.source_id) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><input type="hidden" name="reason" value="securite"><button class="btn btn-sm btn-outline-warning">Mettre en pause</button></form><form method="post" action="{{ url_for('planning.scheduled_complete', task_id=item.source_id) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="btn btn-sm btn-outline-primary">Terminer</button></form>{% endif %}
{% if execution.status == 'paused' %}<form method="post" action="{{ url_for('planning.scheduled_resume', task_id=item.source_id) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="btn btn-sm btn-outline-success">Reprendre</button></form>{% endif %}
</div>
{% endif %}
{% set item_alternatives = alternatives.get((item.source_type, item.source_id), []) %} {% set item_alternatives = alternatives.get((item.source_type, item.source_id), []) %}
{% if item_alternatives and item.status != 'conflit' %} {% if item_alternatives and item.status != 'conflit' %}
<details class="mt-2"> <details class="mt-2">

View file

@ -571,3 +571,7 @@ d'intégration n'a été modifié. Nouveau total : 76/76.
doit pas être comblé par une valeur arbitraire. doit pas être comblé par une valeur arbitraire.
- La sélection en masse filtrée des équipements et le raccord automatique des - 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. 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.

View file

@ -177,3 +177,6 @@ dans ce dépôt.
Aucune donnée `TEST_UI_PHASE21B_*` n'a été créée dans cette passe : les 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. 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.

View file

@ -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.

View file

@ -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.

View file

@ -150,3 +150,6 @@ navigateur instrumenté sera disponible.
| `/lots/<id>/tasks/new` | durée et périodicité d'une action préventive | route existante | | `/lots/<id>/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 | | 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 | | `/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/<id>` | Maintenance couverte par contrat | Utilisateur connecté | Enrichi Phase 2.1c |
| `/planning/scheduled/<id>/{start,pause,resume,complete}` | Exécuter une tâche par segments | Utilisateur connecté | Implémenté Phase 2.1c |

View file

@ -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.

View file

@ -515,3 +515,11 @@ aux administrateurs.
non-venue, date réelle et accompagnement ; page `Passages & contrôles`. 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 - Phase partiellement validée : sélection en masse avancée et scénario complet
UI restent à compléter. 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.

View file

@ -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 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 masse et le scénario de bout en bout n'ont pas encore été rejoués entièrement
depuis l'interface. 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.

View file

@ -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.

View file

@ -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 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 les horaires du technicien ne sont pas configurés ; aucune plage par défaut
n'est inventée. 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.

View file

@ -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)