feat(contracts): gérer les passages entreprise

This commit is contained in:
root 2026-08-23 14:02:38 +00:00
parent 315eabdeba
commit 1ed724555a
11 changed files with 220 additions and 8 deletions

View file

@ -23,8 +23,12 @@ def detail(id):
"""Détail d'une entreprise."""
company = Company.query.get_or_404(id)
from ..core.models.maintenance import Intervention
from ..core.models.maintenance import LotTask
from ..contracts.models import ContractVisit
interventions = Intervention.query.filter_by(company_id=id, is_deleted=False).all()
return render_template('companies/detail.html', company=company, interventions=interventions)
preventive_tasks = LotTask.query.filter_by(company_id=id).order_by(LotTask.tache).all()
visits = ContractVisit.query.join(ContractVisit.contract).filter(ContractVisit.contract.has(company_id=id)).order_by(ContractVisit.scheduled_date).all()
return render_template('companies/detail.html', company=company, interventions=interventions, preventive_tasks=preventive_tasks, visits=visits)
@companies_bp.route('/new', methods=['GET', 'POST'])
@login_required

View file

@ -75,6 +75,18 @@
</div>
{% endif %}
<div class="card mb-3">
<div class="card-header"><i class="bi bi-shield-check"></i> Maintenance préventive confiée ({{ preventive_tasks|length }})</div>
<div class="card-body">
{% if preventive_tasks %}<div class="table-responsive"><table class="table table-sm mb-0"><thead><tr><th>Tâche</th><th>Lot</th><th>Périodicité</th><th>Prochaine échéance</th></tr></thead><tbody>{% for task in preventive_tasks %}<tr><td>{{ task.tache or 'Tâche sans nom' }}</td><td>{{ task.lot.name if task.lot else '—' }}</td><td>{{ task.periodicite or 'À définir' }}</td><td>À planifier</td></tr>{% endfor %}</tbody></table></div>{% else %}<p class="text-muted mb-0">Aucune tâche préventive n'est confiée à cette entreprise.</p>{% endif %}
</div>
</div>
<div class="card mb-3">
<div class="card-header"><i class="bi bi-calendar2-check"></i> Passages & contrôles ({{ visits|length }})</div>
<div class="card-body"><a class="btn btn-sm btn-outline-primary" href="{{ url_for('contracts.visits_dashboard') }}">Ouvrir le suivi des passages</a></div>
</div>
<!-- Lots concernés par cette entreprise -->
<div class="card mb-3">
<div class="card-header"><i class="bi bi-boxes"></i> Lots concernés ({{ company.lots|length }})</div>

View file

@ -125,9 +125,34 @@ class ContractVisit(db.Model):
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")
status = db.Column(db.String(30), nullable=False, default="planifiee")
lot_task_id = db.Column(db.Integer, db.ForeignKey("lot_tasks.id", ondelete="SET NULL"), nullable=True, index=True)
passage_mode = db.Column(db.String(20), nullable=False, default="appointment")
start_time = db.Column(db.Time, nullable=True)
end_time = db.Column(db.Time, nullable=True)
window_start = db.Column(db.Date, nullable=True)
window_end = db.Column(db.Date, nullable=True)
actual_date = db.Column(db.Date, nullable=True)
arrival_at = db.Column(db.DateTime, nullable=True)
accompaniment_mode = db.Column(db.String(20), nullable=False, default="none")
accompaniment_minutes = db.Column(db.Integer, nullable=True)
technician_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
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")
lot_task = db.relationship("LotTask")
technician = db.relationship("User")
@property
def is_fixed_for_planning(self):
return self.passage_mode == "appointment" and self.start_time is not None
@property
def display_mode(self):
return {
"appointment": "Sur rendez-vous",
"date_only": "Date connue, heure inconnue",
"open_window": "Passage libre / sans rendez-vous",
}.get(self.passage_mode, self.passage_mode)

View file

@ -9,11 +9,15 @@ 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, timedelta, timezone
from datetime import datetime, date, timedelta, timezone, time
def _optional_date(value):
return datetime.strptime(value, '%Y-%m-%d').date() if value else None
def _optional_time(value):
return datetime.strptime(value, '%H:%M').time() if value else None
def _apply_visits(contract):
contract.visit_interval_days = request.form.get('visit_interval_days', type=int)
contract.last_visit_date = _optional_date(request.form.get('last_visit_date'))
@ -180,15 +184,82 @@ def complete_visit(id, visit_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.actual_date = date.today()
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
contract.last_visit_date = visit.actual_date
contract.next_visit_date = visit.actual_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('/<int:id>/visits/new', methods=['POST'])
@login_required
def create_visit(id):
"""Enregistre un passage sans inventer d'heure pour les modes date libre."""
contract = Contract.query.get_or_404(id)
scheduled = _optional_date(request.form.get('scheduled_date'))
mode = request.form.get('passage_mode') or 'appointment'
if not scheduled:
flash('La date prévue est obligatoire.', 'danger')
return redirect(url_for('contracts.detail', id=id))
if mode not in ('appointment', 'date_only', 'open_window'):
flash('Mode de passage invalide.', 'danger')
return redirect(url_for('contracts.detail', id=id))
visit = ContractVisit(
contract=contract, scheduled_date=scheduled, passage_mode=mode,
start_time=_optional_time(request.form.get('start_time')) if mode == 'appointment' else None,
end_time=_optional_time(request.form.get('end_time')) if mode == 'appointment' else None,
window_start=_optional_date(request.form.get('window_start')) if mode == 'open_window' else None,
window_end=_optional_date(request.form.get('window_end')) if mode == 'open_window' else None,
accompaniment_mode=request.form.get('accompaniment_mode') or 'none',
accompaniment_minutes=request.form.get('accompaniment_minutes', type=int),
technician_id=request.form.get('technician_id', type=int) or None,
lot_task_id=request.form.get('lot_task_id', type=int) or None,
status='planifiee' if mode != 'open_window' else 'passage_libre',
)
db.session.add(visit)
db.session.commit()
flash('Passage enregistré.', 'success')
return redirect(url_for('contracts.detail', id=id))
@contracts_bp.route('/<int:id>/visits/<int:visit_id>/no-show', methods=['POST'])
@login_required
def no_show_visit(id, visit_id):
visit = ContractVisit.query.filter_by(id=visit_id, contract_id=id).first_or_404()
visit.status = 'non_venue'
visit.report = (request.form.get('report') or '').strip() or visit.report
db.session.commit()
flash('Entreprise marquée non venue : la maintenance reste à réaliser.', 'warning')
return redirect(url_for('contracts.detail', id=id))
@contracts_bp.route('/<int:id>/visits/<int:visit_id>/arrived', methods=['POST'])
@login_required
def arrived_visit(id, visit_id):
visit = ContractVisit.query.filter_by(id=visit_id, contract_id=id).first_or_404()
visit.status = 'entreprise_arrivee'
visit.arrival_at = datetime.now(timezone.utc)
db.session.commit()
flash('Arrivée de lentreprise enregistrée.', 'success')
return redirect(url_for('contracts.detail', id=id))
@contracts_bp.route('/visits')
@login_required
def visits_dashboard():
"""Vue métier des passages et contrôles externalisés."""
status = request.args.get('status')
query = ContractVisit.query.join(Contract)
if status:
query = query.filter(ContractVisit.status == status)
visits = query.order_by(ContractVisit.scheduled_date.asc()).all()
today = date.today()
return render_template('contracts/visits.html', visits=visits, today=today, status=status)
@contracts_bp.route('/api/summary')
@login_required
def api_summary():

View file

@ -57,8 +57,8 @@
<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>
<div class="card-body border-bottom"><form method="post" action="{{ url_for('contracts.create_visit', id=contract.id) }}" class="row g-2 align-items-end"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><div class="col-md-2"><label class="form-label">Date prévue</label><input required type="date" name="scheduled_date" class="form-control"></div><div class="col-md-2"><label class="form-label">Mode</label><select name="passage_mode" class="form-select"><option value="appointment">Sur rendez-vous</option><option value="date_only">Date connue, heure inconnue</option><option value="open_window">Passage libre / sans rendez-vous</option></select></div><div class="col-md-2"><label class="form-label">Début</label><input type="time" name="start_time" class="form-control"></div><div class="col-md-2"><label class="form-label">Fin</label><input type="time" name="end_time" class="form-control"></div><div class="col-md-2"><label class="form-label">Accompagnement</label><select name="accompaniment_mode" class="form-select"><option value="none">Aucun</option><option value="punctual">Ponctuel</option><option value="full">Complet</option></select></div><div class="col-md-2"><label class="form-label">Temps technicien (min)</label><input type="number" min="0" name="accompaniment_minutes" class="form-control"></div><div class="col-12"><button class="btn btn-primary btn-sm">Ajouter un passage</button><a class="btn btn-outline-secondary btn-sm ms-2" href="{{ url_for('contracts.visits_dashboard') }}">Passages &amp; contrôles</a></div></form></div><div class="table-responsive"><table class="table table-sm mb-0"><thead><tr><th>Date / fenêtre</th><th>Mode</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') }}{% if visit.window_end %} → {{ visit.window_end.strftime('%d/%m/%Y') }}{% endif %}{% if visit.start_time %}<div class="small text-muted">{{ visit.start_time.strftime('%H:%M') }}{% if visit.end_time %}{{ visit.end_time.strftime('%H:%M') }}{% endif %}</div>{% endif %}</td><td>{{ visit.display_mode }}{% if visit.accompaniment_mode != 'none' %}<div class="small">Accompagnement : {{ visit.accompaniment_minutes or ('toute la durée' if visit.accompaniment_mode == 'full' else 'à définir') }}</div>{% endif %}</td><td><span class="badge bg-{{ 'success' if visit.status == 'realisee' else ('warning text-dark' if visit.status == 'non_venue' else 'primary') }}">{{ visit.status|replace('_',' ') }}</span></td><td>{{ visit.report or '—' }}</td><td>{% if visit.status == 'passage_libre' %}<form method="post" action="{{ url_for('contracts.arrived_visit', id=contract.id, visit_id=visit.id) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="btn btn-sm btn-outline-primary">Entreprise arrivée</button></form>{% elif visit.status not in ('realisee','non_venue') %}<form method="post" action="{{ url_for('contracts.complete_visit', id=contract.id, visit_id=visit.id) }}" class="d-flex gap-1 mb-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><form method="post" action="{{ url_for('contracts.no_show_visit', id=contract.id, visit_id=visit.id) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="btn btn-sm btn-outline-warning">Non venue</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>

View file

@ -0,0 +1,9 @@
{% extends "base.html" %}
{% block title %}Passages & contrôles{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center mb-3"><div><h1 class="h3">Passages &amp; contrôles</h1><p class="text-muted mb-0">Suivez les visites des entreprises sans confondre date prévue et passage réellement réalisé.</p></div><a class="btn btn-outline-secondary" href="{{ url_for('contracts.index') }}">Contrats</a></div>
<div class="btn-group mb-3" role="group" aria-label="Filtrer les passages"><a class="btn btn-outline-secondary {{ 'active' if not status }}" href="{{ url_for('contracts.visits_dashboard') }}">Tous</a>{% for value,label in [('planifiee','À planifier'),('passage_libre','Passages libres'),('entreprise_arrivee','Arrivées'),('non_venue','Non venues'),('realisee','Réalisés')] %}<a class="btn btn-outline-secondary {{ 'active' if status == value }}" href="{{ url_for('contracts.visits_dashboard', status=value) }}">{{ label }}</a>{% endfor %}</div>
<div class="row g-3">{% for visit in visits %}<div class="col-md-6 col-xl-4"><article class="card h-100 shadow-sm"><div class="card-body"><div class="d-flex justify-content-between"><h2 class="h6 mb-1">{{ visit.contract.company.name if visit.contract.company else 'Entreprise non définie' }}</h2><span class="badge bg-{{ 'success' if visit.status == 'realisee' else ('danger' if visit.scheduled_date < today and visit.status not in ('realisee','annulee') else 'primary') }}">{{ visit.status|replace('_',' ') }}</span></div><p class="mb-1"><strong>{{ visit.contract.name }}</strong></p><p class="small text-muted mb-1">{{ visit.display_mode }} · prévu le {{ visit.scheduled_date.strftime('%d/%m/%Y') }}{% if visit.start_time %} à {{ visit.start_time.strftime('%H:%M') }}{% endif %}</p>{% if visit.actual_date %}<p class="small mb-0">Passage réel : {{ visit.actual_date.strftime('%d/%m/%Y') }}</p>{% endif %}{% if visit.accompaniment_mode != 'none' %}<p class="small mb-0">Accompagnement technicien : {{ visit.accompaniment_minutes or ('toute la durée' if visit.accompaniment_mode == 'full' else 'ponctuel') }}</p>{% endif %}</div><div class="card-footer bg-transparent"><a href="{{ url_for('contracts.detail', id=visit.contract_id) }}" class="btn btn-sm btn-outline-primary">Voir le contrat</a></div></article></div>{% else %}<div class="col-12"><div class="alert alert-info">Aucun passage ne correspond à ce filtre.</div></div>{% endfor %}</div>
</div>
{% endblock %}

View file

@ -209,6 +209,10 @@ class DayPlanner:
for candidate in flexible:
duration = int(candidate.duration_minutes or 0)
if duration <= 0:
if candidate.task_type == "external_company":
candidate.status = "information"
candidate.explanation = "Passage d'entreprise sans plage horaire bloquante."
continue
candidate.status = "à replanifier"
candidate.explanation = "La durée de cette tâche n'est pas configurée."
continue
@ -271,6 +275,7 @@ class DayPlanner:
"""Charge les sources accessibles sans créer de nouvelle table."""
from ..models.planning import ScheduledTask, AdminTask
from ..models.maintenance import Intervention
from ...contracts.models import ContractVisit
candidates = []
tasks = ScheduledTask.query.filter(
@ -335,4 +340,28 @@ class DayPlanner:
fixed_end=(datetime.combine(day, admin.start_time) + timedelta(minutes=int(admin.duration_minutes or 0))).time() if admin.start_time and admin.duration_minutes else None,
status="à planifier",
))
# Les passages d'entreprise sont une source interne facultative. Aucun
# appel Pronote/ENT n'est effectué ici : seul ContractVisit est lu.
visits = ContractVisit.query.filter(
ContractVisit.scheduled_date == day,
ContractVisit.status.notin_(("realisee", "annulee")),
).all()
for visit in visits:
if user_id is not None and visit.technician_id not in (None, user_id):
continue
duration = visit.accompaniment_minutes or 0
fixed_start = visit.start_time if visit.is_fixed_for_planning else None
fixed_end = visit.end_time if visit.is_fixed_for_planning else None
if visit.accompaniment_mode == "full" and fixed_start and fixed_end:
duration = max(0, (_minutes(fixed_end) - _minutes(fixed_start)))
company_name = visit.contract.company.name if visit.contract and visit.contract.company else "Entreprise extérieure"
candidates.append(PlanningCandidate(
source_type="contract_visit", source_id=visit.id,
title=f"Passage {company_name}", task_type="external_company",
priority=2, constraint="fixed" if fixed_start and fixed_end else "flexible",
target_date=day, duration_minutes=duration,
assigned_to=visit.technician_id, status=visit.status,
fixed_start=fixed_start, fixed_end=fixed_end,
explanation=visit.display_mode,
))
return cls.propose(day, candidates, user_id=user_id)

View file

@ -68,6 +68,7 @@
<ul class="dropdown-menu dropdown-menu-dark" style="background:#34495e;">
<li><a class="dropdown-item" href="{{ url_for('companies.index') }}"><i class="bi bi-building"></i> Entreprises</a></li>
<li><a class="dropdown-item" href="{{ url_for('contracts.index') }}"><i class="bi bi-file-earmark-text"></i> Contrats</a></li>
<li><a class="dropdown-item" href="{{ url_for('contracts.visits_dashboard') }}"><i class="bi bi-calendar2-check"></i> Passages &amp; contrôles</a></li>
<li><a class="dropdown-item" href="{{ url_for('services.index') }}"><i class="bi bi-building-gear"></i> Services</a></li>
<li><a class="dropdown-item" href="{{ url_for('parts.index') }}"><i class="bi bi-box-seam"></i> Pièces</a></li>
</ul>

View file

@ -222,6 +222,7 @@
<li><hr class="dropdown-divider" style="border-color:rgba(255,255,255,0.15)"></li>
{% if has_permission('contract.view') %}<li><a class="dropdown-item" href="{{ url_for('companies.index') }}"><i class="bi bi-building"></i> Entreprises</a></li>
<li><a class="dropdown-item" href="{{ url_for('contracts.index') }}"><i class="bi bi-file-earmark-text"></i> Contrats</a></li>
<li><a class="dropdown-item" href="{{ url_for('contracts.visits_dashboard') }}"><i class="bi bi-calendar2-check"></i> Passages &amp; contrôles</a></li>
<li><a class="dropdown-item" href="{{ url_for('lots.index') }}"><i class="bi bi-boxes"></i> Lots</a></li>
<li><a class="dropdown-item" href="{{ url_for('services.index') }}"><i class="bi bi-building-gear"></i> Services</a></li>
<li><a class="dropdown-item" href="{{ url_for('parts.index') }}"><i class="bi bi-box-seam"></i> Pièces</a></li>{% endif %}

View file

@ -0,0 +1,33 @@
"""Phase 2.1b: richer external maintenance passages."""
from alembic import op
import sqlalchemy as sa
revision = "i4d5e6f7a8b9"
down_revision = "h3c4d5e6f7a8"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("contract_visits", sa.Column("lot_task_id", sa.Integer(), nullable=True))
op.add_column("contract_visits", sa.Column("passage_mode", sa.String(20), nullable=False, server_default="appointment"))
op.add_column("contract_visits", sa.Column("start_time", sa.Time(), nullable=True))
op.add_column("contract_visits", sa.Column("end_time", sa.Time(), nullable=True))
op.add_column("contract_visits", sa.Column("window_start", sa.Date(), nullable=True))
op.add_column("contract_visits", sa.Column("window_end", sa.Date(), nullable=True))
op.add_column("contract_visits", sa.Column("actual_date", sa.Date(), nullable=True))
op.add_column("contract_visits", sa.Column("arrival_at", sa.DateTime(), nullable=True))
op.add_column("contract_visits", sa.Column("accompaniment_mode", sa.String(20), nullable=False, server_default="none"))
op.add_column("contract_visits", sa.Column("accompaniment_minutes", sa.Integer(), nullable=True))
op.add_column("contract_visits", sa.Column("technician_id", sa.Integer(), nullable=True))
op.create_foreign_key("fk_contract_visits_lot_task", "contract_visits", "lot_tasks", ["lot_task_id"], ["id"], ondelete="SET NULL")
op.create_foreign_key("fk_contract_visits_technician", "contract_visits", "users", ["technician_id"], ["id"], ondelete="SET NULL")
op.create_index("ix_contract_visits_lot_task_id", "contract_visits", ["lot_task_id"])
def downgrade():
op.drop_index("ix_contract_visits_lot_task_id", table_name="contract_visits")
op.drop_constraint("fk_contract_visits_technician", "contract_visits", type_="foreignkey")
op.drop_constraint("fk_contract_visits_lot_task", "contract_visits", type_="foreignkey")
for name in ("technician_id", "accompaniment_minutes", "accompaniment_mode", "arrival_at", "actual_date", "window_end", "window_start", "end_time", "start_time", "passage_mode", "lot_task_id"):
op.drop_column("contract_visits", name)

View file

@ -0,0 +1,27 @@
from datetime import date, time
from app_new.contracts.models import ContractVisit
def test_visit_modes_do_not_invent_an_hour():
visit = ContractVisit(scheduled_date=date(2026, 9, 12), passage_mode="date_only")
assert visit.start_time is None
assert visit.is_fixed_for_planning is False
assert visit.display_mode == "Date connue, heure inconnue"
def test_appointment_is_fixed_only_when_times_are_present():
visit = ContractVisit(
scheduled_date=date(2026, 9, 12), passage_mode="appointment",
start_time=time(9, 30), end_time=time(11, 0),
)
assert visit.is_fixed_for_planning is True
def test_open_window_is_not_a_fixed_planning_block():
visit = ContractVisit(
scheduled_date=date(2026, 9, 1), passage_mode="open_window",
window_start=date(2026, 9, 1), window_end=date(2026, 9, 15),
)
assert visit.is_fixed_for_planning is False
assert visit.display_mode == "Passage libre / sans rendez-vous"