From 0b3fff43e5d6ff350809c49926acec60935da9ec Mon Sep 17 00:00:00 2001 From: root Date: Fri, 14 Aug 2026 23:26:22 +0000 Subject: [PATCH] =?UTF-8?q?Compl=C3=A8te=20le=20suivi=20op=C3=A9rationnel?= =?UTF-8?q?=20de=20la=20GMAO?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app_new/__init__.py | 2 +- app_new/contracts/models.py | 18 ++++ app_new/contracts/routes.py | 51 ++++++++++- .../contracts/templates/contracts/detail.html | 10 ++ app_new/core/models/__init__.py | 4 +- app_new/core/models/company.py | 22 ++++- app_new/core/models/planning.py | 11 ++- app_new/exports/routes.py | 29 ++++++ app_new/meters/routes.py | 16 +++- app_new/meters/templates/meters/detail.html | 6 +- app_new/parts/routes.py | 65 +++++++++++-- app_new/prevention/routes.py | 43 ++++++++- app_new/templates/base.html | 1 + .../templates/outlook/interpretations.html | 6 +- app_new/templates/parts/detail.html | 16 +++- app_new/templates/parts/form.html | 22 ++--- app_new/templates/prevention/index.html | 2 +- app_new/templates/training/detail.html | 3 + app_new/templates/training/new.html | 10 +- app_new/training/routes.py | 72 ++++++++++++++- ...5f6a7b8c9_complete_operational_tracking.py | 71 +++++++++++++++ tests/integration/test_operational_lot4.py | 91 +++++++++++++++++++ 22 files changed, 531 insertions(+), 40 deletions(-) create mode 100644 migrations/versions/d1e5f6a7b8c9_complete_operational_tracking.py create mode 100644 tests/integration/test_operational_lot4.py diff --git a/app_new/__init__.py b/app_new/__init__.py index dd741a3..5899c2b 100644 --- a/app_new/__init__.py +++ b/app_new/__init__.py @@ -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) diff --git a/app_new/contracts/models.py b/app_new/contracts/models.py index 3e19c7b..01272b8 100644 --- a/app_new/contracts/models.py +++ b/app_new/contracts/models.py @@ -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"" + + +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") diff --git a/app_new/contracts/routes.py b/app_new/contracts/routes.py index 8e4d1b5..105e620 100644 --- a/app_new/contracts/routes.py +++ b/app_new/contracts/routes.py @@ -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('//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('//visits//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(): diff --git a/app_new/contracts/templates/contracts/detail.html b/app_new/contracts/templates/contracts/detail.html index 9c97115..cb379e8 100644 --- a/app_new/contracts/templates/contracts/detail.html +++ b/app_new/contracts/templates/contracts/detail.html @@ -53,6 +53,16 @@ +
+
Visites du prestataire +
+
+
+ {% for visit in contract.visits %} + {% else %}{% endfor %} +
DateStatutCompte rendu
{{ visit.scheduled_date.strftime('%d/%m/%Y') }}{{ visit.status }}{{ visit.report or '—' }}{% if visit.status != 'realisee' %}
{% endif %}
Aucune visite générée
+
+
Dates
diff --git a/app_new/core/models/__init__.py b/app_new/core/models/__init__.py index 5ba3dd4..b20e77e 100644 --- a/app_new/core/models/__init__.py +++ b/app_new/core/models/__init__.py @@ -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', diff --git a/app_new/core/models/company.py b/app_new/core/models/company.py index 8440041..8f45235 100644 --- a/app_new/core/models/company.py +++ b/app_new/core/models/company.py @@ -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"" +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"" \ No newline at end of file + return f"" diff --git a/app_new/core/models/planning.py b/app_new/core/models/planning.py index 73f47cb..63aee6a 100644 --- a/app_new/core/models/planning.py +++ b/app_new/core/models/planning.py @@ -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"" @@ -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") diff --git a/app_new/exports/routes.py b/app_new/exports/routes.py index 9ffad81..e6d2634 100644 --- a/app_new/exports/routes.py +++ b/app_new/exports/routes.py @@ -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 diff --git a/app_new/meters/routes.py b/app_new/meters/routes.py index 8a73dc0..5dcf9bd 100644 --- a/app_new/meters/routes.py +++ b/app_new/meters/routes.py @@ -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()) \ No newline at end of file + today=date.today()) diff --git a/app_new/meters/templates/meters/detail.html b/app_new/meters/templates/meters/detail.html index 36a176b..4e0f0fc 100644 --- a/app_new/meters/templates/meters/detail.html +++ b/app_new/meters/templates/meters/detail.html @@ -123,6 +123,8 @@ {{ meter.unit }}
+
+
@@ -171,7 +173,7 @@ {% endif %} {{ reading.read_by.username if reading.read_by else '—' }} - {{ reading.notes or '—' }} + {% if reading.is_reset %}RAZ {{ reading.reset_reason }}{% else %}{{ reading.notes or '—' }}{% endif %} {% else %} @@ -188,4 +190,4 @@
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/app_new/parts/routes.py b/app_new/parts/routes.py index e6c1b34..cf84c30 100644 --- a/app_new/parts/routes.py +++ b/app_new/parts/routes.py @@ -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('//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') \ No newline at end of file + return render_template('parts/new.html') + + +@parts_bp.route('//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)) diff --git a/app_new/prevention/routes.py b/app_new/prevention/routes.py index 5fb103e..7441eba 100644 --- a/app_new/prevention/routes.py +++ b/app_new/prevention/routes.py @@ -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 diff --git a/app_new/templates/base.html b/app_new/templates/base.html index 387ddc2..6d4fdd7 100644 --- a/app_new/templates/base.html +++ b/app_new/templates/base.html @@ -198,6 +198,7 @@
  • Mes formations
  • Export interventions
  • +
  • Export coûts et stock
  • Planificateur auto
  • Calendrier
  • diff --git a/app_new/templates/outlook/interpretations.html b/app_new/templates/outlook/interpretations.html index 05b8e97..e4679fa 100644 --- a/app_new/templates/outlook/interpretations.html +++ b/app_new/templates/outlook/interpretations.html @@ -102,8 +102,8 @@ {% if interp.status == 'pending' %}
    - +
    {% endif %} -{% endblock %} \ No newline at end of file +
    +
    Mouvements de stock
    +
    + +
    +
    +
    +
    +
    +
    + {% for movement in movements %} + {% else %}{% endfor %} +
    DateTypeVariationStockMotifPar
    {{ movement.created_at|datetime_fmt }}{{ movement.movement_type }}{{ '%+d'|format(movement.quantity_delta) }}{{ movement.quantity_before }} → {{ movement.quantity_after }}{{ movement.reason }}{{ movement.user.full_name if movement.user else 'Système' }}
    Aucun mouvement enregistré
    +
    +{% endblock %} diff --git a/app_new/templates/parts/form.html b/app_new/templates/parts/form.html index 39757a9..a2e680b 100644 --- a/app_new/templates/parts/form.html +++ b/app_new/templates/parts/form.html @@ -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 %}
    -

    Nouvelle pièce

    +

    {{ 'Modifier' if part is defined and part else 'Nouvelle' }} pièce

    Retour @@ -18,37 +18,35 @@
    - +
    - +
    - + + {% if part is defined and part %}{% endif %}
    - +
    - +
    -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/app_new/templates/prevention/index.html b/app_new/templates/prevention/index.html index 27ff338..d5b8c69 100644 --- a/app_new/templates/prevention/index.html +++ b/app_new/templates/prevention/index.html @@ -1,5 +1,5 @@ {% extends "base.html" %}{% block title %}Prévention — GMAO{% endblock %}{% block content %} -

    Assistant de prévention

    Agents, temps dédié, DUERP, habilitations et plan d’actions.

    {{ (minutes / 60)|round(1) }} h / {{ (target / 60)|round(1) }} h
    Objectif 10 %
    +

    Assistant de prévention

    Agents, temps dédié, DUERP, habilitations et plan d’actions.

    Exporter le DUERP
    {{ (minutes / 60)|round(1) }} h / {{ (target / 60)|round(1) }} h
    Objectif 10 % du temps réellement disponible
    Base : {{ (available_minutes / 60)|round(1) }} h cette semaine
    Temps de prévention
    {% for x in logs %}{% endfor %}
    {{ x.work_date|date_fmt }}{{ x.activity }}{{ x.duration_minutes }} min
    Agents et habilitations ({{ staff|length }})
    diff --git a/app_new/templates/training/detail.html b/app_new/templates/training/detail.html index 964db5b..3d52010 100644 --- a/app_new/templates/training/detail.html +++ b/app_new/templates/training/detail.html @@ -20,6 +20,8 @@
    Informations

    Date : {{ 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' }}

    + {% if training.start_time or training.end_time %}

    Horaire : {{ training.start_time.strftime('%H:%M') if training.start_time else '—' }} – {{ training.end_time.strftime('%H:%M') if training.end_time else '—' }}

    {% endif %} + {% if training.source_type %}

    Source : {{ training.source_type }} {{ training.source_subject or '' }}{% if training.source_sender %} — {{ training.source_sender }}{% endif %}

    {% endif %} {% if training.location %}

    Lieu : {{ training.location }}

    {% endif %} {% if training.description %}

    Description : {{ training.description }}

    {% endif %}
    @@ -42,6 +44,7 @@ {% set is_registered = participant_users.get(current_user.id) %}
    + diff --git a/app_new/templates/training/new.html b/app_new/templates/training/new.html index bafc545..9e98460 100644 --- a/app_new/templates/training/new.html +++ b/app_new/templates/training/new.html @@ -10,16 +10,24 @@
    + {% if source_interpretation is defined %} +
    Prérempli depuis un mail. Vérifiez les dates avant de confirmer.
    + {% endif %}
    +
    +
    +
    +
    La période bloque toute maintenance pour le participant confirmé.
    +
    -
    +
    diff --git a/app_new/training/routes.py b/app_new/training/routes.py index dc4c68f..21ac659 100644 --- a/app_new/training/routes.py +++ b/app_new/training/routes.py @@ -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/', 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('/') @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') diff --git a/migrations/versions/d1e5f6a7b8c9_complete_operational_tracking.py b/migrations/versions/d1e5f6a7b8c9_complete_operational_tracking.py new file mode 100644 index 0000000..13a1f7d --- /dev/null +++ b/migrations/versions/d1e5f6a7b8c9_complete_operational_tracking.py @@ -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") diff --git a/tests/integration/test_operational_lot4.py b/tests/integration/test_operational_lot4.py new file mode 100644 index 0000000..924f40b --- /dev/null +++ b/tests/integration/test_operational_lot4.py @@ -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()