Aller au contenu

Cas QR-006 : Pointage des employés + Lecteur QR +

@assignee: @m-kouassi

Module de synchronisation hors ligne : qr-attendance Composants : QR-T2-001 + QR-T5-005 + QR-T5-007 Contexte de la marque : [LES DEUX] Papillon HR Suite + ALTARYS ENTERPRISE HR Suite Priorité : 6 Dépend de : QR-002 (sites + validation QR), QR-003 (plannings des heures travaillées), QR-008 (affectations employé-site — pour le signalement des scans non principaux) Peut être développé en parallèle avec : QR-008 Ordre de fusion : Avant QR-007, QR-009, QR-011, QR-014, QR-016, QR-018 Complexité estimée : L PRD User Stories : US-QR-001


Objectif

Fournir l'expérience de base de pointage via la lecture de codes QR. Il s'agit du « moment phare » de Papillon : un flux de lecture axé sur le mobile et le mode hors ligne qui enregistre les événements de présence, calcule les heures de travail quotidiennes et synchronise les événements hors ligne par lots lorsque la connexion est rétablie. L'histoire couvre l'ensemble de la tranche verticale : interface utilisateur du lecteur de QR, enregistrement des événements de pointage, agrégation de la présence quotidienne, synchronisation par lots avec idempotence et cache de validation des QR pour une utilisation hors ligne.


Portée du backend

Migrations Flyway

V001_012__attendance_clock_event.sql :

CREATE TABLE attendance_clock_event (
    id UUID PRIMARY KEY,
    tenant_id UUID NOT NULL,
    employee_id UUID NOT NULL,
    site_id UUID NOT NULL,
    event_type VARCHAR(10) NOT NULL CHECK (event_type IN ('IN', 'OUT', 'BREAK_OUT', 'BREAK_IN')),
    timestamp TIMESTAMPTZ NOT NULL,
    sync_timestamp TIMESTAMPTZ,
    device_id_hash VARCHAR(64) NOT NULL,
    source VARCHAR(20) NOT NULL DEFAULT 'EMPLOYEE_SCAN' CHECK (source IN ('EMPLOYEE_SCAN', 'SYSTEM', 'HR_REGULARIZATION')),
    is_duplicate BOOLEAN NOT NULL DEFAULT false,
    auto_close_reason VARCHAR(30),
    created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_clock_event_employee ON attendance_clock_event(tenant_id, employee_id, timestamp);
CREATE INDEX idx_clock_event_site ON attendance_clock_event(tenant_id, site_id, timestamp);
CREATE INDEX idx_clock_event_device ON attendance_clock_event(tenant_id, device_id_hash, timestamp);

V001_013__présence_présence_quotidienne.sql

:

CREATE TABLE attendance_daily_attendance (
    id UUID PRIMARY KEY,
    tenant_id UUID NOT NULL,
    employee_id UUID NOT NULL,
    attendance_date DATE NOT NULL,
    status VARCHAR(30) NOT NULL DEFAULT 'EXPECTED',
    first_clock_in TIMESTAMPTZ,
    last_clock_out TIMESTAMPTZ,
    worked_minutes INTEGER DEFAULT 0,
    scheduled_minutes INTEGER DEFAULT 0,
    overtime_minutes INTEGER DEFAULT 0,
    late_minutes INTEGER DEFAULT 0,
    early_departure_minutes INTEGER DEFAULT 0,
    is_auto_closed BOOLEAN NOT NULL DEFAULT false,
    has_anomaly BOOLEAN NOT NULL DEFAULT false,
    anomaly_details JSONB,
    regularization_id UUID,
    created_at TIMESTAMP NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
    UNIQUE(tenant_id, employee_id, attendance_date)
);
CREATE INDEX idx_daily_attendance_date ON attendance_daily_attendance(tenant_id, attendance_date, status);
CREATE INDEX idx_daily_attendance_employee ON attendance_daily_attendance(tenant_id, employee_id, attendance_date);

Entités et enregistrements

Clock

(base de données du tenant) :

Field Type Constraints
id UUID PK, client-generated (for offline idempotency)
tenantId UUID NOT NULL
employeeId UUID NOT NULL
siteId UUID NOT NULL
eventType String NOT NULL, enum: IN, OUT, BREAK_OUT, BREAK_IN
timestamp OffsetDateTime NOT NULL
syncTimestamp OffsetDateTime Nullable (server-receive time)
deviceIdHash String NOT NULL, 64 chars (SHA-256)
source String NOT NULL, enum: EMPLOYEE_SCAN, SYSTEM, HR_REGULARIZATION
isDuplicate boolean NOT NULL, default false
autoCloseReason String Nullable
createdAt OffsetDateTime NOT NULL

Présence quotidienne (base de données du tenant) :

Field Type Constraints
id UUID PK
tenantId UUID NOT NULL
employeeId UUID NOT NULL
attendanceDate LocalDate NOT NULL, UNIQUE with tenantId + employeeId
status String NOT NULL, enum (see below)
firstClockIn OffsetDateTime Nullable
lastClockOut OffsetDateTime Nullable
workedMinutes int default 0
scheduledMinutes int default 0
overtimeMinutes int default 0
lateMinutes int default 0
earlyDepartureMinutes int default 0
isAutoClosed boolean NOT NULL, default false
hasAnomaly boolean NOT NULL, default false
anomalyDetails String (JSONB) Nullable
regularizationId UUID Nullable
createdAt OffsetDateTime NOT NULL
updatedAt OffsetDateTime NOT NULL

Valeurs de l'énumération « DailyAttendance » : EXPECTED, PRESENT, RETARD, ABSENT, TELETRAVAIL, EN_CONGE, FERIE, INCOMPLET, AUTO_CLOSED, ABSENT_PENDING, DEPART_ANTICIPE, REGULARIZED

DTOs :

public record RecordClockEventRequest(
    @NotNull UUID id,
    @NotNull UUID siteId,
    @NotBlank String qrToken,
    @NotNull OffsetDateTime timestamp,
    @NotBlank @Size(min = 64, max = 64) String deviceIdHash
) {}

public record ClockEventResponse(
    UUID id,
    String eventType,
    OffsetDateTime timestamp,
    String siteName,
    String status
) {}

public record BatchSyncRequest(
    @NotEmpty List<RecordClockEventRequest> events
) {}

public record BatchSyncResponse(
    int synced,
    int duplicatesSkipped,
    int failed,
    List<SyncResult> results
) {}

public record SyncResult(
    UUID id,
    String status,
    String eventType,
    String errorMessage
) {}
// SyncResult.status enum: SYNCED, DUPLICATE, FAILED, REJECTED

public record QrCacheResponse(
    UUID tenantId,
    List<QrCacheSite> sites,
    OffsetDateTime generatedAt
) {}

public record QrCacheSite(
    UUID siteId,
    String token,
    String name
) {}

Couche de référentiel

  • ClockEventRepository.java :
  • findByTenantIdAndEmployeeIdAndTimestampBetween(UUID tenantId, UUID employeeid, OffsetDateTime from, OffsetDateTime to) -> List<CLOCKEVENT>
  • findByTenantIdAndEmployeeIdAndAttendanceDate(UUID tenantId, UUID employeeId, LocalDate date) -> List<Clock> — Requête personnalisée filtrant par DATE (horodatage)
  • existsById(UUID id) -> boolean — Pour la vérification de l'idempotence
  • DailyAttendanceRepository.java :
  • findByTenantIdAndEmployeeIdAndAttendanceDate(UUID tenantId, UUID employeeId, LocalDate date) -> Optional<DailyAttendance>
  • findByTenantIdAndAttendanceDateAndStatus(UUID tenantId, LocalDate date, String status) -> List<DailyAttendance>

Couche de service

ClockEventService.java:

  • recordClockEvent(UUID tenantId, UUID employeeId, RecordClockEventRequest) -> ClockEventResponse:
  • Vérification de l'idempotence : si un événement avec le même UUID existe, renvoyer l'événement existant (pas d'insertion en double)
  • Valider le QR : appeler QrCodeValidator.validate() — vérifier le HMAC, la correspondance du tenant, l'activité du site, la validité du token
  • Vérifier l'activité de l'employé : consulter CachedEmployeeData, rejeter si le statut != ACTIVE
  • Déterminer le type d'événement : appeler EventTypeDeterminer.determine() — IN s'il n'y a pas de session ouverte, OUT si la session est ouverte
  • Insérer Clock avec syncTimestamp = OffsetDateTime.now()
  • Appeler DailyAttendanceService.upsert() pour mettre à jour l'agrégation quotidienne
  • Signalement de scan sur un site non principal (issu de QR-008) : appeler SiteAssignmentRepository.findPrimaryByTenantIdAndEmployeeId(tenantId, employeeId). Si une affectation principale existe et que primary.siteId != event.siteId, positionner DailyAttendance.hasAnomaly = true et fusionner dans DailyAttendance.anomalyDetails une entrée de la forme {"type": "NON_PRIMARY_SITE_SCAN", "scannedSiteId": "<uuid>", "primarySiteId": "<uuid>", "at": "<ISO-8601>"}. Si aucune affectation principale n'existe, aucun drapeau n'est positionné (no-op gracieux — supporte les tenants qui n'ont pas affecté de sites principaux).
  • Publier l'événement d'audit

EventTypeDeterminer.java:

  • determine(UUID tenantId, UUID employeeId, LocalDate date) -> String:
  • Interroger les événements d'horloge du jour pour l'employé
  • S'il n'y a pas d'événements ou si le dernier événement est OUT/BREAK_IN -> renvoyer « IN »
  • Si le dernier événement est IN/BREAK_OUT -> renvoyer « OUT »
  • Prend en charge plusieurs paires IN/OUT par jour

DailyAttendanceService.java :

  • upsert(UUID tenantId, UUID employeeId, LocalDate date) -> DailyAttendance:
  • Rechercher ou créer un enregistrement DailyAttendance pour la date
  • À l'arrivée : définir firstClockIn (si ce n'est pas déjà fait), définir status = PRESENT
  • Au départ : définir lastClockOut, calculer workedMinutes
  • Calcul des heures travaillées (FR-QR-042)

: breakTracking désactivé -> somme de toutes les paires (OUT - IN) moins la durée des pauses prévues - Calcul des heures travaillées (FR-QR-043)

: breakTracking activé (4 scans/jour) -> (BREAK_OUT - premier IN) + (dernier OUT - BREAK_IN) - Calculer scheduledMinutes à partir de l'horaire effectif de l'employé (via ScheduleAssignmentService)

BatchSyncService.java:

  • processSync(UUID tenantId, UUID employeeId, BatchSyncRequest) -> BatchSyncResponse:
  • Traiter les événements un par un dans l'ordre chronologique (FIFO)
  • Par événement : valider, enregistrer, suivre le résultat
  • Idempotence : INSERT avec vérification de l'UUID — s'il existe déjà, marquer comme DUPLICATE dans la réponse
  • Renvoyer les comptes agrégés : synced, duplicatesSkipped, failed

Endpoints API

Method Path Request Body Response Auth
POST /api/v1/qrcontr/clock-events RecordClockEventRequest ApiResponse Employee (P4)
POST /api/v1/qrcontr/clock-events/sync BatchSyncRequest ApiResponse Employee (P4)
GET /api/v1/qrcontr/clock-events/me ?date=&dateFrom=&dateTo= ApiResponse> Employee (P4)
GET /api/v1/qrcontr/qr-cache ApiResponse Employee (P4)
### Règles de validation
  • id doit être un UUID valide (généré par le client pour garantir l'idempotence hors ligne)
  • siteId doit exister, être actif et appartenir au tenant
  • qrToken doit correspondre au token actuel du site (validation HMAC via QrCodeValidator)
  • timestamp doit être un format ISO 8601 valide avec fuseau horaire
  • deviceIdHash doit comporter exactement 64 caractères (hachage SHA-256)
  • L'employé doit être ACTIF (et non RÉSILIÉ). Erreur : « Compte désactivé -- contactez les RH »
  • Détection des doublons : même employé, même type d'événement, dans un délai de 15 minutes -> marquer is_duplicate=true, conserver le plus ancien
  • Pointage avant l'heure de début prévue : autorisé, les heures travaillées sont comptabilisées à partir de l'heure de scan effective (FR-QR-014)

Considérations relatives au multi-tenant

  • Toutes les tables incluent la colonne tenant_id
  • Toutes les requêtes DOIVENT filtrer par tenant_id
  • La validation du QR code inclut une vérification de l'ID du tenant (un QR code du tenant A ne peut pas être utilisé chez le tenant B)
  • Le cache des QR codes ne renvoie que les sites du tenant de l'employé authentifié
  • Les événements de pointage sont des enregistrements d'audit immuables — ils ne sont jamais modifiés ni supprimés

Portée du frontend

Pages et routes

  • /qrcontr/scan — Scanner QR (appareil photo en plein écran)
  • /qrcontr/ — Onglet d'accueil de l'employé « Pointage » (page d'accueil par défaut)

Composants

Les composants suivent les maquettes des spécifications de conception 3.1, 3.2 :

QRScanner.tsx (spécification du composant de conception 8.2) : - Props : onScan, onError, isOffline, torchEnabled - États : initialisation, prêt, scan, succès, erreur - Viseur de l'appareil photo en plein écran avec superposition d'un cadre QR - Coins orange animés clignotants (cycle de 1,5 s)

ClockConfirmation.tsx : - État de réussite en plein écran : coche animée, affichage de l'heure, libellé IN/OUT, nom du site, état de synchronisation - Fermeture automatique vers l'historique au bout de 2 secondes - Motif haptique de réussite (navigator.vibrate)

EmployeeHomePage.tsx : - Message d'accueil avec le nom de l'employé - Liste des événements de l'horloge du jour - CTA « Scanner le QR » (grand, hauteur 48 px, orange) - Compteur d'heures travaillées pour la journée en cours

OfflineBanner.tsx : - Fine bannière orange en haut : « Hors ligne -- vos pointages seront synchronisés »

SyncIndicator.tsx : - Persistant dans la zone de la barre supérieure - Point vert = en ligne, point orange clignotant = en cours de synchronisation, point gris = hors ligne

États de l'interface utilisateur (TOUS OBLIGATOIRES)

State Behavior French Copy
Loading (scanner) Camera initializing, shimmer on frame "Demarrage de la camera..."
Empty (no camera) Full-screen permission request "Autorisez l'acces a la camera pour scanner le QR code."
Error (invalid QR) Red shake animation, error message slides up "QR code invalide. Contactez les RH."
Offline Scanner works normally, success shows sync pending "En attente de synchronisation"
Success Full-screen warm gradient, animated checkmark, time "Pointage enregistre" + "HH:MM" + "Arrivee -- [Site name]"

États supplémentaires :

  • Doublon : état d'information orange -> « Pointage déjà enregistré à HH:MM » (fermeture automatique au bout de 2 s)
  • Compté : erreur rouge -> « Compte désactivé -- contactez les RH »
  • QR hors ligne non enregistré dans le cache : « Code QR non reconnu hors ligne. Réessayez en ligne. »

Comportement adaptatif

  • 360px (mobile) : scanner plein écran (cas d'utilisation principal). Page d'accueil : mise en page empilée, grand bouton d'appel à l'action pour le scan
  • 768px (tablette) : scanner plein écran. Accueil : 2 colonnes (événements du jour + compteur d'heures)
  • 1024px+ (ordinateur de bureau) : scanner sous forme de modal centré (60 % de la largeur). Accueil : mise en page tableau de bord complet

Interactions

Flux du scanner :

** - Les coins du cadre s'alignent sur les limites du QR lors de la détection (retour haptique) - Succès : fondu d'entrée avec dégradé chaud (300 ms) + rebond de l'échelle de la coche (0 -> 1,2 -> 1,0, 300 ms) + fondu d'entrée de l'heure - Erreur : secousse (3 fois, 200 ms, translation X de +/-10px)

Page d'accueil :**

** - CTA « Scanner le QR » -> ouvre le scanner plein écran - Événements du jour affichés sous forme de chronologie avec les libellés IN/OUT

Comportement hors ligne :** - Événements stockés dans la file d'attente IndexedDB - Synchronisation automatique dans les 30 secondes suivant la reconnexion (ordre FIFO) - Délai d'attente exponentiel en cas d'échec (3 tentatives max.) - Échec de la synchronisation après les tentatives : « Le pointage du {datetime} n'a pas pu être synchronisé. Contactez les RH."

Cache QR : - Actualisé à chaque synchronisation réussie - Validation hors ligne : comparaison des {siteId, token} scannés avec les paires mises en cache - Format du cache : {tenantId, sites: [{siteId, token, name}], generatedAt}

AC-001: Clock-in event recording via QR scan
Given I am logged into the Papillon app and at a site with a posted QR code
When I scan the QR code
Then a clock-in event is recorded with my employee ID, the site ID, and the device timestamp

AC-002: Successful scan confirmation
Given I scan the QR code
When the scan is successful
Then I see a confirmation message: "Pointage enregistre -- [HH:MM]"

AC-003: Offline clock event with queue and sync
Given I am offline
When I scan the QR code
Then the clock event is stored locally and queued for sync
And when connectivity is restored, the event syncs within 30 seconds

AC-004: Non-primary site scan flagging (moved from QR-008)
Given employee "KONE Aminata" has primary site "Siege Abidjan" (assigned via QR-008)
When she scans the QR code at "Entrepot Yopougon" (a non-primary site)
Then the clock event is recorded normally
And the corresponding DailyAttendance row has hasAnomaly = true
And anomalyDetails contains an entry of type "NON_PRIMARY_SITE_SCAN" with the scanned and primary site IDs

AC-005: No flagging when no primary assignment exists
Given employee "KONE Aminata" has no primary site assignment
When she scans the QR code at any site
Then the clock event is recorded normally and no NON_PRIMARY_SITE_SCAN entry is added

OHADA et règles réglementaires

  • La lecture de codes QR (non biométrique) est explicitement approuvée par l'ARTCI comme étant proportionnée (contrairement à la biométrie, qui est interdite).
  • Hachage de l'UUID de l'appareil : capturé à des fins d'audit, mais l'UUID brut de l'appareil n'est jamais stocké (FR-QR-091).
  • Enregistrements d'audit immuables : tous les événements horodatés sont immuables — ils ne sont jamais modifiés ni supprimés (ADR-10).
  • Horloge de l'appareil faisant autorité : l'horodatage provient de l'appareil de l'employé. syncTimestamp correspond à l'heure de réception par le serveur, uniquement à des fins de piste d'audit.

Normes et conventions

  • docs/standards/java-Spring-guidelines.md — Enregistrements d'entités, couche de service, événements immuables
  • docs/standards/api-guidelines.md — Enveloppe ApiResponse, pagination basée sur un curseur
  • docs/standards/database-guidelines.md — Conception de tables multi-tenant, TIMESTAMPTZ pour les horodatages
  • docs/standards/react-typescript-guidelines.md — Structure des composants, hooks, modèles d'animation
  • docs/standards/offline-sync-guidelines.md — File d'attente IndexedDB, synchronisation FIFO, backoff exponentiel, idempotence via les UUID des clients

Exigences de test

Tests unitaires

  • EventTypeDeterminer : pas d'IN ouvert -> renvoie IN ; IN ouvert -> renvoie OUT ; plusieurs paires par jour
  • DailyAttendanceService : calcul des minutes travaillées sans déduction des pauses (suivi des pauses désactivé)
  • DailyAttendanceService : calcul des minutes travaillées avec suivi des pauses (4 scans)
  • Validation QR : HMAC valide accepté, HMAC invalide rejeté, token expiré rejeté
  • Synchronisation par lots : idempotence (même UUID = pas de doublon créé), validation par événement
  • ClockEventService : rejette les employés licenciés
  • ClockEventService : un scan sur un site non principal positionne hasAnomaly=true et anomalyDetails.type=NON_PRIMARY_SITE_SCAN
  • ClockEventService : un scan sur le site principal ne positionne pas le drapeau « non principal »
  • ClockEventService : un scan en l'absence d'affectation principale est un no-op gracieux (aucun drapeau positionné)

Tests d'intégration

  • POST /clock-events -> 201, événement d'horloge créé, DailyAttendance mis à jour
  • POST /clock-events avec QR invalide -> 400
  • POST /clock-events avec le même UUID deux fois -> idempotent (pas de doublon)
  • POST /clock-events/sync -> traitement par lots avec des résultats mixtes (synchronisé + doublons + échec)
  • GET /clock-events/me -> renvoie les événements de pointage de l'employé pour la plage de dates
  • GET /qr-cache -> renvoie les paires site/token valides pour le tenant
  • Isolation des tenants : les événements d'horloge du tenant A ne sont pas visibles pour le tenant B
  • POST /clock-events sur un site non principal -> la ligne DailyAttendance a hasAnomaly=true avec une entrée NON_PRIMARY_SITE_SCAN dans anomalyDetails

Ce que le contrôle qualité va valider

  • Le scanner QR ouvre l'appareil photo en plein écran sur mobile
  • La lecture d'un QR valide affiche une animation de réussite avec l'heure
  • La lecture d'un QR invalide affiche une erreur avec une animation de secousse
  • La lecture hors ligne enregistre l'événement et affiche la synchronisation en attente
  • La reconnexion en ligne déclenche une synchronisation par lots dans les 30 secondes
  • La page d'accueil de l'employé affiche les événements du jour et les heures travaillées
  • Tous les points de rupture (360px, 768px, 1024px) s'affichent correctement
  • Retour haptique lors de la détection d'un scan
  • Fermeture automatique après 2 secondes en cas de réussite

Hors du périmètre

  • Détection des doublons dans les 15 minutes (QR-007)
  • Rejet des employés licenciés (QR-007)
  • Signalement de départ anticipé (QR-007)
  • Détection des retards (QR-009)
  • Fermeture automatique des lots (QR-010)
  • Détection des anomalies sur les appareils partagés (QR-010)
  • Gestion des scans de pause : les événements BREAK_OUT/BREAK_IN sont enregistrés, mais aucune pause synthétique n'est insérée automatiquement (QR-009)

Définition de « Terminé »

  • [ ] Tous les endpoints backend sont implémentés et renvoient des réponses correctes
  • [ ] Toutes les pages frontend s'affichent correctement à 360px, 768px et 1024px
  • [ ] Les 5 états de l'interface utilisateur sont implémentés (chargement, vide, erreur, hors ligne, succès)
  • [ ] Les micro-textes en français correspondent exactement aux spécifications de conception
  • [ ] Tous les critères d'acceptation sont satisfaits manuellement
  • [ ] Tests unitaires écrits et réussis
  • [ ] Tests d'intégration écrits et réussis
  • [ ] Isolation multi-tenant vérifiée
  • [ ] L'écriture et la synchronisation hors ligne fonctionnent (file d'attente IndexedDB, synchronisation par lots, cache QR)
  • [ ] Pas de any en TypeScript, pas de types bruts en Java
  • [ ] Les réponses API respectent le format d'enveloppe standard
  • [ ] Entrées de piste d'audit pour chaque événement d'horloge