feat: WebSocket temps réel + Finance Workflow + corrections

- Task #6: WebSocket /ws/dashboard + Kafka events (5 topics)
  * Backend: KafkaEventProducer, KafkaEventConsumer
  * Mobile: WebSocketService (reconnection, heartbeat, typed events)
  * DashboardBloc: Auto-refresh depuis WebSocket events

- Finance Workflow: approbations + budgets (backend + mobile)
  * Backend: entities, services, resources, migrations Flyway V6
  * Mobile: features finance_workflow complète avec BLoC

- Corrections DI: interfaces IRepository partout
  * IProfileRepository, IOrganizationRepository, IMembreRepository
  * GetIt configuré avec @injectable

- Spec-Kit: constitution + templates mis à jour
  * .specify/memory/constitution.md enrichie
  * Templates agent, plan, spec, tasks, checklist

- Nettoyage: fichiers temporaires supprimés

Signed-off-by: lions dev Team
This commit is contained in:
dahoud
2026-03-15 02:12:17 +00:00
parent bbc409de9d
commit e8ad874015
635 changed files with 58160 additions and 20674 deletions

View File

@@ -1,10 +1,12 @@
import 'package:injectable/injectable.dart';
import 'package:dartz/dartz.dart';
import '../../domain/entities/dashboard_entity.dart';
import '../../domain/entities/compte_adherent_entity.dart';
import '../../domain/repositories/dashboard_repository.dart';
import '../datasources/dashboard_remote_datasource.dart';
import '../models/dashboard_stats_model.dart';
import '../models/membre_dashboard_synthese_model.dart';
import '../models/compte_adherent_model.dart';
import '../../../../core/error/exceptions.dart';
import '../../../../core/error/failures.dart';
import '../../../../core/network/network_info.dart';
@@ -19,6 +21,21 @@ class DashboardRepositoryImpl implements DashboardRepository {
required this.networkInfo,
});
@override
Future<Either<Failure, CompteAdherentEntity>> getCompteAdherent() async {
if (!await networkInfo.isConnected) {
return const Left(NetworkFailure('No internet connection'));
}
try {
final model = await remoteDataSource.getCompteAdherent();
return Right(_mapCompteToEntity(model));
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} catch (e) {
return Left(ServerFailure('Unexpected error: $e'));
}
}
@override
Future<Either<Failure, DashboardEntity>> getDashboardData(
String organizationId,
@@ -31,9 +48,32 @@ class DashboardRepositoryImpl implements DashboardRepository {
// Membre sans contexte org : utiliser l'API dashboard membre (GET /api/dashboard/membre/me)
final useMemberDashboard = organizationId.trim().isEmpty;
if (useMemberDashboard) {
final synthese = await remoteDataSource.getMemberDashboardData();
return Right(_mapMemberSyntheseToEntity(synthese, userId));
// Chargement parallèle de la synthèse et du compte adhérent unifié
final results = await Future.wait([
remoteDataSource.getMemberDashboardData(),
remoteDataSource.getCompteAdherent(),
]);
final synthese = results[0] as MembreDashboardSyntheseModel;
final compteModel = results[1] as CompteAdherentModel;
// Fallback : si les montants sont à zéro mais qu'il y a des cotisations,
// on complète avec /api/cotisations/mes-cotisations/synthese
Map<String, dynamic>? cotSynthese;
if (synthese.totalCotisationsPayeesToutTemps == 0 ||
synthese.tauxCotisationsPerso == null ||
(synthese.tauxCotisationsPerso ?? 0) == 0) {
cotSynthese = await remoteDataSource.getMesCotisationsSynthese();
}
return Right(_mapMemberSyntheseToEntity(
synthese,
userId,
cotSynthese: cotSynthese,
compteModel: compteModel,
));
}
final dashboardData = await remoteDataSource.getDashboardData(organizationId, userId);
return Right(_mapToEntity(dashboardData));
} on ServerException catch (e) {
@@ -43,24 +83,65 @@ class DashboardRepositoryImpl implements DashboardRepository {
}
}
/// Construit une DashboardEntity à partir de la synthèse membre (même structure pour réutiliser l'UI).
DashboardEntity _mapMemberSyntheseToEntity(MembreDashboardSyntheseModel s, String userId) {
/// Construit une DashboardEntity à partir de la synthèse membre.
/// [cotSynthese] est optionnel : utilisé en fallback quand les montants du dashboard
/// membre sont à zéro (incohérence backend entre /api/dashboard/membre/me
/// et /api/cotisations/mes-cotisations/synthese).
DashboardEntity _mapMemberSyntheseToEntity(
MembreDashboardSyntheseModel s,
String userId, {
Map<String, dynamic>? cotSynthese,
CompteAdherentModel? compteModel,
}) {
final now = DateTime.now();
// Contribution Totale = cotisations payées tout temps ; MON SOLDE TOTAL = cotisations tout temps + épargne
final totalCotisationsToutTemps = s.totalCotisationsPayeesToutTemps;
// ------------------------------------------------------------------
// Montant des cotisations payées tout temps
// ------------------------------------------------------------------
double totalCotisationsToutTemps = s.totalCotisationsPayeesToutTemps;
if (totalCotisationsToutTemps == 0 && cotSynthese != null) {
// totalPayeAnnee = montant payé sur l'année en cours (meilleure approximation disponible)
final totalPayeAnnee = _toDouble(cotSynthese['totalPayeAnnee']);
if (totalPayeAnnee > 0) totalCotisationsToutTemps = totalPayeAnnee;
}
// ------------------------------------------------------------------
// MON SOLDE TOTAL = cotisations payées + épargne
// ------------------------------------------------------------------
final monSoldeTotal = totalCotisationsToutTemps + s.monSoldeEpargne;
// ------------------------------------------------------------------
// Taux d'engagement (en %)
// Priorité : tauxParticipationPerso > tauxCotisationsPerso > calculé depuis cotSynthese
// ------------------------------------------------------------------
int? tauxBrut = s.tauxParticipationPerso ?? s.tauxCotisationsPerso;
double engagementRate = (tauxBrut ?? 0) / 100.0;
if (engagementRate == 0 && cotSynthese != null) {
final montantDu = _toDouble(cotSynthese['montantDu']);
final totalPayeAnnee = _toDouble(cotSynthese['totalPayeAnnee']);
final total = montantDu + totalPayeAnnee;
if (total > 0) engagementRate = totalPayeAnnee / total;
}
// ------------------------------------------------------------------
// Nombre de cotisations — utilize NEW nombreCotisationsTotal if available
// ------------------------------------------------------------------
final int nombreCotisations = s.nombreCotisationsTotal > 0
? s.nombreCotisationsTotal
: s.nombreCotisationsPayees;
final stats = DashboardStatsEntity(
totalMembers: 0,
activeMembers: 0,
totalEvents: 0,
upcomingEvents: s.evenementsAVenir,
totalContributions: s.nombreCotisationsPayees,
totalContributions: nombreCotisations,
totalContributionAmount: monSoldeTotal,
contributionsAmountOnly: totalCotisationsToutTemps,
pendingRequests: 0,
completedProjects: 0,
monthlyGrowth: s.evolutionEpargneNombre,
engagementRate: ((s.tauxParticipationPerso ?? s.tauxCotisationsPerso) ?? 0) / 100.0,
engagementRate: engagementRate,
lastUpdated: now,
totalOrganizations: null,
organizationTypeDistribution: null,
@@ -69,10 +150,20 @@ class DashboardRepositoryImpl implements DashboardRepository {
stats: stats,
recentActivities: const [],
upcomingEvents: const [],
userPreferences: <String, dynamic>{},
userPreferences: const <String, dynamic>{},
organizationId: '',
userId: userId,
monCompte: compteModel != null ? _mapCompteToEntity(compteModel) : null,
);
}
static double _toDouble(dynamic v) {
if (v == null) return 0;
if (v is num) return v.toDouble();
if (v is String) return double.tryParse(v) ?? 0;
return 0;
}
@override
@@ -142,6 +233,28 @@ class DashboardRepositoryImpl implements DashboardRepository {
}
}
CompteAdherentEntity _mapCompteToEntity(CompteAdherentModel model) {
return CompteAdherentEntity(
numeroMembre: model.numeroMembre,
nomComplet: model.nomComplet,
organisationNom: model.organisationNom,
dateAdhesion: model.dateAdhesion != null ? DateTime.tryParse(model.dateAdhesion!) : null,
statutCompte: model.statutCompte,
soldeCotisations: model.soldeCotisations,
soldeEpargne: model.soldeEpargne,
soldeBloque: model.soldeBloque,
soldeTotalDisponible: model.soldeTotalDisponible,
encoursCreditTotal: model.encoursCreditTotal,
capaciteEmprunt: model.capaciteEmprunt,
nombreCotisationsPayees: model.nombreCotisationsPayees,
nombreCotisationsTotal: model.nombreCotisationsTotal,
nombreCotisationsEnRetard: model.nombreCotisationsEnRetard,
engagementRate: (model.tauxEngagement ?? 0) / 100.0,
nombreComptesEpargne: model.nombreComptesEpargne,
dateCalcul: DateTime.tryParse(model.dateCalcul) ?? DateTime.now(),
);
}
// Mappers
DashboardEntity _mapToEntity(DashboardDataModel model) {
return DashboardEntity(

View File

@@ -0,0 +1,89 @@
import 'package:dio/dio.dart';
import 'package:injectable/injectable.dart';
import 'package:unionflow_mobile_apps/core/network/api_client.dart';
import 'package:unionflow_mobile_apps/core/utils/logger.dart';
import '../../presentation/bloc/finance_state.dart';
/// Repository pour les données financières (cotisations, synthèse).
/// Appelle les endpoints /api/cotisations/mes-cotisations/*.
@lazySingleton
class FinanceRepository {
final ApiClient _apiClient;
FinanceRepository(this._apiClient);
/// Synthèse des cotisations du membre connecté (GET /api/cotisations/mes-cotisations/synthese).
Future<FinanceSummary> getFinancialSummary() async {
try {
final response = await _apiClient.get('/api/cotisations/mes-cotisations/synthese');
final data = response.data as Map<String, dynamic>;
final totalPayeAnnee = (data['totalPayeAnnee'] is num)
? (data['totalPayeAnnee'] as num).toDouble()
: 0.0;
final montantDu = (data['montantDu'] is num)
? (data['montantDu'] as num).toDouble()
: 0.0;
final epargneBalance = (data['epargneBalance'] is num)
? (data['epargneBalance'] as num).toDouble()
: 0.0;
return FinanceSummary(
totalContributionsPaid: totalPayeAnnee,
totalContributionsPending: montantDu,
epargneBalance: epargneBalance,
);
} on DioException catch (e, st) {
AppLogger.error('FinanceRepository: getFinancialSummary échoué', error: e, stackTrace: st);
rethrow;
} catch (e, st) {
AppLogger.error('FinanceRepository: getFinancialSummary erreur inattendue', error: e, stackTrace: st);
rethrow;
}
}
/// Cotisations en attente du membre connecté (GET /api/cotisations/mes-cotisations/en-attente).
Future<List<FinanceTransaction>> getTransactions() async {
try {
final response = await _apiClient.get('/api/cotisations/mes-cotisations/en-attente');
final List<dynamic> data = response.data is List ? response.data as List : [];
return data
.map((json) => _transactionFromJson(json as Map<String, dynamic>))
.toList();
} on DioException catch (e, st) {
AppLogger.error('FinanceRepository: getTransactions échoué', error: e, stackTrace: st);
if (e.response?.statusCode == 404) return [];
rethrow;
}
}
static FinanceTransaction _transactionFromJson(Map<String, dynamic> json) {
final id = json['id']?.toString() ?? '';
final ref = json['numeroReference']?.toString() ?? '';
final nomMembre = json['nomMembre']?.toString() ?? 'Cotisation';
final montantDu = (json['montantDu'] is num)
? (json['montantDu'] as num).toDouble()
: 0.0;
final statutLibelle = json['statutLibelle']?.toString() ?? 'En attente';
final dateEcheance = json['dateEcheance']?.toString();
final dateStr = dateEcheance != null
? _parseDateToDisplay(dateEcheance)
: '';
return FinanceTransaction(
id: id,
title: nomMembre.isNotEmpty ? nomMembre : 'Cotisation $ref',
date: dateStr,
amount: montantDu,
status: statutLibelle,
);
}
static String _parseDateToDisplay(String isoDate) {
try {
final d = DateTime.parse(isoDate);
return '${d.day.toString().padLeft(2, '0')}/${d.month.toString().padLeft(2, '0')}/${d.year}';
} catch (e) {
AppLogger.warning('FinanceRepository: _parseDateToDisplay date invalide', tag: isoDate);
return isoDate;
}
}
}