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) :
- LeaveEmployeeProfile — last_accrual_date, annual_balance_ouvrables
- LeaveBalanceLedger — insérer des entrées MONTHLY_ACCRUAL
- CachedEmployeeData — employee_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
LeaveEmployeeProfilepour le tenant - Pour chaque profil, consulter
CachedEmployeeDatapour vérifieremployee_status - Si le statut est dans
ACCRUAL_PAUSE_STATUSES→ ignorer, logger la raison - Si le statut est dans
ACCRUAL_CONTINUE_STATUSES→ appeleraccrualService.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.lastAccrualDateet 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_ACCRUALavecreason = "Acquisition mensuelle - {month}/{year} (rattrapage)" - Mettre à jour
annualBalanceOuvrablesde manière incrémentale par mois - Définir
lastAccrualDateau dernier jour de chaque mois traité
- Pour chaque mois manqué, créer une entrée de ledger
- 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 — voirdocs/standards/multi-datasource-patterns.md) - Pour les profils DB-per-tenant et BYO-DB, le
TenantDataSourceRouterré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 jobsdocs/standards/database-guidelines.md— §BigDecimal for financial calculationsdocs/standards/multi-datasource-patterns.md— §platformJdbcClient for platform DB access, §Never use CrudRepository for platform tablesdocs/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,6AccrualService.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éeAccrualScheduler.processAccrualForTenant(): employé avec statut SICK → accumulation continue normalementAccrualScheduler.processAccrualForTenant(): employé avec statut MATERNITY → accumulation continue normalementAccrualScheduler.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_date3 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