Unifie les règles et échéances de maintenance
Some checks are pending
CI - Tests et Syntax / lint-and-test (push) Waiting to run

This commit is contained in:
root 2026-08-14 21:39:55 +00:00
parent ecc76ddc35
commit b6c94b74d7
16 changed files with 245 additions and 46 deletions

View file

@ -39,6 +39,9 @@ class Contract(db.Model):
# Statut
status = db.Column(db.String(20), default="actif") # actif, expire, resilie, en_attente
notes = db.Column(db.Text, nullable=True)
visit_interval_days = db.Column(db.Integer, nullable=True)
last_visit_date = db.Column(db.Date, nullable=True)
next_visit_date = db.Column(db.Date, nullable=True)
# Documents
contract_file = db.Column(db.String(500), nullable=True)

View file

@ -11,6 +11,14 @@ from ..core.models.equipment import Equipment
from ..companies.routes import Company
from datetime import datetime, date
def _optional_date(value):
return datetime.strptime(value, '%Y-%m-%d').date() if value else None
def _apply_visits(contract):
contract.visit_interval_days = request.form.get('visit_interval_days', type=int)
contract.last_visit_date = _optional_date(request.form.get('last_visit_date'))
contract.next_visit_date = _optional_date(request.form.get('next_visit_date'))
contracts_bp = Blueprint('contracts', __name__, url_prefix='/contracts', template_folder='templates')
@ -64,6 +72,7 @@ def create():
notes=request.form.get('notes', ''),
)
db.session.add(contract)
_apply_visits(contract)
db.session.commit()
flash('Contrat cree avec succes.', 'success')
return redirect(url_for('contracts.detail', id=contract.id))
@ -112,6 +121,7 @@ def edit(id):
contract.annual_amount = float(request.form.get('annual_amount')) if request.form.get('annual_amount') else None
contract.status = request.form.get('status', 'actif')
contract.notes = request.form.get('notes', '')
_apply_visits(contract)
db.session.commit()
flash('Contrat modifie.', 'success')
return redirect(url_for('contracts.detail', id=contract.id))

View file

@ -69,6 +69,7 @@
{% endif %}
{% else %}<span class="text-muted">Duree indeterminee</span>{% endif %}
</td></tr>
<tr><td class="text-muted">Visites extérieures</td><td>{% if contract.visit_interval_days %}Tous les {{ contract.visit_interval_days }} jours · prochaine visite {{ contract.next_visit_date.strftime('%d/%m/%Y') if contract.next_visit_date else 'à planifier' }}{% else %}—{% endif %}</td></tr>
</table>
</div>
</div>

View file

@ -54,6 +54,12 @@
</div>
</div>
<div class="card border-0 shadow-sm mb-3"><div class="card-body"><h6 class="text-muted"><i class="bi bi-tools"></i> Visites de maintenance extérieure</h6><div class="row g-3">
<div class="col-md-4"><label class="form-label">Fréquence (jours)</label><input type="number" min="1" name="visit_interval_days" class="form-control" value="{{ contract.visit_interval_days if contract and contract.visit_interval_days else '' }}" placeholder="30, 60, 180…"></div>
<div class="col-md-4"><label class="form-label">Dernière visite</label><input type="date" name="last_visit_date" class="form-control" value="{{ contract.last_visit_date.isoformat() if contract and contract.last_visit_date else '' }}"></div>
<div class="col-md-4"><label class="form-label">Prochaine visite</label><input type="date" name="next_visit_date" class="form-control" value="{{ contract.next_visit_date.isoformat() if contract and contract.next_visit_date else '' }}"></div>
</div></div></div>
<div class="card border-0 shadow-sm mb-3">
<div class="card-body">
<h6 class="text-muted"><i class="bi bi-calendar3"></i> Dates et duree</h6>

View file

@ -14,6 +14,9 @@ class EquipmentCategory(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False)
description = db.Column(db.Text, nullable=True)
recurrence_alert_enabled = db.Column(db.Boolean, nullable=False, default=False)
recurrence_threshold = db.Column(db.Integer, nullable=False, default=3)
recurrence_window_days = db.Column(db.Integer, nullable=False, default=365)
# Relations
equipments = db.relationship("Equipment", back_populates="category", overlaps="category")
@ -173,7 +176,15 @@ class Equipment(db.Model):
@property
def needs_deep_work(self):
"""Nécessite une intervention profonde."""
return bool(self.recurrence_monitoring and self.curative_count >= 3)
category = self.effective_category
enabled = self.recurrence_monitoring or bool(category and category.recurrence_alert_enabled)
threshold = category.recurrence_threshold if category else 3
if not enabled:
return False
from datetime import timedelta
window = category.recurrence_window_days if category else 365
cutoff = datetime.now(timezone.utc) - timedelta(days=window)
return len([i for i in self.interventions if i.type == 'curatif' and i.created_at and i.created_at >= cutoff]) >= threshold
@property
def is_mobile(self):

View file

@ -379,6 +379,13 @@ class LotTask(db.Model):
jours_entre_interventions = db.Column(db.Integer, nullable=True)
type_maintenance = db.Column(db.String(50), nullable=True)
duree_minutes = db.Column(db.Integer, nullable=True) # Durée estimée en minutes
trigger_type = db.Column(db.String(20), nullable=False, default="calendar")
trigger_event = db.Column(db.String(80), nullable=True)
meter_threshold = db.Column(db.Float, nullable=True)
season_start_month = db.Column(db.Integer, nullable=True)
season_end_month = db.Column(db.Integer, nullable=True)
advance_days = db.Column(db.Integer, nullable=False, default=0)
is_active = db.Column(db.Boolean, nullable=False, default=True)
def effective_duration(self):
"""Duree previsionnelle auto-adaptee pour cette tache de lot.

View file

@ -0,0 +1,58 @@
"""Moteur unique de génération des échéances depuis les tâches de lot."""
from datetime import date, timedelta
from app_new.extensions import db
from app_new.core.models.planning import Meter, ScheduledTask
from app_new.core.models.maintenance import LotTask
from app_new.core.services.planning_service import PlanningService
def _targets(lot):
result = []
for root in lot.equipments.filter_by(is_deleted=False):
if root.tracked_individually:
result.extend([x for x in root.all_children if not x.is_group and not x.is_deleted])
elif root.is_group and root.children.count():
result.extend([x for x in root.children if not x.is_deleted])
else:
result.append(root)
return list({item.id: item for item in result}.values())
def _working_day(candidate):
for _ in range(370):
if PlanningService.get_working_hours(candidate):
return candidate
candidate += timedelta(days=1)
raise ValueError("Aucune journée travaillée disponible dans les 12 prochains mois.")
def generate_due_tasks(task: LotTask, event=None, today=None):
"""Génère les échéances sans doublon et retourne les objets créés."""
today = today or date.today()
if not task.is_active:
return []
if task.trigger_type in ("event", "weather") and task.trigger_event != event:
return []
if task.trigger_type == "season":
start, end = task.season_start_month or 1, task.season_end_month or 12
inside = start <= today.month <= end if start <= end else today.month >= start or today.month <= end
if not inside:
return []
created = []
for equipment in _targets(task.lot):
if task.trigger_type == "meter":
meter = Meter.query.filter_by(equipment_id=equipment.id, is_active=True).order_by(Meter.current_value.desc()).first()
if not meter or task.meter_threshold is None or meter.current_value < task.meter_threshold:
continue
existing = ScheduledTask.query.filter_by(lot_task_id=task.id, equipment_id=equipment.id, status="planned").first()
if existing:
continue
due = _working_day(today + timedelta(days=max(task.advance_days or 0, 0)))
item = ScheduledTask(lot_task_id=task.id, equipment_id=equipment.id,
room_id=equipment.effective_room.id if equipment.effective_room else None,
scheduled_date=due, estimated_duration=task.effective_duration(), status="planned")
db.session.add(item)
created.append(item)
db.session.commit()
return created

View file

@ -440,6 +440,18 @@ def category_detail(id):
return render_template('equipments/category_detail.html', category=category, equipments=equipments)
@main_bp.route('/categories/<int:id>/recurrence-policy', methods=['POST'])
@login_required
def category_recurrence_policy(id):
category = EquipmentCategory.query.get_or_404(id)
category.recurrence_alert_enabled = request.form.get('enabled') == 'on'
category.recurrence_threshold = max(1, request.form.get('threshold', type=int) or 3)
category.recurrence_window_days = max(1, request.form.get('window_days', type=int) or 365)
db.session.commit()
flash('Politique de récurrence mise à jour.', 'success')
return redirect(url_for('equipments.category_detail', id=id))
@main_bp.route('/categories/new', methods=['GET', 'POST'])
@login_required
def create_category():

View file

@ -165,6 +165,13 @@ def edit_task(id):
task.periodicite = request.form.get('periodicite') or None
task.jours_entre_interventions = int(request.form.get('jours_entre_interventions')) if request.form.get('jours_entre_interventions') and request.form.get('jours_entre_interventions').isdigit() else None
task.type_maintenance = request.form.get('type_maintenance') or None
task.trigger_type = request.form.get('trigger_type', 'calendar')
task.trigger_event = request.form.get('trigger_event') or None
task.meter_threshold = request.form.get('meter_threshold', type=float)
task.season_start_month = request.form.get('season_start_month', type=int)
task.season_end_month = request.form.get('season_end_month', type=int)
task.advance_days = request.form.get('advance_days', type=int, default=0)
task.is_active = request.form.get('is_active') == 'on'
task.contrat = request.form.get('contrat') == 'on'
db.session.commit()
@ -174,28 +181,36 @@ def edit_task(id):
return render_template('planning/edit_task.html', task=task)
@planning_bp.route('/tasks/<int:id>/generate', methods=['POST'])
@login_required
def generate_task(id):
from app_new.core.models.maintenance import LotTask
from app_new.core.services.maintenance_engine import generate_due_tasks
task = LotTask.query.get_or_404(id)
created = generate_due_tasks(task, event=(request.form.get('event') or '').strip() or None)
flash(f'{len(created)} échéance(s) créée(s), sans doublon.', 'success' if created else 'info')
return redirect(url_for('planning.tasks'))
@planning_bp.route('/tasks/new', methods=['GET', 'POST'])
@login_required
def new_task():
"""Créer une nouvelle tâche préventive."""
if request.method == 'POST':
task = PreventiveTask(
task = LotTask(
lot_id=request.form.get('lot_id', type=int),
category_id=request.form.get('category_id', type=int) or None,
task_number=request.form.get('task_number'),
name=request.form.get('name'),
description=request.form.get('description'),
location_pattern=request.form.get('location_pattern'),
period_days=request.form.get('period_days', type=int),
duration_minutes=request.form.get('duration_minutes', type=int, default=30),
has_contract=request.form.get('has_contract') == 'on',
manager=request.form.get('manager'),
company_id=request.form.get('company_id', type=int) or None,
trigger_type=request.form.get('trigger_type', 'time'),
meter_type_required=request.form.get('meter_type_required'),
num_tache=request.form.get('task_number'),
tache=request.form.get('name'),
periodicite=request.form.get('periodicite'),
jours_entre_interventions=request.form.get('period_days', type=int),
duree_minutes=request.form.get('duration_minutes', type=int, default=30),
contrat=request.form.get('has_contract') == 'on',
gestionnaire=request.form.get('manager'),
trigger_type=request.form.get('trigger_type', 'calendar'),
trigger_event=request.form.get('trigger_event') or None,
meter_threshold=request.form.get('meter_threshold', type=float),
requires_room_access=request.form.get('requires_room_access') == 'on',
allowed_time_slots=request.form.get('allowed_time_slots', 'always'),
season_start_month=request.form.get('season_start_month', type=int),
season_end_month=request.form.get('season_end_month', type=int),
is_active=True
)
db.session.add(task)
@ -318,5 +333,3 @@ def new_consumable():
title='Nouveau consommable',
consumable=None,
companies=companies)

View file

@ -52,6 +52,15 @@
<small class="text-muted">Ou laissez vide si périodicité textuelle</small>
</div>
</div>
<div class="card bg-light mb-3"><div class="card-body"><h6>Déclenchement</h6><div class="row g-2">
<div class="col-md-4"><label class="form-label">Type</label><select name="trigger_type" class="form-select">{% for value,label in [('calendar','Calendrier'),('meter','Compteur'),('event','Événement'),('weather','Météo'),('season','Saison')] %}<option value="{{ value }}" {% if task.trigger_type == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></div>
<div class="col-md-4"><label class="form-label">Événement</label><input name="trigger_event" class="form-control" value="{{ task.trigger_event or '' }}" placeholder="orage, gel…"></div>
<div class="col-md-4"><label class="form-label">Seuil compteur</label><input type="number" step="0.01" name="meter_threshold" class="form-control" value="{{ task.meter_threshold or '' }}"></div>
<div class="col-md-3"><label class="form-label">Mois début</label><input type="number" min="1" max="12" name="season_start_month" class="form-control" value="{{ task.season_start_month or '' }}"></div>
<div class="col-md-3"><label class="form-label">Mois fin</label><input type="number" min="1" max="12" name="season_end_month" class="form-control" value="{{ task.season_end_month or '' }}"></div>
<div class="col-md-3"><label class="form-label">Anticipation (jours)</label><input type="number" min="0" name="advance_days" class="form-control" value="{{ task.advance_days or 0 }}"></div>
<div class="col-md-3 d-flex align-items-end"><div class="form-check mb-2"><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></div></div>
<div class="row mb-3">
<div class="col-md-6">

View file

@ -22,30 +22,19 @@
</select>
</div>
<div class="mb-3">
<label class="form-label">Catégorie (optionnel)</label>
<select name="category_id" class="form-select">
<option value="">-- Toutes les catégories --</option>
{% for cat in categories %}
<option value="{{ cat.id }}" {% if task and task.category_id == cat.id %}selected{% endif %}>{{ cat.name }}</option>
{% endfor %}
</select>
<small class="text-muted">Filtre par type d'équipement (ex: Fenêtres uniquement)</small>
</div>
<div class="mb-3">
<label class="form-label">Numéro</label>
<input type="text" name="task_number" class="form-control" value="{{ task.task_number if task else '' }}" placeholder="Ex: 201">
<input type="text" name="task_number" class="form-control" placeholder="Ex: 201">
</div>
<div class="mb-3">
<label class="form-label">Nom *</label>
<input type="text" name="name" class="form-control" value="{{ task.name if task else '' }}" required placeholder="Ex: Vérification fenêtres">
<input type="text" name="name" class="form-control" required placeholder="Ex: Vérification fenêtres">
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<textarea name="description" class="form-control" rows="3">{{ task.description if task else '' }}</textarea>
<textarea name="periodicite" class="form-control" rows="2" placeholder="Ex. tous les ans avant l'hiver"></textarea>
</div>
<div class="mb-3">
@ -65,7 +54,7 @@
<div class="card-body">
<div class="mb-3">
<label class="form-label">Périodicité (jours) *</label>
<input type="number" name="period_days" class="form-control" value="{{ task.period_days if task else '365' }}" required min="1">
<input type="number" name="period_days" class="form-control" value="365" min="1">
<small class="text-muted">365 = annuel, 30 = mensuel, 7 = hebdomadaire, 1 = quotidien</small>
</div>
@ -77,8 +66,7 @@
<div class="mb-3">
<label class="form-label">Déclenchement</label>
<select name="trigger_type" class="form-select">
<option value="time" {% if task and task.trigger_type == 'time' %}selected{% endif %}>Basé sur le temps</option>
<option value="meter" {% if task and task.trigger_type == 'meter' %}selected{% endif %}>Basé sur un compteur</option>
<option value="calendar">Calendrier</option><option value="meter">Compteur</option><option value="event">Événement</option><option value="weather">Météo (orage, gel…)</option><option value="season">Saison</option>
</select>
</div>
@ -95,6 +83,7 @@
<input type="number" name="meter_threshold" class="form-control" value="{{ task.meter_threshold if task else '' }}" step="0.1">
<small class="text-muted">Déclencher la maintenance à cette valeur</small>
</div>
<div class="row g-2"><div class="col-md-6"><label class="form-label">Événement déclencheur</label><input name="trigger_event" class="form-control" placeholder="orage, gel, rentrée…"></div><div class="col-md-3"><label class="form-label">Mois début</label><input type="number" min="1" max="12" name="season_start_month" class="form-control"></div><div class="col-md-3"><label class="form-label">Mois fin</label><input type="number" min="1" max="12" name="season_end_month" class="form-control"></div></div>
</div>
</div>

View file

@ -115,6 +115,7 @@
<a href="{{ url_for('planning.edit_task', id=task.id) }}" class="btn btn-sm btn-outline-primary" title="Modifier">
<i class="bi bi-pencil"></i>
</a>
<form class="d-inline" method="post" action="{{ url_for('planning.generate_task', id=task.id) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><input type="hidden" name="event" value="{{ task.trigger_event or '' }}"><button class="btn btn-sm btn-outline-success" title="Générer les échéances"><i class="bi bi-calendar-plus"></i></button></form>
</td>
</tr>
{% else %}

View file

@ -11,7 +11,7 @@ Genere les taches planifiees en croisant :
from datetime import datetime, timezone, date, time, timedelta
from ..extensions import db
from ..core.models.planning import (
WorkSchedule, CollegeClosure, PersonalLeave, Training,
WorkSchedule, CollegeClosure, ClosureWorkDay, PersonalLeave, Training,
PreventiveTask, PlanningDay, PlanningItem
)
from ..core.models.maintenance import Intervention, LotTask, Lot
@ -32,6 +32,12 @@ def get_working_hours(check_date, user_id=1):
).first()
if closure:
exceptional_day = ClosureWorkDay.query.filter_by(
closure_id=closure.id, work_date=check_date
).first()
if exceptional_day:
return (exceptional_day.start_time, exceptional_day.end_time,
exceptional_day.lunch_start, exceptional_day.lunch_end)
if closure.work_hours_type == 'none':
return None # Ferme
# Horaires reduits pendant les vacances
@ -132,7 +138,7 @@ def get_due_tasks(weeks_ahead=4):
})
# 2. LotTask avec jours_entre_interventions
for lt in LotTask.query.all():
for lt in LotTask.query.filter_by(is_active=True, trigger_type='calendar').all():
if not lt.jours_entre_interventions:
continue
lot = Lot.query.get(lt.lot_id)

View file

@ -30,6 +30,7 @@
</dl>
</div>
</div>
<div class="card mb-4"><div class="card-header"><h5 class="mb-0">Alerte de pannes récurrentes</h5></div><div class="card-body"><form method="post" action="{{ url_for('equipments.category_recurrence_policy', id=category.id) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><div class="form-check mb-2"><input class="form-check-input" type="checkbox" name="enabled" id="recurrence" {% if category.recurrence_alert_enabled %}checked{% endif %}><label class="form-check-label" for="recurrence">Activer pour cette catégorie</label></div><div class="row g-2"><div class="col"><label class="form-label">Nombre de pannes</label><input type="number" min="1" name="threshold" class="form-control" value="{{ category.recurrence_threshold }}"></div><div class="col"><label class="form-label">Sur une période de (jours)</label><input type="number" min="1" name="window_days" class="form-control" value="{{ category.recurrence_window_days }}"></div></div><button class="btn btn-sm btn-primary mt-3">Enregistrer</button></form></div></div>
</div>
<div class="col-md-6">

View file

@ -0,0 +1,34 @@
"""Unifie les règles de maintenance dans les tâches de lot.
Revision ID: e6f0a1b2c3d4
Revises: d5e9f0a1b2c3
"""
from alembic import op
import sqlalchemy as sa
revision = "e6f0a1b2c3d4"
down_revision = "d5e9f0a1b2c3"
branch_labels = None
depends_on = None
def upgrade():
for name, column in [
("trigger_type", sa.Column("trigger_type", sa.String(20), nullable=False, server_default="calendar")),
("trigger_event", sa.Column("trigger_event", sa.String(80))),
("meter_threshold", sa.Column("meter_threshold", sa.Float())),
("season_start_month", sa.Column("season_start_month", sa.Integer())),
("season_end_month", sa.Column("season_end_month", sa.Integer())),
("advance_days", sa.Column("advance_days", sa.Integer(), nullable=False, server_default="0")),
("is_active", sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true())),
]: op.add_column("lot_tasks", column)
op.add_column("equipment_categories", sa.Column("recurrence_alert_enabled", sa.Boolean(), nullable=False, server_default=sa.false()))
op.add_column("equipment_categories", sa.Column("recurrence_threshold", sa.Integer(), nullable=False, server_default="3"))
op.add_column("equipment_categories", sa.Column("recurrence_window_days", sa.Integer(), nullable=False, server_default="365"))
op.add_column("contracts", sa.Column("visit_interval_days", sa.Integer()))
op.add_column("contracts", sa.Column("last_visit_date", sa.Date()))
op.add_column("contracts", sa.Column("next_visit_date", sa.Date()))
def downgrade():
for name in ("next_visit_date", "last_visit_date", "visit_interval_days"): op.drop_column("contracts", name)
for name in ("recurrence_window_days", "recurrence_threshold", "recurrence_alert_enabled"): op.drop_column("equipment_categories", name)
for name in ("is_active", "advance_days", "season_end_month", "season_start_month", "meter_threshold", "trigger_event", "trigger_type"): op.drop_column("lot_tasks", name)

View file

@ -0,0 +1,38 @@
from datetime import date, time, timedelta
from uuid import uuid4
from app_new.extensions import db
from app_new.core.models.college import Building, Room
from app_new.core.models.equipment import Equipment, EquipmentCategory
from app_new.core.models.maintenance import Lot, LotTask
from app_new.core.models.planning import CollegeClosure, ClosureWorkDay, ScheduledTask
from app_new.core.services.maintenance_engine import generate_due_tasks
from app_new.scheduler.engine import get_working_hours
def test_exceptional_vacation_day_has_its_specific_hours(app):
with app.app_context():
worked = date.today() + timedelta(days=20)
closure = CollegeClosure(name=f"Vacances {uuid4().hex}", start_date=worked, end_date=worked, work_hours_type="none")
db.session.add(closure); db.session.flush()
db.session.add(ClosureWorkDay(closure_id=closure.id, work_date=worked, start_time=time(7, 30), end_time=time(14)))
db.session.commit()
assert get_working_hours(worked)[:2] == (time(7, 30), time(14))
def test_lot_task_engine_uses_individual_units_and_prevents_duplicates(app):
with app.app_context():
suffix = uuid4().hex[:7]
building = Building(name=f"B {suffix}"); db.session.add(building); db.session.flush()
room = Room(name="Local", building_id=building.id); category = EquipmentCategory(name=f"Cat {suffix}")
lot = Lot(name=f"Lot {suffix}", category=category); db.session.add_all([room, category, lot]); db.session.flush()
root = Equipment(name=f"Groupe {suffix}", lot=lot, category=category, is_group=True, tracked_individually=True)
db.session.add(root); db.session.flush()
unit = Equipment(name=f"Unité {suffix}", parent_id=root.id, room_id=room.id, tracked_individually=True)
task = LotTask(lot=lot, tache="Contrôle", trigger_type="event", trigger_event="gel", is_active=True)
db.session.add_all([unit, task]); db.session.commit()
assert generate_due_tasks(task, event="orage") == []
assert len(generate_due_tasks(task, event="gel")) == 1
assert generate_due_tasks(task, event="gel") == []
scheduled = ScheduledTask.query.filter_by(lot_task_id=task.id).one()
assert scheduled.equipment_id == unit.id