Unifie les règles et échéances de maintenance
Some checks are pending
CI - Tests et Syntax / lint-and-test (push) Waiting to run
Some checks are pending
CI - Tests et Syntax / lint-and-test (push) Waiting to run
This commit is contained in:
parent
ecc76ddc35
commit
b6c94b74d7
16 changed files with 245 additions and 46 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
58
app_new/core/services/maintenance_engine.py
Normal file
58
app_new/core/services/maintenance_engine.py
Normal 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
|
||||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
34
migrations/versions/e6f0a1b2c3d4_unify_maintenance_rules.py
Normal file
34
migrations/versions/e6f0a1b2c3d4_unify_maintenance_rules.py
Normal 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)
|
||||
38
tests/integration/test_maintenance_engine.py
Normal file
38
tests/integration/test_maintenance_engine.py
Normal 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
|
||||
Loading…
Reference in a new issue