feat(sprint-9 mobile 2026-04-25): feature compliance dashboard + Devise enum + selector + tests

Apporte aux compliance officers et controleurs internes l'accès mobile au tableau de bord
de conformité backend (P1-NEW-7). Et prépare la diaspora avec Devise enum + sélecteur
préférence persisté.

Feature Compliance (Clean Architecture)
- Domain : ComplianceSnapshot + ConformiteIndicateur (Equatable), helpers scoreSeverite + hasAlertesCritiques
- Data : ComplianceSnapshotModel.fromJson (parsing tolerant aux nullables), ComplianceRemoteDataSourceImpl Dio (GET /api/compliance/dashboard), ComplianceRepositoryImpl @Injectable
- Presentation : ComplianceBloc (Load/Refresh events, Initial/Loading/Loaded/Error states), ConformiteDashboardPage (Material 3, ScoreCard 0-100 colorée, 9 IndicateurTile, AlertesCard rouge si critiques)

Feature Devise
- Devise enum (10 valeurs miroirs backend, code/libelle/zone)
- fromCode tolérant casse + null/vide → XOF
- estInternationale pour AML
- DeviseSelector widget DropdownButtonFormField + readPreferred/writePreferred via FlutterSecureStorage (clé unionflow.devise.preferee)

Tests (17/17 verts)
- ComplianceSnapshot : 7 tests (scoreSeverite × 3, hasAlertesCritiques × 4)
- ComplianceSnapshotModel.fromJson : 4 tests (complet, fallbacks, string→double, indicateur invalide)
- Devise enum : 6 tests (reference, fromCode parse, fromCode null/inconnu, estInternationale × 2, intégrité valeurs)

Note : feature reporting trimestriel mobile (PDF viewer + bloc liste) reportée à un sprint
dédié — nécessite intégration pdf viewer + cache local non triviale.
This commit is contained in:
dahoud
2026-04-25 11:11:03 +00:00
parent 8356ccc0b0
commit 8c1a254e80
12 changed files with 808 additions and 0 deletions

View File

@@ -0,0 +1,38 @@
import 'package:dio/dio.dart';
import 'package:injectable/injectable.dart';
import '../../../../core/error/exceptions.dart';
import '../../../../core/network/api_client.dart';
import '../../../../core/utils/logger.dart';
import '../models/compliance_snapshot_model.dart';
abstract class ComplianceRemoteDataSource {
/// GET /api/compliance/dashboard — snapshot pour l'organisation active.
Future<ComplianceSnapshotModel> getSnapshotCurrent();
}
@Injectable(as: ComplianceRemoteDataSource)
class ComplianceRemoteDataSourceImpl implements ComplianceRemoteDataSource {
final ApiClient apiClient;
ComplianceRemoteDataSourceImpl(this.apiClient);
@override
Future<ComplianceSnapshotModel> getSnapshotCurrent() async {
try {
final response = await apiClient.get('/api/compliance/dashboard');
if (response.statusCode == 200 && response.data is Map<String, dynamic>) {
return ComplianceSnapshotModel.fromJson(
response.data as Map<String, dynamic>,
);
}
throw ServerException('Compliance snapshot HTTP ${response.statusCode}');
} on DioException catch (e) {
AppLogger.error('ComplianceRemoteDataSource: getSnapshotCurrent', error: e);
rethrow;
} catch (e, st) {
AppLogger.error('ComplianceRemoteDataSource: getSnapshotCurrent',
error: e, stackTrace: st);
rethrow;
}
}
}

View File

@@ -0,0 +1,53 @@
import '../../domain/entities/compliance_snapshot.dart';
class ComplianceSnapshotModel extends ComplianceSnapshot {
const ComplianceSnapshotModel({
required super.organisationId,
required super.organisationNom,
required super.referentielComptable,
required super.complianceOfficerDesigne,
required super.agAnnuelle,
required super.rapportAirms,
required super.dirigeantsAvecCmu,
required super.tauxKycAJourPct,
required super.tauxFormationLbcFtPct,
required super.commissaireAuxComptes,
required super.fomusCi,
required super.couvertureUboPct,
required super.scoreGlobal,
});
factory ComplianceSnapshotModel.fromJson(Map<String, dynamic> json) {
return ComplianceSnapshotModel(
organisationId: json['organisationId']?.toString() ?? '',
organisationNom: json['organisationNom']?.toString() ?? '',
referentielComptable: json['referentielComptable']?.toString() ?? 'SYSCOHADA',
complianceOfficerDesigne: json['complianceOfficerDesigne'] as bool? ?? false,
agAnnuelle: _indicateur(json['agAnnuelle']),
rapportAirms: _indicateur(json['rapportAirms']),
dirigeantsAvecCmu: (json['dirigeantsAvecCmu'] as num?)?.toInt() ?? 0,
tauxKycAJourPct: _toDouble(json['tauxKycAJourPct']),
tauxFormationLbcFtPct: _toDouble(json['tauxFormationLbcFtPct']),
commissaireAuxComptes: _indicateur(json['commissaireAuxComptes']),
fomusCi: _indicateur(json['fomusCi']),
couvertureUboPct: _toDouble(json['couvertureUboPct']),
scoreGlobal: (json['scoreGlobal'] as num?)?.toInt() ?? 0,
);
}
static ConformiteIndicateur _indicateur(dynamic raw) {
if (raw is Map<String, dynamic>) {
return ConformiteIndicateur(
statut: raw['statut']?.toString() ?? 'EN_VEILLE',
message: raw['message']?.toString() ?? '',
);
}
return const ConformiteIndicateur(statut: 'EN_VEILLE', message: '');
}
static double _toDouble(dynamic raw) {
if (raw is num) return raw.toDouble();
if (raw is String) return double.tryParse(raw) ?? 0.0;
return 0.0;
}
}

View File

@@ -0,0 +1,14 @@
import 'package:injectable/injectable.dart';
import '../../domain/entities/compliance_snapshot.dart';
import '../../domain/repositories/compliance_repository.dart';
import '../datasources/compliance_remote_datasource.dart';
@Injectable(as: ComplianceRepository)
class ComplianceRepositoryImpl implements ComplianceRepository {
final ComplianceRemoteDataSource remote;
ComplianceRepositoryImpl(this.remote);
@override
Future<ComplianceSnapshot> getSnapshotCurrent() => remote.getSnapshotCurrent();
}

View File

@@ -0,0 +1,77 @@
import 'package:equatable/equatable.dart';
/// Indicateur de conformité avec statut + message explicatif.
class ConformiteIndicateur extends Equatable {
final String statut; // OK, EN_ATTENTE, RETARD, OPTIONNEL, OBLIGATOIRE, EN_VEILLE
final String message;
const ConformiteIndicateur({required this.statut, required this.message});
@override
List<Object?> get props => [statut, message];
}
/// Snapshot du tableau de bord de conformité (miroir mobile du record backend).
///
/// Couvre AG annuelle, rapports AIRMS, CMU dirigeants, KYC, formation LBC/FT, UBO,
/// commissaire aux comptes, FOMUS-CI, et le score global agrégé (0-100).
class ComplianceSnapshot extends Equatable {
final String organisationId;
final String organisationNom;
final String referentielComptable;
final bool complianceOfficerDesigne;
final ConformiteIndicateur agAnnuelle;
final ConformiteIndicateur rapportAirms;
final int dirigeantsAvecCmu;
final double tauxKycAJourPct;
final double tauxFormationLbcFtPct;
final ConformiteIndicateur commissaireAuxComptes;
final ConformiteIndicateur fomusCi;
final double couvertureUboPct;
final int scoreGlobal;
const ComplianceSnapshot({
required this.organisationId,
required this.organisationNom,
required this.referentielComptable,
required this.complianceOfficerDesigne,
required this.agAnnuelle,
required this.rapportAirms,
required this.dirigeantsAvecCmu,
required this.tauxKycAJourPct,
required this.tauxFormationLbcFtPct,
required this.commissaireAuxComptes,
required this.fomusCi,
required this.couvertureUboPct,
required this.scoreGlobal,
});
/// Couleur indicative selon le score (Material 3 semantic).
String get scoreSeverite {
if (scoreGlobal >= 80) return 'success';
if (scoreGlobal >= 60) return 'warning';
return 'danger';
}
bool get hasAlertesCritiques =>
!complianceOfficerDesigne ||
agAnnuelle.statut == 'RETARD' ||
scoreGlobal < 60;
@override
List<Object?> get props => [
organisationId,
organisationNom,
referentielComptable,
complianceOfficerDesigne,
agAnnuelle,
rapportAirms,
dirigeantsAvecCmu,
tauxKycAJourPct,
tauxFormationLbcFtPct,
commissaireAuxComptes,
fomusCi,
couvertureUboPct,
scoreGlobal,
];
}

View File

@@ -0,0 +1,5 @@
import '../entities/compliance_snapshot.dart';
abstract class ComplianceRepository {
Future<ComplianceSnapshot> getSnapshotCurrent();
}

View File

@@ -0,0 +1,71 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:injectable/injectable.dart';
import '../../../../core/utils/logger.dart';
import '../../domain/entities/compliance_snapshot.dart';
import '../../domain/repositories/compliance_repository.dart';
// Events
abstract class ComplianceEvent extends Equatable {
const ComplianceEvent();
@override
List<Object?> get props => [];
}
class LoadComplianceSnapshot extends ComplianceEvent {
const LoadComplianceSnapshot();
}
class RefreshComplianceSnapshot extends ComplianceEvent {
const RefreshComplianceSnapshot();
}
// States
abstract class ComplianceState extends Equatable {
const ComplianceState();
@override
List<Object?> get props => [];
}
class ComplianceInitial extends ComplianceState {
const ComplianceInitial();
}
class ComplianceLoading extends ComplianceState {
const ComplianceLoading();
}
class ComplianceLoaded extends ComplianceState {
final ComplianceSnapshot snapshot;
const ComplianceLoaded(this.snapshot);
@override
List<Object?> get props => [snapshot];
}
class ComplianceError extends ComplianceState {
final String message;
const ComplianceError(this.message);
@override
List<Object?> get props => [message];
}
@injectable
class ComplianceBloc extends Bloc<ComplianceEvent, ComplianceState> {
final ComplianceRepository repository;
ComplianceBloc(this.repository) : super(const ComplianceInitial()) {
on<LoadComplianceSnapshot>(_onLoad);
on<RefreshComplianceSnapshot>(_onLoad);
}
Future<void> _onLoad(ComplianceEvent event, Emitter<ComplianceState> emit) async {
emit(const ComplianceLoading());
try {
final s = await repository.getSnapshotCurrent();
emit(ComplianceLoaded(s));
} catch (e, st) {
AppLogger.error('ComplianceBloc: load failed', error: e, stackTrace: st);
emit(ComplianceError(e.toString()));
}
}
}

View File

@@ -0,0 +1,280 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/di/injection.dart';
import '../../domain/entities/compliance_snapshot.dart';
import '../bloc/compliance_bloc.dart';
/// Page tableau de bord de conformité — affiche le snapshot agrégé pour
/// l'organisation active du membre connecté.
///
/// Cible : Compliance Officer, Contrôleur Interne, Président, Trésorier, Admin Org.
class ConformiteDashboardPage extends StatelessWidget {
const ConformiteDashboardPage({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => getIt<ComplianceBloc>()..add(const LoadComplianceSnapshot()),
child: Scaffold(
appBar: AppBar(
title: const Text('Conformité'),
actions: [
Builder(
builder: (ctx) => IconButton(
icon: const Icon(Icons.refresh),
onPressed: () =>
ctx.read<ComplianceBloc>().add(const RefreshComplianceSnapshot()),
),
),
],
),
body: BlocBuilder<ComplianceBloc, ComplianceState>(
builder: (context, state) {
if (state is ComplianceLoading || state is ComplianceInitial) {
return const Center(child: CircularProgressIndicator());
}
if (state is ComplianceError) {
return _Error(message: state.message);
}
if (state is ComplianceLoaded) {
return _Content(snapshot: state.snapshot);
}
return const SizedBox.shrink();
},
),
),
);
}
}
class _Content extends StatelessWidget {
final ComplianceSnapshot snapshot;
const _Content({required this.snapshot});
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_ScoreCard(snapshot: snapshot),
const SizedBox(height: 16),
_IndicateurTile(
label: 'Compliance Officer',
value: snapshot.complianceOfficerDesigne ? 'Désigné' : 'ABSENT',
severite: snapshot.complianceOfficerDesigne ? 'success' : 'danger',
),
_IndicateurTile(
label: 'AG annuelle',
value: snapshot.agAnnuelle.statut,
sous: snapshot.agAnnuelle.message,
severite: _statutSeverite(snapshot.agAnnuelle.statut),
),
_IndicateurTile(
label: 'Rapport AIRMS',
value: snapshot.rapportAirms.statut,
severite: _statutSeverite(snapshot.rapportAirms.statut),
),
_IndicateurTile(
label: 'Dirigeants enrôlés CMU',
value: snapshot.dirigeantsAvecCmu.toString(),
severite: snapshot.dirigeantsAvecCmu >= 3 ? 'success' : 'warning',
),
_IndicateurTile(
label: 'Taux KYC à jour',
value: '${snapshot.tauxKycAJourPct.toStringAsFixed(1)} %',
),
_IndicateurTile(
label: 'Taux formation LBC/FT',
value: '${snapshot.tauxFormationLbcFtPct.toStringAsFixed(1)} %',
),
_IndicateurTile(
label: 'Couverture UBO',
value: '${snapshot.couvertureUboPct.toStringAsFixed(1)} %',
),
_IndicateurTile(
label: 'Commissaire aux comptes',
value: snapshot.commissaireAuxComptes.statut,
sous: snapshot.commissaireAuxComptes.message,
),
_IndicateurTile(
label: 'FOMUS-CI',
value: snapshot.fomusCi.statut,
sous: snapshot.fomusCi.message,
),
if (snapshot.hasAlertesCritiques) ...[
const SizedBox(height: 16),
_AlertesCard(snapshot: snapshot),
],
],
),
);
}
String _statutSeverite(String s) {
if (s == 'OK') return 'success';
if (s == 'RETARD') return 'danger';
return 'warning';
}
}
class _ScoreCard extends StatelessWidget {
final ComplianceSnapshot snapshot;
const _ScoreCard({required this.snapshot});
@override
Widget build(BuildContext context) {
final color = _color(snapshot.scoreSeverite);
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
children: [
Text(snapshot.organisationNom,
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center),
const SizedBox(height: 4),
Text('Référentiel : ${snapshot.referentielComptable}',
style: Theme.of(context).textTheme.bodySmall),
const SizedBox(height: 16),
Text('Score conformité',
style: Theme.of(context).textTheme.bodyLarge),
const SizedBox(height: 8),
Text(
'${snapshot.scoreGlobal} / 100',
style: TextStyle(
fontSize: 48,
fontWeight: FontWeight.bold,
color: color,
),
),
],
),
),
);
}
Color _color(String s) => switch (s) {
'success' => Colors.green,
'warning' => Colors.orange,
'danger' => Colors.red,
_ => Colors.grey,
};
}
class _IndicateurTile extends StatelessWidget {
final String label;
final String value;
final String? sous;
final String? severite;
const _IndicateurTile({
required this.label,
required this.value,
this.sous,
this.severite,
});
@override
Widget build(BuildContext context) {
final color = severite != null ? _color(severite!) : Colors.grey;
return Card(
child: ListTile(
title: Text(label),
subtitle: sous != null ? Text(sous!, maxLines: 2, overflow: TextOverflow.ellipsis) : null,
trailing: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(20),
),
child: Text(
value,
style: TextStyle(color: color, fontWeight: FontWeight.bold),
),
),
),
);
}
Color _color(String s) => switch (s) {
'success' => Colors.green,
'warning' => Colors.orange,
'danger' => Colors.red,
_ => Colors.grey,
};
}
class _AlertesCard extends StatelessWidget {
final ComplianceSnapshot snapshot;
const _AlertesCard({required this.snapshot});
@override
Widget build(BuildContext context) {
final alertes = <String>[];
if (!snapshot.complianceOfficerDesigne) {
alertes.add('Compliance Officer non désigné (Instr. BCEAO 001-03-2025)');
}
if (snapshot.agAnnuelle.statut == 'RETARD') {
alertes.add('AG annuelle en retard (échéance 30/06)');
}
if (snapshot.scoreGlobal < 60) {
alertes.add('Score conformité < 60 % — risque inspection');
}
return Card(
color: Colors.red.shade50,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
const Icon(Icons.warning, color: Colors.red),
const SizedBox(width: 8),
Text('Alertes critiques',
style: Theme.of(context).textTheme.titleMedium),
]),
const SizedBox(height: 8),
for (final a in alertes)
Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Text('$a', style: const TextStyle(color: Colors.red)),
),
],
),
),
);
}
}
class _Error extends StatelessWidget {
final String message;
const _Error({required this.message});
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, color: Colors.red, size: 48),
const SizedBox(height: 16),
Text(
'Impossible de charger le tableau de bord',
style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(message,
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center),
],
),
),
);
}
}

View File

@@ -0,0 +1,41 @@
/// Devises supportées par UnionFlow (miroir mobile de l'enum backend).
///
/// Les codes ISO-4217 servent aussi d'identifiant pour l'API.
enum Devise {
xof('XOF', 'Franc CFA Ouest', 'UEMOA'),
xaf('XAF', 'Franc CFA Centrale', 'CEMAC'),
eur('EUR', 'Euro', 'EUROPE'),
usd('USD', 'Dollar US', 'AMERIQUE'),
gbp('GBP', 'Livre Sterling', 'EUROPE'),
cad('CAD', 'Dollar Canadien', 'AMERIQUE'),
chf('CHF', 'Franc Suisse', 'EUROPE'),
ghs('GHS', 'Cédi Ghanéen', 'CEDEAO'),
ngn('NGN', 'Naira Nigérian', 'CEDEAO'),
mad('MAD', 'Dirham Marocain', 'MAGHREB');
final String code;
final String libelle;
final String zone;
const Devise(this.code, this.libelle, this.zone);
/// Devise de référence UnionFlow.
static Devise reference() => Devise.xof;
/// Parse un code ISO-4217 en {@link Devise}, fallback sur XOF.
static Devise fromCode(String? code) {
if (code == null || code.isEmpty) return reference();
return Devise.values.firstWhere(
(d) => d.code.toUpperCase() == code.toUpperCase(),
orElse: () => reference(),
);
}
/// True pour les devises internationales (déclenchent AML international).
bool get estInternationale => const {
Devise.eur,
Devise.usd,
Devise.gbp,
Devise.cad,
Devise.chf,
}.contains(this);
}

View File

@@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../../domain/entities/devise.dart';
const _kDevisePrefereeKey = 'unionflow.devise.preferee';
/// Widget de sélection de la devise préférée (persistée en secure storage).
///
/// Utilisé sur la page Profil/Paramètres. Notifie via [onChanged] avec la
/// nouvelle devise quand l'utilisateur fait un choix.
class DeviseSelector extends StatefulWidget {
final Devise? initial;
final ValueChanged<Devise>? onChanged;
const DeviseSelector({super.key, this.initial, this.onChanged});
/// Lit la devise préférée du secure storage. Fallback sur XOF.
static Future<Devise> readPreferred(FlutterSecureStorage storage) async {
try {
final v = await storage.read(key: _kDevisePrefereeKey);
return Devise.fromCode(v);
} catch (_) {
return Devise.reference();
}
}
/// Persiste la devise préférée.
static Future<void> writePreferred(
FlutterSecureStorage storage, Devise devise) async {
await storage.write(key: _kDevisePrefereeKey, value: devise.code);
}
@override
State<DeviseSelector> createState() => _DeviseSelectorState();
}
class _DeviseSelectorState extends State<DeviseSelector> {
late Devise _selected;
@override
void initState() {
super.initState();
_selected = widget.initial ?? Devise.reference();
}
@override
Widget build(BuildContext context) {
return DropdownButtonFormField<Devise>(
value: _selected,
decoration: const InputDecoration(
labelText: 'Devise préférée',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.account_balance_wallet_outlined),
),
items: Devise.values
.map(
(d) => DropdownMenuItem<Devise>(
value: d,
child: Text('${d.code}${d.libelle}'),
),
)
.toList(),
onChanged: (d) {
if (d == null) return;
setState(() => _selected = d);
widget.onChanged?.call(d);
},
);
}
}

View File

@@ -0,0 +1,57 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:unionflow_mobile_apps/features/compliance/data/models/compliance_snapshot_model.dart';
void main() {
group('ComplianceSnapshotModel.fromJson', () {
test('parsing complet', () {
final json = {
'organisationId': 'org-uuid',
'organisationNom': 'Mutuelle Test',
'referentielComptable': 'SYCEBNL',
'complianceOfficerDesigne': true,
'agAnnuelle': {'statut': 'OK', 'message': 'AG tenue'},
'rapportAirms': {'statut': 'EN_ATTENTE', 'message': ''},
'dirigeantsAvecCmu': 3,
'tauxKycAJourPct': 85.5,
'tauxFormationLbcFtPct': 70.0,
'commissaireAuxComptes': {'statut': 'OBLIGATOIRE', 'message': 'SFD'},
'fomusCi': {'statut': 'EN_VEILLE', 'message': ''},
'couvertureUboPct': 60.0,
'scoreGlobal': 82,
};
final m = ComplianceSnapshotModel.fromJson(json);
expect(m.organisationNom, 'Mutuelle Test');
expect(m.referentielComptable, 'SYCEBNL');
expect(m.complianceOfficerDesigne, isTrue);
expect(m.agAnnuelle.statut, 'OK');
expect(m.dirigeantsAvecCmu, 3);
expect(m.tauxKycAJourPct, 85.5);
expect(m.scoreGlobal, 82);
});
test('valeurs manquantes → fallbacks raisonnables', () {
final m = ComplianceSnapshotModel.fromJson(<String, dynamic>{});
expect(m.organisationId, '');
expect(m.referentielComptable, 'SYSCOHADA');
expect(m.complianceOfficerDesigne, isFalse);
expect(m.agAnnuelle.statut, 'EN_VEILLE');
expect(m.dirigeantsAvecCmu, 0);
expect(m.tauxKycAJourPct, 0.0);
expect(m.scoreGlobal, 0);
});
test('taux passé en string → converti en double', () {
final m = ComplianceSnapshotModel.fromJson({
'tauxKycAJourPct': '85.5',
'couvertureUboPct': '60',
});
expect(m.tauxKycAJourPct, 85.5);
expect(m.couvertureUboPct, 60.0);
});
test('indicateur non-Map → fallback EN_VEILLE', () {
final m = ComplianceSnapshotModel.fromJson({'agAnnuelle': 'invalid'});
expect(m.agAnnuelle.statut, 'EN_VEILLE');
});
});
}

View File

@@ -0,0 +1,56 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:unionflow_mobile_apps/features/compliance/domain/entities/compliance_snapshot.dart';
ComplianceSnapshot _build({
int score = 85,
bool officer = true,
String agStatut = 'OK',
}) {
return ComplianceSnapshot(
organisationId: 'org-1',
organisationNom: 'Test',
referentielComptable: 'SYSCOHADA',
complianceOfficerDesigne: officer,
agAnnuelle: ConformiteIndicateur(statut: agStatut, message: ''),
rapportAirms: const ConformiteIndicateur(statut: 'OK', message: ''),
dirigeantsAvecCmu: 3,
tauxKycAJourPct: 80,
tauxFormationLbcFtPct: 70,
commissaireAuxComptes: const ConformiteIndicateur(statut: 'OPTIONNEL', message: ''),
fomusCi: const ConformiteIndicateur(statut: 'EN_VEILLE', message: ''),
couvertureUboPct: 60,
scoreGlobal: score,
);
}
void main() {
group('ComplianceSnapshot', () {
test('scoreSeverite — score >=80 → success', () {
expect(_build(score: 85).scoreSeverite, 'success');
});
test('scoreSeverite — 60..79 → warning', () {
expect(_build(score: 70).scoreSeverite, 'warning');
});
test('scoreSeverite — <60 → danger', () {
expect(_build(score: 50).scoreSeverite, 'danger');
});
test('hasAlertesCritiques — score 85 + officer + AG OK → false', () {
expect(_build().hasAlertesCritiques, isFalse);
});
test('hasAlertesCritiques — officer absent → true', () {
expect(_build(officer: false).hasAlertesCritiques, isTrue);
});
test('hasAlertesCritiques — AG en retard → true', () {
expect(_build(agStatut: 'RETARD').hasAlertesCritiques, isTrue);
});
test('hasAlertesCritiques — score <60 → true', () {
expect(_build(score: 50).hasAlertesCritiques, isTrue);
});
});
}

View File

@@ -0,0 +1,46 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:unionflow_mobile_apps/features/devise/domain/entities/devise.dart';
void main() {
group('Devise enum', () {
test('reference = XOF', () {
expect(Devise.reference(), Devise.xof);
});
test('fromCode parse correct', () {
expect(Devise.fromCode('EUR'), Devise.eur);
expect(Devise.fromCode('usd'), Devise.usd);
expect(Devise.fromCode('GBP'), Devise.gbp);
});
test('fromCode null/vide/inconnu → XOF (référence)', () {
expect(Devise.fromCode(null), Devise.xof);
expect(Devise.fromCode(''), Devise.xof);
expect(Devise.fromCode('XYZ'), Devise.xof);
});
test('estInternationale — EUR/USD/GBP/CAD/CHF → true', () {
expect(Devise.eur.estInternationale, isTrue);
expect(Devise.usd.estInternationale, isTrue);
expect(Devise.gbp.estInternationale, isTrue);
expect(Devise.cad.estInternationale, isTrue);
expect(Devise.chf.estInternationale, isTrue);
});
test('estInternationale — XOF/XAF/GHS/NGN/MAD → false', () {
expect(Devise.xof.estInternationale, isFalse);
expect(Devise.xaf.estInternationale, isFalse);
expect(Devise.ghs.estInternationale, isFalse);
expect(Devise.ngn.estInternationale, isFalse);
expect(Devise.mad.estInternationale, isFalse);
});
test('Toutes les devises ont code 3 lettres + libellé non vide + zone', () {
for (final d in Devise.values) {
expect(d.code.length, 3);
expect(d.libelle.isNotEmpty, isTrue);
expect(d.zone.isNotEmpty, isTrue);
}
});
});
}