feat(preventive): ajouter duree unitaire et perimetres

This commit is contained in:
root 2026-08-23 13:41:43 +00:00
parent e65e653bd6
commit 22e8f8eda4
14 changed files with 261 additions and 10 deletions

View file

@ -118,6 +118,12 @@ class Zone(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False) name = db.Column(db.String(100), nullable=False)
building_id = db.Column(db.Integer, db.ForeignKey("buildings.id"), nullable=False) building_id = db.Column(db.Integer, db.ForeignKey("buildings.id"), nullable=False)
# Durées propres aux espaces verts de cette zone (minutes). Elles restent
# nulles pour les zones qui ne sont pas concernées.
mowing_minutes = db.Column(db.Integer, nullable=True)
edging_minutes = db.Column(db.Integer, nullable=True)
hedge_minutes = db.Column(db.Integer, nullable=True)
branch_removal_minutes = db.Column(db.Integer, nullable=True)
# Relations # Relations
building = db.relationship("Building", back_populates="zones") building = db.relationship("Building", back_populates="zones")

View file

@ -83,6 +83,10 @@ class Equipment(db.Model):
"EquipmentLifecycleEvent", back_populates="equipment", "EquipmentLifecycleEvent", back_populates="equipment",
order_by="desc(EquipmentLifecycleEvent.created_at)", cascade="all, delete-orphan", order_by="desc(EquipmentLifecycleEvent.created_at)", cascade="all, delete-orphan",
) )
preventive_tasks = db.relationship(
"LotTask", secondary="lot_task_equipments",
back_populates="equipment_scope", lazy="selectin",
)
@property @property
def effective_lot_id(self): def effective_lot_id(self):

View file

@ -7,6 +7,13 @@ from datetime import datetime, timezone
from ...extensions import db from ...extensions import db
lot_task_equipments = db.Table(
"lot_task_equipments",
db.Column("lot_task_id", db.Integer, db.ForeignKey("lot_tasks.id", ondelete="CASCADE"), primary_key=True),
db.Column("equipment_id", db.Integer, db.ForeignKey("equipments.id", ondelete="CASCADE"), primary_key=True),
)
class Intervention(db.Model): class Intervention(db.Model):
"""Intervention de maintenance - compatible avec la base existante.""" """Intervention de maintenance - compatible avec la base existante."""
__tablename__ = "interventions" __tablename__ = "interventions"
@ -444,11 +451,48 @@ class LotTask(db.Model):
advance_days = db.Column(db.Integer, nullable=False, default=0) advance_days = db.Column(db.Integer, nullable=False, default=0)
is_active = db.Column(db.Boolean, nullable=False, default=True) is_active = db.Column(db.Boolean, nullable=False, default=True)
application_scope = db.Column(db.String(20), nullable=False, default="auto") application_scope = db.Column(db.String(20), nullable=False, default="auto")
# V2.1b: la durée est une valeur métier par élément (ou par zone pour
# les espaces verts), jamais une durée implicite du moteur.
duration_mode = db.Column(db.String(20), nullable=False, default="unit")
executor_type = db.Column(db.String(30), nullable=False, default="undefined")
scope_mode = db.Column(db.String(20), nullable=False, default="dynamic")
unavailable_policy = db.Column(db.String(20), nullable=False, default="suspend") 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)
company = db.relationship("Company") company = db.relationship("Company")
contract = db.relationship("Contract") contract = db.relationship("Contract")
equipment_scope = db.relationship(
"Equipment", secondary="lot_task_equipments",
back_populates="preventive_tasks", lazy="selectin",
)
@property
def duration_is_configured(self):
return bool(self.duree_minutes and self.duree_minutes > 0)
def applicable_equipments(self):
"""Retourne le périmètre effectif sans créer de tournée globale.
Un périmètre manuel est strictement limité aux équipements choisis;
le périmètre dynamique suit le lot et inclut les équipements ajoutés.
"""
from .equipment import Equipment
if self.scope_mode == "manual":
return [e for e in self.equipment_scope if not e.is_deleted]
# `effective_lot_id` gère aussi les groupes imbriqués. Cette résolution
# reste volontairement explicite pour ne pas perdre un descendant lors
# d'un déplacement de patrimoine.
return [e for e in Equipment.query.filter(Equipment.is_deleted.is_(False)).all()
if e.effective_lot_id == self.lot_id]
def duration_for_equipment(self, equipment):
"""Durée calculée pour une cible, en tenant compte de sa quantité."""
if not self.duration_is_configured:
return 0
if self.duration_mode != "unit":
return self.duree_minutes
quantity = max(int(getattr(equipment, "quantity", 1) or 1), 1)
return self.duree_minutes * quantity
def effective_duration(self): def effective_duration(self):
"""Duree previsionnelle auto-adaptee pour cette tache de lot. """Duree previsionnelle auto-adaptee pour cette tache de lot.
@ -456,7 +500,7 @@ class LotTask(db.Model):
Si duree_minutes est defini manuellement, on l'utilise. Si duree_minutes est defini manuellement, on l'utilise.
Sinon on calcule la moyenne des durees reelles des interventions Sinon on calcule la moyenne des durees reelles des interventions
passees pour ce lot + ce type de tache. passees pour ce lot + ce type de tache.
Fallback: 60 minutes par defaut. Aucune valeur arbitraire n'est inventée si la durée n'est pas connue.
""" """
from sqlalchemy import func from sqlalchemy import func
if self.duree_minutes: if self.duree_minutes:

View file

@ -41,6 +41,17 @@ def _targets(lot, application_scope="auto", equipment_ids=None):
return list({item.id: item for item in result}.values()) return list({item.id: item for item in result}.values())
def _task_targets(task, equipment_ids=None):
"""Résout le périmètre déclaré par la règle préventive."""
if getattr(task, "scope_mode", "dynamic") == "manual":
targets = list(task.applicable_equipments())
if equipment_ids is not None:
wanted = set(equipment_ids)
targets = [e for e in targets if e.id in wanted]
return targets
return _targets(task.lot, task.application_scope, equipment_ids)
def next_working_day(candidate): def next_working_day(candidate):
for _ in range(370): for _ in range(370):
if PlanningService.get_working_hours(candidate): if PlanningService.get_working_hours(candidate):
@ -99,7 +110,7 @@ def generate_due_tasks(task: LotTask, event=None, today=None, equipment_ids=None
if not task.lot or not task.lot.is_present: if not task.lot or not task.lot.is_present:
return [] return []
requested_ids = set(equipment_ids) if equipment_ids is not None else None requested_ids = set(equipment_ids) if equipment_ids is not None else None
for equipment in _targets(task.lot, task.application_scope, requested_ids): for equipment in _task_targets(task, requested_ids):
if equipment_ids is not None and equipment.id not in set(equipment_ids): if equipment_ids is not None and equipment.id not in set(equipment_ids):
continue continue
if equipment.lifecycle_status == "jete" or equipment.status == "jete": if equipment.lifecycle_status == "jete" or equipment.status == "jete":
@ -135,11 +146,14 @@ def generate_due_tasks(task: LotTask, event=None, today=None, equipment_ids=None
due = next_working_day(max(due, today)) due = next_working_day(max(due, today))
room = equipment.effective_room room = equipment.effective_room
status = "suspended" if unavailable and not task.is_safety_critical and task.unavailable_policy == "suspend" else "planned" status = "suspended" if unavailable and not task.is_safety_critical and task.unavailable_policy == "suspend" else "planned"
# Une règle sans durée reste visible comme « à renseigner », mais ne
# doit pas être engagée automatiquement dans une journée.
estimated_duration = task.duration_for_equipment(equipment)
item = ScheduledTask(lot_task_id=task.id, equipment_id=equipment.id, item = ScheduledTask(lot_task_id=task.id, equipment_id=equipment.id,
room_id=room.id if room else None, room_id=room.id if room else None,
company_id=task.company_id, company_id=task.company_id,
contract_id=task.contract_id, contract_id=task.contract_id,
scheduled_date=due, estimated_duration=task.effective_duration(), status=status, scheduled_date=due, estimated_duration=estimated_duration, status=("needs_duration" if not estimated_duration else status),
suspension_reason="Équipement indisponible" if status == "suspended" else None, suspension_reason="Équipement indisponible" if status == "suspended" else None,
room_name_snapshot=room.name if room else None, room_name_snapshot=room.name if room else None,
lot_name_snapshot=task.lot.name, lot_name_snapshot=task.lot.name,

View file

@ -23,7 +23,11 @@ def create():
if request.method == 'POST': if request.method == 'POST':
zone = Zone( zone = Zone(
name=request.form.get('name'), name=request.form.get('name'),
building_id=request.form.get('building_id', type=int) building_id=request.form.get('building_id', type=int),
mowing_minutes=request.form.get('mowing_minutes', type=int) or None,
edging_minutes=request.form.get('edging_minutes', type=int) or None,
hedge_minutes=request.form.get('hedge_minutes', type=int) or None,
branch_removal_minutes=request.form.get('branch_removal_minutes', type=int) or None,
) )
db.session.add(zone) db.session.add(zone)
db.session.commit() db.session.commit()
@ -43,6 +47,10 @@ def edit(id):
if request.method == 'POST': if request.method == 'POST':
zone.name = request.form.get('name') zone.name = request.form.get('name')
zone.building_id = request.form.get('building_id', type=int) zone.building_id = request.form.get('building_id', type=int)
zone.mowing_minutes = request.form.get('mowing_minutes', type=int) or None
zone.edging_minutes = request.form.get('edging_minutes', type=int) or None
zone.hedge_minutes = request.form.get('hedge_minutes', type=int) or None
zone.branch_removal_minutes = request.form.get('branch_removal_minutes', type=int) or None
db.session.commit() db.session.commit()
flash(f"Zone '{zone.name}' mise à jour.", 'success') flash(f"Zone '{zone.name}' mise à jour.", 'success')
return redirect(url_for('zones.index')) return redirect(url_for('zones.index'))

View file

@ -89,12 +89,18 @@ def _apply_task_form(task):
task.season_end_month = request.form.get('season_end_month', type=int) task.season_end_month = request.form.get('season_end_month', type=int)
task.advance_days = max(request.form.get('advance_days', type=int) or 0, 0) task.advance_days = max(request.form.get('advance_days', type=int) or 0, 0)
task.application_scope = request.form.get('application_scope') or 'auto' task.application_scope = request.form.get('application_scope') or 'auto'
task.duration_mode = request.form.get('duration_mode') or 'unit'
task.scope_mode = request.form.get('scope_mode') or 'dynamic'
task.unavailable_policy = request.form.get('unavailable_policy') or 'suspend' task.unavailable_policy = request.form.get('unavailable_policy') or 'suspend'
task.after_repair_policy = request.form.get('after_repair_policy') or 'preserve' task.after_repair_policy = request.form.get('after_repair_policy') or 'preserve'
task.is_safety_critical = request.form.get('is_safety_critical') == 'on' task.is_safety_critical = request.form.get('is_safety_critical') == 'on'
task.is_active = request.form.get('is_active') == 'on' task.is_active = request.form.get('is_active') == 'on'
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')
if task.scope_mode == 'manual':
selected = {int(value) for value in request.form.getlist('equipment_ids') if value.isdigit()}
task.equipment_scope = Equipment.query.filter(Equipment.id.in_(selected)).all() if selected else []
@lots_bp.route('/<int:lot_id>/tasks/new', methods=['GET', 'POST']) @lots_bp.route('/<int:lot_id>/tasks/new', methods=['GET', 'POST'])
@ -111,7 +117,7 @@ def task_create(lot_id):
db.session.commit() db.session.commit()
flash('Tâche préventive ajoutée au lot.', 'success') flash('Tâche préventive ajoutée au lot.', 'success')
return redirect(url_for('lots.detail', id=lot.id)) return redirect(url_for('lots.detail', id=lot.id))
return render_template('lots/task_form.html', lot=lot, task=task, creating=True, companies=Company.query.order_by(Company.name).all(), contracts=Contract.query.order_by(Contract.name).all()) return render_template('lots/task_form.html', lot=lot, task=task, creating=True, equipments=Equipment.query.filter_by(lot_id=lot.id, is_deleted=False).order_by(Equipment.name).all(), companies=Company.query.order_by(Company.name).all(), contracts=Contract.query.order_by(Contract.name).all())
@lots_bp.route('/<int:lot_id>/tasks/<int:task_id>/edit', methods=['GET', 'POST']) @lots_bp.route('/<int:lot_id>/tasks/<int:task_id>/edit', methods=['GET', 'POST'])
@ -127,7 +133,7 @@ def task_edit(lot_id, task_id):
db.session.commit() db.session.commit()
flash('Règle préventive mise à jour.', 'success') flash('Règle préventive mise à jour.', 'success')
return redirect(url_for('lots.detail', id=lot.id)) return redirect(url_for('lots.detail', id=lot.id))
return render_template('lots/task_form.html', lot=lot, task=task, creating=False, companies=Company.query.order_by(Company.name).all(), contracts=Contract.query.order_by(Contract.name).all()) return render_template('lots/task_form.html', lot=lot, task=task, creating=False, equipments=Equipment.query.filter_by(lot_id=lot.id, is_deleted=False).order_by(Equipment.name).all(), companies=Company.query.order_by(Company.name).all(), contracts=Contract.query.order_by(Contract.name).all())
@lots_bp.route('/<int:lot_id>/generate', methods=['POST']) @lots_bp.route('/<int:lot_id>/generate', methods=['POST'])

View file

@ -19,6 +19,16 @@
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
<fieldset class="border rounded p-3 mb-3">
<legend class="float-none w-auto px-2 fs-6">Durées estimées pour cette zone (espaces verts, minutes)</legend>
<p class="small text-muted">Laissez vide si cette zone n'est pas concernée. Ces durées sont propres à la zone, pas des valeurs génériques.</p>
<div class="row g-2">
<div class="col-md-3"><label class="form-label" for="mowing_minutes">Tonte</label><input id="mowing_minutes" name="mowing_minutes" type="number" min="1" class="form-control" value="{{ zone.mowing_minutes if zone else '' }}"></div>
<div class="col-md-3"><label class="form-label" for="edging_minutes">Bordures</label><input id="edging_minutes" name="edging_minutes" type="number" min="1" class="form-control" value="{{ zone.edging_minutes if zone else '' }}"></div>
<div class="col-md-3"><label class="form-label" for="hedge_minutes">Taille des haies</label><input id="hedge_minutes" name="hedge_minutes" type="number" min="1" class="form-control" value="{{ zone.hedge_minutes if zone else '' }}"></div>
<div class="col-md-3"><label class="form-label" for="branch_removal_minutes">Ramassage / évacuation</label><input id="branch_removal_minutes" name="branch_removal_minutes" type="number" min="1" class="form-control" value="{{ zone.branch_removal_minutes if zone else '' }}"></div>
</div>
</fieldset>
<div class="mt-3"> <div class="mt-3">
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg"></i> Enregistrer</button> <button type="submit" class="btn btn-primary"><i class="bi bi-check-lg"></i> Enregistrer</button>
<a href="{{ url_for('zones.index') }}" class="btn btn-outline-secondary">Annuler</a> <a href="{{ url_for('zones.index') }}" class="btn btn-outline-secondary">Annuler</a>
@ -26,4 +36,4 @@
</form> </form>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -120,8 +120,8 @@
<div class="col"><div class="fw-bold fs-4 text-danger">{{ task_counts.overdue }}</div><small class="text-muted">en retard</small></div> <div class="col"><div class="fw-bold fs-4 text-danger">{{ task_counts.overdue }}</div><small class="text-muted">en retard</small></div>
</div> </div>
</div> </div>
{% if lot.tasks %}<div class="table-responsive"><table class="table table-sm align-middle mb-0"><thead><tr><th></th><th>Tâche</th><th>Déclenchement</th><th>Portée</th><th>Indisponibilité</th><th></th></tr></thead><tbody> {% if lot.tasks %}<div class="table-responsive"><table class="table table-sm align-middle mb-0"><thead><tr><th></th><th>Tâche</th><th>Exécutant</th><th>Périodicité / durée</th><th>Périmètre</th><th></th></tr></thead><tbody>
{% for task in lot.tasks %}<tr class="{{ 'table-secondary' if not task.is_active }}"><td>{{ task.num_tache or '—' }}</td><td><strong>{{ task.tache or 'Tâche sans nom' }}</strong><div class="small text-muted">{{ task.periodicite or (task.jours_entre_interventions|string + ' jours' if task.jours_entre_interventions else 'Périodicité à définir') }}</div>{% if task.is_safety_critical %}<span class="badge bg-danger">Sécurité</span>{% endif %}</td><td>{{ task.trigger_type|replace('calendar','calendrier')|replace('season','saison')|replace('event','événement')|replace('weather','météo')|replace('meter','compteur')|title }}</td><td>{{ {'auto':'Automatique','group':'Groupe / salle','unit':'Par unité'}.get(task.application_scope, task.application_scope) }}</td><td>{{ {'suspend':'Suspendre','keep':'Maintenir','postpone':'Reporter'}.get(task.unavailable_policy, task.unavailable_policy) }}</td><td><a class="btn btn-sm btn-outline-primary" href="{{ url_for('lots.task_edit', lot_id=lot.id, task_id=task.id) }}"><i class="bi bi-pencil"></i></a></td></tr>{% endfor %} {% for task in lot.tasks %}<tr class="{{ 'table-secondary' if not task.is_active }}"><td>{{ task.num_tache or '—' }}</td><td><strong>{{ task.tache or 'Tâche sans nom' }}</strong><div class="small text-muted">{{ task.trigger_type|replace('calendar','calendrier')|replace('season','saison')|replace('event','événement')|replace('weather','météo')|replace('meter','compteur')|title }}{% if task.is_safety_critical %} · sécurité{% endif %}</div></td><td><span class="badge text-bg-light">{{ {'internal':'Interne','external_company':'Entreprise extérieure','department':'Département','mixed':'Mixte','undefined':'À définir'}.get(task.executor_type, 'À définir') }}</span>{% if task.company %}<div class="small">{{ task.company.name }}</div>{% endif %}</td><td>{{ task.periodicite or (task.jours_entre_interventions|string + ' jours' if task.jours_entre_interventions else 'Périodicité à définir') }}<div class="small {% if not task.duration_is_configured %}text-warning{% else %}text-muted{% endif %}">{% if task.duration_is_configured %}{{ task.duree_minutes }} min / élément{% else %}Durée à renseigner{% endif %}</div></td><td>{{ 'Sélection manuelle (%d)'|format(task.equipment_scope|length) if task.scope_mode == 'manual' else 'Dynamique : équipements du lot' }}</td><td><a class="btn btn-sm btn-outline-primary" href="{{ url_for('lots.task_edit', lot_id=lot.id, task_id=task.id) }}" aria-label="Modifier {{ task.tache }}"><i class="bi bi-pencil"></i></a></td></tr>{% endfor %}
</tbody></table></div>{% else %}<div class="card-body text-muted">Aucune règle préventive. Ajoutez-en une pour générer les échéances.</div>{% endif %} </tbody></table></div>{% else %}<div class="card-body text-muted">Aucune règle préventive. Ajoutez-en une pour générer les échéances.</div>{% endif %}
<div class="card-footer"><form method="post" action="{{ url_for('lots.generate_tasks', lot_id=lot.id) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="btn btn-success" {% if not lot.is_present %}disabled{% endif %}><i class="bi bi-magic"></i> Générer les échéances maintenant</button></form></div> <div class="card-footer"><form method="post" action="{{ url_for('lots.generate_tasks', lot_id=lot.id) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="btn btn-success" {% if not lot.is_present %}disabled{% endif %}><i class="bi bi-magic"></i> Générer les échéances maintenant</button></form></div>
</div> </div>

View file

@ -14,15 +14,19 @@
<div class="col-md-4"><label class="form-label">Intervalle (jours)</label><input class="form-control" type="number" min="1" name="jours_entre_interventions" value="{{ task.jours_entre_interventions or '' }}"></div> <div class="col-md-4"><label class="form-label">Intervalle (jours)</label><input class="form-control" type="number" min="1" name="jours_entre_interventions" value="{{ task.jours_entre_interventions or '' }}"></div>
<div class="col-md-4"><label class="form-label">Anticipation (jours)</label><input class="form-control" type="number" min="0" name="advance_days" value="{{ task.advance_days or 0 }}"></div> <div class="col-md-4"><label class="form-label">Anticipation (jours)</label><input class="form-control" type="number" min="0" name="advance_days" value="{{ task.advance_days or 0 }}"></div>
<div class="col-md-4"><label class="form-label">Durée par cible (minutes)</label><input class="form-control" type="number" min="1" name="duree_minutes" value="{{ task.duree_minutes or '' }}"></div> <div class="col-md-4"><label class="form-label">Durée par cible (minutes)</label><input class="form-control" type="number" min="1" name="duree_minutes" value="{{ task.duree_minutes or '' }}"></div>
<div class="col-md-4"><label class="form-label">Type de durée</label><select class="form-select" name="duration_mode"><option value="unit" {% if task.duration_mode == 'unit' %}selected{% endif %}>Par élément (× quantité)</option><option value="fixed" {% if task.duration_mode == 'fixed' %}selected{% endif %}>Forfait par cible</option><option value="zone" {% if task.duration_mode == 'zone' %}selected{% endif %}>Durée définie par la zone</option></select><div class="form-text">Sans durée, la tâche reste à renseigner et n'est pas planifiée.</div></div>
<div class="col-md-4"><label class="form-label">Événement</label><input class="form-control" name="trigger_event" value="{{ task.trigger_event or '' }}" placeholder="orage, gel…"></div> <div class="col-md-4"><label class="form-label">Événement</label><input class="form-control" name="trigger_event" value="{{ task.trigger_event or '' }}" placeholder="orage, gel…"></div>
<div class="col-md-4"><label class="form-label">Seuil compteur</label><input class="form-control" type="number" step="0.01" name="meter_threshold" value="{{ task.meter_threshold or '' }}"></div> <div class="col-md-4"><label class="form-label">Seuil compteur</label><input class="form-control" type="number" step="0.01" name="meter_threshold" value="{{ task.meter_threshold or '' }}"></div>
<div class="col-md-3"><label class="form-label">Début de saison</label><input class="form-control" type="number" min="1" max="12" name="season_start_month" value="{{ task.season_start_month or '' }}"></div> <div class="col-md-3"><label class="form-label">Début de saison</label><input class="form-control" type="number" min="1" max="12" name="season_start_month" value="{{ task.season_start_month or '' }}"></div>
<div class="col-md-3"><label class="form-label">Fin de saison</label><input class="form-control" type="number" min="1" max="12" name="season_end_month" value="{{ task.season_end_month or '' }}"></div> <div class="col-md-3"><label class="form-label">Fin de saison</label><input class="form-control" type="number" min="1" max="12" name="season_end_month" value="{{ task.season_end_month or '' }}"></div>
<div class="col-md-6"><label class="form-label">Portée</label><select class="form-select" name="application_scope"><option value="auto" {% if task.application_scope == 'auto' %}selected{% endif %}>Automatique selon le type déquipement</option><option value="group" {% if task.application_scope == 'group' %}selected{% endif %}>Une tâche par groupe/localisation</option><option value="unit" {% if task.application_scope == 'unit' %}selected{% endif %}>Une tâche par unité individualisée</option></select><div class="form-text">Pour 30 chaises, « groupe » crée une seule tâche dans la salle.</div></div> <div class="col-md-6"><label class="form-label">Portée</label><select class="form-select" name="application_scope"><option value="auto" {% if task.application_scope == 'auto' %}selected{% endif %}>Automatique selon le type déquipement</option><option value="group" {% if task.application_scope == 'group' %}selected{% endif %}>Une tâche par groupe/localisation</option><option value="unit" {% if task.application_scope == 'unit' %}selected{% endif %}>Une tâche par unité individualisée</option></select><div class="form-text">Pour 30 chaises, « groupe » crée une seule tâche dans la salle.</div></div>
<div class="col-md-6"><label class="form-label">Périmètre des équipements</label><select class="form-select" name="scope_mode"><option value="dynamic" {% if task.scope_mode != 'manual' %}selected{% endif %}>Dynamique : équipements actifs de ce lot</option><option value="manual" {% if task.scope_mode == 'manual' %}selected{% endif %}>Manuel : uniquement la sélection ci-dessous</option></select></div>
<div class="col-12"><label class="form-label">Équipements concernés (périmètre manuel)</label><select class="form-select" name="equipment_ids" multiple size="4" aria-describedby="scope-help">{% for equipment in equipments %}<option value="{{ equipment.id }}" {% if equipment in task.equipment_scope %}selected{% endif %}>{{ equipment.name }}{% if equipment.room %} — {{ equipment.room.name }}{% endif %}</option>{% endfor %}</select><div id="scope-help" class="form-text">Maintenez Ctrl/Cmd pour plusieurs équipements. Les nouveaux équipements du lot sont inclus automatiquement en mode dynamique.</div></div>
<div class="col-md-6"><label class="form-label">Si léquipement est indisponible</label><select class="form-select" name="unavailable_policy"><option value="suspend" {% if task.unavailable_policy == 'suspend' %}selected{% endif %}>Suspendre et reprendre après réparation</option><option value="keep" {% if task.unavailable_policy == 'keep' %}selected{% endif %}>Maintenir la tâche</option><option value="postpone" {% if task.unavailable_policy == 'postpone' %}selected{% endif %}>Ne pas générer avant remise en service</option></select></div> <div class="col-md-6"><label class="form-label">Si léquipement est indisponible</label><select class="form-select" name="unavailable_policy"><option value="suspend" {% if task.unavailable_policy == 'suspend' %}selected{% endif %}>Suspendre et reprendre après réparation</option><option value="keep" {% if task.unavailable_policy == 'keep' %}selected{% endif %}>Maintenir la tâche</option><option value="postpone" {% if task.unavailable_policy == 'postpone' %}selected{% endif %}>Ne pas générer avant remise en service</option></select></div>
<div class="col-md-6"><label class="form-label">Après réparation</label><select class="form-select" name="after_repair_policy"><option value="preserve" {% if task.after_repair_policy == 'preserve' %}selected{% endif %}>Conserver léchéance dorigine</option><option value="reset" {% if task.after_repair_policy == 'reset' %}selected{% endif %}>Repartir de la date de réparation</option></select></div> <div class="col-md-6"><label class="form-label">Après réparation</label><select class="form-select" name="after_repair_policy"><option value="preserve" {% if task.after_repair_policy == 'preserve' %}selected{% endif %}>Conserver léchéance dorigine</option><option value="reset" {% if task.after_repair_policy == 'reset' %}selected{% endif %}>Repartir de la date de réparation</option></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">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-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

@ -79,3 +79,11 @@ un modèle de passage séparé. Les champs éventuels seraient notamment : mode
dexécutant, mode de passage, fenêtre, heure, durée daccompagnement, statut, dexécutant, mode de passage, fenêtre, heure, durée daccompagnement, statut,
date réelle et périmètre dynamique. Aucune table ni migration nest créée dans date réelle et périmètre dynamique. Aucune table ni migration nest créée dans
cette étude. cette étude.
# Mise en œuvre Phase 2.1b
Les tâches de lot disposent maintenant d'un exécutant explicite (interne,
entreprise extérieure, Département, mixte ou à définir), d'une entreprise et
d'un contrat facultatifs, ainsi que d'un périmètre dynamique ou manuel.
Le rendez-vous, la fenêtre de passage, la non-venue et l'accompagnement
restent à implémenter autour de `ContractVisit` dans une migration dédiée ;
aucune date/heure fictive n'est donc créée dans cette passe.

View file

@ -0,0 +1,80 @@
# Phase 2.1b — implémentation préventif et entreprises
## Baseline
- Version : `0.1.0-dev.10`.
- Commit de départ : `e65e653`.
- Le dépôt était propre ; les services Docker local (Flask, MariaDB,
Adminer) étaient démarrés et `/health/` répondait.
- Les tests de référence restaient `152 passed, 1 failed, 1 error` ; l'échec
et l'erreur historiques concernent le lookback/contexte GMAO et son
teardown, hors périmètre.
## Ce qui est implémenté
### Durée et périmètre
`LotTask` expose maintenant :
- `duration_mode` : `unit`, `fixed` ou `zone` ;
- `scope_mode` : `dynamic` ou `manual` ;
- `executor_type` : `internal`, `external_company`, `department`, `mixed`,
`undefined`.
En mode `unit`, la durée d'une cible est `duree_minutes × quantity`. Une
quantité de groupe (par exemple 30 chaises) est donc respectée. En mode
`fixed`, la quantité ne multiplie pas le forfait. Le périmètre dynamique suit
les équipements actifs du lot ; le périmètre manuel utilise la sélection
explicitement enregistrée. Les tâches sans durée ont `estimated_duration=0`
et le statut technique `needs_duration`; elles ne sont pas proposées par
`DayPlanner` comme tâches planifiées.
Les valeurs historiques ne sont pas réintroduites comme vérité : le catalogue
reste idempotent et ne remplace jamais une personnalisation existante. Les
propositions de durée du document de référence restent à valider métier avant
d'être injectées en masse.
### Espaces verts
Les zones disposent de quatre durées facultatives propres à la zone : tonte,
bordures, haies et ramassage/évacuation. Elles sont éditables dans le
formulaire normal d'une zone et ne créent aucune durée générique dans un lot.
Le raccord automatique d'une règle de lot à ces quatre champs reste à faire
dans une passe ultérieure après validation du vocabulaire des tâches espaces
verts.
### Entreprises et passages
Les champs existants `company_id`, `contract_id`, `periodicite` et
`jours_entre_interventions` sont exposés dans la fiche de tâche, ainsi que le
type d'exécutant. En revanche, le workflow complet de passage (rendez-vous,
fenêtre, non-venue, date réelle, accompagnement et tableau Passages &
contrôles) n'est pas encore implémenté dans cette passe. `ContractVisit` ne
porte actuellement qu'une date obligatoire et ne peut pas représenter
fidèlement les trois modes demandés sans migration métier dédiée.
## Migration
`h3c4d5e6f7a8_phase21b_preventive_scope.py` ajoute sans perte :
- les trois colonnes de qualification à `lot_tasks` ;
- la table d'association `lot_task_equipments` ;
- les quatre durées d'espaces verts à `zones`.
La migration est additive et possède un downgrade. Elle n'attribue aucun
horaire, lot ou exécutant à `alban`.
## Tests ajoutés
`tests/unit/test_preventive_scope.py` couvre le calcul par quantité, le forfait
par cible et l'absence explicite de durée. La compilation Python et le
chargement des modèles passent. La suite intégration locale est actuellement
bloquée par l'erreur historique de teardown (`equipment_lifecycle_events`
absente de la base de test), déjà présente avant cette passe.
## État de validation
Cette passe est **partiellement validée** : la durée unitaire, le périmètre et
les durées de zone ont une base persistante et une UI simple ; le workflow
entreprise complet et le scénario intégral sans intervention technique restent
à réaliser. Phase 2.2 non commencée.

View file

@ -2,6 +2,15 @@
Ce document est une **proposition métier**, pas une validation automatique des durées. Les cinq durées présentes historiquement sont considérées non fiables et sont ignorées comme référence. Ce document est une **proposition métier**, pas une validation automatique des durées. Les cinq durées présentes historiquement sont considérées non fiables et sont ignorées comme référence.
## Implémentation Phase 2.1b
Les durées proposées sont désormais saisissables/modifiables dans la fiche
d'une tâche via `LotTask.duree_minutes`. Le mode `unit` les interprète par
élément et multiplie par la quantité de l'équipement ; `fixed` conserve un
forfait par cible. Aucune des cinq durées historiques n'est reprise
automatiquement et une tâche sans durée est signalée « Durée à renseigner ».
Les propositions restent soumises à validation métier avant import en masse.
**Répartition proposée :** PREVENTIVE : 151 · RÉGLEMENTAIRE : 9 · CONTRACTUELLE : 55 · CONDITIONNELLE / CURATIVE : 114 · À CONFIRMER : 1 **Répartition proposée :** PREVENTIVE : 151 · RÉGLEMENTAIRE : 9 · CONTRACTUELLE : 55 · CONDITIONNELLE / CURATIVE : 114 · À CONFIRMER : 1
## Règles de lecture ## Règles de lecture
@ -972,4 +981,3 @@ Ce document est une **proposition métier**, pas une validation automatique des
|---:|---|---|---|---|---|---|---|---|---|---| |---:|---|---|---|---|---|---|---|---|---|---|
| 331 | Palettisation des déchets informatiques (ordinateurs réformés) en vue de leur évacuation | Selon besoin | À renseigner | NON | CONDITIONNELLE / CURATIVE | À définir au déclenchement | durée dintervention curative | À estimer lors du signalement | Non | action déclenchée par une anomalie, pas une échéance périodique | | 331 | Palettisation des déchets informatiques (ordinateurs réformés) en vue de leur évacuation | Selon besoin | À renseigner | NON | CONDITIONNELLE / CURATIVE | À définir au déclenchement | durée dintervention curative | À estimer lors du signalement | Non | action déclenchée par une anomalie, pas une échéance périodique |

View file

@ -0,0 +1,38 @@
"""Phase 2.1b: executor, duration mode and explicit preventive scope.
All columns are nullable-safe/defaulted so existing installations keep their
behaviour. The association table is additive and contains no copied files or
business records.
"""
from alembic import op
import sqlalchemy as sa
revision = "h3c4d5e6f7a8"
down_revision = "g2b3c4d5e6f7"
branch_labels = None
depends_on = None
def upgrade():
for name in ("mowing_minutes", "edging_minutes", "hedge_minutes", "branch_removal_minutes"):
op.add_column("zones", sa.Column(name, sa.Integer(), nullable=True))
op.add_column("lot_tasks", sa.Column("duration_mode", sa.String(20), nullable=False, server_default="unit"))
op.add_column("lot_tasks", sa.Column("executor_type", sa.String(30), nullable=False, server_default="undefined"))
op.add_column("lot_tasks", sa.Column("scope_mode", sa.String(20), nullable=False, server_default="dynamic"))
op.create_table(
"lot_task_equipments",
sa.Column("lot_task_id", sa.Integer(), nullable=False),
sa.Column("equipment_id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(["lot_task_id"], ["lot_tasks.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["equipment_id"], ["equipments.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("lot_task_id", "equipment_id"),
)
def downgrade():
for name in ("branch_removal_minutes", "hedge_minutes", "edging_minutes", "mowing_minutes"):
op.drop_column("zones", name)
op.drop_table("lot_task_equipments")
op.drop_column("lot_tasks", "scope_mode")
op.drop_column("lot_tasks", "executor_type")
op.drop_column("lot_tasks", "duration_mode")

View file

@ -0,0 +1,21 @@
from types import SimpleNamespace
from app_new.core.models.maintenance import LotTask
from app_new.contracts.models import Contract # noqa: F401 - enregistre la relation ORM
def test_duration_is_per_element_and_group_quantity():
task = LotTask(duree_minutes=2, duration_mode="unit")
assert task.duration_for_equipment(SimpleNamespace(quantity=10)) == 20
assert task.duration_for_equipment(SimpleNamespace(quantity=1)) == 2
def test_missing_duration_is_explicitly_unplannable():
task = LotTask(duree_minutes=None, duration_mode="unit")
assert task.duration_is_configured is False
assert task.duration_for_equipment(SimpleNamespace(quantity=10)) == 0
def test_fixed_duration_does_not_multiply_quantity():
task = LotTask(duree_minutes=30, duration_mode="fixed")
assert task.duration_for_equipment(SimpleNamespace(quantity=10)) == 30