feat(preventive): appliquer les propositions de duree initiales
This commit is contained in:
parent
1ed724555a
commit
c2cdacb0fa
6 changed files with 49 additions and 3 deletions
27
app_new/core/preventive_duration_proposals.py
Normal file
27
app_new/core/preventive_duration_proposals.py
Normal 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,
|
||||||
|
}
|
||||||
|
|
@ -472,6 +472,7 @@ def api_save_step(step):
|
||||||
# ligne déjà présente n'est jamais écrasée : elle peut avoir été
|
# ligne déjà présente n'est jamais écrasée : elle peut avoir été
|
||||||
# adaptée par l'établissement depuis le dernier import.
|
# adaptée par l'établissement depuis le dernier import.
|
||||||
task_count = 0
|
task_count = 0
|
||||||
|
from ..preventive_duration_proposals import PROPOSED_UNIT_DURATIONS
|
||||||
for item in LOT_TASKS:
|
for item in LOT_TASKS:
|
||||||
lot = lots_by_name.get(item['lot'].casefold())
|
lot = lots_by_name.get(item['lot'].casefold())
|
||||||
if lot is None:
|
if lot is None:
|
||||||
|
|
@ -493,6 +494,14 @@ def api_save_step(step):
|
||||||
):
|
):
|
||||||
if field in item:
|
if field in item:
|
||||||
setattr(task, field, item[field])
|
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)
|
db.session.add(task)
|
||||||
task_count += 1
|
task_count += 1
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -127,7 +127,7 @@ def generate_due_tasks(task: LotTask, event=None, today=None, equipment_ids=None
|
||||||
existing = ScheduledTask.query.filter(
|
existing = ScheduledTask.query.filter(
|
||||||
ScheduledTask.lot_task_id == task.id,
|
ScheduledTask.lot_task_id == task.id,
|
||||||
ScheduledTask.equipment_id == equipment.id,
|
ScheduledTask.equipment_id == equipment.id,
|
||||||
ScheduledTask.status.in_(['planned', 'in_progress', 'suspended']),
|
ScheduledTask.status.in_(['planned', 'in_progress', 'suspended', 'needs_duration']),
|
||||||
).first()
|
).first()
|
||||||
if existing:
|
if existing:
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -121,7 +121,7 @@
|
||||||
</div>
|
</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>Exécutant</th><th>Périodicité / durée</th><th>Périmètre</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>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 %}
|
{% 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 %}
|
</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>
|
||||||
|
|
|
||||||
|
|
@ -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-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-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">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">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">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>
|
<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>
|
</form>
|
||||||
</div>
|
</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 %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ from types import SimpleNamespace
|
||||||
|
|
||||||
from app_new.core.models.maintenance import LotTask
|
from app_new.core.models.maintenance import LotTask
|
||||||
from app_new.contracts.models import Contract # noqa: F401 - enregistre la relation ORM
|
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():
|
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():
|
def test_fixed_duration_does_not_multiply_quantity():
|
||||||
task = LotTask(duree_minutes=30, duration_mode="fixed")
|
task = LotTask(duree_minutes=30, duration_mode="fixed")
|
||||||
assert task.duration_for_equipment(SimpleNamespace(quantity=10)) == 30
|
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())
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue