Complète le suivi opérationnel de la GMAO
Some checks are pending
CI - Tests et Syntax / lint-and-test (push) Waiting to run
Some checks are pending
CI - Tests et Syntax / lint-and-test (push) Waiting to run
This commit is contained in:
parent
d66bd0197c
commit
0b3fff43e5
22 changed files with 531 additions and 40 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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}>"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 l’intervention' }}">
|
||||
<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 %}
|
||||
|
|
|
|||
|
|
@ -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 d’inventaire</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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 d’actions.</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 d’actions.</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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
91
tests/integration/test_operational_lot4.py
Normal file
91
tests/integration/test_operational_lot4.py
Normal 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()
|
||||
Loading…
Reference in a new issue