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 .company import Company, Part, PartStockMovement, Alert, Service
|
||||||
from .planning import (
|
from .planning import (
|
||||||
WorkSchedule, WorkScheduleTemplate, AnnualTimeConfig, TimeEntry, CollegeClosure, ClosureSchedule, ClosureWorkDay, PersonalLeave, Training, TrainingParticipant,
|
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,
|
PreventiveTask, PreventiveTaskConsumable, ScheduledTask, TaskExecutionSegment,
|
||||||
TechnicianAvailability, AdminTask, ZoneAccessRule
|
TechnicianAvailability, AdminTask, ZoneAccessRule
|
||||||
)
|
)
|
||||||
|
|
@ -36,7 +36,7 @@ __all__ = [
|
||||||
'Lot', 'LotTask', 'LotService', 'WorkRequest', 'WorkRequestQuote', 'WorkRequestEvent',
|
'Lot', 'LotTask', 'LotService', 'WorkRequest', 'WorkRequestQuote', 'WorkRequestEvent',
|
||||||
'Company', 'Part', 'PartStockMovement', 'InterventionPart', 'Alert', 'Service',
|
'Company', 'Part', 'PartStockMovement', 'InterventionPart', 'Alert', 'Service',
|
||||||
'WorkSchedule', 'WorkScheduleTemplate', 'AnnualTimeConfig', 'TimeEntry', 'CollegeClosure', 'ClosureSchedule', 'ClosureWorkDay', 'PersonalLeave', 'Training', 'TrainingParticipant',
|
'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',
|
'PreventiveTask', 'PreventiveTaskConsumable', 'ScheduledTask', 'TaskExecutionSegment',
|
||||||
'TechnicianAvailability', 'AdminTask', 'ZoneAccessRule',
|
'TechnicianAvailability', 'AdminTask', 'ZoneAccessRule',
|
||||||
'AppSettings', 'AuditLog', 'TemplateAuditMark',
|
'AppSettings', 'AuditLog', 'TemplateAuditMark',
|
||||||
|
|
|
||||||
|
|
@ -243,15 +243,39 @@ class TrainingParticipant(db.Model):
|
||||||
|
|
||||||
|
|
||||||
class Meter(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"
|
__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)
|
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)
|
name = db.Column(db.String(100), nullable=False)
|
||||||
meter_type = db.Column(db.String(50), default="hours")
|
meter_type = db.Column(db.String(50), default="hours")
|
||||||
unit = db.Column(db.String(20), default="h")
|
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)
|
current_value = db.Column(db.Float, default=0)
|
||||||
initial_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))
|
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"))
|
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")
|
readings = db.relationship("MeterReading", back_populates="meter", lazy="dynamic")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
@ -310,11 +340,13 @@ class MeterReading(db.Model):
|
||||||
|
|
||||||
value = db.Column(db.Float, nullable=False)
|
value = db.Column(db.Float, nullable=False)
|
||||||
reading_date = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
|
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)
|
notes = db.Column(db.Text)
|
||||||
is_reset = db.Column(db.Boolean, nullable=False, default=False)
|
is_reset = db.Column(db.Boolean, nullable=False, default=False)
|
||||||
reset_reason = db.Column(db.String(255), nullable=True)
|
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")
|
meter = db.relationship("Meter", back_populates="readings")
|
||||||
read_by = db.relationship("User")
|
read_by = db.relationship("User")
|
||||||
|
|
@ -323,6 +355,22 @@ class MeterReading(db.Model):
|
||||||
return f"<MeterReading {self.value}>"
|
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):
|
class Consumable(db.Model):
|
||||||
"""Consommable pour maintenance."""
|
"""Consommable pour maintenance."""
|
||||||
__tablename__ = "consumables"
|
__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.college import Room, Building, RoomType, Zone
|
||||||
from ..core.models.maintenance import Lot, Intervention
|
from ..core.models.maintenance import Lot, Intervention
|
||||||
from ..core.models.planning import Meter, MeterReading, Consumable, EquipmentConsumable, ScheduledTask
|
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
|
from app_new.constants import EQUIPMENT_STATUSES
|
||||||
|
|
||||||
def _empty_to_none(v):
|
def _empty_to_none(v):
|
||||||
|
|
@ -74,8 +75,7 @@ def add_meter(id):
|
||||||
"""Ajouter un compteur à un équipement."""
|
"""Ajouter un compteur à un équipement."""
|
||||||
equipment = Equipment.query.get_or_404(id)
|
equipment = Equipment.query.get_or_404(id)
|
||||||
|
|
||||||
meter = Meter(
|
meter = create_meter(
|
||||||
equipment_id=id,
|
|
||||||
name=request.form.get('name'),
|
name=request.form.get('name'),
|
||||||
meter_type=request.form.get('meter_type', 'hours'),
|
meter_type=request.form.get('meter_type', 'hours'),
|
||||||
unit=request.form.get('unit', 'h'),
|
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,
|
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,
|
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,
|
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})
|
return jsonify({'success': True, 'meter_id': meter.id})
|
||||||
|
|
||||||
|
|
@ -100,18 +99,21 @@ def add_meter_reading(id):
|
||||||
meter_id = data.get('meter_id')
|
meter_id = data.get('meter_id')
|
||||||
value = data.get('value')
|
value = data.get('value')
|
||||||
|
|
||||||
meter = Meter.query.filter_by(id=meter_id, equipment_id=id).first_or_404()
|
meter = Meter.query.get_or_404(meter_id)
|
||||||
|
if meter.equipment_id != id:
|
||||||
reading = MeterReading(
|
from flask import abort
|
||||||
meter_id=meter_id,
|
abort(404)
|
||||||
value=value,
|
try:
|
||||||
read_by_id=current_user.id
|
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
|
except MeterDomainError as exc:
|
||||||
db.session.add(reading)
|
db.session.rollback()
|
||||||
db.session.commit()
|
return jsonify({'success': False, 'error': str(exc)}), 400
|
||||||
|
|
||||||
return jsonify({'success': True})
|
return jsonify({'success': True, 'reading_id': reading.id})
|
||||||
|
|
||||||
|
|
||||||
# ==================== CONSOMMABLES ====================
|
# ==================== CONSOMMABLES ====================
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,21 @@
|
||||||
Meters Routes - GMAO Collège
|
Meters Routes - GMAO Collège
|
||||||
Gestion des compteurs
|
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 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.extensions import db
|
||||||
from app_new.core.models.planning import Meter, MeterReading
|
from app_new.core.models.planning import Meter, MeterReading
|
||||||
from app_new.core.models.equipment import Equipment
|
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')
|
meters_bp = Blueprint('meters', __name__, template_folder='templates')
|
||||||
|
|
||||||
|
|
@ -15,8 +25,7 @@ meters_bp = Blueprint('meters', __name__, template_folder='templates')
|
||||||
@login_required
|
@login_required
|
||||||
def index():
|
def index():
|
||||||
"""Liste de tous les compteurs."""
|
"""Liste de tous les compteurs."""
|
||||||
# Récupérer tous les compteurs avec leur équipement
|
meters = Meter.query.order_by(Meter.name).all()
|
||||||
meters = Meter.query.join(Equipment).order_by(Equipment.name, Meter.name).all()
|
|
||||||
|
|
||||||
# Statistiques
|
# Statistiques
|
||||||
total_count = len(meters)
|
total_count = len(meters)
|
||||||
|
|
@ -32,12 +41,49 @@ def index():
|
||||||
maintenance_count=maintenance_count)
|
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>')
|
@meters_bp.route('/<int:meter_id>')
|
||||||
@login_required
|
@login_required
|
||||||
def detail(meter_id):
|
def detail(meter_id):
|
||||||
"""Détails d'un compteur avec historique des relevés."""
|
"""Détails d'un compteur avec historique des relevés."""
|
||||||
meter = Meter.query.get_or_404(meter_id)
|
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()
|
.order_by(MeterReading.reading_date.desc()).limit(50).all()
|
||||||
|
|
||||||
return render_template('meters/detail.html', meter=meter, readings=readings)
|
return render_template('meters/detail.html', meter=meter, readings=readings)
|
||||||
|
|
@ -49,36 +95,35 @@ def add_reading(meter_id):
|
||||||
"""Ajouter un relevé à un compteur."""
|
"""Ajouter un relevé à un compteur."""
|
||||||
meter = Meter.query.get_or_404(meter_id)
|
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'
|
is_reset = request.form.get('is_reset') == '1'
|
||||||
reset_reason = (request.form.get('reset_reason') or '').strip()
|
reset_reason = (request.form.get('reset_reason') or '').strip()
|
||||||
if value < (meter.current_value or 0) and not (is_reset and reset_reason):
|
photo_filename = photo_path = None
|
||||||
flash('Un relevé inférieur exige de cocher « remise à zéro » et de saisir un motif.', 'danger')
|
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))
|
return redirect(url_for('meters.detail', meter_id=meter_id))
|
||||||
if is_reset and not reset_reason:
|
photo_filename = secure_filename(photo.filename)
|
||||||
flash('Le motif de remise à zéro est obligatoire.', 'danger')
|
stored_name = f'{uuid4().hex}_{photo_filename}'
|
||||||
return redirect(url_for('meters.detail', meter_id=meter_id))
|
upload_dir = Path(current_app.config.get('UPLOAD_FOLDER', 'uploads')) / 'meter-readings'
|
||||||
|
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||||
reading = MeterReading(
|
photo_path = str(upload_dir / stored_name)
|
||||||
meter_id=meter_id,
|
photo.save(photo_path)
|
||||||
value=value,
|
photo_filename = stored_name
|
||||||
read_by_id=current_user.id,
|
try:
|
||||||
notes=request.form.get('notes', ''), is_reset=is_reset, reset_reason=reset_reason or None,
|
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
|
except MeterDomainError as exc:
|
||||||
if is_reset:
|
db.session.rollback()
|
||||||
meter.initial_value = value
|
if photo_path:
|
||||||
meter.last_maintenance_value = value
|
Path(photo_path).unlink(missing_ok=True)
|
||||||
meter.updated_at = db.func.now()
|
flash(str(exc), 'danger')
|
||||||
|
return redirect(url_for('meters.detail', meter_id=meter_id))
|
||||||
db.session.add(reading)
|
flash(f'Relevé enregistré: {reading.value} {meter.unit}', 'success')
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
flash(f'Relevé enregistré: {value} {meter.unit}', 'success')
|
|
||||||
return redirect(url_for('meters.detail', meter_id=meter_id))
|
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.maintenance_interval = request.form.get('maintenance_interval', type=float)
|
||||||
meter.last_maintenance_value = request.form.get('last_maintenance_value', 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.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é
|
# Date du dernier relevé
|
||||||
last_reading_date = request.form.get('last_reading_date')
|
last_reading_date = request.form.get('last_reading_date')
|
||||||
|
|
@ -110,9 +156,24 @@ def edit(meter_id):
|
||||||
meter.last_reading_date = None
|
meter.last_reading_date = None
|
||||||
|
|
||||||
# Équipement
|
# Équipement
|
||||||
equipment_id = request.form.get('equipment_id')
|
scope_type = request.form.get('scope_type')
|
||||||
if equipment_id:
|
scope_id = request.form.get('scope_id', type=int)
|
||||||
meter.equipment_id = int(equipment_id)
|
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()
|
db.session.commit()
|
||||||
flash('Compteur modifié avec succès', 'success')
|
flash('Compteur modifié avec succès', 'success')
|
||||||
|
|
@ -121,5 +182,53 @@ def edit(meter_id):
|
||||||
return render_template('meters/edit.html',
|
return render_template('meters/edit.html',
|
||||||
meter=meter,
|
meter=meter,
|
||||||
equipments=equipments,
|
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,
|
timedelta=timedelta,
|
||||||
today=date.today())
|
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">
|
<nav aria-label="breadcrumb" class="mb-3">
|
||||||
<ol class="breadcrumb">
|
<ol class="breadcrumb">
|
||||||
<li class="breadcrumb-item"><a href="{{ url_for('meters.index') }}">Compteurs</a></li>
|
<li class="breadcrumb-item"><a href="{{ url_for('meters.index') }}">Compteurs</a></li>
|
||||||
<li class="breadcrumb-item">
|
<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>
|
||||||
<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 active">{{ meter.name }}</li>
|
<li class="breadcrumb-item active">{{ meter.name }}</li>
|
||||||
</ol>
|
</ol>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
@ -24,13 +22,14 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<dl class="row mb-0">
|
<dl class="row mb-0">
|
||||||
<dt class="col-sm-5">Équipement</dt>
|
<dt class="col-sm-5">Rattachement</dt>
|
||||||
<dd class="col-sm-7">
|
<dd class="col-sm-7">
|
||||||
<a href="{{ url_for('equipments.detail', id=meter.equipment.id) if meter.equipment else '#' }}">
|
{% 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 %}
|
||||||
{{ meter.equipment.name if meter.equipment else 'Sans equipement' }}
|
|
||||||
</a>
|
|
||||||
</dd>
|
</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>
|
<dt class="col-sm-5">Type</dt>
|
||||||
<dd class="col-sm-7">{{ meter.meter_type }}</dd>
|
<dd class="col-sm-7">{{ meter.meter_type }}</dd>
|
||||||
|
|
||||||
|
|
@ -112,7 +111,7 @@
|
||||||
<i class="bi bi-pencil me-1"></i>Nouveau relevé
|
<i class="bi bi-pencil me-1"></i>Nouveau relevé
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<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() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
|
|
@ -123,13 +122,14 @@
|
||||||
<span class="input-group-text">{{ meter.unit }}</span>
|
<span class="input-group-text">{{ meter.unit }}</span>
|
||||||
</div>
|
</div>
|
||||||
</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"><input class="form-control" name="reset_reason" placeholder="Motif obligatoire si remise à zéro"></div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="notes" class="form-label">Notes</label>
|
<label for="notes" class="form-label">Notes</label>
|
||||||
<textarea class="form-control" id="notes" name="notes" rows="2"></textarea>
|
<textarea class="form-control" id="notes" name="notes" rows="2"></textarea>
|
||||||
</div>
|
</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">
|
<button type="submit" class="btn btn-primary w-100">
|
||||||
<i class="bi bi-check-lg me-1"></i>Enregistrer
|
<i class="bi bi-check-lg me-1"></i>Enregistrer
|
||||||
|
|
@ -152,7 +152,7 @@
|
||||||
<th>Valeur</th>
|
<th>Valeur</th>
|
||||||
<th>Variation</th>
|
<th>Variation</th>
|
||||||
<th>Par</th>
|
<th>Par</th>
|
||||||
<th>Notes</th>
|
<th>Notes / correction</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
@ -173,7 +173,7 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>{{ reading.read_by.username if reading.read_by else '—' }}</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>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr>
|
<tr>
|
||||||
|
|
@ -187,6 +187,7 @@
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -22,14 +22,21 @@
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="equipment_id" class="form-label">Équipement</label>
|
<label for="scope_type" class="form-label">Type de rattachement</label>
|
||||||
<select class="form-select" id="equipment_id" name="equipment_id">
|
<select class="form-select" id="scope_type" name="scope_type">
|
||||||
{% for eq in equipments %}
|
<option value="equipment" {% if meter.equipment %}selected{% endif %}>Équipement</option>
|
||||||
<option value="{{ eq.id }}" {% if meter.equipment_id == eq.id %}selected{% endif %}>
|
<option value="building" {% if meter.building %}selected{% endif %}>Bâtiment</option>
|
||||||
{{ eq.name }}
|
<option value="zone" {% if meter.zone %}selected{% endif %}>Zone</option>
|
||||||
{% if eq.room %}({{ eq.room.name }}){% endif %}
|
<option value="room" {% if meter.room %}selected{% endif %}>Local</option>
|
||||||
</option>
|
<option value="housing_unit" {% if meter.housing_unit %}selected{% endif %}>Logement</option>
|
||||||
{% endfor %}
|
</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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -59,8 +66,8 @@
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="current_value" class="form-label">Valeur actuelle</label>
|
<label for="current_value" class="form-label">Valeur actuelle</label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input type="number" step="0.01" class="form-control" id="current_value" name="current_value"
|
<input type="number" step="0.01" class="form-control" id="current_value"
|
||||||
value="{{ meter.current_value }}">
|
value="{{ meter.current_value }}" readonly>
|
||||||
<span class="input-group-text">{{ meter.unit }}</span>
|
<span class="input-group-text">{{ meter.unit }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -165,4 +172,9 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 %}
|
{% endblock %}
|
||||||
|
|
@ -9,8 +9,8 @@
|
||||||
<p class="text-muted mb-0">Suivi des heures de fonctionnement, cycles, et autres métriques</p>
|
<p class="text-muted mb-0">Suivi des heures de fonctionnement, cycles, et autres métriques</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<a href="{{ url_for('planning.meters') }}" class="btn btn-outline-primary">
|
<a href="{{ url_for('meters.new') }}" class="btn btn-primary">
|
||||||
<i class="bi bi-plus-circle me-1"></i>Gestion préventive
|
<i class="bi bi-plus-circle me-1"></i>Nouveau compteur
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -60,7 +60,7 @@
|
||||||
<table class="table table-hover mb-0">
|
<table class="table table-hover mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Équipement</th>
|
<th>Rattachement</th>
|
||||||
<th>Compteur</th>
|
<th>Compteur</th>
|
||||||
<th>Type</th>
|
<th>Type</th>
|
||||||
<th>Valeur</th>
|
<th>Valeur</th>
|
||||||
|
|
@ -75,9 +75,11 @@
|
||||||
{% for meter in meters %}
|
{% for meter in meters %}
|
||||||
<tr class="{{ 'table-warning' if meter.warning_level == 'warning' else '' }}{{ 'table-danger' if meter.warning_level == 'critical' else '' }}">
|
<tr class="{{ 'table-warning' if meter.warning_level == 'warning' else '' }}{{ 'table-danger' if meter.warning_level == 'critical' else '' }}">
|
||||||
<td>
|
<td>
|
||||||
<a href="{{ url_for('equipments.detail', id=meter.equipment.id) }}">
|
{% if meter.equipment %}<a href="{{ url_for('equipments.detail', id=meter.equipment.id) }}">{{ meter.equipment.name }}</a>
|
||||||
{{ meter.equipment.name }}
|
{% elif meter.building %}Bâtiment — {{ meter.building.name }}
|
||||||
</a>
|
{% 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>
|
||||||
<td>
|
<td>
|
||||||
<a href="{{ url_for('meters.detail', meter_id=meter.id) }}">
|
<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
|
@login_required
|
||||||
def meters():
|
def meters():
|
||||||
"""Liste des compteurs."""
|
"""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)
|
return render_template('planning/meters.html', meters=meters)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -684,7 +684,9 @@ def meters():
|
||||||
def new_meter():
|
def new_meter():
|
||||||
"""Créer un nouveau compteur."""
|
"""Créer un nouveau compteur."""
|
||||||
if request.method == 'POST':
|
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),
|
equipment_id=request.form.get('equipment_id', type=int),
|
||||||
name=request.form.get('name'),
|
name=request.form.get('name'),
|
||||||
meter_type=request.form.get('meter_type', 'hours'),
|
meter_type=request.form.get('meter_type', 'hours'),
|
||||||
|
|
@ -694,10 +696,10 @@ def new_meter():
|
||||||
warning_threshold=request.form.get('warning_threshold', type=float),
|
warning_threshold=request.form.get('warning_threshold', type=float),
|
||||||
critical_threshold=request.form.get('critical_threshold', type=float),
|
critical_threshold=request.form.get('critical_threshold', type=float),
|
||||||
maintenance_interval=request.form.get('maintenance_interval', type=float),
|
maintenance_interval=request.form.get('maintenance_interval', type=float),
|
||||||
is_active=True
|
|
||||||
)
|
)
|
||||||
db.session.add(meter)
|
except MeterDomainError as exc:
|
||||||
db.session.commit()
|
flash(str(exc), 'danger')
|
||||||
|
return redirect(url_for('planning.new_meter'))
|
||||||
flash('Compteur créé', 'success')
|
flash('Compteur créé', 'success')
|
||||||
return redirect(url_for('planning.meters'))
|
return redirect(url_for('planning.meters'))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<h1><i class="bi bi-speedometer2"></i> Compteurs</h1>
|
<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
|
<i class="bi bi-plus"></i> Nouveau compteur
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
<table class="table table-hover">
|
<table class="table table-hover">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
<th>Équipement</th>
|
<th>Rattachement</th>
|
||||||
<th>Nom</th>
|
<th>Nom</th>
|
||||||
<th>Type</th>
|
<th>Type</th>
|
||||||
<th>Valeur actuelle</th>
|
<th>Valeur actuelle</th>
|
||||||
|
|
@ -26,7 +26,7 @@
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for meter in meters %}
|
{% for meter in meters %}
|
||||||
<tr>
|
<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><strong>{{ meter.name }}</strong></td>
|
||||||
<td><span class="badge bg-secondary">{{ meter.meter_type }}</span></td>
|
<td><span class="badge bg-secondary">{{ meter.meter_type }}</span></td>
|
||||||
<td>{{ meter.current_value }} {{ meter.unit }}</td>
|
<td>{{ meter.current_value }} {{ meter.unit }}</td>
|
||||||
|
|
@ -62,7 +62,7 @@
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="9" class="text-center text-muted py-4">
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
# A generic, single database configuration.
|
# A generic, single database configuration.
|
||||||
|
|
||||||
[alembic]
|
[alembic]
|
||||||
|
script_location = migrations
|
||||||
|
prepend_sys_path = .
|
||||||
# template used to generate migration files
|
# template used to generate migration files
|
||||||
# file_template = %%(rev)s_%%(slug)s
|
# 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