Cas QR-004b : Initialisation du module via ModuleActivated + tables de référence pays¶
@assignee: @angeisaac-ui
Module : qr-attendance.
Segment : QR-T0 (Tier 0 — doit merger en premier).
Contexte de la marque : [LES DEUX] Papillon HR Suite + ALTARYS ENTERPRISE HR Suite.
Priorité : 5 (fondation, bloquante pour QR-004 refactorisée).
Dépend de : QR-001 (entités AttendanceModuleConfig + OvertimeConfig), CP-001bis (tables de référence country_attendance_config_defaults + country_overtime_band_defaults dans Platform DB), CP-007 (publie ModuleActivated).
Peut être développé en parallèle avec : QR-002, QR-003, QR-005 (après CP-007 merges).
Ordre de fusion : Avant QR-004 (QR-004 refactorisée dépend de ce story pour garantir l'existence de la configuration).
Complexité estimée : M.
PRD User Stories : N/A — Story technique (fondation module initialization pattern — ADR-26).
Objectif¶
Implémenter le pattern d'initialisation de module formalisé par ADR-26 :
écouter l'événement ModuleActivated via @ApplicationModuleListener, lire les tables de référence plateforme
(country_attendance_config_defaults, country_overtime_band_defaults) et initialiser les tables tenant
(AttendanceModuleConfig, OvertimeConfig) en copiant les valeurs pour le pays du tenant.
La logique d'initialisation est encapsulée dans des classes dédiées (AttendanceConfigInitializer,
OvertimeBandsInitializer) injectées dans un listener ultra-fin.
Cela élimine le lazy-init anti-pattern, garantit l'état de configuration dès l'activation, et
est extensible sans modifier le listener (Open/Closed Principle).
Portée du backend¶
Migrations Flyway¶
⚠️ Aucune migration Platform DB dans ce story. Les tables
country_attendance_config_defaults(V006) etcountry_overtime_band_defaults(V007) sont créées et seedées par CP-001bis (story Control Plane). Ce story dépend de CP-001bis — ces tables doivent exister avant que le listener s'exécute.
Migrations tenant DB : aucune dans ce story (les tables tenant attendance_module_config et attendance_overtime_config sont créées par QR-001).
Entités et records¶
CountryAttendanceConfigDefaults (Platform DB, record de lecture uniquement) :
public record CountryAttendanceConfigDefaults(
String countryCode,
int defaultLateToleranceMinutes,
short overtimeWeekStartDay,
int minRemoteWorkAdvanceDays,
Integer maxRemoteWorkPerWeek,
Integer maxRemoteWorkPerMonth,
LocalTime autoCloseTime
) {}
CountryOvertimeBandDefault (Platform DB, record de lecture uniquement) :
public record CountryOvertimeBandDefault(
UUID id,
String countryCode,
String bandCode,
BigDecimal weeklyThresholdStart,
BigDecimal weeklyThresholdEnd,
LocalTime nightStartTime,
LocalTime nightEndTime
) {}
ModuleNotInitializedException (exception métier du module) :
public class ModuleNotInitializedException extends RuntimeException {
public ModuleNotInitializedException(String message) {
super(message);
}
}
Classes d'initialisation¶
Package : com.altarys.papillon.modules.attendance.config
Interface AttendanceModuleInitializer :
/**
* SPI for attendance module initialization steps executed on ModuleActivated.
* Each implementation handles one specific table or concern.
* Implementations MUST be idempotent: check-then-insert.
* See ADR-26.
*/
public interface AttendanceModuleInitializer {
void initialize(UUID tenantId, String countryCode);
}
AttendanceConfigInitializer — initialise AttendanceModuleConfig :
@Component
@Order(1)
@Slf4j
public class AttendanceConfigInitializer implements AttendanceModuleInitializer {
private final JdbcClient platformJdbcClient;
private final JdbcClient tenantJdbcClient;
public AttendanceConfigInitializer(
@Qualifier("platformJdbcClient") JdbcClient platformJdbcClient,
@Qualifier("tenantJdbcClient") JdbcClient tenantJdbcClient) {
this.platformJdbcClient = platformJdbcClient;
this.tenantJdbcClient = tenantJdbcClient;
}
@Override
public void initialize(UUID tenantId, String countryCode) {
// Idempotent: skip if already exists
boolean alreadyInitialized = tenantJdbcClient
.sql("SELECT COUNT(1) FROM attendance_module_config WHERE tenant_id = :tenantId")
.param("tenantId", tenantId)
.query(Integer.class)
.single() > 0;
if (alreadyInitialized) {
log.debug("AttendanceModuleConfig already exists for tenant {} — skipping (idempotent)", tenantId);
return;
}
// Load country defaults from platform DB
CountryAttendanceConfigDefaults defaults = platformJdbcClient
.sql("SELECT * FROM country_attendance_config_defaults WHERE country_code = :countryCode")
.param("countryCode", countryCode)
.query(CountryAttendanceConfigDefaults.class)
.optional()
.orElseThrow(() -> new IllegalStateException(
"No attendance config defaults found for country: " + countryCode
+ ". Seed country_attendance_config_defaults before enabling this country. See ADR-26."
));
// Seed AttendanceModuleConfig with country defaults + fresh HMAC secret
tenantJdbcClient
.sql("""
INSERT INTO attendance_module_config
(id, tenant_id, qr_hmac_secret,
default_late_tolerance_minutes, overtime_week_start_day,
min_remote_work_advance_days, max_remote_work_per_week,
max_remote_work_per_month, auto_close_time,
delegues_consultation_done, created_at, updated_at)
VALUES
(:id, :tenantId, :secret,
:tolerance, :weekStart,
:minAdvance, :maxWeek,
:maxMonth, :closeTime,
false, :now, :now)
""")
.param("id", UUID.randomUUID())
.param("tenantId", tenantId)
.param("secret", generateHmacSecret())
.param("tolerance", defaults.defaultLateToleranceMinutes())
.param("weekStart", defaults.overtimeWeekStartDay())
.param("minAdvance", defaults.minRemoteWorkAdvanceDays())
.param("maxWeek", defaults.maxRemoteWorkPerWeek())
.param("maxMonth", defaults.maxRemoteWorkPerMonth())
.param("closeTime", defaults.autoCloseTime())
.param("now", OffsetDateTime.now())
.update();
log.info("AttendanceModuleConfig initialized for tenant {} with '{}' country defaults", tenantId, countryCode);
}
private String generateHmacSecret() {
SecureRandom random = new SecureRandom();
byte[] bytes = new byte[32]; // 256 bits
random.nextBytes(bytes);
return HexFormat.of().formatHex(bytes);
}
}
OvertimeBandsInitializer — initialise les tranches depuis country_overtime_band_defaults :
@Component
@Order(2)
@Slf4j
public class OvertimeBandsInitializer implements AttendanceModuleInitializer {
private final JdbcClient platformJdbcClient;
private final JdbcClient tenantJdbcClient;
public OvertimeBandsInitializer(
@Qualifier("platformJdbcClient") JdbcClient platformJdbcClient,
@Qualifier("tenantJdbcClient") JdbcClient tenantJdbcClient) {
this.platformJdbcClient = platformJdbcClient;
this.tenantJdbcClient = tenantJdbcClient;
}
@Override
public void initialize(UUID tenantId, String countryCode) {
// Idempotent: skip if bands already exist for this tenant
boolean alreadyInitialized = tenantJdbcClient
.sql("SELECT COUNT(1) FROM attendance_overtime_config WHERE tenant_id = :tenantId")
.param("tenantId", tenantId)
.query(Integer.class)
.single() > 0;
if (alreadyInitialized) {
log.debug("OvertimeConfig bands already exist for tenant {} — skipping (idempotent)", tenantId);
return;
}
// Load reference bands from platform DB for this country
List<CountryOvertimeBandDefault> referenceBands = platformJdbcClient
.sql("SELECT * FROM country_overtime_band_defaults WHERE country_code = :countryCode ORDER BY band_code")
.param("countryCode", countryCode)
.query(CountryOvertimeBandDefault.class)
.list();
if (referenceBands.isEmpty()) {
throw new IllegalStateException(
"No overtime band defaults found for country: " + countryCode
+ ". Seed country_overtime_band_defaults before enabling this country. See ADR-26."
);
}
// Copy reference bands into tenant DB
OffsetDateTime now = OffsetDateTime.now();
referenceBands.forEach(band ->
tenantJdbcClient
.sql("""
INSERT INTO attendance_overtime_config
(id, tenant_id, band_code,
weekly_threshold_start, weekly_threshold_end,
night_start_time, night_end_time,
country_code, created_at, updated_at)
VALUES
(:id, :tenantId, :bandCode,
:thresholdStart, :thresholdEnd,
:nightStart, :nightEnd,
:countryCode, :now, :now)
""")
.param("id", UUID.randomUUID())
.param("tenantId", tenantId)
.param("bandCode", band.bandCode())
.param("thresholdStart", band.weeklyThresholdStart())
.param("thresholdEnd", band.weeklyThresholdEnd())
.param("nightStart", band.nightStartTime())
.param("nightEnd", band.nightEndTime())
.param("countryCode", countryCode)
.param("now", now)
.update()
);
log.info("OvertimeConfig: {} bands seeded for tenant {} (country: {})",
referenceBands.size(), tenantId, countryCode);
}
}
AttendanceModuleActivationListener — listener ultra-fin :
@Component
@Slf4j
public class AttendanceModuleActivationListener {
private static final String MODULE_CODE = "QRCONTR";
private final List<AttendanceModuleInitializer> initializers;
public AttendanceModuleActivationListener(List<AttendanceModuleInitializer> initializers) {
this.initializers = initializers;
}
/**
* Consumes ModuleActivated event for QRCONTR module.
* Delegates all initialization to injected AttendanceModuleInitializer implementations.
* Idempotent: each initializer performs its own check-then-insert.
* See ADR-26.
*/
@ApplicationModuleListener
public void onModuleActivated(ModuleActivated event) {
if (!MODULE_CODE.equals(event.moduleCode())) {
return;
}
log.info("Initializing QRCONTR module for tenant {} (country: {})",
event.tenantId(), event.countryCode());
initializers.forEach(initializer ->
initializer.initialize(event.tenantId(), event.countryCode())
);
log.info("QRCONTR module initialization complete for tenant {}", event.tenantId());
}
}
Couche de service¶
ConfigService.java — getModuleConfig() refactorisée (suppression lazy-init) :
public ModuleConfigResponse getModuleConfig(UUID tenantId) {
return tenantJdbcClient
.sql("SELECT * FROM attendance_module_config WHERE tenant_id = :tenantId")
.param("tenantId", tenantId)
.query(AttendanceModuleConfig.class)
.optional()
.map(config -> new ModuleConfigResponse(
config.id(),
config.defaultLateToleranceMinutes(),
config.overtimeWeekStartDay(),
config.minRemoteWorkAdvanceDays(),
config.maxRemoteWorkPerWeek(),
config.maxRemoteWorkPerMonth(),
config.autoCloseTime(),
config.deleguesConsultationDone(),
config.deleguesConsultationDate(),
config.deleguesPvDocumentId()
))
.orElseThrow(() -> new ModuleNotInitializedException(
"AttendanceModuleConfig not found for tenant " + tenantId
+ ". Module must be activated first via CP-007. "
+ "If this error occurs in production, verify AttendanceModuleActivationListener "
+ "processed ModuleActivated(moduleCode=QRCONTR) successfully."
));
}
Composants¶
Aucun frontend dans ce story — infrastructure purement backend.
Critères d'acceptation¶
AC-001: Module activation triggers listener and all initializers
Given CP-007 publishes ModuleActivated event for QRCONTR
And tenant's countryCode = "CI"
When the listener receives the event
Then AttendanceConfigInitializer creates AttendanceModuleConfig with CI defaults
And OvertimeBandsInitializer seeds 5 OvertimeConfig bands from country_overtime_band_defaults
And HMAC secret is generated (256-bit hex, 64 chars)
AC-002: Idempotency — activating twice is safe
Given module is already activated (AttendanceModuleConfig and OvertimeConfig bands exist)
When ModuleActivated event is published again
Then each initializer detects existing data and exits gracefully (no exception, no duplicate)
And all data remains unchanged
AC-003: Missing attendance config defaults — fail loudly
Given countryCode = "BJ" but BJ not in country_attendance_config_defaults
When ModuleActivated event is published
Then AttendanceConfigInitializer throws IllegalStateException("No attendance config defaults found for country: BJ...")
And activation fails with clear actionable message for ops team
AC-004: Missing overtime band defaults — fail loudly
Given countryCode = "BJ" but BJ not in country_overtime_band_defaults
When ModuleActivated event is published
Then OvertimeBandsInitializer throws IllegalStateException("No overtime band defaults found for country: BJ...")
And activation fails with clear actionable message for ops team
AC-005: getModuleConfig() guarantees state post-activation
Given module is activated (all initializers completed successfully)
When ConfigService.getModuleConfig(tenantId) is called
Then it returns the seeded configuration without any lazy creation
AC-006: getModuleConfig() throws when not activated
Given module has NOT been activated
When ConfigService.getModuleConfig(tenantId) is called
Then it throws ModuleNotInitializedException with actionable message including debugging info
AC-007: Works with all 4 DB profiles
Given tenant uses any of Shared / Schema-per-tenant / DB-per-tenant / BYO-DB
Then all initializers execute identically across profiles
And tenant isolation is enforced in all tenant DB writes
Règles de validation¶
countryCodedans l'événementModuleActivateddoit correspondre à une ligne dans CHAQUE table de référence pays (sinon exception dans l'initializer concerné)- HMAC secret : 256-bit hex-encoded = exactement 64 caractères
AttendanceModuleConfig: UNIQUE surtenant_id— une seule par tenantOvertimeConfigbands : UNIQUE sur(tenant_id, band_code, country_code)- Chaque initializer est idempotent : vérifie l'existence avant d'insérer
@ApplicationModuleListener: exécution asynchrone dans une nouvelle transaction (Spring Modulith idiomatique)@Ordersur les implémentations :AttendanceConfigInitializer= 1,OvertimeBandsInitializer= 2 — garantit un ordre d'exécution déterministe. Toute future implémentation DOIT déclarer son@Order.
Considérations relatives au multi-tenant¶
- Platform DB lue via
@Qualifier("platformJdbcClient")— pas de TenantContext (données partagées) - Tenant DB écrite via
@Qualifier("tenantJdbcClient")— TenantContext propagé par@ApplicationModuleListener - Respecte ADR-26, ADR-03 (Platform DB + Tenant DB separation), ADR-09 (Dual DataSource wiring)
- Le listener est dans le module
attendance: il possède la donnée qu'il initialise (boundary Spring Modulith respectée)
Exigences de test¶
Tests unitaires¶
AttendanceConfigInitializerTest : - Tenant sans config existante → INSERT exécuté avec valeurs CI correctes - Tenant avec config existante → aucun INSERT (idempotent) - Pays inexistant (platformJdbcClient retourne empty) → IllegalStateException
OvertimeBandsInitializerTest : - Tenant sans bands → 5 INSERT exécutés avec seuils corrects (lus depuis la table, pas hardcodés) - Tenant avec bands existants → aucun INSERT (idempotent) - Pays inexistant (liste vide) → IllegalStateException
HMACGenerationTest : - Sortie est 64 chars hex-encoded - Deux appels produisent des valeurs différentes (aléatoire)
Tests d'intégration¶
AttendanceModuleActivationListenerIT :
- Publication ModuleActivated(QRCONTR, CI) → AttendanceModuleConfig créée + 5 bands créées
- Publication x2 (idempotence) → exactement 1 config + 5 bands (pas de doublons)
- Pays inconnu "XX" → listener lève exception, aucune donnée partielle persistée
- getModuleConfig() avant activation → ModuleNotInitializedException
- getModuleConfig() après activation → retourne config correcte
- Isolation tenant : tenant A ne voit pas config de tenant B
- Tous les 4 DB profiles via TestContainers + @SpringBootTest
Ce que le QA/REVIEWER validera¶
@ApplicationModuleListenerutilisé correctement (pas@TransactionalEventListener)- Transactions ACID : si un initializer échoue, toute l'activation échoue (rollback)
- Logs DEBUG/INFO/ERROR présents et traçables par
tenantId - Aucune donnée hardcodée dans les classes Java (tout vient des tables de référence)
- Error messages actionnables pour l'équipe ops
Hors du périmètre¶
- Module activation UI (CP-021)
- Module activation API endpoint (CP-007)
- Configuration éditable HR (QR-004)
- Refactoring de tous les événements du projet pour drop suffixe "Event" (OQ-18, PR séparée)
Définition de « Terminé »¶
- [ ] CP-001bis mergée (tables
country_attendance_config_defaults+country_overtime_band_defaultsprésentes en Platform DB) - [ ] Interface
AttendanceModuleInitializerdéfinie - [ ]
AttendanceConfigInitializerimplémentée et testée (idempotente, exception si pays inconnu) - [ ]
OvertimeBandsInitializerimplémentée et testée (lit depuis table de référence, pas de hardcode) - [ ]
AttendanceModuleActivationListenerultra-fin (délègue aux initializers,@ApplicationModuleListener) - [ ]
ModuleNotInitializedExceptiondéfinie - [ ]
ConfigService.getModuleConfig()refactorisée (pas de lazy-init) - [ ] Tests unitaires verts (par initializer)
- [ ] Tests d'intégration verts (activation, idempotence, pays inconnu, all 4 profiles)
- [ ] Isolation multi-tenant vérifiée
- [ ] Zéro donnée hardcodée dans les classes Java
- [ ] Logs suffisants pour troubleshooting ops (tenantId présent dans tous les logs)
- [ ] JavaDoc sur interface et listener
- [ ] Formellement prêt pour QR-004 refactorisée