gmao/app_new/planning/meter_readings.py

304 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Routes C2 : règles, échéances et tournées de relevés."""
from datetime import date, datetime, timedelta
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
from ..core.authorization import permission_required
from werkzeug.utils import secure_filename
from ..extensions import db
from ..core.models.planning import (
Meter, MeterReading, MeterReadingOccurrence, MeterReadingRound, MeterReadingSchedule,
GasConversion,
)
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,
create_round, ensure_occurrences_for_operational_date, generate_occurrences,
record_occurrence_reading, record_occurrence_without_reading,
)
from ..core.services.meter_analytics import consumption_intervals, visible_open_meter_alerts, close_meter_alert
from ..core.models.planning import MeterAlert, MeterAlertRule
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))
@planning_bp.route('/meter-monitoring')
@login_required
@permission_required('planning.view')
def meter_monitoring():
"""Tableau de surveillance C3, sans recalculer ni modifier les relevés."""
from sqlalchemy.orm import selectinload
period_end = date.today()
period_start = period_end - timedelta(days=29)
meters = Meter.query.filter(Meter.is_active.is_(True)).options(selectinload(Meter.alerts)).order_by(Meter.name).all()
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,
)
@planning_bp.route('/meter-analytics/<int:meter_id>')
@login_required
@permission_required('planning.view')
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
@permission_required('intervention.create')
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 lalerte.', 'success')
return redirect(url_for('interventions.detail', id=intervention.id))
@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':
alert.status = 'INVESTIGATING'
alert.comment = request.form.get('comment') or alert.comment
db.session.commit()
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'))
@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()
ensure_occurrences_for_operational_date(
target_date, round_id=round_.id,
schedule_ids=[member.schedule_id for member in round_.members],
)
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,
)