Complète le suivi opérationnel de la GMAO
Some checks are pending
CI - Tests et Syntax / lint-and-test (push) Waiting to run

This commit is contained in:
root 2026-08-14 23:26:22 +00:00
parent d66bd0197c
commit 0b3fff43e5
22 changed files with 531 additions and 40 deletions

View file

@ -188,7 +188,7 @@ def create_app(config_name='default'):
app.register_blueprint(messagerie_bp)
# Contrats d'entreprise
from .contracts.models import Contract
from .contracts.models import Contract, ContractVisit
from .contracts.routes import contracts_bp
app.register_blueprint(contracts_bp)

View file

@ -53,6 +53,8 @@ class Contract(db.Model):
lot = db.relationship("Lot", backref=db.backref("contracts", lazy="dynamic"))
equipment = db.relationship("Equipment", backref=db.backref("contracts", lazy="dynamic"))
company = db.relationship("Company", backref=db.backref("contracts", lazy="dynamic"))
visits = db.relationship("ContractVisit", back_populates="contract", cascade="all, delete-orphan",
order_by="ContractVisit.scheduled_date")
@property
def is_active(self):
@ -113,3 +115,19 @@ class Contract(db.Model):
def __repr__(self):
return f"<Contract {self.name} ({self.status})>"
class ContractVisit(db.Model):
"""Visite planifiée ou réalisée par le prestataire d'un contrat."""
__tablename__ = "contract_visits"
__table_args__ = (db.UniqueConstraint("contract_id", "scheduled_date", name="uq_contract_visit_date"),)
id = db.Column(db.Integer, primary_key=True)
contract_id = db.Column(db.Integer, db.ForeignKey("contracts.id", ondelete="CASCADE"), nullable=False, index=True)
scheduled_date = db.Column(db.Date, nullable=False, index=True)
status = db.Column(db.String(20), nullable=False, default="planifiee")
completed_at = db.Column(db.DateTime, nullable=True)
report = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
contract = db.relationship("Contract", back_populates="visits")

View file

@ -5,11 +5,11 @@ Suivi des contrats d'entreprise.
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
from flask_login import login_required, current_user
from ..extensions import db
from ..contracts.models import Contract
from ..contracts.models import Contract, ContractVisit
from ..core.models.maintenance import Lot
from ..core.models.equipment import Equipment
from ..companies.routes import Company
from datetime import datetime, date
from datetime import datetime, date, timedelta, timezone
def _optional_date(value):
return datetime.strptime(value, '%Y-%m-%d').date() if value else None
@ -19,6 +19,24 @@ def _apply_visits(contract):
contract.last_visit_date = _optional_date(request.form.get('last_visit_date'))
contract.next_visit_date = _optional_date(request.form.get('next_visit_date'))
def _generate_visits(contract, horizon_days=365):
"""Matérialise le calendrier contractuel sans créer de doublons."""
if not contract.visit_interval_days or contract.visit_interval_days <= 0:
return 0
cursor = contract.next_visit_date or contract.start_date
if contract.last_visit_date and cursor <= contract.last_visit_date:
cursor = contract.last_visit_date + timedelta(days=contract.visit_interval_days)
horizon = min(date.today() + timedelta(days=horizon_days), contract.end_date) if contract.end_date else date.today() + timedelta(days=horizon_days)
existing = {visit.scheduled_date for visit in contract.visits}
created = 0
while cursor and cursor <= horizon:
if cursor >= contract.start_date and cursor not in existing:
db.session.add(ContractVisit(contract=contract, scheduled_date=cursor))
created += 1
cursor += timedelta(days=contract.visit_interval_days)
return created
contracts_bp = Blueprint('contracts', __name__, url_prefix='/contracts', template_folder='templates')
@ -73,6 +91,8 @@ def create():
)
db.session.add(contract)
_apply_visits(contract)
db.session.flush()
_generate_visits(contract)
db.session.commit()
flash('Contrat cree avec succes.', 'success')
return redirect(url_for('contracts.detail', id=contract.id))
@ -122,6 +142,7 @@ def edit(id):
contract.status = request.form.get('status', 'actif')
contract.notes = request.form.get('notes', '')
_apply_visits(contract)
_generate_visits(contract)
db.session.commit()
flash('Contrat modifie.', 'success')
return redirect(url_for('contracts.detail', id=contract.id))
@ -142,6 +163,32 @@ def delete(id):
return redirect(url_for('contracts.index'))
@contracts_bp.route('/<int:id>/visits/generate', methods=['POST'])
@login_required
def generate_visits(id):
contract = Contract.query.get_or_404(id)
count = _generate_visits(contract)
db.session.commit()
flash(f'{count} visite(s) ajoutée(s) au calendrier.', 'success')
return redirect(url_for('contracts.detail', id=id))
@contracts_bp.route('/<int:id>/visits/<int:visit_id>/complete', methods=['POST'])
@login_required
def complete_visit(id, visit_id):
contract = Contract.query.get_or_404(id)
visit = ContractVisit.query.filter_by(id=visit_id, contract_id=id).first_or_404()
visit.status = 'realisee'
visit.completed_at = datetime.now(timezone.utc)
visit.report = (request.form.get('report') or '').strip() or None
contract.last_visit_date = visit.scheduled_date
contract.next_visit_date = visit.scheduled_date + timedelta(days=contract.visit_interval_days) if contract.visit_interval_days else None
_generate_visits(contract)
db.session.commit()
flash('Visite extérieure marquée comme réalisée.', 'success')
return redirect(url_for('contracts.detail', id=id))
@contracts_bp.route('/api/summary')
@login_required
def api_summary():

View file

@ -53,6 +53,16 @@
</div>
</div>
<div class="card border-0 shadow-sm mt-3">
<div class="card-header d-flex justify-content-between"><span><i class="bi bi-calendar-check"></i> Visites du prestataire</span>
<form method="post" action="{{ url_for('contracts.generate_visits', id=contract.id) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="btn btn-sm btn-outline-primary">Générer sur 12 mois</button></form>
</div>
<div class="table-responsive"><table class="table table-sm mb-0"><thead><tr><th>Date</th><th>Statut</th><th>Compte rendu</th><th></th></tr></thead><tbody>
{% for visit in contract.visits %}<tr><td>{{ visit.scheduled_date.strftime('%d/%m/%Y') }}</td><td><span class="badge bg-{{ 'success' if visit.status == 'realisee' else 'primary' }}">{{ visit.status }}</span></td><td>{{ visit.report or '—' }}</td><td>{% if visit.status != 'realisee' %}<form method="post" action="{{ url_for('contracts.complete_visit', id=contract.id, visit_id=visit.id) }}" class="d-flex gap-1"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><input name="report" class="form-control form-control-sm" placeholder="Compte rendu"><button class="btn btn-sm btn-success">Réalisée</button></form>{% endif %}</td></tr>
{% else %}<tr><td colspan="4" class="text-center text-muted">Aucune visite générée</td></tr>{% endfor %}
</tbody></table></div>
</div>
<div class="card border-0 shadow-sm mt-3">
<div class="card-body">
<h6><i class="bi bi-calendar3 text-primary"></i> Dates</h6>

View file

@ -9,7 +9,7 @@ from .maintenance import (
Intervention, StatusChange, InterventionComment, InterventionDocument,
InterventionPart, Lot, LotTask, LotService
)
from .company import Company, Part, Alert, Service
from .company import Company, Part, PartStockMovement, Alert, Service
from .planning import (
WorkSchedule, CollegeClosure, ClosureSchedule, ClosureWorkDay, PersonalLeave, Training, TrainingParticipant,
Meter, MeterReading, Consumable, ConsumableUsage, EquipmentConsumable,
@ -26,7 +26,7 @@ __all__ = [
'EquipmentCategory', 'Equipment', 'EquipmentDocument', 'EquipmentRoomHistory', 'EquipmentQuantityMovement',
'Intervention', 'StatusChange', 'InterventionComment', 'InterventionDocument',
'Lot', 'LotTask', 'LotService',
'Company', 'Part', 'InterventionPart', 'Alert', 'Service',
'Company', 'Part', 'PartStockMovement', 'InterventionPart', 'Alert', 'Service',
'WorkSchedule', 'CollegeClosure', 'ClosureSchedule', 'ClosureWorkDay', 'PersonalLeave', 'Training', 'TrainingParticipant',
'Meter', 'MeterReading', 'Consumable', 'ConsumableUsage', 'EquipmentConsumable',
'PreventiveTask', 'PreventiveTaskConsumable', 'ScheduledTask',

View file

@ -58,6 +58,8 @@ class Part(db.Model):
# Relations - utiliser back_populates pour éviter conflit
usages_list = db.relationship("InterventionPart", back_populates="part", lazy="dynamic")
stock_movements = db.relationship("PartStockMovement", back_populates="part", lazy="dynamic",
cascade="all, delete-orphan")
@property
def is_low_stock(self):
@ -68,6 +70,24 @@ class Part(db.Model):
return f"<Part {self.name}>"
class PartStockMovement(db.Model):
"""Journal immuable des entrées, sorties et corrections de stock."""
__tablename__ = "part_stock_movements"
id = db.Column(db.Integer, primary_key=True)
part_id = db.Column(db.Integer, db.ForeignKey("parts.id", ondelete="CASCADE"), nullable=False, index=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
movement_type = db.Column(db.String(20), nullable=False)
quantity_delta = db.Column(db.Integer, nullable=False)
quantity_before = db.Column(db.Integer, nullable=False)
quantity_after = db.Column(db.Integer, nullable=False)
reason = db.Column(db.String(255), nullable=False)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
part = db.relationship("Part", back_populates="stock_movements")
user = db.relationship("User")
class Alert(db.Model):
"""Alerte système."""
__tablename__ = "alerts"
@ -100,4 +120,4 @@ class Service(db.Model):
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
def __repr__(self):
return f"<Service {self.name}>"
return f"<Service {self.name}>"

View file

@ -128,6 +128,7 @@ class PersonalLeave(db.Model):
class Training(db.Model):
"""Formation."""
__tablename__ = "trainings"
__table_args__ = (db.UniqueConstraint("source_type", "source_id", name="uq_training_source"),)
id = db.Column(db.Integer, primary_key=True)
@ -136,11 +137,17 @@ class Training(db.Model):
start_date = db.Column(db.Date)
end_date = db.Column(db.Date)
start_time = db.Column(db.Time, nullable=True)
end_time = db.Column(db.Time, nullable=True)
location = db.Column(db.String(255))
source_type = db.Column(db.String(30), nullable=True)
source_id = db.Column(db.String(191), nullable=True)
source_subject = db.Column(db.String(500), nullable=True)
source_sender = db.Column(db.String(255), nullable=True)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
participants = db.relationship("TrainingParticipant", back_populates="training")
participants = db.relationship("TrainingParticipant", back_populates="training", cascade="all, delete-orphan")
def __repr__(self):
return f"<Training {self.name}>"
@ -236,6 +243,8 @@ class MeterReading(db.Model):
read_by_id = db.Column(db.Integer, db.ForeignKey("users.id"))
notes = db.Column(db.Text)
is_reset = db.Column(db.Boolean, nullable=False, default=False)
reset_reason = db.Column(db.String(255), nullable=True)
meter = db.relationship("Meter", back_populates="readings")
read_by = db.relationship("User")

View file

@ -82,3 +82,32 @@ def export_interventions_csv():
response.headers['Content-Type'] = 'text/csv; charset=utf-8'
response.headers['Content-Disposition'] = 'attachment; filename=interventions_export.csv'
return response
@exports_bp.route('/exports/costs/csv')
@login_required
def export_costs_csv():
"""Export consolidé des coûts de maintenance, contrats et valeur du stock."""
import csv
import io
from app_new.core.models.maintenance import Intervention
from app_new.core.models.company import Part
from app_new.contracts.models import Contract
output = io.StringIO()
writer = csv.writer(output, delimiter=';')
writer.writerow(['Nature', 'Référence', 'Libellé', 'Date', 'Montant EUR', 'Statut'])
for intervention in Intervention.query.filter(Intervention.is_deleted.is_(False)).all():
writer.writerow(['Intervention', intervention.id, intervention.title,
intervention.completed_at.date().isoformat() if intervention.completed_at else '',
f'{intervention.cost or 0:.2f}', intervention.status])
for contract in Contract.query.all():
writer.writerow(['Contrat annuel', contract.contract_number or contract.id, contract.name,
contract.start_date.isoformat(), f'{contract.annual_amount or 0:.2f}', contract.status])
for part in Part.query.all():
writer.writerow(['Valeur du stock', part.reference or part.id, part.name, '',
f'{(part.quantity or 0) * (part.unit_price or 0):.2f}', f'{part.quantity or 0} {part.unit or ""}'])
response = make_response('\ufeff' + output.getvalue())
response.headers['Content-Type'] = 'text/csv; charset=utf-8'
response.headers['Content-Disposition'] = 'attachment; filename=couts_gmao.csv'
return response

View file

@ -54,13 +54,25 @@ def add_reading(meter_id):
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')
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', '')
notes=request.form.get('notes', ''), is_reset=is_reset, reset_reason=reset_reason or None,
)
meter.current_value = value
if is_reset:
meter.initial_value = value
meter.last_maintenance_value = value
meter.updated_at = db.func.now()
db.session.add(reading)
@ -110,4 +122,4 @@ def edit(meter_id):
meter=meter,
equipments=equipments,
timedelta=timedelta,
today=date.today())
today=date.today())

View file

@ -123,6 +123,8 @@
<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="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>
@ -171,7 +173,7 @@
{% endif %}
</td>
<td>{{ reading.read_by.username if reading.read_by else '—' }}</td>
<td>{{ reading.notes or '—' }}</td>
<td>{% if reading.is_reset %}<span class="badge bg-warning text-dark">RAZ</span> {{ reading.reset_reason }}{% else %}{{ reading.notes or '—' }}{% endif %}</td>
</tr>
{% else %}
<tr>
@ -188,4 +190,4 @@
</div>
</div>
</div>
{% endblock %}
{% endblock %}

View file

@ -3,9 +3,26 @@ Parts Routes - GMAO Collège
Gestion des pièces détachées
"""
from flask import Blueprint, render_template, request, flash, redirect, url_for
from flask_login import login_required
from flask_login import login_required, current_user
from app_new.extensions import db
from ..core.models.company import Part
from ..core.models.company import Part, PartStockMovement
def _record_movement(part, new_quantity, movement_type, reason):
before = int(part.quantity or 0)
after = int(new_quantity)
if after < 0:
raise ValueError('Le stock ne peut pas devenir négatif.')
if after == before:
return None
movement = PartStockMovement(
part=part, user_id=current_user.id, movement_type=movement_type,
quantity_delta=after - before, quantity_before=before,
quantity_after=after, reason=(reason or '').strip() or 'Ajustement manuel',
)
part.quantity = after
db.session.add(movement)
return movement
parts_bp = Blueprint('parts', __name__)
@ -22,7 +39,8 @@ def index():
def detail(id):
"""Détail d'une pièce."""
part = Part.query.get_or_404(id)
return render_template('parts/detail.html', part=part)
movements = part.stock_movements.order_by(PartStockMovement.created_at.desc()).limit(100).all()
return render_template('parts/detail.html', part=part, movements=movements)
@parts_bp.route('/<int:id>/edit', methods=['GET', 'POST'])
@ -33,8 +51,15 @@ def edit(id):
if request.method == 'POST':
part.name = request.form.get('name')
part.reference = request.form.get('reference')
part.quantity = request.form.get('quantity', 0)
try:
_record_movement(part, request.form.get('quantity', type=int, default=0),
'correction', request.form.get('stock_reason'))
except ValueError as exc:
flash(str(exc), 'danger')
return render_template('parts/form.html', part=part), 400
part.unit = request.form.get('unit')
part.unit_price = request.form.get('unit_price', type=float)
part.supplier = request.form.get('supplier')
db.session.commit()
flash('Pièce modifiée avec succès.', 'success')
return redirect(url_for('parts.detail', id=part.id))
@ -46,15 +71,43 @@ def edit(id):
def create():
"""Créer une pièce."""
if request.method == 'POST':
quantity = max(0, request.form.get('quantity', type=int, default=0))
part = Part(
name=request.form.get('name'),
reference=request.form.get('reference'),
quantity=request.form.get('quantity', 0),
quantity=0,
unit=request.form.get('unit')
)
part.unit_price = request.form.get('unit_price', type=float)
part.supplier = request.form.get('supplier')
db.session.add(part)
db.session.flush()
if quantity:
_record_movement(part, quantity, 'entree', 'Stock initial')
db.session.commit()
flash('Pièce créée avec succès.', 'success')
return redirect(url_for('parts.index'))
return render_template('parts/new.html')
return render_template('parts/new.html')
@parts_bp.route('/<int:id>/movement', methods=['POST'])
@login_required
def movement(id):
"""Enregistre une entrée, une sortie ou un inventaire sans écraser l'historique."""
part = Part.query.get_or_404(id)
movement_type = request.form.get('movement_type')
amount = request.form.get('quantity', type=int)
reason = (request.form.get('reason') or '').strip()
if movement_type not in {'entree', 'sortie', 'inventaire'} or amount is None or amount < 0 or not reason:
flash('Type, quantité positive et motif sont obligatoires.', 'danger')
return redirect(url_for('parts.detail', id=id))
new_quantity = amount if movement_type == 'inventaire' else int(part.quantity or 0) + (amount if movement_type == 'entree' else -amount)
try:
_record_movement(part, new_quantity, movement_type, reason)
db.session.commit()
flash('Mouvement de stock enregistré.', 'success')
except ValueError as exc:
db.session.rollback()
flash(str(exc), 'danger')
return redirect(url_for('parts.detail', id=id))

View file

@ -1,10 +1,11 @@
from datetime import date, datetime, timedelta
from flask import Blueprint, flash, redirect, render_template, request, url_for
from flask import Blueprint, flash, make_response, redirect, render_template, request, url_for
from flask_login import current_user, login_required
from sqlalchemy import func
from app_new.extensions import db
from app_new.core.models.user import Staff
from app_new.core.models.prevention import PreventionWorkLog, StaffAuthorization, RiskAssessment, PreventionAction, SafetyRegisterEntry
from app_new.core.services.planning_service import PlanningService
prevention_bp = Blueprint("prevention", __name__)
def _date(value): return datetime.strptime(value, "%Y-%m-%d").date() if value else None
@ -14,8 +15,16 @@ def _date(value): return datetime.strptime(value, "%Y-%m-%d").date() if value el
def index():
monday = date.today() - timedelta(days=date.today().weekday())
minutes = db.session.query(func.coalesce(func.sum(PreventionWorkLog.duration_minutes), 0)).filter(PreventionWorkLog.user_id == current_user.id, PreventionWorkLog.work_date >= monday).scalar()
target = 210 # 10 % d'une semaine de 35 h, paramétrable à l'étape suivante.
return render_template("prevention/index.html", staff=Staff.query.filter_by(is_active=True).order_by(Staff.last_name).all(), logs=PreventionWorkLog.query.order_by(PreventionWorkLog.work_date.desc()).limit(20).all(), risks=RiskAssessment.query.filter_by(is_active=True).all(), actions=PreventionAction.query.order_by(PreventionAction.due_date).all(), entries=SafetyRegisterEntry.query.order_by(SafetyRegisterEntry.entry_date.desc()).limit(20).all(), minutes=minutes, target=target)
available_minutes = 0
for offset in range(7):
hours = PlanningService.get_working_hours(monday + timedelta(days=offset), current_user.id)
if hours:
available_minutes += int((datetime.combine(date.min, hours[1]) - datetime.combine(date.min, hours[0])).total_seconds() / 60)
if hours[2] and hours[3]:
available_minutes -= int((datetime.combine(date.min, hours[3]) - datetime.combine(date.min, hours[2])).total_seconds() / 60)
target = round(available_minutes * 0.10)
logs = PreventionWorkLog.query.filter_by(user_id=current_user.id).order_by(PreventionWorkLog.work_date.desc()).limit(20).all()
return render_template("prevention/index.html", staff=Staff.query.filter_by(is_active=True).order_by(Staff.last_name).all(), logs=logs, risks=RiskAssessment.query.filter_by(is_active=True).all(), actions=PreventionAction.query.order_by(PreventionAction.due_date).all(), entries=SafetyRegisterEntry.query.order_by(SafetyRegisterEntry.entry_date.desc()).limit(20).all(), minutes=minutes, target=target, available_minutes=available_minutes)
@prevention_bp.post("/staff")
@login_required
@ -38,7 +47,14 @@ def add_authorization():
@prevention_bp.post("/risk")
@login_required
def add_risk():
db.session.add(RiskAssessment(work_unit=request.form.get("work_unit", "").strip(), hazard=request.form.get("hazard", "").strip(), exposed_people=request.form.get("exposed_people"), severity=request.form.get("severity", type=int) or 1, probability=request.form.get("probability", type=int) or 1, control_measures=request.form.get("control_measures"), reviewed_on=date.today()))
severity = request.form.get("severity", type=int) or 1
probability = request.form.get("probability", type=int) or 1
work_unit = request.form.get("work_unit", "").strip()
hazard = request.form.get("hazard", "").strip()
if not work_unit or not hazard or severity not in range(1, 5) or probability not in range(1, 5):
flash("Unité, danger et cotations de 1 à 4 sont obligatoires.", "danger")
return redirect(url_for("prevention.index"))
db.session.add(RiskAssessment(work_unit=work_unit, hazard=hazard, exposed_people=request.form.get("exposed_people"), severity=severity, probability=probability, control_measures=request.form.get("control_measures"), reviewed_on=date.today()))
db.session.commit(); flash("Risque ajouté au DUERP.", "success"); return redirect(url_for("prevention.index"))
@prevention_bp.post("/action")
@ -52,3 +68,22 @@ def add_action():
def add_register():
db.session.add(SafetyRegisterEntry(entry_date=_date(request.form.get("entry_date")) or date.today(), entry_type=request.form.get("entry_type", "observation"), title=request.form.get("title", "").strip(), details=request.form.get("details"), location=request.form.get("location"), created_by_id=current_user.id))
db.session.commit(); flash("Entrée ajoutée au registre.", "success"); return redirect(url_for("prevention.index"))
@prevention_bp.get('/duerp.csv')
@login_required
def export_duerp():
"""Exporte le registre DUERP exploitable dans un tableur."""
import csv
import io
output = io.StringIO()
writer = csv.writer(output, delimiter=';')
writer.writerow(['Unité de travail', 'Danger', 'Personnes exposées', 'Gravité', 'Probabilité', 'Criticité', 'Mesures existantes', 'Dernière revue'])
for risk in RiskAssessment.query.order_by(RiskAssessment.work_unit, RiskAssessment.hazard).all():
writer.writerow([risk.work_unit, risk.hazard, risk.exposed_people or '', risk.severity,
risk.probability, (risk.severity or 0) * (risk.probability or 0),
risk.control_measures or '', risk.reviewed_on.isoformat() if risk.reviewed_on else ''])
response = make_response('\ufeff' + output.getvalue())
response.headers['Content-Type'] = 'text/csv; charset=utf-8'
response.headers['Content-Disposition'] = 'attachment; filename=duerp.csv'
return response

View file

@ -198,6 +198,7 @@
<li><a class="dropdown-item" href="{{ url_for('trainings.my_trainings') }}"><i class="bi bi-calendar-check"></i> Mes formations</a></li>
<li><hr class="dropdown-divider" style="border-color:rgba(255,255,255,0.15)"></li>
<li><a class="dropdown-item" href="{{ url_for('exports.export_interventions') }}"><i class="bi bi-file-earmark-pdf"></i> Export interventions</a></li>
<li><a class="dropdown-item" href="{{ url_for('exports.export_costs_csv') }}"><i class="bi bi-cash-stack"></i> Export coûts et stock</a></li>
<li><hr class="dropdown-divider" style="border-color:rgba(255,255,255,0.15)"></li>
<li><a class="dropdown-item" href="{{ url_for('scheduler.index') }}"><i class="bi bi-calendar-week"></i> Planificateur auto</a></li>
<li><a class="dropdown-item" href="{{ url_for('scheduler.calendar') }}"><i class="bi bi-calendar3"></i> Calendrier</a></li>

View file

@ -102,8 +102,8 @@
<td>
{% if interp.status == 'pending' %}
<div class="btn-group btn-group-sm">
<a href="{{ url_for('outlook_dashboard.create_intervention_from_interpretation', interp_id=interp.id) }}"
class="btn btn-outline-success" title="Créer l'intervention">
<a href="{{ url_for('trainings.import_outlook', interpretation_id=interp.id) if interp.analysis_type == 'formation' else url_for('outlook_dashboard.create_intervention_from_interpretation', interp_id=interp.id) }}"
class="btn btn-outline-success" title="{{ 'Importer la formation' if interp.analysis_type == 'formation' else 'Créer lintervention' }}">
<i class="bi bi-check-lg"></i>
</a>
<button type="button" class="btn btn-outline-danger"
@ -176,4 +176,4 @@ function restoreInterpretation(interpId) {
});
}
</script>
{% endblock %}
{% endblock %}

View file

@ -43,4 +43,18 @@
<div class="card-body">{{ part.description }}</div>
</div>
{% endif %}
{% endblock %}
<div class="card mt-3">
<div class="card-header"><i class="bi bi-arrow-left-right"></i> Mouvements de stock</div>
<div class="card-body"><form method="post" action="{{ url_for('parts.movement', id=part.id) }}" class="row g-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="col-md-3"><select name="movement_type" class="form-select" required><option value="entree">Entrée</option><option value="sortie">Sortie</option><option value="inventaire">Valeur dinventaire</option></select></div>
<div class="col-md-2"><input type="number" min="0" name="quantity" class="form-control" placeholder="Quantité" required></div>
<div class="col-md-5"><input name="reason" class="form-control" placeholder="Motif / bon / intervention" required></div>
<div class="col-md-2"><button class="btn btn-primary w-100">Enregistrer</button></div>
</form></div>
<div class="table-responsive"><table class="table table-sm mb-0"><thead><tr><th>Date</th><th>Type</th><th>Variation</th><th>Stock</th><th>Motif</th><th>Par</th></tr></thead><tbody>
{% for movement in movements %}<tr><td>{{ movement.created_at|datetime_fmt }}</td><td>{{ movement.movement_type }}</td><td class="{{ 'text-success' if movement.quantity_delta > 0 else 'text-danger' }}">{{ '%+d'|format(movement.quantity_delta) }}</td><td>{{ movement.quantity_before }} → {{ movement.quantity_after }}</td><td>{{ movement.reason }}</td><td>{{ movement.user.full_name if movement.user else 'Système' }}</td></tr>
{% else %}<tr><td colspan="6" class="text-center text-muted">Aucun mouvement enregistré</td></tr>{% endfor %}
</tbody></table></div>
</div>
{% endblock %}

View file

@ -1,10 +1,10 @@
{% extends "base.html" %}
{% block title %}Nouvelle pièce — GMAO{% endblock %}
{% block title %}{{ 'Modifier' if part is defined and part else 'Nouvelle' }} pièce — GMAO{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1><i class="bi bi-plus-circle"></i> Nouvelle pièce</h1>
<h1><i class="bi bi-plus-circle"></i> {{ 'Modifier' if part is defined and part else 'Nouvelle' }} pièce</h1>
<a href="{{ url_for('parts.index') }}" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> Retour
</a>
@ -18,37 +18,35 @@
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Nom</label>
<input type="text" name="name" class="form-control" required>
<input type="text" name="name" class="form-control" value="{{ part.name if part is defined and part else '' }}" required>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Référence</label>
<input type="text" name="reference" class="form-control">
<input type="text" name="reference" class="form-control" value="{{ part.reference if part is defined and part and part.reference else '' }}">
</div>
</div>
<div class="row">
<div class="col-md-4 mb-3">
<label class="form-label">Quantité</label>
<input type="number" name="quantity" class="form-control" value="0" min="0">
<input type="number" name="quantity" class="form-control" value="{{ part.quantity if part is defined and part else 0 }}" min="0">
{% if part is defined and part %}<input name="stock_reason" class="form-control mt-2" placeholder="Motif si la quantité change">{% endif %}
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Unité</label>
<select name="unit" class="form-select">
<option value="unité">Unité</option>
<option value="mètre">Mètre</option>
<option value="litre">Litre</option>
<option value="kg">Kilogramme</option>
{% for value, label in [('unité','Unité'),('mètre','Mètre'),('litre','Litre'),('kg','Kilogramme')] %}<option value="{{ value }}" {% if part is defined and part and part.unit == value %}selected{% endif %}>{{ label }}</option>{% endfor %}
</select>
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Prix unitaire</label>
<input type="number" name="unit_price" class="form-control" step="0.01" min="0">
<input type="number" name="unit_price" class="form-control" step="0.01" min="0" value="{{ part.unit_price if part is defined and part and part.unit_price is not none else '' }}">
</div>
</div>
<div class="mb-3">
<label class="form-label">Fournisseur</label>
<input type="text" name="supplier" class="form-control">
<input type="text" name="supplier" class="form-control" value="{{ part.supplier if part is defined and part and part.supplier else '' }}">
</div>
<button type="submit" class="btn btn-primary">
@ -58,4 +56,4 @@
</div>
</div>
</div>
{% endblock %}
{% endblock %}

View file

@ -1,5 +1,5 @@
{% extends "base.html" %}{% block title %}Prévention — GMAO{% endblock %}{% block content %}
<div class="d-flex justify-content-between"><div><h1 class="h3"><i class="bi bi-shield-check"></i> Assistant de prévention</h1><p class="text-muted">Agents, temps dédié, DUERP, habilitations et plan dactions.</p></div><div class="text-end"><strong>{{ (minutes / 60)|round(1) }} h / {{ (target / 60)|round(1) }} h</strong><div class="progress" style="width:220px"><div class="progress-bar" style="width:{{ [100, minutes * 100 / target]|min }}%">Objectif 10 %</div></div></div></div>
<div class="d-flex justify-content-between"><div><h1 class="h3"><i class="bi bi-shield-check"></i> Assistant de prévention</h1><p class="text-muted">Agents, temps dédié, DUERP, habilitations et plan dactions.</p><a class="btn btn-sm btn-outline-success" href="{{ url_for('prevention.export_duerp') }}"><i class="bi bi-download"></i> Exporter le DUERP</a></div><div class="text-end"><strong>{{ (minutes / 60)|round(1) }} h / {{ (target / 60)|round(1) }} h</strong><div class="progress" style="width:220px"><div class="progress-bar" style="width:{{ [100, minutes * 100 / target]|min if target else 0 }}%">Objectif 10 % du temps réellement disponible</div></div><small class="text-muted">Base : {{ (available_minutes / 60)|round(1) }} h cette semaine</small></div></div>
<div class="row g-3">
<div class="col-lg-6"><div class="card"><div class="card-header">Temps de prévention</div><div class="card-body"><form method="post" action="{{ url_for('prevention.add_time') }}" class="row g-2"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><div class="col-4"><input type="date" name="work_date" class="form-control" required></div><div class="col-3"><input type="number" min="1" name="duration_minutes" class="form-control" placeholder="Minutes" required></div><div class="col"><input name="activity" class="form-control" placeholder="Activité" required></div><div class="col-auto"><button class="btn btn-primary">Ajouter</button></div></form><table class="table table-sm mt-3"><tbody>{% for x in logs %}<tr><td>{{ x.work_date|date_fmt }}</td><td>{{ x.activity }}</td><td>{{ x.duration_minutes }} min</td></tr>{% endfor %}</tbody></table></div></div></div>
<div class="col-lg-6"><div class="card"><div class="card-header">Agents et habilitations ({{ staff|length }})</div><div class="card-body"><form method="post" action="{{ url_for('prevention.add_staff') }}" class="row g-2 mb-3"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><div class="col"><input name="first_name" class="form-control" placeholder="Prénom" required></div><div class="col"><input name="last_name" class="form-control" placeholder="Nom" required></div><div class="col"><input name="function" class="form-control" placeholder="Fonction"></div><div class="col-auto"><button class="btn btn-primary">Ajouter</button></div></form><form method="post" action="{{ url_for('prevention.add_authorization') }}" class="row g-2"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><div class="col"><select name="staff_id" class="form-select" required><option value="">Agent…</option>{% for x in staff %}<option value="{{ x.id }}">{{ x.full_name }}</option>{% endfor %}</select></div><div class="col"><input name="name" class="form-control" placeholder="Habilitation / formation" required></div><div class="col"><input type="date" name="expires_on" class="form-control"></div><div class="col-auto"><button class="btn btn-outline-primary">Ajouter</button></div></form></div></div></div>

View file

@ -20,6 +20,8 @@
<div class="card-header"><i class="bi bi-info-circle"></i> Informations</div>
<div class="card-body">
<p><strong>Date :</strong> {{ training.start_date.strftime('%d/%m/%Y') if training.start_date else 'À confirmer' }} au {{ training.end_date.strftime('%d/%m/%Y') if training.end_date else 'À confirmer' }}</p>
{% if training.start_time or training.end_time %}<p><strong>Horaire :</strong> {{ training.start_time.strftime('%H:%M') if training.start_time else '—' }} {{ training.end_time.strftime('%H:%M') if training.end_time else '—' }}</p>{% endif %}
{% if training.source_type %}<p><strong>Source :</strong> <span class="badge bg-info">{{ training.source_type }}</span> {{ training.source_subject or '' }}{% if training.source_sender %} — {{ training.source_sender }}{% endif %}</p>{% endif %}
{% if training.location %}<p><strong>Lieu :</strong> {{ training.location }}</p>{% endif %}
{% if training.description %}<p><strong>Description :</strong> {{ training.description }}</p>{% endif %}
</div>
@ -42,6 +44,7 @@
{% set is_registered = participant_users.get(current_user.id) %}
<form method="POST" action="{% if is_registered %}{{ url_for('trainings.unregister', id=training.id) }}{% else %}{{ url_for('trainings.register', id=training.id) }}{% endif %}" class="mt-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-{% if is_registered %}warning{% else %}success{% endif %}">
{% if is_registered %}<i class="bi bi-x-lg"></i> Se désinscrire{% else %}<i class="bi bi-plus-lg"></i> S'inscrire{% endif %}
</button>

View file

@ -10,16 +10,24 @@
<div class="card"><div class="card-body">
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if source_interpretation is defined %}
<div class="alert alert-info"><i class="bi bi-envelope"></i> Prérempli depuis un mail. Vérifiez les dates avant de confirmer.</div>
{% endif %}
<div class="mb-3"><label class="form-label">Titre *</label><input type="text" name="title" class="form-control" value="{{ training.name if training is defined else '' }}" required></div>
<div class="mb-3"><label class="form-label">Description</label><textarea name="description" class="form-control" rows="3">{{ training.description if training is defined and training.description else '' }}</textarea></div>
<div class="row mb-3">
<div class="col-md-6"><label class="form-label">Date début *</label><input type="date" name="start_date" class="form-control" value="{{ training.start_date.isoformat() if training is defined and training.start_date else '' }}" required></div>
<div class="col-md-6"><label class="form-label">Date fin</label><input type="date" name="end_date" class="form-control" value="{{ training.end_date.isoformat() if training is defined and training.end_date else '' }}"></div>
</div>
<div class="row mb-3">
<div class="col-md-6"><label class="form-label">Heure de début</label><input type="time" name="start_time" class="form-control" value="{{ training.start_time.strftime('%H:%M') if training is defined and training.start_time else '' }}"></div>
<div class="col-md-6"><label class="form-label">Heure de fin</label><input type="time" name="end_time" class="form-control" value="{{ training.end_time.strftime('%H:%M') if training is defined and training.end_time else '' }}"></div>
<div class="form-text">La période bloque toute maintenance pour le participant confirmé.</div>
</div>
<div class="row mb-3">
<div class="col-md-12"><label class="form-label">Lieu</label><input type="text" name="location" class="form-control" value="{{ training.location if training is defined and training.location else '' }}"></div>
</div>
<div class="text-end"><button type="submit" class="btn btn-primary"><i class="bi bi-check-lg"></i> Créer</button></div>
<div class="text-end"><button type="submit" class="btn btn-primary"><i class="bi bi-check-lg"></i> {{ 'Importer et bloquer le planning' if source_interpretation is defined else 'Enregistrer' }}</button></div>
</form>
</div></div>
</div>

View file

@ -28,11 +28,17 @@ def create():
if not start_date or end_date < start_date:
flash('La période de formation est invalide.', 'danger')
return render_template('training/new.html'), 400
title = (request.form.get('title') or '').strip()
if not title:
flash('Le titre est obligatoire.', 'danger')
return render_template('training/new.html'), 400
training = Training(
name=(request.form.get('title') or '').strip(),
name=title,
description=request.form.get('description'),
start_date=start_date,
end_date=end_date,
start_time=_parse_time(request.form.get('start_time')),
end_time=_parse_time(request.form.get('end_time')),
location=request.form.get('location'),
)
db.session.add(training)
@ -88,6 +94,68 @@ def _parse_date(value):
return None
def _parse_time(value):
try:
return datetime.strptime(value, '%H:%M').time() if value else None
except (TypeError, ValueError):
return None
@training_bp.route('/import/outlook/<int:interpretation_id>', methods=['GET', 'POST'])
@login_required
def import_outlook(interpretation_id):
"""Confirme une formation détectée dans un mail Outlook sans effacer le mail source."""
from app_new.outlook.models import OutlookMailInterpretation
interpretation = OutlookMailInterpretation.query.get_or_404(interpretation_id)
if interpretation.user_id != current_user.id:
return 'Non autorisé', 403
if interpretation.analysis_type != 'formation':
flash("Ce mail n'a pas été identifié comme une formation.", 'danger')
return redirect(url_for('outlook_dashboard.interpretations'))
existing = Training.query.filter_by(source_type='outlook', source_id=str(interpretation.id)).first()
if existing:
flash('Cette formation a déjà été importée.', 'warning')
return redirect(url_for('trainings.detail', id=existing.id))
mail = interpretation.mail
suggested_date = interpretation.suggested_date.date() if interpretation.suggested_date else None
prefill = Training(
name=interpretation.suggested_title or (mail.subject if mail else '') or 'Formation',
description=interpretation.suggested_description or (mail.body_preview if mail else ''),
start_date=suggested_date,
end_date=suggested_date,
location=interpretation.suggested_location,
)
if request.method == 'POST':
start_date = _parse_date(request.form.get('start_date'))
end_date = _parse_date(request.form.get('end_date')) or start_date
title = (request.form.get('title') or '').strip()
if not title or not start_date or end_date < start_date:
flash('Le titre et une période valide sont obligatoires.', 'danger')
return render_template('training/new.html', training=prefill,
source_interpretation=interpretation), 400
training = Training(
name=title, description=request.form.get('description'),
start_date=start_date, end_date=end_date,
start_time=_parse_time(request.form.get('start_time')),
end_time=_parse_time(request.form.get('end_time')),
location=request.form.get('location'), source_type='outlook',
source_id=str(interpretation.id),
source_subject=mail.subject if mail else None,
source_sender=(mail.sender or mail.sender_email) if mail else None,
)
db.session.add(training)
db.session.flush()
db.session.add(TrainingParticipant(training_id=training.id, user_id=current_user.id,
is_confirmed=True, notes='Importée depuis Outlook'))
interpretation.status = 'accepted'
db.session.commit()
flash('Formation importée. Toute maintenance est bloquée sur cette période.', 'success')
return redirect(url_for('trainings.detail', id=training.id))
return render_template('training/new.html', training=prefill, source_interpretation=interpretation)
@training_bp.route('/<int:id>')
@login_required
def detail(id):
@ -112,6 +180,8 @@ def edit(id):
training.description = request.form.get('description')
training.start_date = start_date
training.end_date = end_date
training.start_time = _parse_time(request.form.get('start_time'))
training.end_time = _parse_time(request.form.get('end_time'))
training.location = request.form.get('location')
db.session.commit()
flash('Formation mise à jour.', 'success')

View file

@ -0,0 +1,71 @@
"""Complète le suivi opérationnel des formations, stocks, contrats et compteurs.
Revision ID: d1e5f6a7b8c9
Revises: c0d4e5f6a7b8
"""
from alembic import op
import sqlalchemy as sa
revision = "d1e5f6a7b8c9"
down_revision = "c0d4e5f6a7b8"
branch_labels = None
depends_on = None
def upgrade():
with op.batch_alter_table("trainings") as batch:
batch.add_column(sa.Column("start_time", sa.Time(), nullable=True))
batch.add_column(sa.Column("end_time", sa.Time(), nullable=True))
batch.add_column(sa.Column("source_type", sa.String(30), nullable=True))
batch.add_column(sa.Column("source_id", sa.String(191), nullable=True))
batch.add_column(sa.Column("source_subject", sa.String(500), nullable=True))
batch.add_column(sa.Column("source_sender", sa.String(255), nullable=True))
batch.create_unique_constraint("uq_training_source", ["source_type", "source_id"])
with op.batch_alter_table("meter_readings") as batch:
batch.add_column(sa.Column("is_reset", sa.Boolean(), nullable=False, server_default=sa.false()))
batch.add_column(sa.Column("reset_reason", sa.String(255), nullable=True))
op.create_table(
"part_stock_movements",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("part_id", sa.Integer(), sa.ForeignKey("parts.id", ondelete="CASCADE"), nullable=False),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("movement_type", sa.String(20), nullable=False),
sa.Column("quantity_delta", sa.Integer(), nullable=False),
sa.Column("quantity_before", sa.Integer(), nullable=False),
sa.Column("quantity_after", sa.Integer(), nullable=False),
sa.Column("reason", sa.String(255), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
)
op.create_index("ix_part_stock_movements_part_id", "part_stock_movements", ["part_id"])
op.create_table(
"contract_visits",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("contract_id", sa.Integer(), sa.ForeignKey("contracts.id", ondelete="CASCADE"), nullable=False),
sa.Column("scheduled_date", sa.Date(), nullable=False),
sa.Column("status", sa.String(20), nullable=False, server_default="planifiee"),
sa.Column("completed_at", sa.DateTime(), nullable=True),
sa.Column("report", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.UniqueConstraint("contract_id", "scheduled_date", name="uq_contract_visit_date"),
)
op.create_index("ix_contract_visits_contract_id", "contract_visits", ["contract_id"])
op.create_index("ix_contract_visits_scheduled_date", "contract_visits", ["scheduled_date"])
def downgrade():
op.drop_table("contract_visits")
op.drop_table("part_stock_movements")
with op.batch_alter_table("meter_readings") as batch:
batch.drop_column("reset_reason")
batch.drop_column("is_reset")
with op.batch_alter_table("trainings") as batch:
batch.drop_constraint("uq_training_source", type_="unique")
batch.drop_column("source_sender")
batch.drop_column("source_subject")
batch.drop_column("source_id")
batch.drop_column("source_type")
batch.drop_column("end_time")
batch.drop_column("start_time")

View file

@ -0,0 +1,91 @@
from datetime import date, timedelta
from uuid import uuid4
from app_new.extensions import db
from app_new.contracts.models import Contract, ContractVisit
from app_new.core.models.company import Part, PartStockMovement
from app_new.core.models.college import Building, Room
from app_new.core.models.equipment import Equipment, EquipmentCategory
from app_new.core.models.planning import Meter, MeterReading, Training, TrainingParticipant
from app_new.outlook.models import OutlookAccount, OutlookMail, OutlookMailInterpretation
def test_outlook_training_import_blocks_maintenance(authenticated_client, app, admin_user):
with app.app_context():
account = OutlookAccount(user_id=admin_user['id'], email='formation@example.test', tenant_id='test')
mail = OutlookMail(id=f'mail-{uuid4().hex}', account=account, subject='Formation habilitation', sender='Rectorat')
interpretation = OutlookMailInterpretation(
mail=mail, user_id=admin_user['id'], interpretation='Formation détectée',
analysis_type='formation', suggested_title='Habilitation électrique',
)
db.session.add_all([account, mail, interpretation]); db.session.commit()
interpretation_id = interpretation.id
day = date.today() + timedelta(days=10)
with app.app_context():
response = authenticated_client.post(f'/training/import/outlook/{interpretation_id}', data={
'title': 'Habilitation électrique', 'start_date': day.isoformat(),
'end_date': day.isoformat(), 'start_time': '09:00', 'end_time': '16:00',
})
assert response.status_code == 302
with app.app_context():
training = Training.query.filter_by(source_type='outlook', source_id=str(interpretation_id)).one()
assert training.start_time.hour == 9
assert TrainingParticipant.query.filter_by(training_id=training.id, user_id=admin_user['id'], is_confirmed=True).count() == 1
db.session.delete(training)
OutlookMailInterpretation.query.filter_by(id=interpretation_id).delete()
OutlookMail.query.filter(OutlookMail.id.like('mail-%')).delete(synchronize_session=False)
OutlookAccount.query.filter_by(user_id=admin_user['id']).delete(synchronize_session=False)
db.session.commit()
def test_part_stock_movements_are_traced(authenticated_client, app):
with app.app_context():
part = Part(name=f'Lampe {uuid4().hex[:6]}', quantity=5, unit='unité')
db.session.add(part); db.session.commit(); part_id = part.id
with app.app_context():
response = authenticated_client.post(f'/parts/{part_id}/movement', data={
'movement_type': 'sortie', 'quantity': '2', 'reason': 'Intervention test',
})
assert response.status_code == 302
with app.app_context():
assert db.session.get(Part, part_id).quantity == 3
movement = PartStockMovement.query.filter_by(part_id=part_id).one()
assert (movement.quantity_before, movement.quantity_after) == (5, 3)
def test_contract_visits_are_generated_without_duplicates(authenticated_client, app):
with app.app_context():
contract = Contract(name=f'Contrat {uuid4().hex[:6]}', start_date=date.today(),
visit_interval_days=30, next_visit_date=date.today())
db.session.add(contract); db.session.commit(); contract_id = contract.id
with app.app_context():
assert authenticated_client.post(f'/contracts/{contract_id}/visits/generate').status_code == 302
assert authenticated_client.post(f'/contracts/{contract_id}/visits/generate').status_code == 302
with app.app_context():
visits = ContractVisit.query.filter_by(contract_id=contract_id).all()
assert len(visits) >= 12
assert len({visit.scheduled_date for visit in visits}) == len(visits)
def test_meter_decrease_requires_a_documented_reset(authenticated_client, app):
with app.app_context():
building = Building(name=f'Bâtiment {uuid4().hex[:6]}')
room = Room(name='Local', building=building)
category = EquipmentCategory(name=f'Catégorie {uuid4().hex[:6]}')
equipment = Equipment(name='Compteur test', category=category, room=room)
meter = Meter(name='Heures', equipment=equipment, current_value=100, initial_value=0)
db.session.add_all([building, room, category, equipment, meter]); db.session.commit(); meter_id = meter.id
with app.app_context():
response = authenticated_client.post(f'/meters/{meter_id}/reading', data={'value': '10'})
assert response.status_code == 302
with app.app_context():
assert db.session.get(Meter, meter_id).current_value == 100
with app.app_context():
authenticated_client.post(f'/meters/{meter_id}/reading', data={
'value': '10', 'is_reset': '1', 'reset_reason': 'Compteur remplacé',
})
with app.app_context():
assert db.session.get(Meter, meter_id).current_value == 10
assert MeterReading.query.filter_by(meter_id=meter_id, is_reset=True).count() == 1
MeterReading.query.filter_by(meter_id=meter_id).delete()
db.session.commit()