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

@@ -0,0 +1,229 @@
/// Datasource distant pour le workflow financier (API)
library finance_workflow_remote_datasource;
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:injectable/injectable.dart';
import '../../../../core/config/environment.dart';
import '../../../../core/error/exceptions.dart';
import '../models/transaction_approval_model.dart';
import '../models/budget_model.dart';
import '../../domain/entities/transaction_approval.dart';
import '../../domain/entities/budget.dart';
@lazySingleton
class FinanceWorkflowRemoteDatasource {
final http.Client client;
final FlutterSecureStorage secureStorage;
FinanceWorkflowRemoteDatasource({
required this.client,
required this.secureStorage,
});
/// Headers HTTP avec authentification
Future<Map<String, String>> _getHeaders() async {
final token = await secureStorage.read(key: 'access_token');
return {
'Content-Type': 'application/json',
'Accept': 'application/json',
if (token != null) 'Authorization': 'Bearer $token',
};
}
// === APPROBATIONS ===
Future<List<TransactionApprovalModel>> getPendingApprovals({
String? organizationId,
}) async {
final uri = Uri.parse('${AppConfig.apiBaseUrl}/api/finance/approvals/pending')
.replace(queryParameters: {
if (organizationId != null) 'organizationId': organizationId,
});
final response = await client.get(uri, headers: await _getHeaders());
if (response.statusCode == 200) {
final List<dynamic> jsonList = json.decode(response.body);
return jsonList
.map((json) => TransactionApprovalModel.fromJson(json))
.toList();
} else if (response.statusCode == 401) {
throw UnauthorizedException();
} else {
throw ServerException('Erreur lors de la récupération des approbations');
}
}
Future<TransactionApprovalModel> getApprovalById(String approvalId) async {
final uri = Uri.parse(
'${AppConfig.apiBaseUrl}/api/finance/approvals/$approvalId');
final response = await client.get(uri, headers: await _getHeaders());
if (response.statusCode == 200) {
return TransactionApprovalModel.fromJson(json.decode(response.body));
} else if (response.statusCode == 404) {
throw NotFoundException('Approbation non trouvée');
} else if (response.statusCode == 401) {
throw UnauthorizedException();
} else {
throw ServerException('Erreur lors de la récupération de l\'approbation');
}
}
Future<TransactionApprovalModel> approveTransaction({
required String approvalId,
String? comment,
}) async {
final uri =
Uri.parse('${AppConfig.apiBaseUrl}/api/finance/approvals/$approvalId/approve');
final body = json.encode({
if (comment != null) 'comment': comment,
});
final response = await client.post(
uri,
headers: await _getHeaders(),
body: body,
);
if (response.statusCode == 200) {
return TransactionApprovalModel.fromJson(json.decode(response.body));
} else if (response.statusCode == 401) {
throw UnauthorizedException();
} else if (response.statusCode == 403) {
throw ForbiddenException('Permission insuffisante pour approuver');
} else {
throw ServerException('Erreur lors de l\'approbation');
}
}
Future<TransactionApprovalModel> rejectTransaction({
required String approvalId,
required String reason,
}) async {
final uri =
Uri.parse('${AppConfig.apiBaseUrl}/api/finance/approvals/$approvalId/reject');
final body = json.encode({
'reason': reason,
});
final response = await client.post(
uri,
headers: await _getHeaders(),
body: body,
);
if (response.statusCode == 200) {
return TransactionApprovalModel.fromJson(json.decode(response.body));
} else if (response.statusCode == 401) {
throw UnauthorizedException();
} else if (response.statusCode == 403) {
throw ForbiddenException('Permission insuffisante pour rejeter');
} else {
throw ServerException('Erreur lors du rejet');
}
}
// === BUDGETS ===
Future<List<BudgetModel>> getBudgets({
String? organizationId,
String? status,
int? year,
}) async {
final uri = Uri.parse('${AppConfig.apiBaseUrl}/api/finance/budgets')
.replace(queryParameters: {
if (organizationId != null) 'organizationId': organizationId,
if (status != null) 'status': status,
if (year != null) 'year': year.toString(),
});
final response = await client.get(uri, headers: await _getHeaders());
if (response.statusCode == 200) {
final List<dynamic> jsonList = json.decode(response.body);
return jsonList.map((json) => BudgetModel.fromJson(json)).toList();
} else if (response.statusCode == 401) {
throw UnauthorizedException();
} else {
throw ServerException('Erreur lors de la récupération des budgets');
}
}
Future<BudgetModel> getBudgetById(String budgetId) async {
final uri =
Uri.parse('${AppConfig.apiBaseUrl}/api/finance/budgets/$budgetId');
final response = await client.get(uri, headers: await _getHeaders());
if (response.statusCode == 200) {
return BudgetModel.fromJson(json.decode(response.body));
} else if (response.statusCode == 404) {
throw NotFoundException('Budget non trouvé');
} else if (response.statusCode == 401) {
throw UnauthorizedException();
} else {
throw ServerException('Erreur lors de la récupération du budget');
}
}
Future<BudgetModel> createBudget({
required String name,
String? description,
required String organizationId,
required String period,
required int year,
int? month,
required List<Map<String, dynamic>> lines,
}) async {
final uri = Uri.parse('${AppConfig.apiBaseUrl}/api/finance/budgets');
final body = json.encode({
'name': name,
if (description != null) 'description': description,
'organizationId': organizationId,
'period': period,
'year': year,
if (month != null) 'month': month,
'lines': lines,
});
final response = await client.post(
uri,
headers: await _getHeaders(),
body: body,
);
if (response.statusCode == 201 || response.statusCode == 200) {
return BudgetModel.fromJson(json.decode(response.body));
} else if (response.statusCode == 401) {
throw UnauthorizedException();
} else if (response.statusCode == 403) {
throw ForbiddenException('Permission insuffisante pour créer un budget');
} else {
throw ServerException('Erreur lors de la création du budget');
}
}
Future<Map<String, dynamic>> getBudgetTracking({
required String budgetId,
}) async {
final uri = Uri.parse(
'${AppConfig.apiBaseUrl}/api/finance/budgets/$budgetId/tracking');
final response = await client.get(uri, headers: await _getHeaders());
if (response.statusCode == 200) {
return json.decode(response.body) as Map<String, dynamic>;
} else if (response.statusCode == 401) {
throw UnauthorizedException();
} else {
throw ServerException('Erreur lors de la récupération du suivi budgétaire');
}
}
}

View File

@@ -0,0 +1,100 @@
/// Model de données Budget avec sérialisation JSON
library budget_model;
import 'package:json_annotation/json_annotation.dart';
import '../../domain/entities/budget.dart';
part 'budget_model.g.dart';
@JsonSerializable(explicitToJson: true)
class BudgetModel extends Budget {
@JsonKey(
fromJson: _linesFromJson,
toJson: _linesToJson,
)
@override
final List<BudgetLine> lines;
const BudgetModel({
required super.id,
required super.name,
super.description,
required super.organizationId,
required super.period,
required super.year,
super.month,
required super.status,
this.lines = const [],
required super.totalPlanned,
super.totalRealized,
super.currency,
required super.createdBy,
required super.createdAt,
super.approvedAt,
super.approvedBy,
required super.startDate,
required super.endDate,
super.metadata,
}) : super(lines: lines);
static List<BudgetLine> _linesFromJson(List<dynamic>? json) =>
json?.map((e) => BudgetLineModel.fromJson(e as Map<String, dynamic>)).toList() ?? [];
static List<Map<String, dynamic>> _linesToJson(List<BudgetLine>? lines) =>
lines?.map((l) => BudgetLineModel(
id: l.id,
category: l.category,
name: l.name,
description: l.description,
amountPlanned: l.amountPlanned,
amountRealized: l.amountRealized,
notes: l.notes,
).toJson()).toList() ?? [];
factory BudgetModel.fromJson(Map<String, dynamic> json) =>
_$BudgetModelFromJson(json);
Map<String, dynamic> toJson() => _$BudgetModelToJson(this);
factory BudgetModel.fromEntity(Budget entity) {
return BudgetModel(
id: entity.id,
name: entity.name,
description: entity.description,
organizationId: entity.organizationId,
period: entity.period,
year: entity.year,
month: entity.month,
status: entity.status,
lines: entity.lines,
totalPlanned: entity.totalPlanned,
totalRealized: entity.totalRealized,
currency: entity.currency,
createdBy: entity.createdBy,
createdAt: entity.createdAt,
approvedAt: entity.approvedAt,
approvedBy: entity.approvedBy,
startDate: entity.startDate,
endDate: entity.endDate,
metadata: entity.metadata,
);
}
}
@JsonSerializable()
class BudgetLineModel extends BudgetLine {
const BudgetLineModel({
required super.id,
required super.category,
required super.name,
super.description,
required super.amountPlanned,
super.amountRealized,
super.notes,
});
factory BudgetLineModel.fromJson(Map<String, dynamic> json) =>
_$BudgetLineModelFromJson(json);
Map<String, dynamic> toJson() => _$BudgetLineModelToJson(this);
}

View File

@@ -0,0 +1,92 @@
/// Model de données TransactionApproval avec sérialisation JSON
library transaction_approval_model;
import 'package:json_annotation/json_annotation.dart';
import '../../domain/entities/transaction_approval.dart';
part 'transaction_approval_model.g.dart';
@JsonSerializable(explicitToJson: true)
class TransactionApprovalModel extends TransactionApproval {
@JsonKey(
fromJson: _approversFromJson,
toJson: _approversToJson,
)
@override
final List<ApproverAction> approvers;
const TransactionApprovalModel({
required super.id,
required super.transactionId,
required super.transactionType,
required super.amount,
super.currency,
required super.requesterId,
required super.requesterName,
super.organizationId,
required super.requiredLevel,
required super.status,
this.approvers = const [],
super.rejectionReason,
required super.createdAt,
super.expiresAt,
super.completedAt,
super.metadata,
}) : super(approvers: approvers);
static List<ApproverAction> _approversFromJson(List<dynamic>? json) =>
json?.map((e) => ApproverActionModel.fromJson(e as Map<String, dynamic>)).toList() ?? [];
static List<Map<String, dynamic>> _approversToJson(List<ApproverAction>? approvers) =>
approvers?.map((a) => ApproverActionModel(
approverId: a.approverId,
approverName: a.approverName,
approverRole: a.approverRole,
decision: a.decision,
comment: a.comment,
decidedAt: a.decidedAt,
).toJson()).toList() ?? [];
factory TransactionApprovalModel.fromJson(Map<String, dynamic> json) =>
_$TransactionApprovalModelFromJson(json);
Map<String, dynamic> toJson() => _$TransactionApprovalModelToJson(this);
factory TransactionApprovalModel.fromEntity(TransactionApproval entity) {
return TransactionApprovalModel(
id: entity.id,
transactionId: entity.transactionId,
transactionType: entity.transactionType,
amount: entity.amount,
currency: entity.currency,
requesterId: entity.requesterId,
requesterName: entity.requesterName,
organizationId: entity.organizationId,
requiredLevel: entity.requiredLevel,
status: entity.status,
approvers: entity.approvers,
rejectionReason: entity.rejectionReason,
createdAt: entity.createdAt,
expiresAt: entity.expiresAt,
completedAt: entity.completedAt,
metadata: entity.metadata,
);
}
}
@JsonSerializable()
class ApproverActionModel extends ApproverAction {
const ApproverActionModel({
required super.approverId,
required super.approverName,
required super.approverRole,
required super.decision,
super.comment,
super.decidedAt,
});
factory ApproverActionModel.fromJson(Map<String, dynamic> json) =>
_$ApproverActionModelFromJson(json);
Map<String, dynamic> toJson() => _$ApproverActionModelToJson(this);
}

View File

@@ -0,0 +1,413 @@
/// Implémentation du repository de workflow financier
library finance_workflow_repository_impl;
import 'dart:async';
import 'package:dartz/dartz.dart';
import 'package:injectable/injectable.dart';
import '../../../../core/error/exceptions.dart';
import '../../../../core/error/failures.dart';
import '../../../../core/network/network_info.dart';
import '../../../../core/network/retry_policy.dart';
import '../../../../core/network/offline_manager.dart';
import '../../domain/entities/budget.dart';
import '../../domain/entities/financial_audit_log.dart';
import '../../domain/entities/transaction_approval.dart';
import '../../domain/repositories/finance_workflow_repository.dart';
import '../datasources/finance_workflow_remote_datasource.dart';
@LazySingleton(as: FinanceWorkflowRepository)
class FinanceWorkflowRepositoryImpl implements FinanceWorkflowRepository {
final FinanceWorkflowRemoteDatasource remoteDatasource;
final NetworkInfo networkInfo;
final OfflineManager offlineManager;
final RetryPolicy _retryPolicy;
FinanceWorkflowRepositoryImpl({
required this.remoteDatasource,
required this.networkInfo,
required this.offlineManager,
}) : _retryPolicy = RetryPolicy(config: RetryConfig.standard);
@override
Future<Either<Failure, List<TransactionApproval>>> getPendingApprovals({
String? organizationId,
}) async {
if (!await networkInfo.isConnected) {
return Left(NetworkFailure('Pas de connexion Internet'));
}
try {
final approvals = await _retryPolicy.execute(
operation: () => remoteDatasource.getPendingApprovals(
organizationId: organizationId,
),
shouldRetry: (error) => _isRetryableError(error),
);
return Right(approvals);
} on UnauthorizedException {
return Left(UnauthorizedFailure('Session expirée'));
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} on TimeoutException {
return Left(NetworkFailure('Délai d\'attente dépassé'));
} catch (e) {
return Left(UnexpectedFailure('Erreur inattendue: $e'));
}
}
@override
Future<Either<Failure, TransactionApproval>> getApprovalById(
String approvalId) async {
if (!await networkInfo.isConnected) {
return Left(NetworkFailure('Pas de connexion Internet'));
}
try {
final approval = await _retryPolicy.execute(
operation: () => remoteDatasource.getApprovalById(approvalId),
shouldRetry: (error) => _isRetryableError(error),
);
return Right(approval);
} on NotFoundException {
return Left(NotFoundFailure('Approbation non trouvée'));
} on UnauthorizedException {
return Left(UnauthorizedFailure('Session expirée'));
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} on TimeoutException {
return Left(NetworkFailure('Délai d\'attente dépassé'));
} catch (e) {
return Left(UnexpectedFailure('Erreur inattendue: $e'));
}
}
@override
Future<Either<Failure, TransactionApproval>> approveTransaction({
required String approvalId,
String? comment,
}) async {
if (!await networkInfo.isConnected) {
// Queue for retry when back online
await offlineManager.queueOperation(
operationType: 'approveTransaction',
endpoint: '/api/finance/approvals/$approvalId/approve',
data: {'approvalId': approvalId, 'comment': comment},
);
return Left(NetworkFailure('Pas de connexion Internet. Opération mise en attente.'));
}
try {
final approval = await _retryPolicy.execute(
operation: () => remoteDatasource.approveTransaction(
approvalId: approvalId,
comment: comment,
),
shouldRetry: (error) => _isRetryableError(error),
);
return Right(approval);
} on ForbiddenException catch (e) {
return Left(ForbiddenFailure(e.message));
} on UnauthorizedException {
return Left(UnauthorizedFailure('Session expirée'));
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} on TimeoutException {
return Left(NetworkFailure('Délai d\'attente dépassé'));
} catch (e) {
return Left(UnexpectedFailure('Erreur inattendue: $e'));
}
}
@override
Future<Either<Failure, TransactionApproval>> rejectTransaction({
required String approvalId,
required String reason,
}) async {
if (!await networkInfo.isConnected) {
// Queue for retry when back online
await offlineManager.queueOperation(
operationType: 'rejectTransaction',
endpoint: '/api/finance/approvals/$approvalId/reject',
data: {'approvalId': approvalId, 'reason': reason},
);
return Left(NetworkFailure('Pas de connexion Internet. Opération mise en attente.'));
}
try {
final approval = await _retryPolicy.execute(
operation: () => remoteDatasource.rejectTransaction(
approvalId: approvalId,
reason: reason,
),
shouldRetry: (error) => _isRetryableError(error),
);
return Right(approval);
} on ForbiddenException catch (e) {
return Left(ForbiddenFailure(e.message));
} on UnauthorizedException {
return Left(UnauthorizedFailure('Session expirée'));
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} on TimeoutException {
return Left(NetworkFailure('Délai d\'attente dépassé'));
} catch (e) {
return Left(UnexpectedFailure('Erreur inattendue: $e'));
}
}
@override
Future<Either<Failure, List<Budget>>> getBudgets({
String? organizationId,
BudgetStatus? status,
int? year,
}) async {
if (!await networkInfo.isConnected) {
return Left(NetworkFailure('Pas de connexion Internet'));
}
try {
final budgets = await _retryPolicy.execute(
operation: () => remoteDatasource.getBudgets(
organizationId: organizationId,
status: status?.name,
year: year,
),
shouldRetry: (error) => _isRetryableError(error),
);
return Right(budgets);
} on UnauthorizedException {
return Left(UnauthorizedFailure('Session expirée'));
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} on TimeoutException {
return Left(NetworkFailure('Délai d\'attente dépassé'));
} catch (e) {
return Left(UnexpectedFailure('Erreur inattendue: $e'));
}
}
@override
Future<Either<Failure, Budget>> getBudgetById(String budgetId) async {
if (!await networkInfo.isConnected) {
return Left(NetworkFailure('Pas de connexion Internet'));
}
try {
final budget = await _retryPolicy.execute(
operation: () => remoteDatasource.getBudgetById(budgetId),
shouldRetry: (error) => _isRetryableError(error),
);
return Right(budget);
} on NotFoundException {
return Left(NotFoundFailure('Budget non trouvé'));
} on UnauthorizedException {
return Left(UnauthorizedFailure('Session expirée'));
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} on TimeoutException {
return Left(NetworkFailure('Délai d\'attente dépassé'));
} catch (e) {
return Left(UnexpectedFailure('Erreur inattendue: $e'));
}
}
@override
Future<Either<Failure, Budget>> createBudget({
required String name,
String? description,
required String organizationId,
required BudgetPeriod period,
required int year,
int? month,
required List<BudgetLine> lines,
}) async {
if (!await networkInfo.isConnected) {
// Queue for retry when back online
await offlineManager.queueOperation(
operationType: 'createBudget',
endpoint: '/api/finance/budgets',
data: {
'name': name,
'description': description,
'organizationId': organizationId,
'period': period.name,
'year': year,
'month': month,
'lines': lines.map((line) => {
'id': line.id,
'category': line.category.name,
'name': line.name,
'description': line.description,
'amountPlanned': line.amountPlanned,
'amountRealized': line.amountRealized,
'notes': line.notes,
}).toList(),
},
);
return Left(NetworkFailure('Pas de connexion Internet. Opération mise en attente.'));
}
try {
final budget = await _retryPolicy.execute(
operation: () => remoteDatasource.createBudget(
name: name,
description: description,
organizationId: organizationId,
period: period.name,
year: year,
month: month,
lines: lines.map((line) => {
'id': line.id,
'category': line.category.name,
'name': line.name,
'description': line.description,
'amountPlanned': line.amountPlanned,
'amountRealized': line.amountRealized,
'notes': line.notes,
}).toList(),
),
shouldRetry: (error) => _isRetryableError(error),
);
return Right(budget);
} on ForbiddenException catch (e) {
return Left(ForbiddenFailure(e.message));
} on UnauthorizedException {
return Left(UnauthorizedFailure('Session expirée'));
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} on TimeoutException {
return Left(NetworkFailure('Délai d\'attente dépassé'));
} catch (e) {
return Left(UnexpectedFailure('Erreur inattendue: $e'));
}
}
@override
Future<Either<Failure, Map<String, dynamic>>> getBudgetTracking({
required String budgetId,
}) async {
if (!await networkInfo.isConnected) {
return Left(NetworkFailure('Pas de connexion Internet'));
}
try {
final tracking = await _retryPolicy.execute(
operation: () => remoteDatasource.getBudgetTracking(budgetId: budgetId),
shouldRetry: (error) => _isRetryableError(error),
);
return Right(tracking);
} on UnauthorizedException {
return Left(UnauthorizedFailure('Session expirée'));
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} on TimeoutException {
return Left(NetworkFailure('Délai d\'attente dépassé'));
} catch (e) {
return Left(UnexpectedFailure('Erreur inattendue: $e'));
}
}
/// Determine if an error is retryable
bool _isRetryableError(dynamic error) {
// Server errors are retryable
if (error is ServerException) return true;
// Timeout errors are retryable
if (error is TimeoutException) return true;
// Client errors are not retryable
if (error is UnauthorizedException) return false;
if (error is ForbiddenException) return false;
if (error is NotFoundException) return false;
if (error is ValidationException) return false;
// Unknown errors - default to not retryable
return false;
}
// === MÉTHODES NON IMPLÉMENTÉES (Stubs) ===
@override
Future<Either<Failure, TransactionApproval>> requestApproval({
required String transactionId,
required TransactionType transactionType,
required double amount,
}) async {
return Left(
NotImplementedFailure('Fonctionnalité en cours de développement'));
}
@override
Future<Either<Failure, List<TransactionApproval>>> getApprovalsHistory({
String? organizationId,
DateTime? startDate,
DateTime? endDate,
ApprovalStatus? status,
}) async {
return Left(
NotImplementedFailure('Fonctionnalité en cours de développement'));
}
@override
Future<Either<Failure, Budget>> updateBudget({
required String budgetId,
String? name,
String? description,
List<BudgetLine>? lines,
BudgetStatus? status,
}) async {
return Left(
NotImplementedFailure('Fonctionnalité en cours de développement'));
}
@override
Future<Either<Failure, void>> deleteBudget(String budgetId) async {
return Left(
NotImplementedFailure('Fonctionnalité en cours de développement'));
}
@override
Future<Either<Failure, List<FinancialAuditLog>>> getAuditLogs({
String? organizationId,
DateTime? startDate,
DateTime? endDate,
AuditOperation? operation,
AuditEntityType? entityType,
AuditSeverity? severity,
int? limit,
}) async {
return Left(
NotImplementedFailure('Fonctionnalité en cours de développement'));
}
@override
Future<Either<Failure, List<FinancialAuditLog>>> getAnomalies({
String? organizationId,
DateTime? startDate,
DateTime? endDate,
}) async {
return Left(
NotImplementedFailure('Fonctionnalité en cours de développement'));
}
@override
Future<Either<Failure, String>> exportAuditLogs({
required String organizationId,
DateTime? startDate,
DateTime? endDate,
String format = 'csv',
}) async {
return Left(
NotImplementedFailure('Fonctionnalité en cours de développement'));
}
@override
Future<Either<Failure, Map<String, dynamic>>> getWorkflowStats({
required String organizationId,
DateTime? startDate,
DateTime? endDate,
}) async {
return Left(
NotImplementedFailure('Fonctionnalité en cours de développement'));
}
}