diff --git a/loko/contracts/admin.py b/loko/contracts/admin.py index 34c8f98..9e881d1 100644 --- a/loko/contracts/admin.py +++ b/loko/contracts/admin.py @@ -15,6 +15,7 @@ from .models import ( CompanyTeam, CompanyMember, CompanyMemberHistory, + CompanyMemberAbsence, ContractOrderQuote, ContractOrderQuoteItem, ContractOrderProgressState, @@ -383,6 +384,14 @@ class CompanyMemberHistoryAdmin(admin.ModelAdmin): return False +@admin.register(CompanyMemberAbsence) +class CompanyMemberAbsenceAdmin(admin.ModelAdmin): + list_display = ('member', 'date', 'reason', 'created_by', 'created_at') + list_filter = ('reason', 'date') + search_fields = ('member__name', 'comment') + date_hierarchy = 'date' + + # --------------------------------------------------------------------------- # Commandes directes # --------------------------------------------------------------------------- diff --git a/loko/contracts/migrations/0005_companymemberabsence.py b/loko/contracts/migrations/0005_companymemberabsence.py new file mode 100644 index 0000000..c492262 --- /dev/null +++ b/loko/contracts/migrations/0005_companymemberabsence.py @@ -0,0 +1,34 @@ +# Generated by Django 6.0.7 on 2026-09-02 08:57 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contracts', '0004_contract_blocked_at_contract_blocked_reason_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='CompanyMemberAbsence', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date', models.DateField(verbose_name="Date d'absence")), + ('reason', models.CharField(blank=True, choices=[('conge', 'Congé / RTT'), ('maladie', 'Maladie / Arrêt'), ('formation', 'Formation'), ('autre', 'Autre')], default='conge', max_length=50, verbose_name="Motif d'absence")), + ('comment', models.TextField(blank=True, verbose_name='Commentaire')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Créé le')), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='member_absences_recorded', to=settings.AUTH_USER_MODEL, verbose_name='Enregistré par')), + ('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='absences', to='contracts.companymember', verbose_name='Membre')), + ], + options={ + 'verbose_name': "Absence d'un membre", + 'verbose_name_plural': 'Absences des membres', + 'ordering': ['-date', 'member'], + 'unique_together': {('member', 'date')}, + }, + ), + ] diff --git a/loko/contracts/models.py b/loko/contracts/models.py index fd1cd3d..6dbf168 100644 --- a/loko/contracts/models.py +++ b/loko/contracts/models.py @@ -207,6 +207,50 @@ class CompanyMemberHistory(models.Model): ordering = ['member', '-join_date'] +class CompanyMemberAbsence(models.Model): + """Enregistrement d'une absence journalière pour un membre d'équipe.""" + ABSENCE_REASON_CHOICES = [ + ('conge', _('Congé / RTT')), + ('maladie', _('Maladie / Arrêt')), + ('formation', _('Formation')), + ('autre', _('Autre')), + ] + + member = models.ForeignKey( + CompanyMember, + on_delete=models.CASCADE, + related_name='absences', + verbose_name=_("Membre"), + ) + date = models.DateField(verbose_name=_("Date d'absence")) + reason = models.CharField( + max_length=50, + choices=ABSENCE_REASON_CHOICES, + default='conge', + blank=True, + verbose_name=_("Motif d'absence"), + ) + comment = models.TextField(blank=True, verbose_name=_("Commentaire")) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='member_absences_recorded', + verbose_name=_("Enregistré par"), + ) + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Créé le")) + + def __str__(self): + return f"{self.member.name} - {self.get_reason_display()} ({self.date.strftime('%d/%m/%Y')})" + + class Meta: + verbose_name = _("Absence d'un membre") + verbose_name_plural = _("Absences des membres") + unique_together = ('member', 'date') + ordering = ['-date', 'member'] + + class UserCompanyTeamAccess(models.Model): """Permissions d'accès d'un utilisateur à une équipe de société/organisme.""" user_config = models.ForeignKey( diff --git a/loko/contracts/services_teams.py b/loko/contracts/services_teams.py new file mode 100644 index 0000000..c52e2c1 --- /dev/null +++ b/loko/contracts/services_teams.py @@ -0,0 +1,357 @@ +""" +Service de gestion temporelle et de planification des équipes. +Gère les appartenances continues, l'historique d'équipe, les absences journalières et la duplication. +""" +from collections import defaultdict +from datetime import timedelta, date +from typing import Optional, List, Dict, Set, Any + +from django.db import transaction +from django.db.models import Q +from django.utils import timezone + +from contracts.models import ( + CompanyMember, + CompanyTeam, + CompanyMemberHistory, + CompanyMemberAbsence, +) + + +def get_member_team_for_date(member: CompanyMember, target_date: date) -> Optional[CompanyTeam]: + """ + Retourne l'équipe à laquelle appartient le membre à la date donnée (selon CompanyMemberHistory). + Retourne None si le membre n'est dans aucune équipe ce jour-là. + """ + histories = list( + CompanyMemberHistory.objects + .filter(member=member, join_date__lte=target_date) + .filter(Q(leave_date__isnull=True) | Q(leave_date__gte=target_date)) + .select_related('team') + .order_by('-join_date', '-id') + ) + if not histories: + return None + # Si un historique est ouvert (leave_date IS NULL) ou se termine strictement après target_date, + # on le préfère à un historique se terminant exactement à target_date (transfert en cours de journée) + continuing = [h for h in histories if h.leave_date is None or h.leave_date > target_date] + if continuing: + return continuing[0].team + return histories[0].team + + +@transaction.atomic +def set_member_team_schedule( + member: CompanyMember, + team_id: Optional[int], + target_date: date, + changed_by=None, + sync_current: bool = True, +) -> Optional[CompanyMemberHistory]: + """ + Planifie l'appartenance d'un membre à une équipe à partir de `target_date`. + Règle de persistance : le membre reste dans cette équipe à partir de `target_date` + jusqu'à une éventuelle réassignation future. + + Si team_id est None, le membre devient non assigné à partir de target_date. + """ + today = timezone.now().date() + + # 1. Récupérer les enregistrements d'historique actifs ou futurs + active_histories = list( + CompanyMemberHistory.objects + .filter(member=member) + .filter( + Q(leave_date__isnull=True) | Q(leave_date__gte=target_date) + ) + .order_by('join_date') + ) + + # Trouver l'historique actif à la date target_date + current_at_target = None + for h in active_histories: + if h.join_date <= target_date and (h.leave_date is None or h.leave_date >= target_date): + current_at_target = h + break + + # Si le membre est déjà dans la même équipe à cette date exacte, rien à faire + if current_at_target and current_at_target.team_id == team_id: + return current_at_target + if not current_at_target and team_id is None: + return None + + # Trouver le prochain historique qui commence STRICTEMENT après target_date (s'il y en a) + next_hist = None + for h in active_histories: + if h.join_date > target_date: + if next_hist is None or h.join_date < next_hist.join_date: + next_hist = h + + next_leave_bound = (next_hist.join_date - timedelta(days=1)) if next_hist else None + + # 2. Clôturer ou ajuster l'historique courant (actif à target_date) + if current_at_target: + if current_at_target.join_date == target_date: + current_at_target.leave_date = target_date + current_at_target.save(update_fields=['leave_date']) + else: + # L'historique commençait avant target_date : on le clôture la veille + prev_leave = target_date - timedelta(days=1) + current_at_target.leave_date = prev_leave + current_at_target.save(update_fields=['leave_date']) + else: + # Aucun historique actif à target_date (membre non assigné) + pass + + # 3. Créer le nouvel enregistrement s'il y a une nouvelle équipe + new_history = None + if team_id is not None: + new_history = CompanyMemberHistory.objects.create( + member=member, + team_id=team_id, + join_date=target_date, + leave_date=next_leave_bound, + changed_by=changed_by, + ) + + _sync_teams_if_needed(member, target_date, today, sync_current) + return new_history + + +def _sync_teams_if_needed(member: CompanyMember, target_date: date, today: date, sync_current: bool): + """Synchronise member.teams si la date modifiée impacte la date d'aujourd'hui.""" + if sync_current and target_date <= today: + sync_member_current_teams(member, today) + + +def sync_member_current_teams(member: CompanyMember, for_date: Optional[date] = None): + """ + Synchronise le champ ManyToMany member.teams avec l'équipe active + dans CompanyMemberHistory pour la date spécifiée (par défaut aujourd'hui). + """ + if for_date is None: + for_date = timezone.now().date() + + active_team = get_member_team_for_date(member, for_date) + current_team_ids = set(member.teams.values_list('id', flat=True)) + target_team_ids = {active_team.id} if active_team else set() + + if current_team_ids != target_team_ids: + # Utiliser .set() pour synchroniser + member.teams.set(list(target_team_ids)) + + +def get_teams_composition_for_period( + team_ids: Set[int], + start_date: date, + end_date: date, +) -> Dict[tuple, List[Dict[str, Any]]]: + """ + Calcule la composition journalière de chaque équipe sur une période donnée [start_date, end_date]. + Retourne un dict: (team_id, date) -> [{'id': member_id, 'name': member_name, 'user_id': user_id, 'role': role}] + """ + histories = ( + CompanyMemberHistory.objects + .filter( + team_id__in=team_ids, + join_date__lte=end_date, + ) + .filter( + Q(leave_date__isnull=True) | Q(leave_date__gte=start_date) + ) + .select_related('member', 'member__user') + ) + + # Récupérer les rôles et informations utiles + member_histories = defaultdict(lambda: {'member': None, 'histories': []}) + for h in histories: + member_histories[h.member_id]['member'] = h.member + member_histories[h.member_id]['histories'].append({ + 'team_id': h.team_id, + 'join_date': h.join_date, + 'leave_date': h.leave_date, + }) + + num_days = (end_date - start_date).days + 1 + team_compositions_sets = defaultdict(set) + + for i in range(num_days): + d = start_date + timedelta(days=i) + for m_id, m_data in member_histories.items(): + member = m_data['member'] + # Trouver les historiques actifs à la date d + active = [] + for h in m_data['histories']: + if h['join_date'] <= d and (h['leave_date'] is None or h['leave_date'] >= d): + active.append(h) + + if not active: + continue + + # En cas de multiplicité, privilégier celui ayant join_date le plus récent + if len(active) > 1: + active.sort(key=lambda x: x['join_date'], reverse=True) + active = [active[0]] + + for h in active: + team_compositions_sets[(h['team_id'], d)].add( + (member.id, member.name, getattr(member.user, 'id', None)) + ) + + result = defaultdict(list) + for (t_id, d), m_set in team_compositions_sets.items(): + sorted_members = sorted(list(m_set), key=lambda x: x[1]) + result[(t_id, d)] = [ + {'id': mid, 'name': mname, 'user_id': uid} + for mid, mname, uid in sorted_members + ] + return result + + +def get_member_absences_for_period( + member_ids: Optional[Set[int]], + start_date: date, + end_date: date, +) -> Dict[tuple, Dict[str, Any]]: + """ + Récupère toutes les absences sur la période [start_date, end_date]. + Retourne un dict: (member_id, date) -> {'reason': str, 'reason_display': str, 'comment': str} + """ + qs = CompanyMemberAbsence.objects.filter( + date__range=(start_date, end_date) + ) + if member_ids is not None: + qs = qs.filter(member_id__in=member_ids) + + absences = {} + for a in qs: + absences[(a.member_id, a.date)] = { + 'id': a.id, + 'reason': a.reason, + 'reason_display': a.get_reason_display(), + 'comment': a.comment, + } + return absences + + +@transaction.atomic +def toggle_member_absence( + member: CompanyMember, + target_date: date, + is_absent: bool = True, + reason: str = 'conge', + comment: str = '', + created_by=None, +) -> Optional[CompanyMemberAbsence]: + """Active ou désactive l'absence d'un membre pour une date donnée.""" + if is_absent: + obj, _ = CompanyMemberAbsence.objects.update_or_create( + member=member, + date=target_date, + defaults={ + 'reason': reason or 'conge', + 'comment': comment or '', + 'created_by': created_by, + } + ) + return obj + else: + CompanyMemberAbsence.objects.filter(member=member, date=target_date).delete() + return None + + +@transaction.atomic +def duplicate_day_schedule( + source_date: date, + target_date: date, + allowed_team_ids: Optional[Set[int]] = None, + copy_absences: bool = False, + changed_by=None, +) -> Dict[str, int]: + """ + Duplique l'affectation des équipes d'une journée source vers une journée cible. + Si allowed_team_ids est fourni, seules les équipes autorisées sont dupliquées. + """ + # 1. Trouver tous les membres et leur équipe sur source_date + all_members = CompanyMember.objects.all() + if allowed_team_ids is not None: + # Membres qui étaient dans les équipes autorisées ou non assignés + pass + + assigned_count = 0 + absences_count = 0 + + for member in all_members: + team = get_member_team_for_date(member, source_date) + if team: + if allowed_team_ids is not None and team.id not in allowed_team_ids: + continue + set_member_team_schedule( + member=member, + team_id=team.id, + target_date=target_date, + changed_by=changed_by, + sync_current=False, + ) + assigned_count += 1 + else: + # Membre non assigné le jour source : le désassigner aussi sur le jour cible s'il était dans une équipe autorisée + target_team = get_member_team_for_date(member, target_date) + if target_team and (allowed_team_ids is None or target_team.id in allowed_team_ids): + set_member_team_schedule( + member=member, + team_id=None, + target_date=target_date, + changed_by=changed_by, + sync_current=False, + ) + + if copy_absences: + source_absences = CompanyMemberAbsence.objects.filter(date=source_date) + for a in source_absences: + CompanyMemberAbsence.objects.update_or_create( + member=a.member, + date=target_date, + defaults={ + 'reason': a.reason, + 'comment': a.comment, + 'created_by': changed_by, + } + ) + absences_count += 1 + + # Synchronisation globale pour aujourd'hui + today = timezone.now().date() + if target_date <= today: + for m in all_members: + sync_member_current_teams(m, today) + + return {'assigned_count': assigned_count, 'absences_count': absences_count} + + +@transaction.atomic +def duplicate_week_schedule( + source_monday: date, + target_monday: date, + allowed_team_ids: Optional[Set[int]] = None, + copy_absences: bool = False, + changed_by=None, +) -> Dict[str, int]: + """Duplique une semaine complète (7 jours) vers une autre semaine.""" + total_assigned = 0 + total_absences = 0 + + for i in range(7): + s_day = source_monday + timedelta(days=i) + t_day = target_monday + timedelta(days=i) + res = duplicate_day_schedule( + source_date=s_day, + target_date=t_day, + allowed_team_ids=allowed_team_ids, + copy_absences=copy_absences, + changed_by=changed_by, + ) + total_assigned += res['assigned_count'] + total_absences += res['absences_count'] + + return {'assigned_count': total_assigned, 'absences_count': total_absences} diff --git a/loko/contracts/templates/contracts/teams_index.html b/loko/contracts/templates/contracts/teams_index.html index a4d83a0..0bcba2c 100644 --- a/loko/contracts/templates/contracts/teams_index.html +++ b/loko/contracts/templates/contracts/teams_index.html @@ -5,7 +5,25 @@ {% block head %} + + + {% endblock %} {% block content %}
- -
-

- {% trans "Gestion des équipes" %} -

+ +
+
+

+ {% trans "Gestion des équipes" %} +

+ {% trans "Affectation des membres, suivi des présences et planification calendaire." %} +
+ +
- {% if not teams %} -
- {% trans "Aucune équipe accessible." %} -
- {% else %} + +
- -
+ +
- -
-
-
-
-
- {% trans "Non assignés" %} + {% if not teams %} +
+ {% trans "Aucune équipe accessible." %} +
+ {% else %} + + +
+ + +
+
+
+
+
+ {% trans "Non assignés" %} +
+ {% trans "Aucune équipe" %} +
+ {% if can_edit %} + + {% endif %} +
+
+
    + {% for member in unassigned_members %} +
  • + {% if can_edit %} + + {% endif %} +
    + {{ member.name }} + {% if member.user %} + {{ member.user.get_full_name|default:member.user.username }} + {% endif %} +
    + {% if can_edit %} +
    + + +
    + {% endif %} +
  • + {% empty %} +
  • + + {% trans "Aucun membre" %} +
  • + {% endfor %} +
+
+ - {% trans "Aucune équipe" %}
+
+ + + {% for team in teams %} +
+
+
+
+
{{ team.name }}
+ {{ team.company.name }} +
+
+ {% if team.active_interventions_count > 0 %} + + {% endif %} + {% if can_edit %} + + {% endif %} +
+
+ +
+
    + {% for member in team.members.all %} +
  • + {% if can_edit %} + + {% endif %} +
    + {{ member.name }} + {% if member.user %} + {{ member.user.get_full_name|default:member.user.username }} + {% endif %} +
    + {% if can_edit %} +
    + + +
    + {% endif %} +
  • + {% empty %} +
  • + + {% trans "Aucun membre" %} +
  • + {% endfor %} +
+
+ + +
+
+ {% endfor %} +
+ {% endif %} + +
+ + +
+ + +
+
+
+ + + +
+ + +
+ + +
+ + +
+ + + +
+
+ +
+ +
+ +
+ {% if can_edit %} - {% endif %} -
-
-
    - {% for member in unassigned_members %} -
  • - {% if can_edit %} - - {% endif %} -
    - {{ member.name }} - {% if member.user %} - {{ member.user.get_full_name|default:member.user.username }} - {% endif %} -
    - {% if can_edit %} -
    - - -
    - {% endif %} -
  • - {% empty %} -
  • - - {% trans "Aucun membre" %} -
  • - {% endfor %} -
-
-
-
- - {% for team in teams %} -
-
-
-
-
{{ team.name }}
- {{ team.company.name }} + +
+ + +
+
+
+ {% trans "Chargement du planning..." %} +
-
- {% if team.active_interventions_count > 0 %} - - {% endif %} - {% if can_edit %} - - {% endif %} + + + + + + + + + + + +
{% trans "Équipes" %}
+
+ + +
+
+
+ + {% trans "Membres" %} + + 0 +
+ + +
+ +
+ + +
+
+ +
+
+ +
+
+
+ +
+
-
-
    - {% for member in team.members.all %} -
  • - {% if can_edit %} - - {% endif %} -
    - {{ member.name }} - {% if member.user %} - {{ member.user.get_full_name|default:member.user.username }} - {% endif %} -
    - {% if can_edit %} -
    - - -
    - {% endif %} -
  • - {% empty %} -
  • - - {% trans "Aucun membre" %} -
  • - {% endfor %} -
-
- -
+
- {% endfor %} +
- {% endif %}
@@ -288,8 +681,160 @@ ============================================================ --> {% if can_edit %} + + - + + + + + + +