feat(meters): establish checkpoint C1 meter foundation

This commit is contained in:
root 2026-08-23 22:55:17 +00:00
parent d9730c1558
commit 86a9015778
13 changed files with 570 additions and 107 deletions

View file

@ -12,7 +12,7 @@ from .maintenance import (
from .company import Company, Part, PartStockMovement, Alert, Service
from .planning import (
WorkSchedule, WorkScheduleTemplate, AnnualTimeConfig, TimeEntry, CollegeClosure, ClosureSchedule, ClosureWorkDay, PersonalLeave, Training, TrainingParticipant,
Meter, MeterReading, Consumable, ConsumableUsage, EquipmentConsumable,
Meter, MeterReading, MeterReadingCorrection, Consumable, ConsumableUsage, EquipmentConsumable,
PreventiveTask, PreventiveTaskConsumable, ScheduledTask, TaskExecutionSegment,
TechnicianAvailability, AdminTask, ZoneAccessRule
)
@ -36,7 +36,7 @@ __all__ = [
'Lot', 'LotTask', 'LotService', 'WorkRequest', 'WorkRequestQuote', 'WorkRequestEvent',
'Company', 'Part', 'PartStockMovement', 'InterventionPart', 'Alert', 'Service',
'WorkSchedule', 'WorkScheduleTemplate', 'AnnualTimeConfig', 'TimeEntry', 'CollegeClosure', 'ClosureSchedule', 'ClosureWorkDay', 'PersonalLeave', 'Training', 'TrainingParticipant',
'Meter', 'MeterReading', 'Consumable', 'ConsumableUsage', 'EquipmentConsumable',
'Meter', 'MeterReading', 'MeterReadingCorrection', 'Consumable', 'ConsumableUsage', 'EquipmentConsumable',
'PreventiveTask', 'PreventiveTaskConsumable', 'ScheduledTask', 'TaskExecutionSegment',
'TechnicianAvailability', 'AdminTask', 'ZoneAccessRule',
'AppSettings', 'AuditLog', 'TemplateAuditMark',

View file

@ -243,15 +243,39 @@ class TrainingParticipant(db.Model):
class Meter(db.Model):
"""Compteur sur un équipement."""
"""Compteur physique rattaché à un élément du patrimoine.
``equipment_id`` reste le rattachement historique, mais un compteur peut
désormais être porté directement par un bâtiment, une zone, un local ou
un logement. Les rattachements sont explicites afin de conserver
l'intégrité référentielle SQL.
"""
__tablename__ = "meters"
__table_args__ = (
db.CheckConstraint(
"(equipment_id IS NOT NULL) + (building_id IS NOT NULL) + "
"(zone_id IS NOT NULL) + (room_id IS NOT NULL) + "
"(housing_unit_id IS NOT NULL) = 1",
name="ck_meters_one_scope",
),
)
id = db.Column(db.Integer, primary_key=True)
equipment_id = db.Column(db.Integer, db.ForeignKey("equipments.id"), nullable=False)
equipment_id = db.Column(db.Integer, db.ForeignKey("equipments.id"), nullable=True, index=True)
building_id = db.Column(db.Integer, db.ForeignKey("buildings.id"), nullable=True, index=True)
zone_id = db.Column(db.Integer, db.ForeignKey("zones.id"), nullable=True, index=True)
room_id = db.Column(db.Integer, db.ForeignKey("rooms.id"), nullable=True, index=True)
housing_unit_id = db.Column(db.Integer, db.ForeignKey("housing_units.id"), nullable=True, index=True)
parent_id = db.Column(db.Integer, db.ForeignKey("meters.id"), nullable=True, index=True)
replaces_meter_id = db.Column(db.Integer, db.ForeignKey("meters.id"), nullable=True, index=True)
name = db.Column(db.String(100), nullable=False)
meter_type = db.Column(db.String(50), default="hours")
unit = db.Column(db.String(20), default="h")
usage = db.Column(db.String(255), nullable=True)
status = db.Column(db.String(30), nullable=False, default="active", server_default="active")
remainder_label = db.Column(db.String(255), nullable=True)
current_value = db.Column(db.Float, default=0)
initial_value = db.Column(db.Float, default=0)
@ -271,6 +295,12 @@ class Meter(db.Model):
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
equipment = db.relationship("Equipment", backref=db.backref("meters", lazy="dynamic"))
building = db.relationship("Building", backref=db.backref("meters", lazy="dynamic"))
zone = db.relationship("Zone", backref=db.backref("meters", lazy="dynamic"))
room = db.relationship("Room", backref=db.backref("meters", lazy="dynamic"))
housing_unit = db.relationship("HousingUnit", backref=db.backref("meters", lazy="dynamic"))
parent = db.relationship("Meter", remote_side=[id], foreign_keys=[parent_id], backref=db.backref("children", lazy="dynamic"))
replaced_meter = db.relationship("Meter", remote_side=[id], foreign_keys=[replaces_meter_id], backref=db.backref("replacement", uselist=False))
readings = db.relationship("MeterReading", back_populates="meter", lazy="dynamic")
@property
@ -310,11 +340,13 @@ class MeterReading(db.Model):
value = db.Column(db.Float, nullable=False)
reading_date = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
read_by_id = db.Column(db.Integer, db.ForeignKey("users.id"))
read_by_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"))
notes = db.Column(db.Text)
is_reset = db.Column(db.Boolean, nullable=False, default=False)
reset_reason = db.Column(db.String(255), nullable=True)
photo_filename = db.Column(db.String(255), nullable=True)
photo_path = db.Column(db.String(500), nullable=True)
meter = db.relationship("Meter", back_populates="readings")
read_by = db.relationship("User")
@ -323,6 +355,22 @@ class MeterReading(db.Model):
return f"<MeterReading {self.value}>"
class MeterReadingCorrection(db.Model):
"""Audit immuable de chaque correction d'un relevé existant."""
__tablename__ = "meter_reading_corrections"
id = db.Column(db.Integer, primary_key=True)
reading_id = db.Column(db.Integer, db.ForeignKey("meter_readings.id", ondelete="CASCADE"), nullable=False, index=True)
old_value = db.Column(db.Float, nullable=False)
new_value = db.Column(db.Float, nullable=False)
corrected_by_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
corrected_at = db.Column(db.DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
reason = db.Column(db.Text, nullable=False)
reading = db.relationship("MeterReading", backref=db.backref("corrections", lazy="selectin"))
corrected_by = db.relationship("User")
class Consumable(db.Model):
"""Consommable pour maintenance."""
__tablename__ = "consumables"

View file

@ -0,0 +1,178 @@
"""Services métier communs aux compteurs et à leurs relevés."""
from datetime import datetime, timezone
from math import isfinite
from ..models.college import Building, HousingUnit, Room, Zone
from ..models.equipment import Equipment
from ..models.planning import Meter, MeterReading, MeterReadingCorrection
from ...extensions import db
class MeterDomainError(ValueError):
"""Erreur de validation métier d'un compteur ou d'un relevé."""
SCOPE_MODELS = {
"equipment": (Equipment, "equipment_id"),
"building": (Building, "building_id"),
"zone": (Zone, "zone_id"),
"room": (Room, "room_id"),
"housing_unit": (HousingUnit, "housing_unit_id"),
}
def _scope_values(**scope_ids):
values = {name: value for name, value in scope_ids.items() if value is not None}
if len(values) != 1:
raise MeterDomainError("Un compteur doit avoir exactement un rattachement patrimoine.")
return values
def _validate_parent(meter, parent):
if parent is None:
return
if parent.id == meter.id:
raise MeterDomainError("Un compteur ne peut pas être son propre parent.")
cursor = parent
seen = set()
while cursor is not None:
if cursor.id in seen:
raise MeterDomainError("La hiérarchie des compteurs contient déjà un cycle.")
seen.add(cursor.id)
if cursor.id == meter.id:
raise MeterDomainError("Cette relation créerait un cycle de compteurs.")
cursor = cursor.parent
def validate_meter_scope(meter):
"""Valide le rattachement principal et la hiérarchie d'un compteur."""
with db.session.no_autoflush:
_scope_values(
equipment_id=meter.equipment_id,
building_id=meter.building_id,
zone_id=meter.zone_id,
room_id=meter.room_id,
housing_unit_id=meter.housing_unit_id,
)
_validate_parent(meter, meter.parent)
return meter
def create_meter(*, name, meter_type="hours", unit="h", usage=None,
initial_value=0, current_value=None, parent=None,
remainder_label=None, status="active", replaces_meter_id=None,
warning_threshold=None, critical_threshold=None, maintenance_interval=None,
last_maintenance_value=None, reading_interval_days=None, commit=True,
**scope_ids):
"""Crée un compteur avec les mêmes règles pour toutes les routes."""
name = (name or "").strip()
if not name:
raise MeterDomainError("Le nom du compteur est obligatoire.")
scope = _scope_values(**scope_ids)
model = Meter(name=name, meter_type=meter_type or "hours", unit=unit or "h",
usage=usage, initial_value=initial_value or 0,
current_value=initial_value if current_value is None else current_value,
remainder_label=remainder_label, status=status or "active",
replaces_meter_id=replaces_meter_id,
warning_threshold=warning_threshold, critical_threshold=critical_threshold,
maintenance_interval=maintenance_interval, last_maintenance_value=last_maintenance_value,
reading_interval_days=reading_interval_days,
**scope)
if parent is not None:
model.parent = parent
validate_meter_scope(model)
db.session.add(model)
if commit:
db.session.commit()
return model
def _latest_reading(meter):
return (MeterReading.query.filter_by(meter_id=meter.id)
.order_by(MeterReading.reading_date.desc(), MeterReading.id.desc())
.first())
def record_meter_reading(*, meter, value, user_id, reading_date=None, notes=None,
is_reset=False, reset_reason=None,
photo_filename=None, photo_path=None, commit=True):
"""Enregistre une vraie mesure et synchronise l'index courant."""
try:
value = float(value)
except (TypeError, ValueError):
raise MeterDomainError("La valeur du relevé est invalide.")
if not isfinite(value):
raise MeterDomainError("La valeur du relevé est invalide.")
reset_reason = (reset_reason or "").strip()
if is_reset and not reset_reason:
raise MeterDomainError("Le motif de remise à zéro est obligatoire.")
if value < (meter.current_value or 0) and not is_reset:
raise MeterDomainError("Un relevé inférieur exige une remise à zéro explicite et justifiée.")
reading_date = reading_date or datetime.now(timezone.utc)
reading = MeterReading(
meter=meter, value=value, reading_date=reading_date, read_by_id=user_id,
notes=notes, is_reset=bool(is_reset), reset_reason=reset_reason or None,
photo_filename=photo_filename, photo_path=photo_path,
)
meter.current_value = value
meter.last_reading_date = reading_date.date()
if is_reset:
meter.initial_value = value
meter.last_maintenance_value = value
db.session.add(reading)
if commit:
db.session.commit()
return reading
def correct_meter_reading(*, reading, new_value, user_id, reason, commit=True):
"""Corrige un relevé en conservant une trace relationnelle complète."""
reason = (reason or "").strip()
if not reason:
raise MeterDomainError("La justification de correction est obligatoire.")
try:
new_value = float(new_value)
except (TypeError, ValueError):
raise MeterDomainError("La valeur corrigée est invalide.")
if not isfinite(new_value):
raise MeterDomainError("La valeur corrigée est invalide.")
old_value = reading.value
if old_value == new_value:
raise MeterDomainError("La correction doit modifier la valeur.")
db.session.add(MeterReadingCorrection(
reading=reading, old_value=old_value, new_value=new_value,
corrected_by_id=user_id, reason=reason,
))
reading.value = new_value
meter = reading.meter
latest = _latest_reading(meter)
if latest is not None:
meter.current_value = latest.value
meter.last_reading_date = latest.reading_date.date()
if commit:
db.session.commit()
return reading
def replace_meter(*, old_meter, name, user_id=None, commit=True, **kwargs):
"""Crée un nouveau compteur et clôt explicitement l'ancien."""
if old_meter.status == "replaced":
raise MeterDomainError("Ce compteur est déjà remplacé.")
scope = {
key: getattr(old_meter, key)
for key in ("equipment_id", "building_id", "zone_id", "room_id", "housing_unit_id")
}
new_meter = create_meter(
name=name, meter_type=kwargs.pop("meter_type", old_meter.meter_type),
unit=kwargs.pop("unit", old_meter.unit), usage=kwargs.pop("usage", old_meter.usage),
initial_value=kwargs.pop("initial_value", 0), current_value=kwargs.pop("current_value", 0),
replaces_meter_id=old_meter.id, commit=False, **scope,
)
old_meter.status = "replaced"
old_meter.is_active = False
db.session.add(new_meter)
if commit:
db.session.commit()
return new_meter

View file

@ -7,6 +7,7 @@ from ..core.models.equipment import Equipment, EquipmentCategory, EquipmentDocum
from ..core.models.college import Room, Building, RoomType, Zone
from ..core.models.maintenance import Lot, Intervention
from ..core.models.planning import Meter, MeterReading, Consumable, EquipmentConsumable, ScheduledTask
from ..core.services.meter_service import MeterDomainError, create_meter, record_meter_reading
from app_new.constants import EQUIPMENT_STATUSES
def _empty_to_none(v):
@ -74,8 +75,7 @@ def add_meter(id):
"""Ajouter un compteur à un équipement."""
equipment = Equipment.query.get_or_404(id)
meter = Meter(
equipment_id=id,
meter = create_meter(
name=request.form.get('name'),
meter_type=request.form.get('meter_type', 'hours'),
unit=request.form.get('unit', 'h'),
@ -84,10 +84,9 @@ def add_meter(id):
warning_threshold=float(request.form.get('warning_threshold')) if request.form.get('warning_threshold') else None,
critical_threshold=float(request.form.get('critical_threshold')) if request.form.get('critical_threshold') else None,
maintenance_interval=float(request.form.get('maintenance_interval')) if request.form.get('maintenance_interval') else None,
last_maintenance_value=float(request.form.get('last_maintenance_value')) if request.form.get('last_maintenance_value') else None
last_maintenance_value=float(request.form.get('last_maintenance_value')) if request.form.get('last_maintenance_value') else None,
equipment_id=id,
)
db.session.add(meter)
db.session.commit()
return jsonify({'success': True, 'meter_id': meter.id})
@ -100,18 +99,21 @@ def add_meter_reading(id):
meter_id = data.get('meter_id')
value = data.get('value')
meter = Meter.query.filter_by(id=meter_id, equipment_id=id).first_or_404()
reading = MeterReading(
meter_id=meter_id,
value=value,
read_by_id=current_user.id
)
meter.current_value = value
db.session.add(reading)
db.session.commit()
return jsonify({'success': True})
meter = Meter.query.get_or_404(meter_id)
if meter.equipment_id != id:
from flask import abort
abort(404)
try:
reading = record_meter_reading(
meter=meter, value=value, user_id=current_user.id,
notes=data.get('notes'), is_reset=bool(data.get('is_reset')),
reset_reason=data.get('reset_reason'), commit=True,
)
except MeterDomainError as exc:
db.session.rollback()
return jsonify({'success': False, 'error': str(exc)}), 400
return jsonify({'success': True, 'reading_id': reading.id})
# ==================== CONSOMMABLES ====================

View file

@ -2,11 +2,21 @@
Meters Routes - GMAO Collège
Gestion des compteurs
"""
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, current_app, send_file, abort
from flask_login import login_required, current_user
from datetime import datetime
from pathlib import Path
from uuid import uuid4
from werkzeug.utils import secure_filename
from sqlalchemy.orm import joinedload
from app_new.extensions import db
from app_new.core.models.planning import Meter, MeterReading
from app_new.core.models.equipment import Equipment
from app_new.core.models.college import Building, HousingUnit, Room, Zone
from app_new.core.services.meter_service import (
MeterDomainError, correct_meter_reading, create_meter, record_meter_reading,
replace_meter,
)
meters_bp = Blueprint('meters', __name__, template_folder='templates')
@ -15,8 +25,7 @@ meters_bp = Blueprint('meters', __name__, template_folder='templates')
@login_required
def index():
"""Liste de tous les compteurs."""
# Récupérer tous les compteurs avec leur équipement
meters = Meter.query.join(Equipment).order_by(Equipment.name, Meter.name).all()
meters = Meter.query.order_by(Meter.name).all()
# Statistiques
total_count = len(meters)
@ -32,12 +41,49 @@ def index():
maintenance_count=maintenance_count)
@meters_bp.route('/new', methods=['GET', 'POST'])
@login_required
def new():
"""Créer un compteur rattaché à un élément patrimoine."""
if request.method == 'POST':
try:
scope_type = request.form.get('scope_type')
scope_id = request.form.get('scope_id', type=int)
scope_key = {
'equipment': 'equipment_id', 'building': 'building_id', 'zone': 'zone_id',
'room': 'room_id', 'housing_unit': 'housing_unit_id',
}.get(scope_type)
if not scope_key or not scope_id:
raise MeterDomainError('Un rattachement patrimoine est obligatoire.')
meter = create_meter(
name=(request.form.get('name') or '').strip(),
meter_type=request.form.get('meter_type') or 'hours',
unit=(request.form.get('unit') or '').strip() or 'h',
usage=(request.form.get('usage') or '').strip() or None,
initial_value=request.form.get('initial_value', type=float, default=0),
parent=Meter.query.get(request.form.get('parent_id', type=int)) if request.form.get('parent_id', type=int) else None,
remainder_label=(request.form.get('remainder_label') or '').strip() or None,
**{scope_key: scope_id},
)
flash(f'Compteur « {meter.name} » créé.', 'success')
return redirect(url_for('meters.detail', meter_id=meter.id))
except MeterDomainError as exc:
db.session.rollback()
flash(str(exc), 'danger')
return render_template(
'meters/new.html', equipments=Equipment.query.filter_by(is_group=False).order_by(Equipment.name).all(),
buildings=Building.query.order_by(Building.name).all(), zones=Zone.query.order_by(Zone.name).all(),
rooms=Room.query.order_by(Room.name).all(), housing_units=HousingUnit.query.order_by(HousingUnit.name).all(),
parents=Meter.query.filter(Meter.status != 'replaced').order_by(Meter.name).all(),
)
@meters_bp.route('/<int:meter_id>')
@login_required
def detail(meter_id):
"""Détails d'un compteur avec historique des relevés."""
meter = Meter.query.get_or_404(meter_id)
readings = MeterReading.query.filter_by(meter_id=meter_id)\
readings = MeterReading.query.options(joinedload(MeterReading.read_by)).filter_by(meter_id=meter_id)\
.order_by(MeterReading.reading_date.desc()).limit(50).all()
return render_template('meters/detail.html', meter=meter, readings=readings)
@ -49,36 +95,35 @@ def add_reading(meter_id):
"""Ajouter un relevé à un compteur."""
meter = Meter.query.get_or_404(meter_id)
try:
value = float(request.form.get('value'))
except (TypeError, ValueError):
return jsonify({'success': False, 'error': 'Valeur invalide'}), 400
is_reset = request.form.get('is_reset') == '1'
reset_reason = (request.form.get('reset_reason') or '').strip()
if value < (meter.current_value or 0) and not (is_reset and reset_reason):
flash('Un relevé inférieur exige de cocher « remise à zéro » et de saisir un motif.', 'danger')
photo_filename = photo_path = None
photo = request.files.get('photo')
if photo and photo.filename:
allowed = {'jpg', 'jpeg', 'png', 'gif'}
if '.' not in photo.filename or photo.filename.rsplit('.', 1)[1].lower() not in allowed:
flash('La photo doit être une image JPG, PNG ou GIF.', 'danger')
return redirect(url_for('meters.detail', meter_id=meter_id))
photo_filename = secure_filename(photo.filename)
stored_name = f'{uuid4().hex}_{photo_filename}'
upload_dir = Path(current_app.config.get('UPLOAD_FOLDER', 'uploads')) / 'meter-readings'
upload_dir.mkdir(parents=True, exist_ok=True)
photo_path = str(upload_dir / stored_name)
photo.save(photo_path)
photo_filename = stored_name
try:
reading = record_meter_reading(
meter=meter, value=request.form.get('value'), user_id=current_user.id,
notes=request.form.get('notes', ''), is_reset=is_reset, reset_reason=reset_reason,
photo_filename=photo_filename, photo_path=photo_path,
)
except MeterDomainError as exc:
db.session.rollback()
if photo_path:
Path(photo_path).unlink(missing_ok=True)
flash(str(exc), 'danger')
return redirect(url_for('meters.detail', meter_id=meter_id))
if is_reset and not reset_reason:
flash('Le motif de remise à zéro est obligatoire.', 'danger')
return redirect(url_for('meters.detail', meter_id=meter_id))
reading = MeterReading(
meter_id=meter_id,
value=value,
read_by_id=current_user.id,
notes=request.form.get('notes', ''), is_reset=is_reset, reset_reason=reset_reason or None,
)
meter.current_value = value
if is_reset:
meter.initial_value = value
meter.last_maintenance_value = value
meter.updated_at = db.func.now()
db.session.add(reading)
db.session.commit()
flash(f'Relevé enregistré: {value} {meter.unit}', 'success')
flash(f'Relevé enregistré: {reading.value} {meter.unit}', 'success')
return redirect(url_for('meters.detail', meter_id=meter_id))
@ -100,7 +145,8 @@ def edit(meter_id):
meter.maintenance_interval = request.form.get('maintenance_interval', type=float)
meter.last_maintenance_value = request.form.get('last_maintenance_value', type=float)
meter.reading_interval_days = request.form.get('reading_interval_days', type=int)
meter.current_value = request.form.get('current_value', type=float, default=0)
# L'index courant est piloté exclusivement par les relevés. Les
# corrections passent par l'historique afin de rester auditées.
# Date du dernier relevé
last_reading_date = request.form.get('last_reading_date')
@ -110,16 +156,79 @@ def edit(meter_id):
meter.last_reading_date = None
# Équipement
equipment_id = request.form.get('equipment_id')
if equipment_id:
meter.equipment_id = int(equipment_id)
scope_type = request.form.get('scope_type')
scope_id = request.form.get('scope_id', type=int)
if scope_type and scope_id:
scope_keys = ('equipment_id', 'building_id', 'zone_id', 'room_id', 'housing_unit_id')
scope_key = {'equipment': 'equipment_id', 'building': 'building_id', 'zone': 'zone_id', 'room': 'room_id', 'housing_unit': 'housing_unit_id'}.get(scope_type)
if scope_key:
for key in scope_keys:
setattr(meter, key, scope_id if key == scope_key else None)
meter.usage = (request.form.get('usage') or '').strip() or None
meter.remainder_label = (request.form.get('remainder_label') or '').strip() or None
meter.status = request.form.get('status') or meter.status
try:
from app_new.core.services.meter_service import validate_meter_scope
validate_meter_scope(meter)
except MeterDomainError as exc:
db.session.rollback()
flash(str(exc), 'danger')
return redirect(url_for('meters.edit', meter_id=meter_id))
db.session.commit()
flash('Compteur modifié avec succès', 'success')
return redirect(url_for('meters.detail', meter_id=meter_id))
return render_template('meters/edit.html',
return render_template('meters/edit.html',
meter=meter,
equipments=equipments,
buildings=Building.query.order_by(Building.name).all(),
zones=Zone.query.order_by(Zone.name).all(),
rooms=Room.query.order_by(Room.name).all(),
housing_units=HousingUnit.query.order_by(HousingUnit.name).all(),
timedelta=timedelta,
today=date.today())
@meters_bp.route('/<int:meter_id>/readings/<int:reading_id>/correct', methods=['POST'])
@login_required
def correct_reading(meter_id, reading_id):
reading = MeterReading.query.filter_by(id=reading_id, meter_id=meter_id).first_or_404()
try:
correct_meter_reading(
reading=reading, new_value=request.form.get('value'),
user_id=current_user.id, reason=request.form.get('reason'),
)
flash('Relevé corrigé et correction auditée.', 'success')
except MeterDomainError as exc:
db.session.rollback()
flash(str(exc), 'danger')
return redirect(url_for('meters.detail', meter_id=meter_id))
@meters_bp.route('/<int:meter_id>/readings/<int:reading_id>/photo')
@login_required
def reading_photo(meter_id, reading_id):
reading = MeterReading.query.filter_by(id=reading_id, meter_id=meter_id).first_or_404()
upload_root = Path(current_app.config.get('UPLOAD_FOLDER', 'uploads')).resolve()
photo_path = Path(reading.photo_path or '').resolve()
if not reading.photo_path or upload_root not in photo_path.parents or not photo_path.is_file():
abort(404)
return send_file(photo_path, download_name=reading.photo_filename or photo_path.name)
@meters_bp.route('/<int:meter_id>/replace', methods=['POST'])
@login_required
def replace(meter_id):
old_meter = Meter.query.get_or_404(meter_id)
try:
new_meter = replace_meter(
old_meter=old_meter, name=(request.form.get('name') or '').strip(),
user_id=current_user.id, meter_type=request.form.get('meter_type'),
unit=request.form.get('unit'), usage=request.form.get('usage'),
)
flash(f'Compteur remplacé par « {new_meter.name} ».', 'success')
except MeterDomainError as exc:
db.session.rollback()
flash(str(exc), 'danger')
return redirect(url_for('meters.detail', meter_id=meter_id))

View file

@ -6,9 +6,7 @@
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ url_for('meters.index') }}">Compteurs</a></li>
<li class="breadcrumb-item">
<a href="{{ url_for('equipments.detail', id=meter.equipment.id) if meter.equipment else '#' }}">{{ meter.equipment.name if meter.equipment else 'Sans equipement' }}</a>
</li>
<li class="breadcrumb-item">{% if meter.equipment %}<a href="{{ url_for('equipments.detail', id=meter.equipment.id) }}">{{ meter.equipment.name }}</a>{% elif meter.building %}{{ meter.building.name }}{% elif meter.zone %}{{ meter.zone.name }}{% elif meter.room %}{{ meter.room.name }}{% elif meter.housing_unit %}{{ meter.housing_unit.name }}{% else %}Patrimoine{% endif %}</li>
<li class="breadcrumb-item active">{{ meter.name }}</li>
</ol>
</nav>
@ -24,12 +22,13 @@
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-5">Équipement</dt>
<dt class="col-sm-5">Rattachement</dt>
<dd class="col-sm-7">
<a href="{{ url_for('equipments.detail', id=meter.equipment.id) if meter.equipment else '#' }}">
{{ meter.equipment.name if meter.equipment else 'Sans equipement' }}
</a>
{% if meter.equipment %}Équipement — {{ meter.equipment.name }}{% elif meter.building %}Bâtiment — {{ meter.building.name }}{% elif meter.zone %}Zone — {{ meter.zone.name }}{% elif meter.room %}Local — {{ meter.room.name }}{% elif meter.housing_unit %}Logement — {{ meter.housing_unit.name }}{% endif %}
</dd>
<dt class="col-sm-5">Usage</dt><dd class="col-sm-7">{{ meter.usage or '—' }}</dd>
<dt class="col-sm-5">Reste sous-compté</dt><dd class="col-sm-7">{{ meter.remainder_label or '—' }}</dd>
<dt class="col-sm-5">Type</dt>
<dd class="col-sm-7">{{ meter.meter_type }}</dd>
@ -112,7 +111,7 @@
<i class="bi bi-pencil me-1"></i>Nouveau relevé
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('meters.add_reading', meter_id=meter.id) }}">
<form method="POST" enctype="multipart/form-data" action="{{ url_for('meters.add_reading', meter_id=meter.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
@ -123,13 +122,14 @@
<span class="input-group-text">{{ meter.unit }}</span>
</div>
</div>
<div class="form-check mb-2"><input class="form-check-input" type="checkbox" value="1" name="is_reset" id="is_reset"><label class="form-check-label" for="is_reset">Remise à zéro / remplacement du compteur</label></div>
<div class="form-check mb-2"><input class="form-check-input" type="checkbox" value="1" name="is_reset" id="is_reset"><label class="form-check-label" for="is_reset">Remise à zéro explicite</label></div>
<div class="mb-3"><input class="form-control" name="reset_reason" placeholder="Motif obligatoire si remise à zéro"></div>
<div class="mb-3">
<label for="notes" class="form-label">Notes</label>
<textarea class="form-control" id="notes" name="notes" rows="2"></textarea>
</div>
<div class="mb-3"><label class="form-label" for="photo">Photo facultative</label><input class="form-control" type="file" id="photo" name="photo" accept="image/jpeg,image/png,image/gif"></div>
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-check-lg me-1"></i>Enregistrer
@ -152,7 +152,7 @@
<th>Valeur</th>
<th>Variation</th>
<th>Par</th>
<th>Notes</th>
<th>Notes / correction</th>
</tr>
</thead>
<tbody>
@ -173,7 +173,7 @@
{% endif %}
</td>
<td>{{ reading.read_by.username if reading.read_by else '—' }}</td>
<td>{% if reading.is_reset %}<span class="badge bg-warning text-dark">RAZ</span> {{ reading.reset_reason }}{% else %}{{ reading.notes or '—' }}{% endif %}</td>
<td>{% if reading.is_reset %}<span class="badge bg-warning text-dark">RAZ</span> {{ reading.reset_reason }}{% else %}{{ reading.notes or '—' }}{% endif %}{% if reading.photo_path %}<div class="small"><a href="{{ url_for('meters.reading_photo', meter_id=meter.id, reading_id=reading.id) }}">Photo jointe</a></div>{% endif %}<details class="mt-1"><summary class="small">Corriger</summary><form method="post" action="{{ url_for('meters.correct_reading', meter_id=meter.id, reading_id=reading.id) }}" class="d-flex gap-1 mt-1"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><input class="form-control form-control-sm" type="number" step="0.01" name="value" value="{{ reading.value }}" aria-label="Nouvelle valeur"><input class="form-control form-control-sm" name="reason" required placeholder="Justification"><button class="btn btn-sm btn-outline-warning">Enregistrer</button></form>{% for correction in reading.corrections %}<div class="small text-muted">{{ correction.old_value }} → {{ correction.new_value }} · {{ correction.reason }}</div>{% endfor %}</details></td>
</tr>
{% else %}
<tr>
@ -187,6 +187,7 @@
</table>
</div>
</div>
{% if meter.status != 'replaced' %}<div class="card mt-3"><div class="card-header">Remplacer ce compteur</div><div class="card-body"><form method="post" action="{{ url_for('meters.replace', meter_id=meter.id) }}" class="row g-2"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><div class="col-md-5"><input class="form-control" name="name" required placeholder="Nom du nouveau compteur"></div><div class="col-md-3"><input class="form-control" name="meter_type" value="{{ meter.meter_type }}"></div><div class="col-md-2"><input class="form-control" name="unit" value="{{ meter.unit }}"></div><div class="col-md-2"><button class="btn btn-outline-danger w-100">Remplacer</button></div></form></div></div>{% endif %}
</div>
</div>
</div>

View file

@ -22,14 +22,21 @@
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label for="equipment_id" class="form-label">Équipement</label>
<select class="form-select" id="equipment_id" name="equipment_id">
{% for eq in equipments %}
<option value="{{ eq.id }}" {% if meter.equipment_id == eq.id %}selected{% endif %}>
{{ eq.name }}
{% if eq.room %}({{ eq.room.name }}){% endif %}
</option>
{% endfor %}
<label for="scope_type" class="form-label">Type de rattachement</label>
<select class="form-select" id="scope_type" name="scope_type">
<option value="equipment" {% if meter.equipment %}selected{% endif %}>Équipement</option>
<option value="building" {% if meter.building %}selected{% endif %}>Bâtiment</option>
<option value="zone" {% if meter.zone %}selected{% endif %}>Zone</option>
<option value="room" {% if meter.room %}selected{% endif %}>Local</option>
<option value="housing_unit" {% if meter.housing_unit %}selected{% endif %}>Logement</option>
</select>
<label for="scope_id" class="form-label mt-2">Élément patrimoine</label>
<select class="form-select" id="scope_id" name="scope_id">
{% for eq in equipments %}<option data-scope="equipment" value="{{ eq.id }}" {% if meter.equipment_id == eq.id %}selected{% endif %}>Équipement — {{ eq.name }}</option>{% endfor %}
{% for item in buildings %}<option data-scope="building" value="{{ item.id }}" {% if meter.building_id == item.id %}selected{% endif %}>Bâtiment — {{ item.name }}</option>{% endfor %}
{% for item in zones %}<option data-scope="zone" value="{{ item.id }}" {% if meter.zone_id == item.id %}selected{% endif %}>Zone — {{ item.name }}</option>{% endfor %}
{% for item in rooms %}<option data-scope="room" value="{{ item.id }}" {% if meter.room_id == item.id %}selected{% endif %}>Local — {{ item.name }}</option>{% endfor %}
{% for item in housing_units %}<option data-scope="housing_unit" value="{{ item.id }}" {% if meter.housing_unit_id == item.id %}selected{% endif %}>Logement — {{ item.name }}</option>{% endfor %}
</select>
</div>
@ -59,8 +66,8 @@
<div class="mb-3">
<label for="current_value" class="form-label">Valeur actuelle</label>
<div class="input-group">
<input type="number" step="0.01" class="form-control" id="current_value" name="current_value"
value="{{ meter.current_value }}">
<input type="number" step="0.01" class="form-control" id="current_value"
value="{{ meter.current_value }}" readonly>
<span class="input-group-text">{{ meter.unit }}</span>
</div>
</div>
@ -165,4 +172,9 @@
</div>
</div>
</div>
{% endblock %}
<script>
const type = document.getElementById('scope_type'), target = document.getElementById('scope_id');
function filterScopes() { const value = type.value; [...target.options].forEach(o => { o.hidden = o.dataset.scope !== value; }); }
type.addEventListener('change', filterScopes); filterScopes();
</script>
{% endblock %}

View file

@ -9,8 +9,8 @@
<p class="text-muted mb-0">Suivi des heures de fonctionnement, cycles, et autres métriques</p>
</div>
<div>
<a href="{{ url_for('planning.meters') }}" class="btn btn-outline-primary">
<i class="bi bi-plus-circle me-1"></i>Gestion préventive
<a href="{{ url_for('meters.new') }}" class="btn btn-primary">
<i class="bi bi-plus-circle me-1"></i>Nouveau compteur
</a>
</div>
</div>
@ -60,7 +60,7 @@
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Équipement</th>
<th>Rattachement</th>
<th>Compteur</th>
<th>Type</th>
<th>Valeur</th>
@ -75,9 +75,11 @@
{% for meter in meters %}
<tr class="{{ 'table-warning' if meter.warning_level == 'warning' else '' }}{{ 'table-danger' if meter.warning_level == 'critical' else '' }}">
<td>
<a href="{{ url_for('equipments.detail', id=meter.equipment.id) }}">
{{ meter.equipment.name }}
</a>
{% if meter.equipment %}<a href="{{ url_for('equipments.detail', id=meter.equipment.id) }}">{{ meter.equipment.name }}</a>
{% elif meter.building %}Bâtiment — {{ meter.building.name }}
{% elif meter.zone %}Zone — {{ meter.zone.name }}
{% elif meter.room %}Local — {{ meter.room.name }}
{% elif meter.housing_unit %}Logement — {{ meter.housing_unit.name }}{% endif %}
</td>
<td>
<a href="{{ url_for('meters.detail', meter_id=meter.id) }}">
@ -132,4 +134,4 @@
</div>
</div>
</div>
{% endblock %}
{% endblock %}

View file

@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block title %}Nouveau compteur — GMAO{% endblock %}
{% block content %}
<div class="container-fluid">
<h1 class="h3 mb-3">Nouveau compteur</h1>
<form method="post" class="card">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="card-body row g-3">
<div class="col-md-6"><label class="form-label" for="name">Nom *</label><input class="form-control" id="name" name="name" required></div>
<div class="col-md-3"><label class="form-label" for="meter_type">Type</label><input class="form-control" id="meter_type" name="meter_type" value="eau" required></div>
<div class="col-md-3"><label class="form-label" for="unit">Unité physique</label><input class="form-control" id="unit" name="unit" value="m³" required></div>
<div class="col-12"><label class="form-label" for="usage">Usage mesuré</label><input class="form-control" id="usage" name="usage" placeholder="Ex. alimentation générale"></div>
<div class="col-md-6"><label class="form-label" for="scope_type">Rattachement *</label><select class="form-select" id="scope_type" name="scope_type" required><option value="">Choisir…</option><option value="equipment">Équipement</option><option value="building">Bâtiment</option><option value="zone">Zone</option><option value="room">Local</option><option value="housing_unit">Logement</option></select></div>
<div class="col-md-6"><label class="form-label" for="scope_id">Élément patrimoine *</label><select class="form-select" id="scope_id" name="scope_id" required><option value="">Choisir…</option>{% for item in equipments %}<option data-scope="equipment" value="{{ item.id }}">Équipement — {{ item.name }}</option>{% endfor %}{% for item in buildings %}<option data-scope="building" value="{{ item.id }}">Bâtiment — {{ item.name }}</option>{% endfor %}{% for item in zones %}<option data-scope="zone" value="{{ item.id }}">Zone — {{ item.name }}</option>{% endfor %}{% for item in rooms %}<option data-scope="room" value="{{ item.id }}">Local — {{ item.name }}</option>{% endfor %}{% for item in housing_units %}<option data-scope="housing_unit" value="{{ item.id }}">Logement — {{ item.name }}</option>{% endfor %}</select></div>
<div class="col-md-4"><label class="form-label" for="initial_value">Index initial</label><input class="form-control" type="number" step="0.01" id="initial_value" name="initial_value" value="0"></div>
<div class="col-md-4"><label class="form-label" for="parent_id">Compteur parent (facultatif)</label><select class="form-select" id="parent_id" name="parent_id"><option value="">Aucun</option>{% for parent in parents %}<option value="{{ parent.id }}">{{ parent.name }}</option>{% endfor %}</select></div>
<div class="col-md-4"><label class="form-label" for="remainder_label">Libellé du reste (facultatif)</label><input class="form-control" id="remainder_label" name="remainder_label" placeholder="Ex. Parties communes"></div>
</div>
<div class="card-footer"><button class="btn btn-primary">Créer</button> <a class="btn btn-outline-secondary" href="{{ url_for('meters.index') }}">Annuler</a></div>
</form>
</div>
<script>
const type = document.getElementById('scope_type'), target = document.getElementById('scope_id');
function filterScopes() { const value = type.value; [...target.options].forEach(o => { o.hidden = o.value && o.dataset.scope !== value; }); if (target.selectedOptions[0]?.hidden) target.value = ''; }
type.addEventListener('change', filterScopes); filterScopes();
</script>
{% endblock %}

View file

@ -675,7 +675,7 @@ def scheduled_detail(id):
@login_required
def meters():
"""Liste des compteurs."""
meters = Meter.query.order_by(Meter.equipment_id).all()
meters = Meter.query.order_by(Meter.name).all()
return render_template('planning/meters.html', meters=meters)
@ -684,20 +684,22 @@ def meters():
def new_meter():
"""Créer un nouveau compteur."""
if request.method == 'POST':
meter = Meter(
equipment_id=request.form.get('equipment_id', type=int),
name=request.form.get('name'),
meter_type=request.form.get('meter_type', 'hours'),
unit=request.form.get('unit', 'h'),
current_value=request.form.get('current_value', type=float, default=0),
initial_value=request.form.get('initial_value', type=float, default=0),
warning_threshold=request.form.get('warning_threshold', type=float),
critical_threshold=request.form.get('critical_threshold', type=float),
maintenance_interval=request.form.get('maintenance_interval', type=float),
is_active=True
)
db.session.add(meter)
db.session.commit()
from ..core.services.meter_service import create_meter, MeterDomainError
try:
meter = create_meter(
equipment_id=request.form.get('equipment_id', type=int),
name=request.form.get('name'),
meter_type=request.form.get('meter_type', 'hours'),
unit=request.form.get('unit', 'h'),
current_value=request.form.get('current_value', type=float, default=0),
initial_value=request.form.get('initial_value', type=float, default=0),
warning_threshold=request.form.get('warning_threshold', type=float),
critical_threshold=request.form.get('critical_threshold', type=float),
maintenance_interval=request.form.get('maintenance_interval', type=float),
)
except MeterDomainError as exc:
flash(str(exc), 'danger')
return redirect(url_for('planning.new_meter'))
flash('Compteur créé', 'success')
return redirect(url_for('planning.meters'))

View file

@ -4,7 +4,7 @@
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1><i class="bi bi-speedometer2"></i> Compteurs</h1>
<a href="{{ url_for('planning.new_meter') }}" class="btn btn-primary">
<a href="{{ url_for('meters.new') }}" class="btn btn-primary">
<i class="bi bi-plus"></i> Nouveau compteur
</a>
</div>
@ -12,7 +12,7 @@
<table class="table table-hover">
<thead class="table-light">
<tr>
<th>Équipement</th>
<th>Rattachement</th>
<th>Nom</th>
<th>Type</th>
<th>Valeur actuelle</th>
@ -26,7 +26,7 @@
<tbody>
{% for meter in meters %}
<tr>
<td>{{ meter.equipment.name if meter.equipment else '-' }}</td>
<td>{% if meter.equipment %}{{ meter.equipment.name }}{% elif meter.building %}Bâtiment — {{ meter.building.name }}{% elif meter.zone %}Zone — {{ meter.zone.name }}{% elif meter.room %}Local — {{ meter.room.name }}{% elif meter.housing_unit %}Logement — {{ meter.housing_unit.name }}{% else %}—{% endif %}</td>
<td><strong>{{ meter.name }}</strong></td>
<td><span class="badge bg-secondary">{{ meter.meter_type }}</span></td>
<td>{{ meter.current_value }} {{ meter.unit }}</td>
@ -62,10 +62,10 @@
{% else %}
<tr>
<td colspan="9" class="text-center text-muted py-4">
Aucun compteur. <a href="{{ url_for('planning.new_meter') }}">Créer le premier</a>
Aucun compteur. <a href="{{ url_for('meters.new') }}">Créer le premier</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
{% endblock %}

View file

@ -1,6 +1,8 @@
# A generic, single database configuration.
[alembic]
script_location = migrations
prepend_sys_path = .
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s

View file

@ -0,0 +1,80 @@
"""Checkpoint C1: socle compteurs, rattachements et relevés audités."""
from alembic import op
import sqlalchemy as sa
revision = "n9c0d1e2f3g4"
down_revision = "m8b9c0d1e2f3"
branch_labels = None
depends_on = None
def upgrade():
# Les relevés restent auditables après suppression d'un utilisateur ;
# seule la référence vers l'auteur devient NULL.
op.drop_constraint("meter_readings_ibfk_2", "meter_readings", type_="foreignkey")
op.create_foreign_key(
"fk_meter_readings_read_by", "meter_readings", "users",
["read_by_id"], ["id"], ondelete="SET NULL",
)
op.alter_column("meters", "equipment_id", existing_type=sa.Integer(), nullable=True)
for name, target in (
("building_id", "buildings"), ("zone_id", "zones"),
("room_id", "rooms"), ("housing_unit_id", "housing_units"),
):
op.add_column("meters", sa.Column(name, sa.Integer(), nullable=True))
op.create_index(f"ix_meters_{name}", "meters", [name])
op.create_foreign_key(f"fk_meters_{name}", "meters", target, [name], ["id"])
op.add_column("meters", sa.Column("parent_id", sa.Integer(), nullable=True))
op.add_column("meters", sa.Column("replaces_meter_id", sa.Integer(), nullable=True))
op.add_column("meters", sa.Column("usage", sa.String(255), nullable=True))
op.add_column("meters", sa.Column("status", sa.String(30), nullable=False, server_default="active"))
op.add_column("meters", sa.Column("remainder_label", sa.String(255), nullable=True))
for name in ("parent_id", "replaces_meter_id"):
op.create_index(f"ix_meters_{name}", "meters", [name])
op.create_foreign_key(f"fk_meters_{name}", "meters", "meters", [name], ["id"])
op.create_check_constraint(
"ck_meters_one_scope", "meters",
"(equipment_id IS NOT NULL) + (building_id IS NOT NULL) + "
"(zone_id IS NOT NULL) + (room_id IS NOT NULL) + "
"(housing_unit_id IS NOT NULL) = 1",
)
op.add_column("meter_readings", sa.Column("photo_filename", sa.String(255), nullable=True))
op.add_column("meter_readings", sa.Column("photo_path", sa.String(500), nullable=True))
op.create_table(
"meter_reading_corrections",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("reading_id", sa.Integer(), nullable=False),
sa.Column("old_value", sa.Float(), nullable=False),
sa.Column("new_value", sa.Float(), nullable=False),
sa.Column("corrected_by_id", sa.Integer(), nullable=True),
sa.Column("corrected_at", sa.DateTime(), nullable=False),
sa.Column("reason", sa.Text(), nullable=False),
sa.ForeignKeyConstraint(["reading_id"], ["meter_readings.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["corrected_by_id"], ["users.id"], ondelete="SET NULL"),
)
op.create_index("ix_meter_reading_corrections_reading_id", "meter_reading_corrections", ["reading_id"])
op.create_index("ix_meter_reading_corrections_corrected_by_id", "meter_reading_corrections", ["corrected_by_id"])
def downgrade():
op.drop_constraint("fk_meter_readings_read_by", "meter_readings", type_="foreignkey")
op.create_foreign_key("meter_readings_ibfk_2", "meter_readings", "users", ["read_by_id"], ["id"])
op.drop_index("ix_meter_reading_corrections_corrected_by_id", table_name="meter_reading_corrections")
op.drop_index("ix_meter_reading_corrections_reading_id", table_name="meter_reading_corrections")
op.drop_table("meter_reading_corrections")
op.drop_column("meter_readings", "photo_path")
op.drop_column("meter_readings", "photo_filename")
op.drop_constraint("ck_meters_one_scope", "meters", type_="check")
for name in ("replaces_meter_id", "parent_id"):
op.drop_constraint(f"fk_meters_{name}", "meters", type_="foreignkey")
op.drop_index(f"ix_meters_{name}", table_name="meters")
op.drop_column("meters", name)
for name in ("remainder_label", "status", "usage"):
op.drop_column("meters", name)
for name, target in reversed((("building_id", "buildings"), ("zone_id", "zones"), ("room_id", "rooms"), ("housing_unit_id", "housing_units"))):
op.drop_constraint(f"fk_meters_{name}", "meters", type_="foreignkey")
op.drop_index(f"ix_meters_{name}", table_name="meters")
op.drop_column("meters", name)
op.alter_column("meters", "equipment_id", existing_type=sa.Integer(), nullable=False)