diff --git a/lib/features/compliance/data/datasources/compliance_remote_datasource.dart b/lib/features/compliance/data/datasources/compliance_remote_datasource.dart new file mode 100644 index 0000000..ce192f8 --- /dev/null +++ b/lib/features/compliance/data/datasources/compliance_remote_datasource.dart @@ -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 getSnapshotCurrent(); +} + +@Injectable(as: ComplianceRemoteDataSource) +class ComplianceRemoteDataSourceImpl implements ComplianceRemoteDataSource { + final ApiClient apiClient; + + ComplianceRemoteDataSourceImpl(this.apiClient); + + @override + Future getSnapshotCurrent() async { + try { + final response = await apiClient.get('/api/compliance/dashboard'); + if (response.statusCode == 200 && response.data is Map) { + return ComplianceSnapshotModel.fromJson( + response.data as Map, + ); + } + 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; + } + } +} diff --git a/lib/features/compliance/data/models/compliance_snapshot_model.dart b/lib/features/compliance/data/models/compliance_snapshot_model.dart new file mode 100644 index 0000000..b608223 --- /dev/null +++ b/lib/features/compliance/data/models/compliance_snapshot_model.dart @@ -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 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) { + 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; + } +} diff --git a/lib/features/compliance/data/repositories/compliance_repository_impl.dart b/lib/features/compliance/data/repositories/compliance_repository_impl.dart new file mode 100644 index 0000000..a00c2dc --- /dev/null +++ b/lib/features/compliance/data/repositories/compliance_repository_impl.dart @@ -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 getSnapshotCurrent() => remote.getSnapshotCurrent(); +} diff --git a/lib/features/compliance/domain/entities/compliance_snapshot.dart b/lib/features/compliance/domain/entities/compliance_snapshot.dart new file mode 100644 index 0000000..2af99b9 --- /dev/null +++ b/lib/features/compliance/domain/entities/compliance_snapshot.dart @@ -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 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 get props => [ + organisationId, + organisationNom, + referentielComptable, + complianceOfficerDesigne, + agAnnuelle, + rapportAirms, + dirigeantsAvecCmu, + tauxKycAJourPct, + tauxFormationLbcFtPct, + commissaireAuxComptes, + fomusCi, + couvertureUboPct, + scoreGlobal, + ]; +} diff --git a/lib/features/compliance/domain/repositories/compliance_repository.dart b/lib/features/compliance/domain/repositories/compliance_repository.dart new file mode 100644 index 0000000..c0bd43a --- /dev/null +++ b/lib/features/compliance/domain/repositories/compliance_repository.dart @@ -0,0 +1,5 @@ +import '../entities/compliance_snapshot.dart'; + +abstract class ComplianceRepository { + Future getSnapshotCurrent(); +} diff --git a/lib/features/compliance/presentation/bloc/compliance_bloc.dart b/lib/features/compliance/presentation/bloc/compliance_bloc.dart new file mode 100644 index 0000000..b614b32 --- /dev/null +++ b/lib/features/compliance/presentation/bloc/compliance_bloc.dart @@ -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 get props => []; +} + +class LoadComplianceSnapshot extends ComplianceEvent { + const LoadComplianceSnapshot(); +} + +class RefreshComplianceSnapshot extends ComplianceEvent { + const RefreshComplianceSnapshot(); +} + +// States +abstract class ComplianceState extends Equatable { + const ComplianceState(); + @override + List 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 get props => [snapshot]; +} + +class ComplianceError extends ComplianceState { + final String message; + const ComplianceError(this.message); + @override + List get props => [message]; +} + +@injectable +class ComplianceBloc extends Bloc { + final ComplianceRepository repository; + + ComplianceBloc(this.repository) : super(const ComplianceInitial()) { + on(_onLoad); + on(_onLoad); + } + + Future _onLoad(ComplianceEvent event, Emitter 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())); + } + } +} diff --git a/lib/features/compliance/presentation/pages/conformite_dashboard_page.dart b/lib/features/compliance/presentation/pages/conformite_dashboard_page.dart new file mode 100644 index 0000000..ca6c3e9 --- /dev/null +++ b/lib/features/compliance/presentation/pages/conformite_dashboard_page.dart @@ -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()..add(const LoadComplianceSnapshot()), + child: Scaffold( + appBar: AppBar( + title: const Text('Conformité'), + actions: [ + Builder( + builder: (ctx) => IconButton( + icon: const Icon(Icons.refresh), + onPressed: () => + ctx.read().add(const RefreshComplianceSnapshot()), + ), + ), + ], + ), + body: BlocBuilder( + 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 = []; + 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), + ], + ), + ), + ); + } +} diff --git a/lib/features/devise/domain/entities/devise.dart b/lib/features/devise/domain/entities/devise.dart new file mode 100644 index 0000000..fd4c3a2 --- /dev/null +++ b/lib/features/devise/domain/entities/devise.dart @@ -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); +} diff --git a/lib/features/devise/presentation/widgets/devise_selector.dart b/lib/features/devise/presentation/widgets/devise_selector.dart new file mode 100644 index 0000000..0070d09 --- /dev/null +++ b/lib/features/devise/presentation/widgets/devise_selector.dart @@ -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? onChanged; + + const DeviseSelector({super.key, this.initial, this.onChanged}); + + /// Lit la devise préférée du secure storage. Fallback sur XOF. + static Future 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 writePreferred( + FlutterSecureStorage storage, Devise devise) async { + await storage.write(key: _kDevisePrefereeKey, value: devise.code); + } + + @override + State createState() => _DeviseSelectorState(); +} + +class _DeviseSelectorState extends State { + late Devise _selected; + + @override + void initState() { + super.initState(); + _selected = widget.initial ?? Devise.reference(); + } + + @override + Widget build(BuildContext context) { + return DropdownButtonFormField( + 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( + value: d, + child: Text('${d.code} — ${d.libelle}'), + ), + ) + .toList(), + onChanged: (d) { + if (d == null) return; + setState(() => _selected = d); + widget.onChanged?.call(d); + }, + ); + } +} diff --git a/test/features/compliance/data/compliance_snapshot_model_test.dart b/test/features/compliance/data/compliance_snapshot_model_test.dart new file mode 100644 index 0000000..3176f26 --- /dev/null +++ b/test/features/compliance/data/compliance_snapshot_model_test.dart @@ -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({}); + 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'); + }); + }); +} diff --git a/test/features/compliance/domain/compliance_snapshot_test.dart b/test/features/compliance/domain/compliance_snapshot_test.dart new file mode 100644 index 0000000..569d011 --- /dev/null +++ b/test/features/compliance/domain/compliance_snapshot_test.dart @@ -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); + }); + }); +} diff --git a/test/features/devise/devise_test.dart b/test/features/devise/devise_test.dart new file mode 100644 index 0000000..f3cabde --- /dev/null +++ b/test/features/devise/devise_test.dart @@ -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); + } + }); + }); +}