feat(preventive): appliquer les propositions de duree initiales

This commit is contained in:
root 2026-08-23 14:02:42 +00:00
parent 1ed724555a
commit c2cdacb0fa
6 changed files with 49 additions and 3 deletions

View file

@ -0,0 +1,27 @@
"""Valeurs initiales de durée unitaire issues de la proposition métier.
Ces valeurs sont des suggestions modifiables, jamais des durées
réglementaires. Elles ne sont utilisées que lors de la création d'une
nouvelle ligne de catalogue dont la durée est encore vide.
"""
PROPOSED_UNIT_DURATIONS = {
114: 2, 119: 2, 120: 2, 125: 5, 126: 5, 127: 10, 128: 5,
231: 5, 236: 10, 239: 2, 240: 5, 254: 5, 255: 5, 251: 5,
252: 10, 253: 10, 243: 10, 242: 2, 258: 5, 259: 5, 261: 5,
245: 5, 246: 5, 247: 5, 1: 2, 3: 5, 2: 2, 5: 2, 7: 10,
9: 2, 6: 2, 17: 10, 18: 2, 24: 2, 25: 5, 26: 5, 19: 2,
20: 5, 31: 10, 32: 5, 75: 2, 76: 2, 78: 5, 79: 10, 82: 5,
86: 5, 87: 10, 91: 5, 92: 10, 93: 10, 95: 10, 96: 10,
98: 10, 99: 5, 109: 5, 112: 5, 158: 5, 154: 10, 160: 5,
182: 5, 186: 10, 190: 5, 193: 5, 197: 5, 198: 5, 199: 5,
181: 2, 180: 5, 203: 10, 205: 10, 209: 2, 210: 5, 211: 10,
214: 5, 216: 5, 218: 2, 219: 5, 226: 5, 227: 5, 228: 5,
215: 2, 224: 5, 263: 5, 264: 10, 268: 10, 269: 5, 271: 2,
272: 2, 282: 2, 283: 10, 285: 5, 291: 10, 289: 5, 295: 5,
296: 5, 299: 5, 300: 5, 319: 10, 320: 5, 328: 10, 35: 2,
36: 5, 39: 10, 38: 5, 37: 10, 138: 2, 139: 2, 329: 10,
41: 2, 42: 10, 43: 5, 44: 5, 45: 5, 46: 5, 49: 2, 50: 5,
51: 5, 54: 5, 53: 2, 60: 5, 61: 5, 62: 5, 63: 10, 56: 10,
57: 10, 11: 2, 12: 5, 13: 5, 14: 2, 15: 5, 70: 2, 71: 5,
}

View file

@ -472,6 +472,7 @@ def api_save_step(step):
# ligne déjà présente n'est jamais écrasée : elle peut avoir été
# adaptée par l'établissement depuis le dernier import.
task_count = 0
from ..preventive_duration_proposals import PROPOSED_UNIT_DURATIONS
for item in LOT_TASKS:
lot = lots_by_name.get(item['lot'].casefold())
if lot is None:
@ -493,6 +494,14 @@ def api_save_step(step):
):
if field in item:
setattr(task, field, item[field])
# Les propositions sont uniquement des valeurs initiales pour
# une nouvelle ligne. Une ligne existante/personnalisée n'est
# jamais écrasée par une réexécution du wizard.
if not task.duree_minutes and task.num_tache:
try:
task.duree_minutes = PROPOSED_UNIT_DURATIONS.get(int(task.num_tache))
except (TypeError, ValueError):
task.duree_minutes = None
db.session.add(task)
task_count += 1

View file

@ -127,7 +127,7 @@ def generate_due_tasks(task: LotTask, event=None, today=None, equipment_ids=None
existing = ScheduledTask.query.filter(
ScheduledTask.lot_task_id == task.id,
ScheduledTask.equipment_id == equipment.id,
ScheduledTask.status.in_(['planned', 'in_progress', 'suspended']),
ScheduledTask.status.in_(['planned', 'in_progress', 'suspended', 'needs_duration']),
).first()
if existing:
continue

View file

@ -121,7 +121,7 @@
</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>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 %}
{% 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 %}<a class="text-warning" href="{{ url_for('lots.task_edit', lot_id=lot.id, task_id=task.id) }}">Durée à renseigner</a>{% 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>

View file

@ -21,7 +21,7 @@
<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-12"><label class="form-label" for="equipment-filter">Équipements concernés (périmètre manuel)</label><input id="equipment-filter" class="form-control mb-2" type="search" placeholder="Filtrer par équipement, salle, zone ou bâtiment" oninput="filterEquipmentScope(this.value)"><div class="d-flex gap-2 mb-2"><button type="button" class="btn btn-sm btn-outline-secondary" onclick="selectVisibleEquipment(true)">Tout sélectionner</button><button type="button" class="btn btn-sm btn-outline-secondary" onclick="selectVisibleEquipment(false)">Tout désélectionner</button></div><select id="equipment-scope" class="form-select" name="equipment_ids" multiple size="6" aria-describedby="scope-help">{% for equipment in equipments %}<option value="{{ equipment.id }}" data-search="{{ equipment.name }} {{ equipment.room.name if equipment.room else '' }} {{ equipment.room.zone.name if equipment.room and equipment.room.zone else '' }} {{ equipment.room.building.name if equipment.room and equipment.room.building else '' }}" {% 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">La sélection s'applique aux équipements filtrés. 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 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>
@ -34,4 +34,8 @@
<div class="card-footer d-flex justify-content-end gap-2"><a class="btn btn-outline-secondary" href="{{ url_for('lots.detail', id=lot.id) }}">Annuler</a><button class="btn btn-primary"><i class="bi bi-check-lg"></i> Enregistrer</button></div>
</form>
</div>
<script>
function filterEquipmentScope(value) { const q = value.toLowerCase(); document.querySelectorAll('#equipment-scope option').forEach(o => { o.hidden = q && !(o.dataset.search || '').toLowerCase().includes(q); }); }
function selectVisibleEquipment(selected) { document.querySelectorAll('#equipment-scope option').forEach(o => { if (!o.hidden) o.selected = selected; }); }
</script>
{% endblock %}

View file

@ -2,6 +2,7 @@ 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
from app_new.core.preventive_duration_proposals import PROPOSED_UNIT_DURATIONS
def test_duration_is_per_element_and_group_quantity():
@ -19,3 +20,8 @@ def test_missing_duration_is_explicitly_unplannable():
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
def test_duration_proposals_are_explicit_and_not_historical_fallbacks():
assert len(PROPOSED_UNIT_DURATIONS) == 132
assert all(value > 0 for value in PROPOSED_UNIT_DURATIONS.values())