From 1ed724555ae16d552f462af8844d69c2c32ed9df Mon Sep 17 00:00:00 2001 From: root Date: Sun, 23 Aug 2026 14:02:38 +0000 Subject: [PATCH] =?UTF-8?q?feat(contracts):=20g=C3=A9rer=20les=20passages?= =?UTF-8?q?=20entreprise?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app_new/companies/routes.py | 6 +- app_new/companies/templates/detail.html | 14 +++- app_new/contracts/models.py | 27 ++++++- app_new/contracts/routes.py | 77 ++++++++++++++++++- .../contracts/templates/contracts/detail.html | 4 +- .../contracts/templates/contracts/visits.html | 9 +++ app_new/core/services/day_planner.py | 29 +++++++ app_new/templates/_navigation_phase16.html | 1 + app_new/templates/base.html | 1 + .../i4d5e6f7a8b9_phase21b_contract_visits.py | 33 ++++++++ tests/unit/test_external_maintenance_visit.py | 27 +++++++ 11 files changed, 220 insertions(+), 8 deletions(-) create mode 100644 app_new/contracts/templates/contracts/visits.html create mode 100644 migrations/versions/i4d5e6f7a8b9_phase21b_contract_visits.py create mode 100644 tests/unit/test_external_maintenance_visit.py diff --git a/app_new/companies/routes.py b/app_new/companies/routes.py index 8590c55..4b58de4 100644 --- a/app_new/companies/routes.py +++ b/app_new/companies/routes.py @@ -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 diff --git a/app_new/companies/templates/detail.html b/app_new/companies/templates/detail.html index d0402bc..d2daf50 100644 --- a/app_new/companies/templates/detail.html +++ b/app_new/companies/templates/detail.html @@ -75,6 +75,18 @@ {% endif %} +
+
Maintenance préventive confiée ({{ preventive_tasks|length }})
+
+ {% if preventive_tasks %}
{% for task in preventive_tasks %}{% endfor %}
TâcheLotPériodicitéProchaine échéance
{{ task.tache or 'Tâche sans nom' }}{{ task.lot.name if task.lot else '—' }}{{ task.periodicite or 'À définir' }}À planifier
{% else %}

Aucune tâche préventive n'est confiée à cette entreprise.

{% endif %} +
+
+ +
+
Passages & contrôles ({{ visits|length }})
+ +
+
Lots concernés ({{ company.lots|length }})
@@ -126,4 +138,4 @@ {% endif %}
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/app_new/contracts/models.py b/app_new/contracts/models.py index 01272b8..c92fb62 100644 --- a/app_new/contracts/models.py +++ b/app_new/contracts/models.py @@ -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) diff --git a/app_new/contracts/routes.py b/app_new/contracts/routes.py index 105e620..050eb77 100644 --- a/app_new/contracts/routes.py +++ b/app_new/contracts/routes.py @@ -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('//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('//visits//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('//visits//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(): diff --git a/app_new/contracts/templates/contracts/detail.html b/app_new/contracts/templates/contracts/detail.html index cb379e8..2802325 100644 --- a/app_new/contracts/templates/contracts/detail.html +++ b/app_new/contracts/templates/contracts/detail.html @@ -57,8 +57,8 @@
Visites du prestataire
-
- {% for visit in contract.visits %} +
Passages & contrôles
DateStatutCompte rendu
{{ visit.scheduled_date.strftime('%d/%m/%Y') }}{{ visit.status }}{{ visit.report or '—' }}{% if visit.status != 'realisee' %}
{% endif %}
+ {% for visit in contract.visits %} {% else %}{% endfor %}
Date / fenêtreModeStatutCompte rendu
{{ visit.scheduled_date.strftime('%d/%m/%Y') }}{% if visit.window_end %} → {{ visit.window_end.strftime('%d/%m/%Y') }}{% endif %}{% if visit.start_time %}
{{ visit.start_time.strftime('%H:%M') }}{% if visit.end_time %}–{{ visit.end_time.strftime('%H:%M') }}{% endif %}
{% endif %}
{{ visit.display_mode }}{% if visit.accompaniment_mode != 'none' %}
Accompagnement : {{ visit.accompaniment_minutes or ('toute la durée' if visit.accompaniment_mode == 'full' else 'à définir') }}
{% endif %}
{{ visit.status|replace('_',' ') }}{{ visit.report or '—' }}{% if visit.status == 'passage_libre' %}
{% elif visit.status not in ('realisee','non_venue') %}
{% endif %}
Aucune visite générée
diff --git a/app_new/contracts/templates/contracts/visits.html b/app_new/contracts/templates/contracts/visits.html new file mode 100644 index 0000000..2cfde2e --- /dev/null +++ b/app_new/contracts/templates/contracts/visits.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} +{% block title %}Passages & contrôles{% endblock %} +{% block content %} +
+

Passages & contrôles

Suivez les visites des entreprises sans confondre date prévue et passage réellement réalisé.

Contrats
+
Tous{% for value,label in [('planifiee','À planifier'),('passage_libre','Passages libres'),('entreprise_arrivee','Arrivées'),('non_venue','Non venues'),('realisee','Réalisés')] %}{{ label }}{% endfor %}
+
{% for visit in visits %}

{{ visit.contract.company.name if visit.contract.company else 'Entreprise non définie' }}

{{ visit.status|replace('_',' ') }}

{{ visit.contract.name }}

{{ visit.display_mode }} · prévu le {{ visit.scheduled_date.strftime('%d/%m/%Y') }}{% if visit.start_time %} à {{ visit.start_time.strftime('%H:%M') }}{% endif %}

{% if visit.actual_date %}

Passage réel : {{ visit.actual_date.strftime('%d/%m/%Y') }}

{% endif %}{% if visit.accompaniment_mode != 'none' %}

Accompagnement technicien : {{ visit.accompaniment_minutes or ('toute la durée' if visit.accompaniment_mode == 'full' else 'ponctuel') }}

{% endif %}
{% else %}
Aucun passage ne correspond à ce filtre.
{% endfor %}
+
+{% endblock %} diff --git a/app_new/core/services/day_planner.py b/app_new/core/services/day_planner.py index eaed4ca..e486bdc 100644 --- a/app_new/core/services/day_planner.py +++ b/app_new/core/services/day_planner.py @@ -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) diff --git a/app_new/templates/_navigation_phase16.html b/app_new/templates/_navigation_phase16.html index eb04602..8bfe825 100644 --- a/app_new/templates/_navigation_phase16.html +++ b/app_new/templates/_navigation_phase16.html @@ -68,6 +68,7 @@ diff --git a/app_new/templates/base.html b/app_new/templates/base.html index 0c83be3..5af265f 100644 --- a/app_new/templates/base.html +++ b/app_new/templates/base.html @@ -222,6 +222,7 @@
  • {% if has_permission('contract.view') %}
  • Entreprises
  • Contrats
  • +
  • Passages & contrôles
  • Lots
  • Services
  • Pièces
  • {% endif %} diff --git a/migrations/versions/i4d5e6f7a8b9_phase21b_contract_visits.py b/migrations/versions/i4d5e6f7a8b9_phase21b_contract_visits.py new file mode 100644 index 0000000..f5c7b60 --- /dev/null +++ b/migrations/versions/i4d5e6f7a8b9_phase21b_contract_visits.py @@ -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) diff --git a/tests/unit/test_external_maintenance_visit.py b/tests/unit/test_external_maintenance_visit.py new file mode 100644 index 0000000..6fb9f34 --- /dev/null +++ b/tests/unit/test_external_maintenance_visit.py @@ -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"