feat(meters): establish checkpoint C1 meter foundation
This commit is contained in:
parent
d9730c1558
commit
86a9015778
13 changed files with 570 additions and 107 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
178
app_new/core/services/meter_service.py
Normal file
178
app_new/core/services/meter_service.py
Normal 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
|
||||
|
|
@ -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 = 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,
|
||||
)
|
||||
meter.current_value = value
|
||||
db.session.add(reading)
|
||||
db.session.commit()
|
||||
except MeterDomainError as exc:
|
||||
db.session.rollback()
|
||||
return jsonify({'success': False, 'error': str(exc)}), 400
|
||||
|
||||
return jsonify({'success': True})
|
||||
return jsonify({'success': True, 'reading_id': reading.id})
|
||||
|
||||
|
||||
# ==================== CONSOMMABLES ====================
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
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,
|
||||
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,
|
||||
)
|
||||
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')
|
||||
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))
|
||||
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,9 +156,24 @@ 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')
|
||||
|
|
@ -121,5 +182,53 @@ def edit(meter_id):
|
|||
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))
|
||||
|
|
|
|||
|
|
@ -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,13 +22,14 @@
|
|||
</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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<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 %}
|
||||
|
|
@ -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) }}">
|
||||
|
|
|
|||
27
app_new/meters/templates/meters/new.html
Normal file
27
app_new/meters/templates/meters/new.html
Normal 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 %}
|
||||
|
|
@ -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,7 +684,9 @@ def meters():
|
|||
def new_meter():
|
||||
"""Créer un nouveau compteur."""
|
||||
if request.method == 'POST':
|
||||
meter = Meter(
|
||||
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'),
|
||||
|
|
@ -694,10 +696,10 @@ def new_meter():
|
|||
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()
|
||||
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'))
|
||||
|
||||
|
|
|
|||
|
|
@ -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,7 +62,7 @@
|
|||
{% 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 %}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
80
migrations/versions/n9c0d1e2f3g4_meter_c1_foundation.py
Normal file
80
migrations/versions/n9c0d1e2f3g4_meter_c1_foundation.py
Normal 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)
|
||||
Loading…
Reference in a new issue