Aller au contenu

Story AB-005 : Scheduled Accrual Job — Cas limites + Multi-Tenant

Module : absence-management Slice : VS-AB-004 (part 2) from architecture Brand context : [BOTH] Papillon HR Suite + ALTARYS ENTERPRISE HR Suite Priority : 5 Depends on : AB-004 Can develop concurrently with : AB-006, AB-007, AB-008 Merge order : Après AB-004 Estimated complexity : M PRD User Stories : N/A - Technical Story


Objectif

Étendre le job d'accumulation pour gérer les cas limites de production : itération multi-tenant (le scheduler s'exécute pour chaque tenant actif), rattrapage des mois manqués (par exemple après une indisponibilité système), et règles de pause/reprise basées sur le statut de congé de l'employé selon le droit du travail OHADA. Sans cette story, le job d'accumulation ne fonctionne que pour un seul tenant et ne peut pas se remettre d'exécutions manquées.


Périmètre Backend

Entités & Records

Entités utilisées (définies dans AB-002, utilisées dans AB-004) : - LeaveEmployeeProfilelast_accrual_date, annual_balance_ouvrables - LeaveBalanceLedger — insérer des entrées MONTHLY_ACCRUAL - CachedEmployeeDataemployee_status pour les vérifications de pause/reprise

Enums (extension de AB-004) :

// Employee statuses that PAUSE accrual (FR-ABS-017)
public static final Set<String> ACCRUAL_PAUSE_STATUSES = Set.of(
    "UNPAID_LEAVE",          // Conge sans solde
    "UNJUSTIFIED_ABSENCE",   // Absence injustifiee
    "SUSPENDED"              // Suspension disciplinaire
);

// Employee statuses that CONTINUE accrual (FR-ABS-016)
public static final Set<String> ACCRUAL_CONTINUE_STATUSES = Set.of(
    "ACTIVE",
    "SICK",                  // Maladie
    "MATERNITY",             // Conge de maternite
    "PATERNITY",             // Conge de paternite
    "EXCEPTIONAL",           // Permissions exceptionnelles
    "WORK_ACCIDENT"          // Accident du travail
);

Couche Repository

Pas de nouveaux repositories. Utilise les existants : - LeaveEmployeeProfileRepository.findAllByTenantId(UUID tenantId) - CachedEmployeeDataRepository.findByTenantIdAndEmployeeId(UUID tenantId, UUID employeeId) - LeaveBalanceLedgerRepository.save(LeaveBalanceLedger entry)

Requête platform-scoped nécessaire : - Récupérer tous les IDs de tenants actifs — utilise @Qualifier("platformJdbcClient") JdbcClient pour interroger SELECT id FROM tenant WHERE status = 'ACTIVE' depuis la base platform (pas un repository Spring Data — voir docs/standards/multi-datasource-patterns.md)

Couche Service

AccrualScheduler.java — extension de AB-004 :

@Scheduled(cron = "0 0 2 1 * *")  // 1st of each month at 02:00
public void runMonthlyAccrual() {
    List<UUID> tenantIds = getAllActiveTenantIds();  // platform DB query
    for (UUID tenantId : tenantIds) {
        try {
            ScopedValue.where(TENANT_CONTEXT, new TenantContext(tenantId, ...))
                .run(() -> processAccrualForTenant(tenantId));
        } catch (Exception e) {
            log.error("Accrual failed for tenant {}: {}", tenantId, e.getMessage());
            // Continue processing other tenants — do NOT abort the entire job
        }
    }
    log.info("Monthly accrual completed for {} tenants", tenantIds.size());
}
  • processAccrualForTenant(UUID tenantId) :
  • Trouver tous les LeaveEmployeeProfile pour le tenant
  • Pour chaque profil, consulter CachedEmployeeData pour vérifier employee_status
  • Si le statut est dans ACCRUAL_PAUSE_STATUSES → ignorer, logger la raison
  • Si le statut est dans ACCRUAL_CONTINUE_STATUSES → appeler accrualService.processMonthlyAccrual(profile)
  • Si le statut est inconnu → logger un warning, ignorer (sécurité par défaut)

AccrualService.java — extension de AB-004 :

  • processMonthlyAccrualWithCatchUp(LeaveEmployeeProfile profile) :
  • Calculer les mois manqués : différence entre profile.lastAccrualDate et le mois en cours
  • Si 0 mois manqué → déjà traité, ignorer
  • Si 1 mois manqué → traitement normal (accumulation simple)
  • Si 2+ mois manqués → traiter chaque mois manqué séquentiellement :
    • Pour chaque mois manqué, créer une entrée de ledger MONTHLY_ACCRUAL avec reason = "Acquisition mensuelle - {month}/{year} (rattrapage)"
    • Mettre à jour annualBalanceOuvrables de manière incrémentale par mois
    • Définir lastAccrualDate au dernier jour de chaque mois traité
  • Log : "Catch-up: processed {} missed months for employee {} tenant {}", missedCount, employeeId, tenantId

Endpoints API

N/A — Extension du job planifié, pas d'endpoints API.

Règles de validation

  • Tous les calculs BigDecimal utilisent RoundingMode.HALF_UP, scale(4)
  • Le rattrapage ne doit pas excéder 12 mois (limite de sécurité) — si plus de 12 mois manqués, logger une erreur et ignorer l'employé (nécessite une investigation manuelle)
  • Les échecs d'un tenant ne doivent pas interrompre l'ensemble du job — chaque tenant est traité indépendamment
  • Un statut employé inconnu doit être traité comme un skip (sécurité par défaut), pas comme une continuation

Considérations multi-tenant

  • Le scheduler itère sur TOUS les tenants actifs depuis la base platform
  • L'accumulation de chaque tenant s'exécute dans un scope ScopedValue<TenantContext>
  • Isolation tenant : les employés de chaque tenant sont traités indépendamment ; un échec sur un tenant n'affecte pas les autres
  • L'accès à la base platform utilise @Qualifier("platformJdbcClient") JdbcClient (pas un CrudRepository — voir docs/standards/multi-datasource-patterns.md)
  • Pour les profils DB-per-tenant et BYO-DB, le TenantDataSourceRouter résout la bonne connexion de base de données par tenant

Périmètre Frontend

N/A — Story d'infrastructure backend.

Pages & Routes

N/A

Composants

N/A

États UI (TOUS REQUIS)

N/A — Backend uniquement.

Comportement responsive

N/A

Interactions

N/A


Critères d'acceptation

AC-014: Catch-up for missed months
Given an employee's last_accrual_date is 2026-01-31 and today is 2026-04-01
When the accrual job runs
Then 3 MONTHLY_ACCRUAL entries are created (Feb, Mar, Apr) and last_accrual_date is updated to 2026-04-30

AC-015: Job runs in TenantContext for each tenant
Given there are 3 active tenants
When the accrual job runs
Then each tenant's employees are processed within that tenant's TenantContext (ScopedValue)

AC-016: Accrual skipped for paused employees
Given an employee has status = UNPAID_LEAVE (conge sans solde)
When the accrual job processes this employee
Then no MONTHLY_ACCRUAL entry is created and a log entry records the skip reason

Règles OHADA & réglementaires

  • FR-ABS-016 (CT Art. 25.4, CCI Art. 25.8) : L'accumulation CONTINUE pendant les absences suivantes — ces périodes comptent comme travail effectif pour l'accumulation des congés :
  • Congé maladie (conge de maladie) — jusqu'à 6 mois par période glissante de 12 mois
  • Congé maternité (conge de maternite) — 14 semaines
  • Congé paternité (conge de paternite) — 3 jours
  • Permissions exceptionnelles (congé exceptionnel pour événements familiaux)
  • Congé accident du travail (accident du travail)

  • FR-ABS-017 (CT Art. 25.5) : L'accumulation est en PAUSE pendant les absences suivantes — ces périodes NE comptent PAS comme travail effectif :

  • Congé sans solde (unpaid leave) — absence volontaire sans rémunération
  • Absence injustifiée (unjustified absence) — absence non approuvée
  • Suspension disciplinaire (disciplinary suspension) — suspension imposée par l'employeur

Standards & conventions

  • docs/standards/java-spring-guidelines.md — §Scheduled jobs, §ScopedValue pattern, §Transaction boundaries, §Error handling in batch jobs
  • docs/standards/database-guidelines.md — §BigDecimal for financial calculations
  • docs/standards/multi-datasource-patterns.md — §platformJdbcClient for platform DB access, §Never use CrudRepository for platform tables
  • docs/standards/api-guidelines.md — §System user ID convention

Exigences de test

Tests unitaires

  • AccrualService.processMonthlyAccrualWithCatchUp() : 3 mois manqués → 3 entrées de ledger créées, solde incrémenté de 6,6
  • AccrualService.processMonthlyAccrualWithCatchUp() : 0 mois manqué → pas d'entrées (déjà traité)
  • AccrualService.processMonthlyAccrualWithCatchUp() : 13 mois manqués → erreur loggée, employé ignoré (limite de sécurité)
  • AccrualScheduler.processAccrualForTenant() : employé avec statut UNPAID_LEAVE → ignoré, raison loggée
  • AccrualScheduler.processAccrualForTenant() : employé avec statut SICK → accumulation continue normalement
  • AccrualScheduler.processAccrualForTenant() : employé avec statut MATERNITY → accumulation continue normalement
  • AccrualScheduler.processAccrualForTenant() : employé avec statut inconnu → ignoré (sécurité par défaut)

Tests d'intégration

  • Multi-tenant : créer 2 tenants avec des employés → exécuter l'accumulation → vérifier que les employés de chaque tenant sont traités indépendamment
  • Rattrapage : définir last_accrual_date 3 mois en arrière → exécuter l'accumulation → vérifier 3 entrées en base avec les dates correctes
  • Isolation des échecs tenant : le tenant A a des données invalides causant une exception, le tenant B est valide → vérifier que le tenant B est quand même traité
  • Statut pause : employé avec UNPAID_LEAVE → vérifier qu'aucune entrée de ledger n'est en base

Ce que la QA validera

  • N/A — Pas de fonctionnalité visible par l'utilisateur. Le développeur valide via les tests d'intégration. L'exactitude de l'accumulation sera vérifiée dans les stories d'affichage des soldes.

Hors périmètre

  • Calcul du bonus d'ancienneté (FR-ABS-010 à FR-ABS-012) — ce sont des opérations en limite de période de référence, pas de l'accumulation mensuelle. Story séparée.
  • Bonus enfants (FR-ABS-013 à FR-ABS-015) — opération en limite de période de référence. Story séparée.
  • Traitement du report (FR-ABS-022) — déclenché en limite de période de référence, pas mensuellement. Story séparée.
  • Configuration du taux d'accumulation (permettre aux tenants de surcharger le taux CCI de 2,2) — V2
  • Mécanisme de retry pour le traitement échoué des tenants — V2 (comportement actuel : logger l'erreur, ignorer le tenant, continuer)

Définition de Done

  • [ ] Tous les endpoints backend implémentés et retournant les réponses correctes — N/A (job planifié, pas d'endpoints)
  • [ ] Toutes les pages frontend s'affichent correctement à 360px, 768px, 1024px — N/A (backend uniquement)
  • [ ] Les 5 états UI implémentés (loading, empty, error, offline, success) — N/A
  • [ ] Le micro-copy français correspond exactement à la spec de design — N/A
  • [ ] Tous les critères d'acceptation passent manuellement
  • [ ] Tests unitaires écrits et passants
  • [ ] Tests d'intégration écrits et passants
  • [ ] Isolation multi-tenant vérifiée — Chaque tenant traité dans son propre TenantContext, échecs isolés
  • [ ] L'écriture + synchronisation offline fonctionne (Papillon) / N/A (Enterprise) — N/A (backend uniquement)
  • [ ] Pas de TypeScript any — N/A
  • [ ] Pas de Java raw types — tous les generics explicites
  • [ ] Les réponses API suivent le format d'enveloppe standard — N/A (pas d'endpoints)
  • [ ] Entrées de piste d'audit pour chaque mutation de données — Les entrées de ledger servent de piste d'audit ; les employés ignorés sont loggés avec la raison