feat(contracts): gérer les passages entreprise
This commit is contained in:
parent
315eabdeba
commit
1ed724555a
11 changed files with 220 additions and 8 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -126,4 +138,4 @@
|
|||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 l’entreprise 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():
|
||||
|
|
|
|||
|
|
@ -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 & 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>
|
||||
|
|
|
|||
9
app_new/contracts/templates/contracts/visits.html
Normal file
9
app_new/contracts/templates/contracts/visits.html
Normal 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 & 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 %}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 & 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>
|
||||
|
|
|
|||
|
|
@ -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 & 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 %}
|
||||
|
|
|
|||
33
migrations/versions/i4d5e6f7a8b9_phase21b_contract_visits.py
Normal file
33
migrations/versions/i4d5e6f7a8b9_phase21b_contract_visits.py
Normal 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)
|
||||
27
tests/unit/test_external_maintenance_visit.py
Normal file
27
tests/unit/test_external_maintenance_visit.py
Normal 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"
|
||||
Loading…
Reference in a new issue