2026-08-24 02:17:48 +02:00
|
|
|
|
"""Routes C2 : règles, échéances et tournées de relevés."""
|
|
|
|
|
|
|
2026-08-24 14:08:57 +02:00
|
|
|
|
from datetime import date, datetime, timedelta
|
2026-08-24 02:17:48 +02:00
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
from uuid import uuid4
|
|
|
|
|
|
|
|
|
|
|
|
from flask import abort, current_app, flash, jsonify, redirect, render_template, request, url_for
|
|
|
|
|
|
from flask_login import current_user, login_required
|
2026-08-24 13:14:06 +02:00
|
|
|
|
from ..core.authorization import permission_required
|
2026-08-24 02:17:48 +02:00
|
|
|
|
from werkzeug.utils import secure_filename
|
|
|
|
|
|
|
|
|
|
|
|
from ..extensions import db
|
|
|
|
|
|
from ..core.models.planning import (
|
2026-08-24 14:08:57 +02:00
|
|
|
|
Meter, MeterReading, MeterReadingOccurrence, MeterReadingRound, MeterReadingSchedule,
|
|
|
|
|
|
GasConversion,
|
2026-08-24 02:17:48 +02:00
|
|
|
|
)
|
|
|
|
|
|
from ..core.models.user import User
|
|
|
|
|
|
from ..core.services.meter_reading_planning import (
|
|
|
|
|
|
FREQUENCIES, NO_READING_REASONS, MeterDomainError, add_round_member, create_or_update_schedule,
|
2026-08-24 11:56:19 +02:00
|
|
|
|
create_round, ensure_occurrences_for_operational_date, generate_occurrences,
|
2026-08-24 02:17:48 +02:00
|
|
|
|
record_occurrence_reading, record_occurrence_without_reading,
|
|
|
|
|
|
)
|
2026-08-24 14:16:46 +02:00
|
|
|
|
from ..core.services.meter_analytics import (
|
|
|
|
|
|
consumption_intervals, visible_open_meter_alerts, close_meter_alert,
|
|
|
|
|
|
investigate_meter_alert,
|
|
|
|
|
|
)
|
2026-08-24 12:47:01 +02:00
|
|
|
|
from ..core.models.planning import MeterAlert, MeterAlertRule
|
2026-08-24 02:17:48 +02:00
|
|
|
|
from .schedules import planning_bp
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _admin_required():
|
|
|
|
|
|
if not current_user.is_admin():
|
|
|
|
|
|
abort(403)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@planning_bp.route('/meter-schedules')
|
|
|
|
|
|
@login_required
|
|
|
|
|
|
def meter_schedules():
|
|
|
|
|
|
_admin_required()
|
|
|
|
|
|
schedules = MeterReadingSchedule.query.order_by(MeterReadingSchedule.id.desc()).all()
|
|
|
|
|
|
return render_template('planning/meter_schedules.html', schedules=schedules)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@planning_bp.route('/meter-schedules/new', methods=['GET', 'POST'])
|
|
|
|
|
|
@login_required
|
|
|
|
|
|
def new_meter_schedule():
|
|
|
|
|
|
_admin_required()
|
|
|
|
|
|
meters = Meter.query.filter(Meter.status != 'replaced', Meter.is_active.is_(True)).order_by(Meter.name).all()
|
|
|
|
|
|
users = User.query.filter_by(is_active=True).order_by(User.full_name, User.username).all()
|
|
|
|
|
|
if request.method == 'POST':
|
|
|
|
|
|
meter = db.session.get(Meter, request.form.get('meter_id', type=int))
|
|
|
|
|
|
if not meter:
|
|
|
|
|
|
flash('Compteur invalide.', 'danger')
|
|
|
|
|
|
else:
|
|
|
|
|
|
try:
|
|
|
|
|
|
reference_date = date.fromisoformat(request.form.get('reference_date'))
|
|
|
|
|
|
target_time = datetime.strptime(request.form['target_time'], '%H:%M').time() if request.form.get('target_time') else None
|
|
|
|
|
|
create_or_update_schedule(
|
|
|
|
|
|
meter=meter, frequency=request.form.get('frequency'), reference_date=reference_date,
|
|
|
|
|
|
target_time=target_time, duration_minutes=request.form.get('duration_minutes', type=int, default=5),
|
|
|
|
|
|
assigned_to_id=request.form.get('assigned_to_id', type=int),
|
|
|
|
|
|
calendar_scope=request.form.get('calendar_scope') or None,
|
|
|
|
|
|
fixed_month=request.form.get('fixed_month', type=int), fixed_day=request.form.get('fixed_day', type=int),
|
|
|
|
|
|
)
|
|
|
|
|
|
flash('Règle de relevé enregistrée.', 'success')
|
|
|
|
|
|
return redirect(url_for('planning.meter_schedules'))
|
|
|
|
|
|
except (ValueError, TypeError) as exc:
|
|
|
|
|
|
flash(str(exc) or 'Date ou heure invalide.', 'danger')
|
|
|
|
|
|
return render_template('planning/meter_schedule_form.html', meters=meters, users=users, frequencies=sorted(FREQUENCIES))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@planning_bp.route('/meter-occurrences/<int:occurrence_id>')
|
|
|
|
|
|
@login_required
|
|
|
|
|
|
def meter_occurrence_detail(occurrence_id):
|
|
|
|
|
|
occurrence = MeterReadingOccurrence.query.get_or_404(occurrence_id)
|
|
|
|
|
|
if occurrence.assigned_to_id not in (None, current_user.id) and not current_user.is_admin():
|
|
|
|
|
|
abort(403)
|
|
|
|
|
|
return render_template(
|
|
|
|
|
|
'planning/meter_occurrence.html', occurrence=occurrence,
|
|
|
|
|
|
no_reading_reasons=NO_READING_REASONS,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _optional_photo():
|
|
|
|
|
|
photo = request.files.get('photo')
|
|
|
|
|
|
if not photo or not photo.filename:
|
|
|
|
|
|
return None, None
|
|
|
|
|
|
allowed = {'jpg', 'jpeg', 'png', 'gif'}
|
|
|
|
|
|
if '.' not in photo.filename or photo.filename.rsplit('.', 1)[1].lower() not in allowed:
|
|
|
|
|
|
raise ValueError('La photo doit être une image JPG, PNG ou GIF.')
|
|
|
|
|
|
safe_name = secure_filename(photo.filename)
|
|
|
|
|
|
upload_dir = Path(current_app.config.get('UPLOAD_FOLDER', 'uploads')) / 'meter-readings'
|
|
|
|
|
|
upload_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
path = upload_dir / f'{uuid4().hex}_{safe_name}'
|
|
|
|
|
|
photo.save(path)
|
|
|
|
|
|
return path.name, str(path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@planning_bp.route('/meter-occurrences/<int:occurrence_id>/reading', methods=['POST'])
|
|
|
|
|
|
@login_required
|
|
|
|
|
|
def meter_occurrence_reading(occurrence_id):
|
|
|
|
|
|
photo_path = None
|
|
|
|
|
|
try:
|
|
|
|
|
|
photo_filename, photo_path = _optional_photo()
|
|
|
|
|
|
record_occurrence_reading(
|
|
|
|
|
|
occurrence_id=occurrence_id, value=request.form.get('value'), user_id=current_user.id,
|
|
|
|
|
|
notes=request.form.get('notes'), is_reset=request.form.get('is_reset') == '1',
|
|
|
|
|
|
reset_reason=request.form.get('reset_reason'), photo_filename=photo_filename, photo_path=photo_path,
|
|
|
|
|
|
)
|
|
|
|
|
|
flash('Relevé enregistré et échéance traitée.', 'success')
|
|
|
|
|
|
except (ValueError, MeterDomainError) as exc:
|
|
|
|
|
|
db.session.rollback()
|
|
|
|
|
|
if photo_path:
|
|
|
|
|
|
Path(photo_path).unlink(missing_ok=True)
|
|
|
|
|
|
flash(str(exc), 'danger')
|
|
|
|
|
|
return redirect(url_for('planning.meter_occurrence_detail', occurrence_id=occurrence_id))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@planning_bp.route('/meter-occurrences/<int:occurrence_id>/without-reading', methods=['POST'])
|
|
|
|
|
|
@login_required
|
|
|
|
|
|
def meter_occurrence_without_reading(occurrence_id):
|
|
|
|
|
|
try:
|
|
|
|
|
|
record_occurrence_without_reading(
|
|
|
|
|
|
occurrence_id=occurrence_id, reason=request.form.get('reason'),
|
|
|
|
|
|
user_id=current_user.id, comment=request.form.get('comment'),
|
|
|
|
|
|
)
|
|
|
|
|
|
flash('Échéance traitée sans relevé.', 'success')
|
|
|
|
|
|
except MeterDomainError as exc:
|
|
|
|
|
|
db.session.rollback()
|
|
|
|
|
|
flash(str(exc), 'danger')
|
|
|
|
|
|
return redirect(url_for('planning.meter_occurrence_detail', occurrence_id=occurrence_id))
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-24 12:47:01 +02:00
|
|
|
|
@planning_bp.route('/meter-monitoring')
|
|
|
|
|
|
@login_required
|
2026-08-24 13:14:06 +02:00
|
|
|
|
@permission_required('planning.view')
|
2026-08-24 12:47:01 +02:00
|
|
|
|
def meter_monitoring():
|
|
|
|
|
|
"""Tableau de surveillance C3, sans recalculer ni modifier les relevés."""
|
|
|
|
|
|
from sqlalchemy.orm import selectinload
|
2026-08-24 14:08:57 +02:00
|
|
|
|
period_end = date.today()
|
|
|
|
|
|
period_start = period_end - timedelta(days=29)
|
2026-08-24 12:47:01 +02:00
|
|
|
|
meters = Meter.query.filter(Meter.is_active.is_(True)).options(selectinload(Meter.alerts)).order_by(Meter.name).all()
|
2026-08-24 14:08:57 +02:00
|
|
|
|
alerts = visible_open_meter_alerts(current_user)
|
|
|
|
|
|
summaries = {"eau": {"raw": 0.0}, "gaz": {"raw": 0.0, "kwh": 0.0}, "electricite": {"raw": 0.0}}
|
|
|
|
|
|
insufficient = []
|
|
|
|
|
|
meter_ids = [meter.id for meter in meters]
|
|
|
|
|
|
readings_by_meter = {meter_id: [] for meter_id in meter_ids}
|
|
|
|
|
|
readings = (MeterReading.query.filter(MeterReading.meter_id.in_(meter_ids))
|
|
|
|
|
|
.order_by(MeterReading.meter_id, MeterReading.reading_date, MeterReading.id).all()) if meter_ids else []
|
|
|
|
|
|
for reading in readings:
|
|
|
|
|
|
readings_by_meter[reading.meter_id].append(reading)
|
|
|
|
|
|
conversions_by_meter = {meter_id: [] for meter_id in meter_ids}
|
|
|
|
|
|
for conversion in GasConversion.query.filter(GasConversion.meter_id.in_(meter_ids)).all() if meter_ids else []:
|
|
|
|
|
|
conversions_by_meter[conversion.meter_id].append(conversion)
|
|
|
|
|
|
reading_counts = {meter_id: len(rows) for meter_id, rows in readings_by_meter.items()}
|
|
|
|
|
|
for meter in meters:
|
|
|
|
|
|
rows = readings_by_meter[meter.id]
|
|
|
|
|
|
intervals = []
|
|
|
|
|
|
for previous, current in zip(rows, rows[1:]):
|
|
|
|
|
|
if current.reading_date.date() < period_start or current.reading_date.date() > period_end:
|
|
|
|
|
|
continue
|
|
|
|
|
|
if previous.is_reset or current.is_reset or current.value < previous.value:
|
|
|
|
|
|
continue
|
|
|
|
|
|
intervals.append((current.reading_date.date(), current.value - previous.value))
|
|
|
|
|
|
rules = [rule for rule in meter.alert_rules if rule.is_active and rule.metric in ("consumption_per_day", "consumption_interval")]
|
|
|
|
|
|
if rules and any(len(intervals) < rule.min_comparable_intervals for rule in rules):
|
|
|
|
|
|
insufficient.append(meter)
|
|
|
|
|
|
bucket = summaries.get(meter.meter_type)
|
|
|
|
|
|
if not bucket:
|
|
|
|
|
|
continue
|
|
|
|
|
|
bucket["raw"] += sum(value for _, value in intervals)
|
|
|
|
|
|
if meter.meter_type == "gaz":
|
|
|
|
|
|
for on_date, value in intervals:
|
|
|
|
|
|
applicable = [conversion for conversion in conversions_by_meter[meter.id]
|
|
|
|
|
|
if conversion.valid_from <= on_date and
|
|
|
|
|
|
(conversion.valid_to is None or conversion.valid_to >= on_date)]
|
|
|
|
|
|
manual = next((conversion for conversion in sorted(applicable, key=lambda item: item.valid_from, reverse=True)
|
|
|
|
|
|
if conversion.origin == "MANUAL"), None)
|
|
|
|
|
|
coefficient = (manual or max(applicable, key=lambda item: item.valid_from) if applicable else None)
|
|
|
|
|
|
coefficient = coefficient.coefficient_kwh_per_m3 if coefficient else None
|
|
|
|
|
|
if coefficient is not None:
|
|
|
|
|
|
bucket["kwh"] += value * coefficient
|
|
|
|
|
|
return render_template(
|
|
|
|
|
|
'planning/meter_monitoring.html', meters=meters, alerts=alerts,
|
|
|
|
|
|
period_start=period_start, period_end=period_end, summaries=summaries,
|
|
|
|
|
|
insufficient_meters=insufficient, reading_counts=reading_counts,
|
|
|
|
|
|
)
|
2026-08-24 12:47:01 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@planning_bp.route('/meter-analytics/<int:meter_id>')
|
|
|
|
|
|
@login_required
|
2026-08-24 13:14:06 +02:00
|
|
|
|
@permission_required('planning.view')
|
2026-08-24 12:47:01 +02:00
|
|
|
|
def meter_analytics(meter_id):
|
|
|
|
|
|
meter = Meter.query.get_or_404(meter_id)
|
|
|
|
|
|
intervals = consumption_intervals(meter)
|
|
|
|
|
|
return render_template('planning/meter_analytics.html', meter=meter, intervals=intervals)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@planning_bp.route('/meter-alerts/<int:alert_id>/create-intervention', methods=['POST'])
|
|
|
|
|
|
@login_required
|
2026-08-24 13:14:06 +02:00
|
|
|
|
@permission_required('intervention.create')
|
2026-08-24 12:47:01 +02:00
|
|
|
|
def create_intervention_from_meter_alert(alert_id):
|
|
|
|
|
|
from ..core.models.maintenance import Intervention
|
|
|
|
|
|
alert = MeterAlert.query.get_or_404(alert_id)
|
|
|
|
|
|
if alert.intervention_id:
|
|
|
|
|
|
return redirect(url_for('interventions.detail', id=alert.intervention_id))
|
|
|
|
|
|
meter = alert.meter
|
|
|
|
|
|
interval_text = f"Valeur observée : {alert.observed_value}; référence : {alert.reference_value}" if alert.reference_value is not None else f"Valeur observée : {alert.observed_value}"
|
|
|
|
|
|
intervention = Intervention(
|
|
|
|
|
|
title=request.form.get('title') or f"Analyser l'alerte compteur — {meter.name}",
|
|
|
|
|
|
description=(request.form.get('description') or f"Alerte {alert.alert_type} ({alert.level}). {interval_text}"),
|
|
|
|
|
|
type='curatif', workflow_type='corrective', status='brouillon', priority='haute' if alert.level == 'CRITICAL' else 'normale',
|
|
|
|
|
|
equipment_id=meter.equipment_id, room_id=meter.room_id, author_id=current_user.id,
|
|
|
|
|
|
)
|
|
|
|
|
|
db.session.add(intervention)
|
|
|
|
|
|
db.session.flush()
|
|
|
|
|
|
alert.intervention_id = intervention.id
|
|
|
|
|
|
db.session.commit()
|
|
|
|
|
|
flash('Intervention créée depuis l’alerte.', 'success')
|
|
|
|
|
|
return redirect(url_for('interventions.detail', id=intervention.id))
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-24 14:08:57 +02:00
|
|
|
|
@planning_bp.route('/meter-alerts/<int:alert_id>/close', methods=['POST'])
|
|
|
|
|
|
@login_required
|
|
|
|
|
|
@permission_required('planning.manage')
|
|
|
|
|
|
def close_meter_alert_route(alert_id):
|
|
|
|
|
|
alert = MeterAlert.query.get_or_404(alert_id)
|
|
|
|
|
|
action = request.form.get('status', 'CLOSED').upper()
|
|
|
|
|
|
if action == 'INVESTIGATING':
|
2026-08-24 14:16:46 +02:00
|
|
|
|
investigate_meter_alert(alert=alert, comment=request.form.get('comment'), user_id=current_user.id)
|
2026-08-24 14:08:57 +02:00
|
|
|
|
flash('Alerte passée en investigation.', 'success')
|
|
|
|
|
|
else:
|
|
|
|
|
|
try:
|
|
|
|
|
|
close_meter_alert(
|
|
|
|
|
|
alert=alert, conclusion=request.form.get('conclusion', 'OTHER'),
|
|
|
|
|
|
comment=request.form.get('comment'), user_id=current_user.id,
|
|
|
|
|
|
)
|
|
|
|
|
|
flash('Alerte clôturée.', 'success')
|
|
|
|
|
|
except ValueError as exc:
|
|
|
|
|
|
db.session.rollback()
|
|
|
|
|
|
flash(str(exc), 'danger')
|
|
|
|
|
|
return redirect(url_for('planning.meter_monitoring'))
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-24 02:17:48 +02:00
|
|
|
|
@planning_bp.route('/meter-rounds')
|
|
|
|
|
|
@login_required
|
|
|
|
|
|
def meter_rounds():
|
|
|
|
|
|
_admin_required()
|
|
|
|
|
|
rounds = MeterReadingRound.query.order_by(MeterReadingRound.name).all()
|
|
|
|
|
|
return render_template('planning/meter_rounds.html', rounds=rounds)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@planning_bp.route('/meter-rounds/new', methods=['GET', 'POST'])
|
|
|
|
|
|
@login_required
|
|
|
|
|
|
def new_meter_round():
|
|
|
|
|
|
_admin_required()
|
|
|
|
|
|
schedules = MeterReadingSchedule.query.filter_by(is_active=True).order_by(MeterReadingSchedule.id).all()
|
|
|
|
|
|
users = User.query.filter_by(is_active=True).order_by(User.full_name, User.username).all()
|
|
|
|
|
|
if request.method == 'POST':
|
|
|
|
|
|
try:
|
|
|
|
|
|
round_ = create_round(
|
|
|
|
|
|
name=request.form.get('name'), default_assigned_to_id=request.form.get('assigned_to_id', type=int),
|
|
|
|
|
|
estimated_duration_minutes=request.form.get('estimated_duration_minutes', type=int),
|
|
|
|
|
|
commit=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
selected = request.form.getlist('schedule_ids')
|
|
|
|
|
|
for position, schedule_id in enumerate(selected, 1):
|
|
|
|
|
|
schedule = db.session.get(MeterReadingSchedule, int(schedule_id))
|
|
|
|
|
|
if schedule:
|
|
|
|
|
|
add_round_member(round_=round_, schedule=schedule, position=position, commit=False)
|
|
|
|
|
|
db.session.commit()
|
|
|
|
|
|
flash('Tournée enregistrée.', 'success')
|
|
|
|
|
|
return redirect(url_for('planning.meter_round_detail', round_id=round_.id))
|
|
|
|
|
|
except (ValueError, TypeError, MeterDomainError) as exc:
|
|
|
|
|
|
db.session.rollback()
|
|
|
|
|
|
flash(str(exc), 'danger')
|
|
|
|
|
|
return render_template('planning/meter_round_form.html', schedules=schedules, users=users)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@planning_bp.route('/meter-rounds/<int:round_id>')
|
|
|
|
|
|
@login_required
|
|
|
|
|
|
def meter_round_detail(round_id):
|
|
|
|
|
|
round_ = MeterReadingRound.query.get_or_404(round_id)
|
|
|
|
|
|
if not current_user.is_admin():
|
|
|
|
|
|
assigned = {member.schedule.assigned_to_id for member in round_.members}
|
|
|
|
|
|
if round_.default_assigned_to_id != current_user.id and current_user.id not in assigned:
|
|
|
|
|
|
abort(403)
|
|
|
|
|
|
target_date = request.args.get('date', date.today().isoformat())
|
|
|
|
|
|
try:
|
|
|
|
|
|
target_date = date.fromisoformat(target_date)
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
target_date = date.today()
|
2026-08-24 11:56:19 +02:00
|
|
|
|
ensure_occurrences_for_operational_date(
|
|
|
|
|
|
target_date, round_id=round_.id,
|
|
|
|
|
|
schedule_ids=[member.schedule_id for member in round_.members],
|
|
|
|
|
|
)
|
2026-08-24 02:17:48 +02:00
|
|
|
|
round_occurrence = next((item for item in round_.occurrences if item.operational_date == target_date), None)
|
|
|
|
|
|
members = []
|
|
|
|
|
|
if round_occurrence:
|
|
|
|
|
|
members = sorted(round_occurrence.occurrences, key=lambda item: item.schedule.meter.name)
|
|
|
|
|
|
members.sort(key=lambda item: next((m.position for m in round_.members if m.schedule_id == item.schedule_id), 9999))
|
|
|
|
|
|
return render_template(
|
|
|
|
|
|
'planning/meter_round_detail.html', round=round_, round_occurrence=round_occurrence,
|
|
|
|
|
|
members=members, target_date=target_date,
|
|
|
|
|
|
)
|