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'idempotenceDailyAttendanceRepository.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
ClockavecsyncTimestamp = 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 queprimary.siteId != event.siteId, positionnerDailyAttendance.hasAnomaly = trueet fusionner dansDailyAttendance.anomalyDetailsune 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éfinirstatus = PRESENT - Au départ : définir
lastClockOut, calculerworkedMinutes - 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 |
iddoit être un UUID valide (généré par le client pour garantir l'idempotence hors ligne)siteIddoit exister, être actif et appartenir au tenantqrTokendoit correspondre au token actuel du site (validation HMAC via QrCodeValidator)timestampdoit être un format ISO 8601 valide avec fuseau horairedeviceIdHashdoit 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 immuablesdocs/standards/api-guidelines.md— Enveloppe ApiResponse, pagination basée sur un curseurdocs/standards/database-guidelines.md— Conception de tables multi-tenant, TIMESTAMPTZ pour les horodatagesdocs/standards/react-typescript-guidelines.md— Structure des composants, hooks, modèles d'animationdocs/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=trueetanomalyDetails.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=trueavec une entrée NON_PRIMARY_SITE_SCAN dansanomalyDetails
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
anyen 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