From 86a9015778912ec20ad5d551ac952e9bb7fd9478 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 23 Aug 2026 22:55:17 +0000 Subject: [PATCH] feat(meters): establish checkpoint C1 meter foundation --- app_new/core/models/__init__.py | 4 +- app_new/core/models/planning.py | 54 +++++- app_new/core/services/meter_service.py | 178 +++++++++++++++++ app_new/equipments/meters.py | 36 ++-- app_new/meters/routes.py | 181 ++++++++++++++---- app_new/meters/templates/meters/detail.html | 23 +-- app_new/meters/templates/meters/edit.html | 34 ++-- app_new/meters/templates/meters/index.html | 16 +- app_new/meters/templates/meters/new.html | 27 +++ app_new/planning/schedules.py | 32 ++-- .../planning/templates/planning/meters.html | 10 +- migrations/alembic.ini | 2 + .../n9c0d1e2f3g4_meter_c1_foundation.py | 80 ++++++++ 13 files changed, 570 insertions(+), 107 deletions(-) create mode 100644 app_new/core/services/meter_service.py create mode 100644 app_new/meters/templates/meters/new.html create mode 100644 migrations/versions/n9c0d1e2f3g4_meter_c1_foundation.py diff --git a/app_new/core/models/__init__.py b/app_new/core/models/__init__.py index 77c939b..ade3bdf 100644 --- a/app_new/core/models/__init__.py +++ b/app_new/core/models/__init__.py @@ -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', diff --git a/app_new/core/models/planning.py b/app_new/core/models/planning.py index 27e232c..3229000 100644 --- a/app_new/core/models/planning.py +++ b/app_new/core/models/planning.py @@ -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"" +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" diff --git a/app_new/core/services/meter_service.py b/app_new/core/services/meter_service.py new file mode 100644 index 0000000..04a4d7e --- /dev/null +++ b/app_new/core/services/meter_service.py @@ -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 diff --git a/app_new/equipments/meters.py b/app_new/equipments/meters.py index c2dfb8c..e525a53 100644 --- a/app_new/equipments/meters.py +++ b/app_new/equipments/meters.py @@ -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 ==================== diff --git a/app_new/meters/routes.py b/app_new/meters/routes.py index 5dcf9bd..37c04d0 100644 --- a/app_new/meters/routes.py +++ b/app_new/meters/routes.py @@ -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('/') @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('//readings//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('//readings//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('//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)) diff --git a/app_new/meters/templates/meters/detail.html b/app_new/meters/templates/meters/detail.html index 4e0f0fc..55be5bf 100644 --- a/app_new/meters/templates/meters/detail.html +++ b/app_new/meters/templates/meters/detail.html @@ -6,9 +6,7 @@ @@ -24,12 +22,13 @@
-
Équipement
+
Rattachement
- - {{ meter.equipment.name if meter.equipment else 'Sans equipement' }} - + {% 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 %}
+ +
Usage
{{ meter.usage or '—' }}
+
Reste sous-compté
{{ meter.remainder_label or '—' }}
Type
{{ meter.meter_type }}
@@ -112,7 +111,7 @@ Nouveau relevé
-
+
@@ -123,13 +122,14 @@ {{ meter.unit }}
-
+
+
{% for correction in reading.corrections %}
{{ correction.old_value }} → {{ correction.new_value }} · {{ correction.reason }}
{% endfor %} {% else %} @@ -187,6 +187,7 @@ + {% if meter.status != 'replaced' %}
Remplacer ce compteur
{% endif %} diff --git a/app_new/meters/templates/meters/edit.html b/app_new/meters/templates/meters/edit.html index 541c235..6d7809f 100644 --- a/app_new/meters/templates/meters/edit.html +++ b/app_new/meters/templates/meters/edit.html @@ -22,14 +22,21 @@
- - + + + + + + + +
@@ -59,8 +66,8 @@
- + {{ meter.unit }}
@@ -165,4 +172,9 @@ -{% endblock %} \ No newline at end of file + +{% endblock %} diff --git a/app_new/meters/templates/meters/index.html b/app_new/meters/templates/meters/index.html index a9972ae..b65ea90 100644 --- a/app_new/meters/templates/meters/index.html +++ b/app_new/meters/templates/meters/index.html @@ -9,8 +9,8 @@

Suivi des heures de fonctionnement, cycles, et autres métriques

@@ -60,7 +60,7 @@ - + @@ -75,9 +75,11 @@ {% for meter in meters %}
ÉquipementRattachement Compteur Type Valeur
- - {{ meter.equipment.name }} - + {% 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 }}{% endif %} @@ -132,4 +134,4 @@ -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/app_new/meters/templates/meters/new.html b/app_new/meters/templates/meters/new.html new file mode 100644 index 0000000..7eaaddd --- /dev/null +++ b/app_new/meters/templates/meters/new.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} +{% block title %}Nouveau compteur — GMAO{% endblock %} +{% block content %} + + +{% endblock %} diff --git a/app_new/planning/schedules.py b/app_new/planning/schedules.py index afa0d69..4175e65 100644 --- a/app_new/planning/schedules.py +++ b/app_new/planning/schedules.py @@ -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')) diff --git a/app_new/planning/templates/planning/meters.html b/app_new/planning/templates/planning/meters.html index b40f8cb..989261d 100644 --- a/app_new/planning/templates/planning/meters.html +++ b/app_new/planning/templates/planning/meters.html @@ -4,7 +4,7 @@ {% block content %} @@ -12,7 +12,7 @@ - + @@ -26,7 +26,7 @@ {% for meter in meters %} - + @@ -62,10 +62,10 @@ {% else %} {% endfor %}
ÉquipementRattachement Nom Type Valeur actuelle
{{ meter.equipment.name if meter.equipment else '-' }}{% 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 %} {{ meter.name }} {{ meter.meter_type }} {{ meter.current_value }} {{ meter.unit }}
- Aucun compteur. Créer le premier + Aucun compteur. Créer le premier
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/migrations/alembic.ini b/migrations/alembic.ini index ec9d45c..cf56098 100644 --- a/migrations/alembic.ini +++ b/migrations/alembic.ini @@ -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 diff --git a/migrations/versions/n9c0d1e2f3g4_meter_c1_foundation.py b/migrations/versions/n9c0d1e2f3g4_meter_c1_foundation.py new file mode 100644 index 0000000..e0f653b --- /dev/null +++ b/migrations/versions/n9c0d1e2f3g4_meter_c1_foundation.py @@ -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)