feat(preventive): ajouter duree unitaire et perimetres
This commit is contained in:
parent
e65e653bd6
commit
22e8f8eda4
14 changed files with 261 additions and 10 deletions
|
|
@ -118,6 +118,12 @@ class Zone(db.Model):
|
|||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(100), nullable=False)
|
||||
building_id = db.Column(db.Integer, db.ForeignKey("buildings.id"), nullable=False)
|
||||
# Durées propres aux espaces verts de cette zone (minutes). Elles restent
|
||||
# nulles pour les zones qui ne sont pas concernées.
|
||||
mowing_minutes = db.Column(db.Integer, nullable=True)
|
||||
edging_minutes = db.Column(db.Integer, nullable=True)
|
||||
hedge_minutes = db.Column(db.Integer, nullable=True)
|
||||
branch_removal_minutes = db.Column(db.Integer, nullable=True)
|
||||
|
||||
# Relations
|
||||
building = db.relationship("Building", back_populates="zones")
|
||||
|
|
|
|||
|
|
@ -83,6 +83,10 @@ class Equipment(db.Model):
|
|||
"EquipmentLifecycleEvent", back_populates="equipment",
|
||||
order_by="desc(EquipmentLifecycleEvent.created_at)", cascade="all, delete-orphan",
|
||||
)
|
||||
preventive_tasks = db.relationship(
|
||||
"LotTask", secondary="lot_task_equipments",
|
||||
back_populates="equipment_scope", lazy="selectin",
|
||||
)
|
||||
|
||||
@property
|
||||
def effective_lot_id(self):
|
||||
|
|
|
|||
|
|
@ -7,6 +7,13 @@ from datetime import datetime, timezone
|
|||
from ...extensions import db
|
||||
|
||||
|
||||
lot_task_equipments = db.Table(
|
||||
"lot_task_equipments",
|
||||
db.Column("lot_task_id", db.Integer, db.ForeignKey("lot_tasks.id", ondelete="CASCADE"), primary_key=True),
|
||||
db.Column("equipment_id", db.Integer, db.ForeignKey("equipments.id", ondelete="CASCADE"), primary_key=True),
|
||||
)
|
||||
|
||||
|
||||
class Intervention(db.Model):
|
||||
"""Intervention de maintenance - compatible avec la base existante."""
|
||||
__tablename__ = "interventions"
|
||||
|
|
@ -444,11 +451,48 @@ class LotTask(db.Model):
|
|||
advance_days = db.Column(db.Integer, nullable=False, default=0)
|
||||
is_active = db.Column(db.Boolean, nullable=False, default=True)
|
||||
application_scope = db.Column(db.String(20), nullable=False, default="auto")
|
||||
# V2.1b: la durée est une valeur métier par élément (ou par zone pour
|
||||
# les espaces verts), jamais une durée implicite du moteur.
|
||||
duration_mode = db.Column(db.String(20), nullable=False, default="unit")
|
||||
executor_type = db.Column(db.String(30), nullable=False, default="undefined")
|
||||
scope_mode = db.Column(db.String(20), nullable=False, default="dynamic")
|
||||
unavailable_policy = db.Column(db.String(20), nullable=False, default="suspend")
|
||||
after_repair_policy = db.Column(db.String(20), nullable=False, default="preserve")
|
||||
is_safety_critical = db.Column(db.Boolean, nullable=False, default=False)
|
||||
company = db.relationship("Company")
|
||||
contract = db.relationship("Contract")
|
||||
equipment_scope = db.relationship(
|
||||
"Equipment", secondary="lot_task_equipments",
|
||||
back_populates="preventive_tasks", lazy="selectin",
|
||||
)
|
||||
|
||||
@property
|
||||
def duration_is_configured(self):
|
||||
return bool(self.duree_minutes and self.duree_minutes > 0)
|
||||
|
||||
def applicable_equipments(self):
|
||||
"""Retourne le périmètre effectif sans créer de tournée globale.
|
||||
|
||||
Un périmètre manuel est strictement limité aux équipements choisis;
|
||||
le périmètre dynamique suit le lot et inclut les équipements ajoutés.
|
||||
"""
|
||||
from .equipment import Equipment
|
||||
if self.scope_mode == "manual":
|
||||
return [e for e in self.equipment_scope if not e.is_deleted]
|
||||
# `effective_lot_id` gère aussi les groupes imbriqués. Cette résolution
|
||||
# reste volontairement explicite pour ne pas perdre un descendant lors
|
||||
# d'un déplacement de patrimoine.
|
||||
return [e for e in Equipment.query.filter(Equipment.is_deleted.is_(False)).all()
|
||||
if e.effective_lot_id == self.lot_id]
|
||||
|
||||
def duration_for_equipment(self, equipment):
|
||||
"""Durée calculée pour une cible, en tenant compte de sa quantité."""
|
||||
if not self.duration_is_configured:
|
||||
return 0
|
||||
if self.duration_mode != "unit":
|
||||
return self.duree_minutes
|
||||
quantity = max(int(getattr(equipment, "quantity", 1) or 1), 1)
|
||||
return self.duree_minutes * quantity
|
||||
|
||||
def effective_duration(self):
|
||||
"""Duree previsionnelle auto-adaptee pour cette tache de lot.
|
||||
|
|
@ -456,7 +500,7 @@ class LotTask(db.Model):
|
|||
Si duree_minutes est defini manuellement, on l'utilise.
|
||||
Sinon on calcule la moyenne des durees reelles des interventions
|
||||
passees pour ce lot + ce type de tache.
|
||||
Fallback: 60 minutes par defaut.
|
||||
Aucune valeur arbitraire n'est inventée si la durée n'est pas connue.
|
||||
"""
|
||||
from sqlalchemy import func
|
||||
if self.duree_minutes:
|
||||
|
|
|
|||
|
|
@ -41,6 +41,17 @@ def _targets(lot, application_scope="auto", equipment_ids=None):
|
|||
return list({item.id: item for item in result}.values())
|
||||
|
||||
|
||||
def _task_targets(task, equipment_ids=None):
|
||||
"""Résout le périmètre déclaré par la règle préventive."""
|
||||
if getattr(task, "scope_mode", "dynamic") == "manual":
|
||||
targets = list(task.applicable_equipments())
|
||||
if equipment_ids is not None:
|
||||
wanted = set(equipment_ids)
|
||||
targets = [e for e in targets if e.id in wanted]
|
||||
return targets
|
||||
return _targets(task.lot, task.application_scope, equipment_ids)
|
||||
|
||||
|
||||
def next_working_day(candidate):
|
||||
for _ in range(370):
|
||||
if PlanningService.get_working_hours(candidate):
|
||||
|
|
@ -99,7 +110,7 @@ def generate_due_tasks(task: LotTask, event=None, today=None, equipment_ids=None
|
|||
if not task.lot or not task.lot.is_present:
|
||||
return []
|
||||
requested_ids = set(equipment_ids) if equipment_ids is not None else None
|
||||
for equipment in _targets(task.lot, task.application_scope, requested_ids):
|
||||
for equipment in _task_targets(task, requested_ids):
|
||||
if equipment_ids is not None and equipment.id not in set(equipment_ids):
|
||||
continue
|
||||
if equipment.lifecycle_status == "jete" or equipment.status == "jete":
|
||||
|
|
@ -135,11 +146,14 @@ def generate_due_tasks(task: LotTask, event=None, today=None, equipment_ids=None
|
|||
due = next_working_day(max(due, today))
|
||||
room = equipment.effective_room
|
||||
status = "suspended" if unavailable and not task.is_safety_critical and task.unavailable_policy == "suspend" else "planned"
|
||||
# Une règle sans durée reste visible comme « à renseigner », mais ne
|
||||
# doit pas être engagée automatiquement dans une journée.
|
||||
estimated_duration = task.duration_for_equipment(equipment)
|
||||
item = ScheduledTask(lot_task_id=task.id, equipment_id=equipment.id,
|
||||
room_id=room.id if room else None,
|
||||
company_id=task.company_id,
|
||||
contract_id=task.contract_id,
|
||||
scheduled_date=due, estimated_duration=task.effective_duration(), status=status,
|
||||
scheduled_date=due, estimated_duration=estimated_duration, status=("needs_duration" if not estimated_duration else status),
|
||||
suspension_reason="Équipement indisponible" if status == "suspended" else None,
|
||||
room_name_snapshot=room.name if room else None,
|
||||
lot_name_snapshot=task.lot.name,
|
||||
|
|
|
|||
|
|
@ -23,7 +23,11 @@ def create():
|
|||
if request.method == 'POST':
|
||||
zone = Zone(
|
||||
name=request.form.get('name'),
|
||||
building_id=request.form.get('building_id', type=int)
|
||||
building_id=request.form.get('building_id', type=int),
|
||||
mowing_minutes=request.form.get('mowing_minutes', type=int) or None,
|
||||
edging_minutes=request.form.get('edging_minutes', type=int) or None,
|
||||
hedge_minutes=request.form.get('hedge_minutes', type=int) or None,
|
||||
branch_removal_minutes=request.form.get('branch_removal_minutes', type=int) or None,
|
||||
)
|
||||
db.session.add(zone)
|
||||
db.session.commit()
|
||||
|
|
@ -43,6 +47,10 @@ def edit(id):
|
|||
if request.method == 'POST':
|
||||
zone.name = request.form.get('name')
|
||||
zone.building_id = request.form.get('building_id', type=int)
|
||||
zone.mowing_minutes = request.form.get('mowing_minutes', type=int) or None
|
||||
zone.edging_minutes = request.form.get('edging_minutes', type=int) or None
|
||||
zone.hedge_minutes = request.form.get('hedge_minutes', type=int) or None
|
||||
zone.branch_removal_minutes = request.form.get('branch_removal_minutes', type=int) or None
|
||||
db.session.commit()
|
||||
flash(f"Zone '{zone.name}' mise à jour.", 'success')
|
||||
return redirect(url_for('zones.index'))
|
||||
|
|
|
|||
|
|
@ -89,12 +89,18 @@ def _apply_task_form(task):
|
|||
task.season_end_month = request.form.get('season_end_month', type=int)
|
||||
task.advance_days = max(request.form.get('advance_days', type=int) or 0, 0)
|
||||
task.application_scope = request.form.get('application_scope') or 'auto'
|
||||
task.duration_mode = request.form.get('duration_mode') or 'unit'
|
||||
task.scope_mode = request.form.get('scope_mode') or 'dynamic'
|
||||
task.unavailable_policy = request.form.get('unavailable_policy') or 'suspend'
|
||||
task.after_repair_policy = request.form.get('after_repair_policy') or 'preserve'
|
||||
task.is_safety_critical = request.form.get('is_safety_critical') == 'on'
|
||||
task.is_active = request.form.get('is_active') == 'on'
|
||||
task.company_id = request.form.get('company_id', type=int) or None
|
||||
task.contract_id = request.form.get('contract_id', type=int) or None
|
||||
task.executor_type = request.form.get('executor_type') or ('external_company' if task.company_id else 'undefined')
|
||||
if task.scope_mode == 'manual':
|
||||
selected = {int(value) for value in request.form.getlist('equipment_ids') if value.isdigit()}
|
||||
task.equipment_scope = Equipment.query.filter(Equipment.id.in_(selected)).all() if selected else []
|
||||
|
||||
|
||||
@lots_bp.route('/<int:lot_id>/tasks/new', methods=['GET', 'POST'])
|
||||
|
|
@ -111,7 +117,7 @@ def task_create(lot_id):
|
|||
db.session.commit()
|
||||
flash('Tâche préventive ajoutée au lot.', 'success')
|
||||
return redirect(url_for('lots.detail', id=lot.id))
|
||||
return render_template('lots/task_form.html', lot=lot, task=task, creating=True, companies=Company.query.order_by(Company.name).all(), contracts=Contract.query.order_by(Contract.name).all())
|
||||
return render_template('lots/task_form.html', lot=lot, task=task, creating=True, equipments=Equipment.query.filter_by(lot_id=lot.id, is_deleted=False).order_by(Equipment.name).all(), companies=Company.query.order_by(Company.name).all(), contracts=Contract.query.order_by(Contract.name).all())
|
||||
|
||||
|
||||
@lots_bp.route('/<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()
|
||||
flash('Règle préventive mise à jour.', 'success')
|
||||
return redirect(url_for('lots.detail', id=lot.id))
|
||||
return render_template('lots/task_form.html', lot=lot, task=task, creating=False, companies=Company.query.order_by(Company.name).all(), contracts=Contract.query.order_by(Contract.name).all())
|
||||
return render_template('lots/task_form.html', lot=lot, task=task, creating=False, equipments=Equipment.query.filter_by(lot_id=lot.id, is_deleted=False).order_by(Equipment.name).all(), companies=Company.query.order_by(Company.name).all(), contracts=Contract.query.order_by(Contract.name).all())
|
||||
|
||||
|
||||
@lots_bp.route('/<int:lot_id>/generate', methods=['POST'])
|
||||
|
|
|
|||
|
|
@ -19,6 +19,16 @@
|
|||
{% endfor %}
|
||||
</select>
|
||||
</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">
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</div>
|
||||
{% if lot.tasks %}<div class="table-responsive"><table class="table table-sm align-middle mb-0"><thead><tr><th>N°</th><th>Tâche</th><th>Déclenchement</th><th>Portée</th><th>Indisponibilité</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 %}
|
||||
{% if lot.tasks %}<div class="table-responsive"><table class="table table-sm align-middle mb-0"><thead><tr><th>N°</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.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 %}
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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">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">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">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">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">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">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 d’origine</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">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-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>
|
||||
|
|
|
|||
|
|
@ -79,3 +79,11 @@ un modèle de passage séparé. Les champs éventuels seraient notamment : mode
|
|||
d’exécutant, mode de passage, fenêtre, heure, durée d’accompagnement, statut,
|
||||
date réelle et périmètre dynamique. Aucune table ni migration n’est créée dans
|
||||
cette étude.
|
||||
# Mise en œuvre Phase 2.1b
|
||||
|
||||
Les tâches de lot disposent maintenant d'un exécutant explicite (interne,
|
||||
entreprise extérieure, Département, mixte ou à définir), d'une entreprise et
|
||||
d'un contrat facultatifs, ainsi que d'un périmètre dynamique ou manuel.
|
||||
Le rendez-vous, la fenêtre de passage, la non-venue et l'accompagnement
|
||||
restent à implémenter autour de `ContractVisit` dans une migration dédiée ;
|
||||
aucune date/heure fictive n'est donc créée dans cette passe.
|
||||
|
|
|
|||
80
docs/ui-audit/IMPLEMENTATION_PHASE21B.md
Normal file
80
docs/ui-audit/IMPLEMENTATION_PHASE21B.md
Normal 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.
|
||||
|
|
@ -2,6 +2,15 @@
|
|||
|
||||
Ce document est une **proposition métier**, pas une validation automatique des durées. Les cinq durées présentes historiquement sont considérées non fiables et sont ignorées comme référence.
|
||||
|
||||
## Implémentation Phase 2.1b
|
||||
|
||||
Les durées proposées sont désormais saisissables/modifiables dans la fiche
|
||||
d'une tâche via `LotTask.duree_minutes`. Le mode `unit` les interprète par
|
||||
élément et multiplie par la quantité de l'équipement ; `fixed` conserve un
|
||||
forfait par cible. Aucune des cinq durées historiques n'est reprise
|
||||
automatiquement et une tâche sans durée est signalée « Durée à renseigner ».
|
||||
Les propositions restent soumises à validation métier avant import en masse.
|
||||
|
||||
**Répartition proposée :** PREVENTIVE : 151 · RÉGLEMENTAIRE : 9 · CONTRACTUELLE : 55 · CONDITIONNELLE / CURATIVE : 114 · À CONFIRMER : 1
|
||||
|
||||
## Règles de lecture
|
||||
|
|
@ -972,4 +981,3 @@ Ce document est une **proposition métier**, pas une validation automatique des
|
|||
|---:|---|---|---|---|---|---|---|---|---|---|
|
||||
| 331 | Palettisation des déchets informatiques (ordinateurs réformés) en vue de leur évacuation | Selon besoin | À renseigner | NON | CONDITIONNELLE / CURATIVE | À définir au déclenchement | durée d’intervention curative | À estimer lors du signalement | Non | action déclenchée par une anomalie, pas une échéance périodique |
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
21
tests/unit/test_preventive_scope.py
Normal file
21
tests/unit/test_preventive_scope.py
Normal 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
|
||||
Loading…
Reference in a new issue