Premier commit

This commit is contained in:
2026-09-11 14:55:41 +02:00
commit 8ed04aae89
134 changed files with 36627 additions and 0 deletions
+248
View File
@@ -0,0 +1,248 @@
# Supprimer la ligne ci-dessous si vous voulez hériter les paramètres .editorconfig des répertoires supérieurs
root = true
# Fichiers C#
[*.cs]
#### Options EditorConfig principales ####
# Indentation et espacement
indent_size = 4
indent_style = space
tab_width = 4
# Préférences de nouvelle ligne
end_of_line = crlf
insert_final_newline = false
#### Actions du code .NET ####
# Membres du type
dotnet_hide_advanced_members = false
dotnet_member_insertion_location = with_other_members_of_the_same_kind
dotnet_property_generation_behavior = prefer_throwing_properties
# Recherche de symbole
dotnet_search_reference_assemblies = true
#### Conventions de codage .NET ####
# Organiser les instructions Using
dotnet_separate_import_directive_groups = false
dotnet_sort_system_directives_first = false
file_header_template = unset
# Préférences de this. et Me.
dotnet_style_qualification_for_event = false
dotnet_style_qualification_for_field = false
dotnet_style_qualification_for_method = false
dotnet_style_qualification_for_property = false
# Préférences des mots clés de langage par rapport aux types BCL
dotnet_style_predefined_type_for_locals_parameters_members = true
dotnet_style_predefined_type_for_member_access = true
# Préférences de parenthèses
dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity
dotnet_style_parentheses_in_other_binary_operators = always_for_clarity
dotnet_style_parentheses_in_other_operators = never_if_unnecessary
dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity
# Préférences de modificateur
dotnet_style_require_accessibility_modifiers = for_non_interface_members
# Préférences de niveau expression
dotnet_prefer_system_hash_code = true
dotnet_style_coalesce_expression = true
dotnet_style_collection_initializer = true
dotnet_style_explicit_tuple_names = true
dotnet_style_namespace_match_folder = true
dotnet_style_null_propagation = true
dotnet_style_object_initializer = true
dotnet_style_operator_placement_when_wrapping = beginning_of_line
dotnet_style_prefer_auto_properties = true
dotnet_style_prefer_collection_expression = when_types_loosely_match
dotnet_style_prefer_compound_assignment = true
dotnet_style_prefer_conditional_expression_over_assignment = true
dotnet_style_prefer_conditional_expression_over_return = true
dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed
dotnet_style_prefer_inferred_anonymous_type_member_names = true
dotnet_style_prefer_inferred_tuple_names = true
dotnet_style_prefer_is_null_check_over_reference_equality_method = true
dotnet_style_prefer_non_hidden_explicit_cast_in_source = true
dotnet_style_prefer_simplified_boolean_expressions = true
dotnet_style_prefer_simplified_interpolation = true
# Préférences de champ
dotnet_style_readonly_field = true
# Préférences de paramètre
dotnet_code_quality_unused_parameters = all
# Préférences de suppression
dotnet_remove_unnecessary_suppression_exclusions = none
# Préférences de nouvelle ligne
dotnet_style_allow_multiple_blank_lines_experimental = true
dotnet_style_allow_statement_immediately_after_block_experimental = true
#### Conventions de codage C# ####
# Préférences de var
csharp_style_var_elsewhere = false
csharp_style_var_for_built_in_types = false
csharp_style_var_when_type_is_apparent = false
# Membres expression-bodied
csharp_style_expression_bodied_accessors = true
csharp_style_expression_bodied_constructors = false
csharp_style_expression_bodied_indexers = true
csharp_style_expression_bodied_lambdas = true
csharp_style_expression_bodied_local_functions = false
csharp_style_expression_bodied_methods = false
csharp_style_expression_bodied_operators = false
csharp_style_expression_bodied_properties = true
# Préférences correspondants au modèle
csharp_style_pattern_matching_over_as_with_null_check = true
csharp_style_pattern_matching_over_is_with_cast_check = true
csharp_style_prefer_extended_property_pattern = true
csharp_style_prefer_not_pattern = true
csharp_style_prefer_pattern_matching = true
csharp_style_prefer_switch_expression = true
# Préférences de vérification de valeur Null
csharp_style_conditional_delegate_call = true
# Préférences de modificateur
csharp_prefer_static_anonymous_function = true
csharp_prefer_static_local_function = true
csharp_preferred_modifier_order = public,private,protected,internal,file,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async
csharp_style_prefer_readonly_struct = true
csharp_style_prefer_readonly_struct_member = true
# Préférences de bloc de code
csharp_prefer_braces = true
csharp_prefer_simple_using_statement = true
csharp_prefer_system_threading_lock = true
csharp_style_namespace_declarations = block_scoped
csharp_style_prefer_method_group_conversion = true
csharp_style_prefer_primary_constructors = true
csharp_style_prefer_simple_property_accessors = true
csharp_style_prefer_top_level_statements = true
# Préférences de niveau expression
csharp_prefer_simple_default_expression = true
csharp_style_deconstructed_variable_declaration = true
csharp_style_implicit_object_creation_when_type_is_apparent = true
csharp_style_inlined_variable_declaration = true
csharp_style_prefer_implicitly_typed_lambda_expression = true
csharp_style_prefer_index_operator = true
csharp_style_prefer_local_over_anonymous_function = true
csharp_style_prefer_null_check_over_type_check = true
csharp_style_prefer_range_operator = true
csharp_style_prefer_tuple_swap = true
csharp_style_prefer_unbound_generic_type_in_nameof = true
csharp_style_prefer_utf8_string_literals = true
csharp_style_throw_expression = true
csharp_style_unused_value_assignment_preference = discard_variable
csharp_style_unused_value_expression_statement_preference = discard_variable
# Préférences pour la directive 'using'
csharp_using_directive_placement = outside_namespace
# Préférences de nouvelle ligne
csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = true
csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = true
csharp_style_allow_blank_line_after_token_in_conditional_expression_experimental = true
csharp_style_allow_blank_lines_between_consecutive_braces_experimental = true
csharp_style_allow_embedded_statements_on_same_line_experimental = true
#### Règles de formatage C# ####
# Préférences de nouvelle ligne
csharp_new_line_before_catch = true
csharp_new_line_before_else = true
csharp_new_line_before_finally = true
csharp_new_line_before_members_in_anonymous_types = true
csharp_new_line_before_members_in_object_initializers = true
csharp_new_line_before_open_brace = all
csharp_new_line_between_query_expression_clauses = true
# Préférences de mise en retrait
csharp_indent_block_contents = true
csharp_indent_braces = false
csharp_indent_case_contents = true
csharp_indent_case_contents_when_block = true
csharp_indent_labels = one_less_than_current
csharp_indent_switch_labels = true
# Préférences d'espace
csharp_space_after_cast = false
csharp_space_after_colon_in_inheritance_clause = true
csharp_space_after_comma = true
csharp_space_after_dot = false
csharp_space_after_keywords_in_control_flow_statements = true
csharp_space_after_semicolon_in_for_statement = true
csharp_space_around_binary_operators = before_and_after
csharp_space_around_declaration_statements = false
csharp_space_before_colon_in_inheritance_clause = true
csharp_space_before_comma = false
csharp_space_before_dot = false
csharp_space_before_open_square_brackets = false
csharp_space_before_semicolon_in_for_statement = false
csharp_space_between_empty_square_brackets = false
csharp_space_between_method_call_empty_parameter_list_parentheses = false
csharp_space_between_method_call_name_and_opening_parenthesis = false
csharp_space_between_method_call_parameter_list_parentheses = false
csharp_space_between_method_declaration_empty_parameter_list_parentheses = false
csharp_space_between_method_declaration_name_and_open_parenthesis = false
csharp_space_between_method_declaration_parameter_list_parentheses = false
csharp_space_between_parentheses = false
csharp_space_between_square_brackets = false
# Préférences d'enveloppement
csharp_preserve_single_line_blocks = true
csharp_preserve_single_line_statements = true
#### Styles de nommage ####
# Règles de nommage
dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion
dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface
dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i
dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.types_should_be_pascal_case.symbols = types
dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case
dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members
dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case
# Spécifications de symboles
dotnet_naming_symbols.interface.applicable_kinds = interface
dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.interface.required_modifiers =
dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum
dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.types.required_modifiers =
dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method
dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.non_field_members.required_modifiers =
# Styles de nommage
dotnet_naming_style.pascal_case.required_prefix =
dotnet_naming_style.pascal_case.required_suffix =
dotnet_naming_style.pascal_case.word_separator =
dotnet_naming_style.pascal_case.capitalization = pascal_case
dotnet_naming_style.begins_with_i.required_prefix = I
dotnet_naming_style.begins_with_i.required_suffix =
dotnet_naming_style.begins_with_i.word_separator =
dotnet_naming_style.begins_with_i.capitalization = pascal_case
+20
View File
@@ -0,0 +1,20 @@
@"
# Visual Studio
.vs/
*.user
*.suo
# Build outputs
bin/
obj/
**/bin/
**/obj/
# Node (si frontend JS/TS)
frontend/node_modules/
frontend/dist/
frontend/build/
# Autres
*.log
"@ | Out-File -Encoding utf8 .gitignore
+54
View File
@@ -0,0 +1,54 @@
using Microsoft.EntityFrameworkCore;
using SIFinance.Shared.Entities;
namespace SIFinance.Api;
// Envoie une requête minimale à SQL Server à intervalle régulier pour garder une
// connexion du pool active. Sans ça, une connexion inutilisée trop longtemps entre
// deux sessions de travail peut être coupée silencieusement par un équipement réseau
// (pare-feu/routeur) entre la machine de dev et le serveur SQL distant — la requête
// suivante doit alors attendre l'expiration de la connexion morte avant d'en ouvrir
// une nouvelle, d'où un premier chargement lent (plusieurs secondes) après une pause.
public class DbKeepAliveService(IServiceScopeFactory scopeFactory, ILogger<DbKeepAliveService> logger) : BackgroundService
{
private static readonly TimeSpan Interval = TimeSpan.FromMinutes(2);
// Le Dashboard (et d'autres pages) lancent plusieurs requêtes en parallèle au
// chargement (demandes/factures/avoirs) — chacune a besoin de sa propre connexion
// du pool. Ne garder qu'UNE connexion chaude ne profite qu'à la première requête
// servie ; les autres ouvrent quand même une connexion à froid en parallèle, avec
// le même délai. On chauffe donc plusieurs connexions à la fois.
private const int WarmConnections = 4;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Premier ping immédiat au démarrage : sans ça, PeriodicTimer attend un
// intervalle complet avant son premier tick, donc les toutes premières
// requêtes réelles de l'appli (juste après un dotnet run) n'en profitent
// jamais et paient quand même le coût d'ouverture de connexion à froid.
await PingMany(stoppingToken);
using var timer = new PeriodicTimer(Interval);
while (await timer.WaitForNextTickAsync(stoppingToken))
{
await PingMany(stoppingToken);
}
}
private Task PingMany(CancellationToken ct) =>
Task.WhenAll(Enumerable.Range(0, WarmConnections).Select(_ => Ping(ct)));
private async Task Ping(CancellationToken ct)
{
try
{
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SIFinanceDbContext>();
await db.Database.ExecuteSqlRawAsync("SELECT 1", ct);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogWarning(ex, "Échec du ping de maintien de connexion vers SQL Server.");
}
}
}
+153
View File
@@ -0,0 +1,153 @@
using System.Collections.Concurrent;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using SIFinance.Shared;
using SIFinance.Shared.Entities;
namespace SIFinance.Api;
// Le backend pilote lui-même le flux OAuth2 "authorization code + PKCE" avec Entra ID
// (plutôt que MSAL.js côté frontend) : /api/auth/microsoft redirige vers Microsoft,
// /api/auth/callback échange le code, résout l'utilisateur (déjà provisionné par
// EntraSyncService) et émet le jeton "maison" (JwtTokenService) consommé par le
// frontend pour tous les appels API suivants.
public static class EntraAuthEndpoints
{
// État PKCE en mémoire (code_verifier par "state"), expire après 10 min — un seul
// process API tourne en dev ; à revoir (cache distribué) si on scale horizontalement.
private static readonly ConcurrentDictionary<string, (string CodeVerifier, DateTime Expiry)> PkceStore = new();
public static IEndpointRouteBuilder MapEntraAuthEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/api/auth/microsoft", (IOptions<AzureAdOptions> azureOptions) =>
{
var opts = azureOptions.Value;
var state = Convert.ToHexString(RandomNumberGenerator.GetBytes(16));
var codeVerifier = Base64UrlEncode(RandomNumberGenerator.GetBytes(32));
var codeChallenge = Base64UrlEncode(SHA256.HashData(Encoding.UTF8.GetBytes(codeVerifier)));
CleanupExpired();
PkceStore[state] = (codeVerifier, DateTime.UtcNow.AddMinutes(10));
var url = QueryHelpers.AddQueryString(
$"https://login.microsoftonline.com/{opts.TenantId}/oauth2/v2.0/authorize",
new Dictionary<string, string?>
{
["client_id"] = opts.ClientId,
["response_type"] = "code",
["redirect_uri"] = opts.RedirectUri,
["response_mode"] = "query",
["scope"] = "openid profile email User.Read",
["state"] = state,
["prompt"] = "select_account",
["code_challenge"] = codeChallenge,
["code_challenge_method"] = "S256",
});
return Results.Redirect(url);
});
app.MapGet("/api/auth/callback", async (
string? code, string? state, string? error,
IOptions<AzureAdOptions> azureOptions,
IHttpClientFactory httpClientFactory,
SIFinanceDbContext db,
JwtTokenService jwtService,
IConfiguration config,
ILogger<Program> logger,
CancellationToken ct) =>
{
var frontendUrl = (config["FrontendUrl"] ?? "http://localhost:5173").TrimEnd('/');
if (!string.IsNullOrEmpty(error))
return Results.Redirect($"{frontendUrl}/login?error={Uri.EscapeDataString(error)}");
if (string.IsNullOrEmpty(code) || string.IsNullOrEmpty(state)
|| !PkceStore.TryRemove(state, out var entry) || entry.Expiry < DateTime.UtcNow)
return Results.Redirect($"{frontendUrl}/login?error=invalid_state");
var opts = azureOptions.Value;
var http = httpClientFactory.CreateClient();
var tokenResponse = await http.PostAsync(
$"https://login.microsoftonline.com/{opts.TenantId}/oauth2/v2.0/token",
new FormUrlEncodedContent(new Dictionary<string, string>
{
["client_id"] = opts.ClientId,
["client_secret"] = opts.ClientSecret,
["code"] = code,
["redirect_uri"] = opts.RedirectUri,
["grant_type"] = "authorization_code",
["code_verifier"] = entry.CodeVerifier,
}), ct);
if (!tokenResponse.IsSuccessStatusCode)
{
var body = await tokenResponse.Content.ReadAsStringAsync(ct);
logger.LogWarning("Échange du code OAuth2 échoué ({Status}) : {Body}", tokenResponse.StatusCode, body);
return Results.Redirect($"{frontendUrl}/login?error=token_exchange_failed");
}
var tokenJson = JsonDocument.Parse(await tokenResponse.Content.ReadAsStringAsync(ct)).RootElement;
var accessToken = tokenJson.GetProperty("access_token").GetString();
var meRequest = new HttpRequestMessage(HttpMethod.Get, "https://graph.microsoft.com/v1.0/me");
meRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
var meResponse = await http.SendAsync(meRequest, ct);
if (!meResponse.IsSuccessStatusCode)
{
var body = await meResponse.Content.ReadAsStringAsync(ct);
logger.LogWarning("Appel Graph /me échoué ({Status}) : {Body}", meResponse.StatusCode, body);
return Results.Redirect($"{frontendUrl}/login?error=graph_me_failed");
}
var me = JsonDocument.Parse(await meResponse.Content.ReadAsStringAsync(ct)).RootElement;
var email = GetStr(me, "mail") ?? GetStr(me, "userPrincipalName");
if (string.IsNullOrWhiteSpace(email))
return Results.Redirect($"{frontendUrl}/login?error=no_email");
var user = await db.Utilisateurs
.FirstOrDefaultAsync(u => u.Email == email && u.Actif, ct);
// Pas de création à la volée ici : l'utilisateur doit être membre du groupe
// Entra ID synchronisé par EntraSyncService pour pouvoir se connecter.
if (user is null)
{
logger.LogWarning("Connexion refusée — aucun utilisateur actif trouvé pour l'email {Email}.", email);
return Results.Redirect($"{frontendUrl}/login?error=user_not_found");
}
user.DateDerniereConnexion = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
var roles = await db.UtilisateurRoles
.AsNoTracking()
.Where(r => r.AzureOid == user.AzureOid)
.Select(r => r.Role)
.ToListAsync(ct);
var estResponsable = await db.Utilisateurs.AsNoTracking().AnyAsync(u => u.ResponsableId == user.Id, ct);
var jwt = jwtService.GenerateToken(user, roles, estResponsable);
return Results.Redirect($"{frontendUrl}/auth/callback?token={Uri.EscapeDataString(jwt)}");
});
return app;
}
private static string Base64UrlEncode(byte[] bytes) =>
Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
private static string? GetStr(JsonElement e, string name) =>
e.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.String ? p.GetString() : null;
private static void CleanupExpired()
{
foreach (var key in PkceStore.Where(kv => kv.Value.Expiry < DateTime.UtcNow).Select(kv => kv.Key).ToList())
PkceStore.TryRemove(key, out _);
}
}
+189
View File
@@ -0,0 +1,189 @@
using System.Net.Http.Headers;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using SIFinance.Shared;
using SIFinance.Shared.Entities;
namespace SIFinance.Api;
// Synchronise périodiquement les membres du groupe Entra ID (AzureAd:GroupId) dans
// core.utilisateurs : création des nouveaux membres (Profil par défaut "DEMANDEUR",
// à ajuster manuellement en base pour les profils Finance), mise à jour des noms/
// emails, désactivation de ceux qui ont quitté le groupe. Remplace la résolution
// "just-in-time" à la connexion — un utilisateur doit être membre du groupe pour
// pouvoir se connecter (voir EntraAuthEndpoints).
public class EntraSyncService(
IServiceScopeFactory scopeFactory,
IHttpClientFactory httpClientFactory,
EntraGraphTokenProvider tokenProvider,
IOptions<AzureAdOptions> options,
ILogger<EntraSyncService> logger) : BackgroundService
{
private static readonly TimeSpan Interval = TimeSpan.FromMinutes(30);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await SyncOnce(stoppingToken);
using var timer = new PeriodicTimer(Interval);
while (await timer.WaitForNextTickAsync(stoppingToken))
{
await SyncOnce(stoppingToken);
}
}
private async Task SyncOnce(CancellationToken ct)
{
var opts = options.Value;
if (string.IsNullOrWhiteSpace(opts.GroupId) || string.IsNullOrWhiteSpace(opts.ClientSecret))
{
logger.LogWarning("Synchro Entra ID ignorée — configuration AzureAd incomplète (GroupId/ClientSecret manquants).");
return;
}
try
{
var token = await tokenProvider.GetTokenAsync(ct);
var http = httpClientFactory.CreateClient();
var membres = new List<JsonElement>();
string? url = $"https://graph.microsoft.com/v1.0/groups/{opts.GroupId}/members" +
"?$select=id,givenName,surname,mail,userPrincipalName,accountEnabled&$top=999";
while (url is not null)
{
var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var response = await http.SendAsync(request, ct);
response.EnsureSuccessStatusCode();
using var stream = await response.Content.ReadAsStreamAsync(ct);
var json = await JsonDocument.ParseAsync(stream, cancellationToken: ct);
membres.AddRange(json.RootElement.GetProperty("value").EnumerateArray().Select(e => e.Clone()));
url = json.RootElement.TryGetProperty("@odata.nextLink", out var next) ? next.GetString() : null;
}
var valides = membres.Where(EstMembreValide).ToList();
var emailsActifs = valides.Select(m => GetStr(m, "mail")!.Trim().ToLowerInvariant()).ToHashSet();
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SIFinanceDbContext>();
var existants = await db.Utilisateurs.ToDictionaryAsync(u => u.Email.Trim().ToLowerInvariant(), ct);
var crees = 0;
var mis = 0;
foreach (var m in valides)
{
var email = GetStr(m, "mail")!.Trim().ToLowerInvariant();
var oid = GetStr(m, "id") ?? "";
var prenom = GetStr(m, "givenName") ?? "";
var nom = GetStr(m, "surname") ?? "";
if (existants.TryGetValue(email, out var existant))
{
existant.AzureOid = oid;
if (!string.IsNullOrWhiteSpace(nom)) existant.Nom = nom;
if (!string.IsNullOrWhiteSpace(prenom)) existant.Prenom = prenom;
existant.Actif = true;
mis++;
}
else
{
db.Utilisateurs.Add(new Utilisateur
{
AzureOid = oid,
Nom = nom,
Prenom = prenom,
Email = email,
Profil = "DEMANDEUR",
Actif = true,
DateCreation = DateTime.UtcNow,
});
crees++;
}
}
var desactives = 0;
foreach (var (email, existant) in existants)
{
if (existant.Actif && !emailsActifs.Contains(email))
{
existant.Actif = false;
desactives++;
}
}
// SaveChanges ici pour que les nouveaux utilisateurs aient un Id avant la
// résolution des liens responsable→collaborateur juste après (impossible de
// référencer un Utilisateur pas encore enregistré en base).
await db.SaveChangesAsync(ct);
var responsablesResolus = await ResoudreResponsablesAsync(db, valides, token, http, ct);
logger.LogInformation(
"Synchro Entra ID terminée : {Total} membres valides, {Crees} créés, {Mis} mis à jour, {Desactives} désactivés, {Responsables} liens responsable résolus.",
valides.Count, crees, mis, desactives, responsablesResolus);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogWarning(ex, "Échec de la synchro Entra ID.");
}
}
// Interroge le manager Entra ID de chaque membre (GET /users/{id}/manager) et
// résout son Id interne via l'email — le manager doit lui-même déjà exister comme
// Utilisateur (généralement le cas, les managers étant aussi membres du groupe
// synchronisé). Un appel par membre plutôt qu'un batch Graph : plus simple/robuste,
// le coût (~une centaine d'appels toutes les 30 min, en tâche de fond) est négligeable.
private async Task<int> ResoudreResponsablesAsync(SIFinanceDbContext db, List<JsonElement> valides, string token, HttpClient http, CancellationToken ct)
{
var utilisateurs = await db.Utilisateurs.ToDictionaryAsync(u => u.Email.Trim().ToLowerInvariant(), ct);
var resolus = 0;
foreach (var m in valides)
{
var oid = GetStr(m, "id");
var email = GetStr(m, "mail")?.Trim().ToLowerInvariant();
if (oid is null || email is null || !utilisateurs.TryGetValue(email, out var collaborateur)) continue;
try
{
var request = new HttpRequestMessage(HttpMethod.Get, $"https://graph.microsoft.com/v1.0/users/{oid}/manager?$select=id,mail,userPrincipalName");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var response = await http.SendAsync(request, ct);
if (!response.IsSuccessStatusCode) continue; // pas de manager (404) ou erreur ponctuelle — on laisse ResponsableId tel quel
using var managerStream = await response.Content.ReadAsStreamAsync(ct);
using var managerDoc = await JsonDocument.ParseAsync(managerStream, cancellationToken: ct);
var managerEmail = (GetStr(managerDoc.RootElement, "mail") ?? GetStr(managerDoc.RootElement, "userPrincipalName"))?.Trim().ToLowerInvariant();
if (managerEmail is null || !utilisateurs.TryGetValue(managerEmail, out var responsable)) continue;
if (responsable.Id == collaborateur.Id) continue; // garde-fou, ne devrait pas arriver
if (collaborateur.ResponsableId != responsable.Id)
{
collaborateur.ResponsableId = responsable.Id;
resolus++;
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogWarning(ex, "Échec de résolution du manager pour {Email}.", email);
}
}
await db.SaveChangesAsync(ct);
return resolus;
}
private static bool EstMembreValide(JsonElement m)
{
var mail = GetStr(m, "mail");
if (string.IsNullOrWhiteSpace(mail)) return false;
// accountEnabled absent (utilisateur non hydraté) est traité comme actif ;
// seule une valeur explicite "false" exclut le membre.
return !m.TryGetProperty("accountEnabled", out var p) || p.ValueKind != JsonValueKind.False;
}
private static string? GetStr(JsonElement e, string name) =>
e.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.String ? p.GetString() : null;
}
+11
View File
@@ -0,0 +1,11 @@
namespace SIFinance.Api;
// Section "Jwt" de appsettings.Development.json — clé de signature des jetons émis
// par SIFinance.Api après connexion Entra ID (voir JwtTokenService / EntraAuthEndpoints).
public class JwtOptions
{
public const string SectionName = "Jwt";
public string Secret { get; set; } = "";
public int ExpiresHours { get; set; } = 8;
}
+52
View File
@@ -0,0 +1,52 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using SIFinance.Shared.Entities;
namespace SIFinance.Api;
// Émet le jeton "maison" utilisé pour authentifier les appels à l'API après la
// connexion Entra ID (voir EntraAuthEndpoints.MapGet("/api/auth/callback")). Le
// claim "id" porte directement l'Id de la ligne core.utilisateurs — CurrentUserService
// n'a donc plus besoin d'accéder à la base pour résoudre l'utilisateur courant.
public class JwtTokenService(IOptions<JwtOptions> options)
{
public string GenerateToken(Utilisateur user, IEnumerable<string>? roles = null, bool estResponsable = false)
{
var opts = options.Value;
if (string.IsNullOrWhiteSpace(opts.Secret))
throw new InvalidOperationException("Configuration Jwt:Secret manquante — voir appsettings.Development.json.");
var claims = new List<Claim>
{
new("id", user.Id.ToString()),
new(ClaimTypes.Email, user.Email),
new("nom", user.Nom),
new("prenom", user.Prenom),
new("profil", user.Profil),
// true seulement si au moins un utilisateur a ce compte comme responsable_id —
// pilote l'affichage "Responsable" côté frontend (Layout.tsx).
new("estResponsable", estResponsable ? "true" : "false"),
};
// Sert à scoper les vues (ex. Pilotage) au service du collaborateur connecté —
// absent si l'utilisateur n'est rattaché à aucun service.
if (user.ServiceId.HasValue)
claims.Add(new Claim("serviceId", user.ServiceId.Value.ToString()));
// Un utilisateur peut cumuler plusieurs rôles métier (ex. DEMANDEUR + FINANCE) —
// "profil" reste la colonne historique à valeur unique, "roles" (claim répétée)
// porte la liste réelle depuis core.utilisateur_roles, utilisée pour le bascule
// d'espace de travail côté frontend.
foreach (var role in roles ?? [])
claims.Add(new Claim("roles", role));
var key = new SymmetricSecurityKey(Convert.FromBase64String(opts.Secret));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
claims: claims,
expires: DateTime.UtcNow.AddHours(opts.ExpiresHours <= 0 ? 8 : opts.ExpiresHours),
signingCredentials: creds);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
+106
View File
@@ -0,0 +1,106 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using SIFinance.Api;
using SIFinance.Modules.Achats.Endpoints;
using SIFinance.Modules.Finance.Endpoints;
using SIFinance.Modules.SousTraitants.Endpoints;
using SIFinance.Shared;
using SIFinance.Shared.Entities;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
builder.Services.AddDbContext<SIFinanceDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
builder.Services.AddHostedService<DbKeepAliveService>();
builder.Services.AddHttpClient();
builder.Services.Configure<AzureAdOptions>(builder.Configuration.GetSection(AzureAdOptions.SectionName));
builder.Services.Configure<JwtOptions>(builder.Configuration.GetSection(JwtOptions.SectionName));
builder.Services.AddSingleton<EntraGraphTokenProvider>();
builder.Services.AddSingleton<JwtTokenService>();
builder.Services.AddHostedService<EntraSyncService>();
// Valide le jeton "maison" émis par SIFinance.Api après l'échange OAuth2 avec Entra ID
// (voir EntraAuthEndpoints) — pas de validation directe de jeton Entra ID : le backend
// pilote lui-même le flux de connexion, comme l'appli NDF existante (JWT maison +
// synchro périodique du groupe Entra ID via EntraSyncService), plutôt que MSAL.js +
// Microsoft.Identity.Web côté frontend.
var jwtSecret = builder.Configuration["Jwt:Secret"]
?? throw new InvalidOperationException("Configuration Jwt:Secret manquante — voir appsettings.Development.json.");
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = false,
ValidateAudience = false,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Convert.FromBase64String(jwtSecret)),
};
});
builder.Services.AddAuthorization();
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<CurrentUserService>();
builder.Services.Configure<SharePointOptions>(builder.Configuration.GetSection(SharePointOptions.SectionName));
// Pas de client Graph enregistré directement en DI : tant que TenantId/ClientId/
// ClientSecret ne sont pas renseignés (ou invalides), ClientSecretCredential lève une
// ArgumentException dès sa construction — avant même le premier appel Graph. Si le
// client était un singleton construit par le conteneur DI, cette exception surviendrait
// pendant la résolution de la requête, hors de portée d'un try/catch dans l'endpoint,
// et remonterait comme une erreur 500 brute. SharePointGraphClientFactory construit le
// client à la demande, à l'intérieur du bloc try/catch de l'endpoint.
builder.Services.AddSingleton<SharePointGraphClientFactory>();
builder.Services.AddSingleton<SharePointUploadService>();
builder.Services.AddSingleton<NotificationEmailService>();
const string FrontendCorsPolicy = "Frontend";
builder.Services.AddCors(options =>
{
options.AddPolicy(FrontendCorsPolicy, policy =>
policy.WithOrigins(
"http://localhost:5173", "http://localhost:5174",
"http://127.0.0.1:5173", "http://127.0.0.1:5174")
.AllowAnyHeader()
.AllowAnyMethod()
// Le navigateur ne mémorise le résultat d'un preflight OPTIONS que sur la durée
// indiquée ici (par défaut ~5s) : on l'étend pour éviter un aller-retour OPTIONS
// avant chaque POST/PUT/PATCH répété (création/édition) pendant la session.
.SetPreflightMaxAge(TimeSpan.FromMinutes(30)));
});
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
if (!app.Environment.IsDevelopment())
{
app.UseHttpsRedirection();
}
app.UseCors(FrontendCorsPolicy);
app.UseAuthentication();
app.UseAuthorization();
app.MapAchatsEndpoints();
app.MapContratsEndpoints();
app.MapBudgetsEndpoints();
app.MapFacturesEndpoints();
app.MapAvoirsEndpoints();
app.MapPaiementsEndpoints();
app.MapDashboardEndpoints();
app.MapSharePointEndpoints();
app.MapEntraAuthEndpoints();
app.MapOrganismesEndpoints();
app.MapContratsPrestatairesEndpoints();
app.Run();
@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://0.0.0.0:5272",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://0.0.0.0:7286;http://0.0.0.0:5272",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
+29
View File
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<NuGetAuditMode>direct</NuGetAuditMode>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.Identity" Version="1.21.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.9" />
<PackageReference Include="Microsoft.Graph" Version="6.2.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\SIFinance.Shared\SIFinance.Shared.csproj" />
<ProjectReference Include="..\SIFinance.Modules.Achats\SIFinance.Modules.Achats.csproj" />
<ProjectReference Include="..\SIFinance.Modules.Finance\SIFinance.Modules.Finance.csproj" />
<ProjectReference Include="..\SIFinance.Modules.SousTraitants\SIFinance.Modules.SousTraitants.csproj" />
</ItemGroup>
</Project>
+6
View File
@@ -0,0 +1,6 @@
@SIFinance.Api_HostAddress = http://localhost:5272
GET {{SIFinance.Api_HostAddress}}/weatherforecast/
Accept: application/json
###
+101
View File
@@ -0,0 +1,101 @@
using Azure.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Graph;
using SIFinance.Shared;
using SIFinance.Shared.Entities;
namespace SIFinance.Api;
public record SharePointSiteDto(string Id, string Name, string? WebUrl);
public record SharePointUploadDto(string WebUrl, string ItemId, long Size, string Nom);
// Endpoints de découverte SharePoint + upload de documents dans les dossiers modules
// (Achats / Sous-Traitants / Contrat Etudiant) du drive configuré (SharePoint:DriveId).
public static class SharePointEndpoints
{
// Whitelist stricte plutôt que d'accepter n'importe quel nom de dossier envoyé par le
// client — évite qu'un chemin arbitraire soit construit dans l'URL Graph.
private static readonly HashSet<string> DossiersAutorises =
new(StringComparer.OrdinalIgnoreCase) { "Achats", "Sous-Traitants", "Contrat Etudiant" };
public static IEndpointRouteBuilder MapSharePointEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/sharepoint").WithTags("SharePoint").RequireAuthorization();
group.MapGet("/sites", async (SharePointGraphClientFactory graphFactory, CancellationToken ct) =>
{
try
{
var graph = graphFactory.Create();
var result = await graph.Sites.GetAsync(cfg => cfg.QueryParameters.Search = "*", ct);
var sites = (result?.Value ?? [])
.Select(s => new SharePointSiteDto(s.Id ?? "", s.Name ?? s.DisplayName ?? "(sans nom)", s.WebUrl))
.ToList();
return Results.Ok(sites);
}
catch (Exception ex)
{
// Cas fréquents : secret pas encore renseigné, permission Sites.Read.All
// absente, ou admin consent pas encore accordé (403).
return Results.BadRequest(new
{
error = "Impossible de récupérer la liste des sites SharePoint — vérifie les identifiants et les permissions (Sites.Read.All ou Sites.ReadWrite.All, avec admin consent).",
detail = ex.Message,
});
}
});
group.MapPost("/upload", async (
HttpRequest request, SharePointUploadService uploadService,
SIFinanceDbContext db, CurrentUserService currentUser, CancellationToken ct) =>
{
if (!request.HasFormContentType)
return Results.BadRequest(new { error = "Contenu multipart attendu." });
var form = await request.ReadFormAsync(ct);
var file = form.Files.GetFile("file");
var dossier = form["dossier"].ToString();
var service = form["service"].ToString();
if (file is null || file.Length == 0)
return Results.BadRequest(new { error = "Fichier manquant." });
if (!DossiersAutorises.Contains(dossier))
return Results.BadRequest(new { error = "Dossier de destination invalide." });
// Le sous-dossier "collaborateur" est résolu depuis l'utilisateur authentifié,
// jamais depuis une valeur envoyée par le client — sinon n'importe qui pourrait
// faire apparaître son upload dans le dossier d'un collègue.
var userId = await currentUser.GetOrCreateCurrentUserIdAsync(ct);
var user = await db.Utilisateurs.AsNoTracking().FirstOrDefaultAsync(u => u.Id == userId, ct);
var collaborateur = user is null ? "Autre" : $"{user.Prenom} {user.Nom}";
var segments = new List<string> { dossier };
if (!string.IsNullOrWhiteSpace(service)) segments.Add(SanitizeSegment(service));
segments.Add(SanitizeSegment(collaborateur));
var cheminDossier = string.Join('/', segments);
try
{
await using var stream = file.OpenReadStream();
var result = await uploadService.UploadAsync(cheminDossier, file.FileName, stream, file.ContentType, ct);
return Results.Ok(new SharePointUploadDto(result.WebUrl, result.ItemId, result.Size, file.FileName));
}
catch (Exception ex)
{
return Results.BadRequest(new { error = "Échec de l'upload vers SharePoint.", detail = ex.Message });
}
});
return app;
}
private static string SanitizeSegment(string value)
{
var trimmed = value.Trim();
if (trimmed.Length == 0) return "Autre";
var invalides = new[] { '"', '*', ':', '<', '>', '?', '/', '\\', '|' };
var nettoye = new string(trimmed.Select(c => invalides.Contains(c) ? '-' : c).ToArray());
return nettoye.Length == 0 ? "Autre" : nettoye;
}
}
@@ -0,0 +1,22 @@
using Azure.Identity;
using Microsoft.Extensions.Options;
using Microsoft.Graph;
namespace SIFinance.Api;
// Construit le GraphServiceClient à la demande plutôt qu'au démarrage : tant que les
// identifiants SharePoint (TenantId/ClientId/ClientSecret) ne sont pas renseignés,
// ClientSecretCredential lève une exception à la construction — on veut que ça arrive
// à l'intérieur du try/catch de l'endpoint qui l'appelle, pas pendant la résolution DI.
public class SharePointGraphClientFactory(IOptions<SharePointOptions> options)
{
public GraphServiceClient Create()
{
var opts = options.Value;
if (string.IsNullOrWhiteSpace(opts.TenantId) || string.IsNullOrWhiteSpace(opts.ClientId) || string.IsNullOrWhiteSpace(opts.ClientSecret))
throw new InvalidOperationException("Configuration SharePoint incomplète (TenantId/ClientId/ClientSecret) — voir SIFinance.Api/appsettings.Development.json.");
var credential = new ClientSecretCredential(opts.TenantId, opts.ClientId, opts.ClientSecret);
return new GraphServiceClient(credential, ["https://graph.microsoft.com/.default"]);
}
}
+16
View File
@@ -0,0 +1,16 @@
namespace SIFinance.Api;
// Section "SharePoint" de appsettings.Development.json — identifiants de l'app
// registration Azure AD (flux client credentials, app-only, pas de connexion
// utilisateur). SiteId/DriveId sont renseignés une fois trouvés via
// GET /api/sharepoint/sites (et le futur endpoint pour les drives).
public class SharePointOptions
{
public const string SectionName = "SharePoint";
public string TenantId { get; set; } = "";
public string ClientId { get; set; } = "";
public string ClientSecret { get; set; } = "";
public string SiteId { get; set; } = "";
public string DriveId { get; set; } = "";
}
+80
View File
@@ -0,0 +1,80 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.Options;
using SIFinance.Shared;
namespace SIFinance.Api;
public record SharePointUploadResult(string WebUrl, string ItemId, long Size);
// Dépose un fichier dans un sous-dossier du drive SharePoint configuré (SharePoint:DriveId)
// — un dossier par module (Achats / Sous-Traitants / Contrat Etudiant), créés lors du
// provisioning du site. Réutilise le jeton Graph "app-only" déjà mis en cache par
// EntraGraphTokenProvider plutôt que ClientSecretCredential/GraphServiceClient : plus
// simple pour un simple PUT de contenu, et le jeton couvre déjà Sites.ReadWrite.All.
public class SharePointUploadService(
IHttpClientFactory httpClientFactory,
EntraGraphTokenProvider tokenProvider,
IOptions<SharePointOptions> options)
{
// Upload "simple" (PUT direct) : suffisant jusqu'à 4 Mo, ce qui couvre largement les
// justificatifs (devis, factures, bons de commande scannés). Au-delà, Graph exige une
// session d'upload par blocs — pas implémenté ici, hors scope du premier jet.
// cheminDossier peut contenir plusieurs segments (ex: "Achats/Informatique/Jean Dupont") —
// Graph crée automatiquement les dossiers intermédiaires manquants lors d'un upload
// vers un chemin qui n'existe pas encore, pas besoin de les créer explicitement.
public async Task<SharePointUploadResult> UploadAsync(string cheminDossier, string fileName, Stream content, string? contentType, CancellationToken ct)
{
var opts = options.Value;
if (string.IsNullOrWhiteSpace(opts.DriveId))
throw new InvalidOperationException("Configuration SharePoint incomplète (DriveId) — voir appsettings.Development.json.");
var token = await tokenProvider.GetTokenAsync(ct);
var http = httpClientFactory.CreateClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var safeName = Uri.EscapeDataString(fileName);
var url = $"https://graph.microsoft.com/v1.0/drives/{opts.DriveId}/root:/{cheminDossier}/{safeName}:/content?@microsoft.graph.conflictBehavior=rename";
using var streamContent = new StreamContent(content);
streamContent.Headers.ContentType = new MediaTypeHeaderValue(string.IsNullOrWhiteSpace(contentType) ? "application/octet-stream" : contentType);
var response = await http.PutAsync(url, streamContent, ct);
var body = await response.Content.ReadAsStringAsync(ct);
if (!response.IsSuccessStatusCode)
throw new InvalidOperationException($"Échec de l'upload SharePoint ({response.StatusCode}) : {body}");
var json = JsonDocument.Parse(body).RootElement;
var itemWebUrl = json.GetProperty("webUrl").GetString()!;
var itemId = json.GetProperty("id").GetString()!;
var size = json.TryGetProperty("size", out var sizeProp) ? sizeProp.GetInt64() : content.Length;
// L'URL brute de l'item n'est accessible qu'aux membres du groupe SharePoint
// sous-jacent — un lien de partage "organisation" permet à n'importe quel
// utilisateur du tenant, connecté avec son compte Microsoft, de l'ouvrir sans
// avoir besoin d'être membre du groupe. On le préfère donc à l'URL brute quand
// Graph accepte de le créer (si ça échoue, on retombe sur l'URL brute plutôt
// que de faire échouer tout l'upload).
var lienOrganisation = await CreerLienOrganisationAsync(opts.DriveId, itemId, http, ct);
return new SharePointUploadResult(lienOrganisation ?? itemWebUrl, itemId, size);
}
private static async Task<string?> CreerLienOrganisationAsync(string driveId, string itemId, HttpClient http, CancellationToken ct)
{
try
{
var response = await http.PostAsJsonAsync(
$"https://graph.microsoft.com/v1.0/drives/{driveId}/items/{itemId}/createLink",
new { type = "view", scope = "organization" }, ct);
if (!response.IsSuccessStatusCode) return null;
var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)).RootElement;
return json.GetProperty("link").GetProperty("webUrl").GetString();
}
catch
{
return null;
}
}
}
@@ -0,0 +1,22 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AzureAd": {
"ClientSecret": "xA.8Q~fRieIg-HZEunz__xLws0a4E.hdJU88sb8s",
"GroupId": "c1ea877c-6bca-4f47-bfad-f223640813a0"
},
"Jwt": {
"Secret": "83/Ug9ginhRDJJYUWy2YWlAxu5egbzgewuYaV8elGPxlxXwzWI5GokMD7I9QvhbmA7tz9+IlPOkqy7/uInwUcg=="
},
"SharePoint": {
"TenantId": "9840a2a0-6ae1-4688-b03d-d2ec291be0f9",
"ClientId": "277b070b-83f7-4c15-8787-7acb51c2f567",
"ClientSecret": "xA.8Q~fRieIg-HZEunz__xLws0a4E.hdJU88sb8s",
"SiteId": "ensup.sharepoint.com,9096beec-6c67-45a9-bba1-693f767c00d4,dc598513-c302-44ce-b25e-6b369c0ba602",
"DriveId": "b!7L6WkGdsqUW7oWk_dnwA1BOFWdwCw85Esl5rNpwLpgIIr8dZ90UYTqtD1bA3wKY7"
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"Default": "Server=192.168.0.3,1433;Database=SIFinance;Trusted_Connection=True;TrustServerCertificate=True;Connection Lifetime=60;Min Pool Size=4"
},
"AzureAd": {
"TenantId": "9840a2a0-6ae1-4688-b03d-d2ec291be0f9",
"ClientId": "277b070b-83f7-4c15-8787-7acb51c2f567",
"RedirectUri": "http://localhost:5272/api/auth/callback"
},
"FrontendUrl": "http://localhost:5173"
}
@@ -0,0 +1,21 @@
namespace SIFinance.Modules.Achats.Dtos;
public record BudgetRevisionDto(int Id, decimal MontantRevise, string? Commentaire, string RevisePar, DateTime DateRevision);
public record BudgetServiceDto(
int Id,
int ServiceId,
string Service,
int ExerciceAnnee,
decimal MontantInitial,
decimal? MontantRevise,
List<BudgetRevisionDto> Revisions);
// L'exercice n'est pas calendaire : ExerciceAnnee=2026 désigne la période du 01/09/2026
// au 31/08/2027 (cf. BudgetService.cs). Un budget initial est unique par service et par
// exercice — la création échoue s'il en existe déjà un (contrainte d'unicité en base).
public record CreateBudgetServiceDto(int ServiceId, int ExerciceAnnee, decimal MontantInitial);
// Chaque révision est ajoutée à l'historique, jamais écrasée — MontantRevise doit
// représenter le nouveau plafond total, pas un delta à ajouter.
public record CreateBudgetRevisionDto(decimal MontantRevise, string? Commentaire);
@@ -0,0 +1,57 @@
namespace SIFinance.Modules.Achats.Dtos;
public record ContratEcheanceDto(int Id, DateOnly Date, decimal Montant, string Statut, string? JustificatifNom);
public record ContratDocumentDto(int Id, DateOnly Date, string Tag, string Nom);
// Rapprochement facture ↔ contrat : permet de voir, sur la fiche contrat, quelles factures
// ont déjà été saisies contre lui (sans lien automatique aux échéances — le rapprochement
// avec une échéance précise reste une vérification humaine).
public record ContratFactureLieeDto(int Id, string Numero, decimal MontantTtc, DateOnly DateFacture, string Statut);
public record ContratDto(
int Id,
int FournisseurId,
string Fournisseur,
int? ServiceId,
string? Service,
int? CampusId,
string? Campus,
string? Reference,
string Objet,
DateOnly? DateSignature,
DateOnly DateDebut,
DateOnly DateFin,
DateOnly? DateResiliation,
string? MotifResiliation,
decimal MontantTotal,
string Periodicite,
string Statut,
List<ContratEcheanceDto> Echeances,
List<ContratDocumentDto> Documents,
List<ContratFactureLieeDto> FacturesLiees);
public record CreateContratEcheanceDto(DateOnly Date, decimal Montant);
public record CreateContratDto(
int FournisseurId,
int? ServiceId,
int? CampusId,
string Objet,
string? Reference,
DateOnly? DateSignature,
DateOnly DateDebut,
DateOnly DateFin,
decimal MontantTotal,
string Periodicite,
List<CreateContratEcheanceDto> Echeances);
public record ChangerStatutContratDto(string Statut, DateOnly? DateResiliation, string? MotifResiliation, string? Commentaire);
public record ContratHistoriqueDto(int Id, string? AncienStatut, string NouveauStatut, string? Commentaire, string Utilisateur, DateTime DateAction);
public record UpdateEcheanceDto(DateOnly Date, decimal Montant, string Statut, string? JustificatifNom);
public record AddDocumentDto(string Tag, string Nom, DateOnly? Date);
public record UpdateDocumentDto(string Tag, DateOnly Date);
@@ -0,0 +1,127 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using SIFinance.Shared.Entities;
namespace SIFinance.Modules.Achats.Endpoints;
// Endpoint agrégé du tableau de bord : tous les compteurs et sommes sont calculés
// par SQL Server (COUNT/SUM), et seules les quelques lignes réellement affichées
// sont transférées. Le temps de réponse ne dépend donc plus du volume en base.
public static class DashboardEndpoints
{
private static readonly string[] StatutsEnAttente = ["SOUMISE", "EN_VALIDATION", "COMPLEMENT_REQUIS"];
private static readonly string[] StatutsEngages = ["VALIDEE", "FACTUREE"];
public static IEndpointRouteBuilder MapDashboardEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/dashboard").WithTags("Dashboard").RequireAuthorization();
group.MapGet("", async (SIFinanceDbContext db, CancellationToken ct) =>
{
var today = DateTime.UtcNow.Date;
// DateEcheance est un DateOnly côté entité : les comparaisons doivent se
// faire avec un DateOnly pour être traduites en SQL.
var todayDate = DateOnly.FromDateTime(today);
// Bornes du trimestre courant et du précédent, calculées côté C# pour que
// la comparaison reste une simple comparaison de dates traduisible en SQL.
var trimCourant = (today.Month - 1) / 3;
var debutTrimestre = new DateTime(today.Year, trimCourant * 3 + 1, 1);
var debutTrimestrePrecedent = debutTrimestre.AddMonths(-3);
var demandes = db.DemandesAchats.AsNoTracking();
var factures = db.Factures.AsNoTracking();
// --- KPI demandes ---
var enAttente = demandes.Where(d => StatutsEnAttente.Contains(d.Statut));
var nbAValider = await enAttente.CountAsync(ct);
var montantAValider = await enAttente.SumAsync(d => (decimal?)d.MontantTtc, ct) ?? 0m;
var engagees = demandes.Where(d => StatutsEngages.Contains(d.Statut));
var engageCourant = await engagees
.Where(d => d.DateCreation >= debutTrimestre)
.SumAsync(d => (decimal?)d.MontantTtc, ct) ?? 0m;
var engagePrecedent = await engagees
.Where(d => d.DateCreation >= debutTrimestrePrecedent && d.DateCreation < debutTrimestre)
.SumAsync(d => (decimal?)d.MontantTtc, ct) ?? 0m;
// --- KPI factures ---
var aPayer = factures.Where(f => f.Statut == "validee");
var nbAPayer = await aPayer.CountAsync(ct);
var montantAPayer = await aPayer.SumAsync(f => (decimal?)f.MontantTtc, ct) ?? 0m;
var enCours = factures.Where(f => f.Statut != "payee");
var nbEchues = await enCours.CountAsync(f => f.DateEcheance < todayDate, ct);
var nbProches = await enCours.CountAsync(f => f.DateEcheance >= todayDate && f.DateEcheance <= todayDate.AddDays(7), ct);
// --- Listes affichées (volumes bornés par Take) ---
var demandesRecentes = await demandes
.OrderByDescending(d => d.DateCreation)
.Take(4)
.Select(d => new DashboardDemandeDto(d.Id, d.Reference, d.Objet, d.MontantTtc, d.Statut, d.TypeDepenseId))
.ToListAsync(ct);
var demandesAValiderApercu = await enAttente
.OrderByDescending(d => d.DateCreation)
.Take(1)
.Select(d => new DashboardDemandeDto(d.Id, d.Reference, d.Objet, d.MontantTtc, d.Statut, d.TypeDepenseId))
.ToListAsync(ct);
var echeances = await enCours
.OrderBy(f => f.DateEcheance)
.Take(5)
.Select(f => new DashboardEcheanceDto(f.Id, f.NumeroFacture, f.Fournisseur.RaisonSociale, f.MontantTtc, f.DateEcheance))
.ToListAsync(ct);
// Alertes : uniquement les premières lignes réellement affichées.
var facturesEchues = await enCours
.Where(f => f.DateEcheance < todayDate)
.OrderBy(f => f.DateEcheance)
.Take(5)
.Select(f => new DashboardEcheanceDto(f.Id, f.NumeroFacture, f.Fournisseur.RaisonSociale, f.MontantTtc, f.DateEcheance))
.ToListAsync(ct);
var avoirsDisponibles = await db.Avoirs
.AsNoTracking()
.Where(a => a.SoldeDisponible > 0)
.OrderByDescending(a => a.DateCreation)
.Take(5)
.Select(a => new DashboardAvoirDto(a.Id, a.NumeroAvoir, a.Fournisseur.RaisonSociale, a.SoldeDisponible))
.ToListAsync(ct);
return Results.Ok(new DashboardDto(
engageCourant, engagePrecedent,
nbAValider, montantAValider,
nbAPayer, montantAPayer,
nbProches, nbEchues,
demandesRecentes,
demandesAValiderApercu.FirstOrDefault(),
facturesEchues,
avoirsDisponibles,
echeances));
});
return app;
}
}
public record DashboardDemandeDto(int Id, string Reference, string Objet, decimal MontantTtc, string Statut, int TypeDepenseId);
public record DashboardEcheanceDto(int Id, string Numero, string Fournisseur, decimal MontantTtc, DateOnly DateEcheance);
public record DashboardAvoirDto(int Id, string Numero, string Fournisseur, decimal SoldeDisponible);
public record DashboardDto(
decimal EngageCourant,
decimal EngagePrecedent,
int NbAValider,
decimal MontantAValider,
int NbAPayer,
decimal MontantAPayer,
int NbEcheancesProches,
int NbFacturesEchues,
IReadOnlyList<DashboardDemandeDto> DemandesRecentes,
DashboardDemandeDto? PremiereAValider,
IReadOnlyList<DashboardEcheanceDto> FacturesEchues,
IReadOnlyList<DashboardAvoirDto> AvoirsDisponibles,
IReadOnlyList<DashboardEcheanceDto> Echeances);
@@ -0,0 +1,41 @@
namespace SIFinance.Modules.Achats.Dtos;
public record CreateJustificatifDto(
string Libelle, string Nom,
string? SharepointUrl, string? SharepointItemId, long? TailleOctets);
public record DemandeAchatCreateDto(
int ServiceId,
string Objet,
string? Description,
int? FournisseurId,
string? FournisseurLibre,
string? CategorieFournisseurLibre,
int TypeDepenseId,
decimal MontantHt,
decimal TauxTva,
DateOnly DatePrevisionnelle,
int ModePaiementId,
List<CreateJustificatifDto>? Justificatifs);
public record UpdateDemandeStatutDto(string Statut);
// Le workflow matériel utilise des actions, plutôt qu'un statut libre envoyé par le
// navigateur : l'API conserve ainsi le contrôle des transitions et des rôles autorisés.
public record WorkflowDemandeDto(
string Action,
string? Commentaire,
string? DocumentNom = null,
string? DocumentSharepointUrl = null,
string? DocumentSharepointItemId = null,
long? DocumentTailleOctets = null);
public record AjouterFactureDemandeDto(
string Numero,
decimal Montant,
DateOnly DateFacture,
DateOnly DateEcheance,
string DocumentNom,
string DocumentSharepointUrl,
string DocumentSharepointItemId,
long DocumentTailleOctets);
@@ -0,0 +1,35 @@
namespace SIFinance.Modules.Achats.Dtos;
public record DemandeJustificatifDto(int Id, string Libelle, string Nom, DateTime? DateUpload, string? SharepointUrl);
public record DemandeWorkflowHistoriqueDto(string Action, string? Commentaire, string? AncienStatut, string? NouveauStatut, string Auteur, DateTime? DateAction);
public record DemandeAchatDto(
int Id,
string Reference,
int ServiceId,
string Objet,
string? Description,
int? FournisseurId,
string? FournisseurLibre,
string? CategorieFournisseurLibre,
int TypeDepenseId,
decimal MontantHt,
decimal TauxTva,
decimal MontantTva,
decimal MontantTtc,
DateOnly DatePrevisionnelle,
int ModePaiementId,
string Statut,
int DemandeurId,
string DemandeurNom,
int? ValideurId,
string? ValideurNom,
decimal SeuilApplicable,
DateTime? DateCreation,
DateTime? DateSoumission,
DateTime? DateDerniereMaj,
List<DemandeJustificatifDto> Justificatifs,
bool PeutValider,
bool FactureAjoutee = false,
string? FactureUrl = null,
string? FactureNom = null);
@@ -0,0 +1,17 @@
namespace SIFinance.Modules.Achats.Dtos;
public record ServiceDto(int Id, string Libelle);
public record TypeDepenseDto(int Id, string Libelle);
public record ModePaiementDto(int Id, string Libelle, bool NecessiteXml);
public record FournisseurDto(int Id, string RaisonSociale, string? Siret, string Statut);
public record FournisseurDetailDto(
int Id, string RaisonSociale, string? Siret, string? Email, string? Telephone,
string? Adresse, string Statut, DateTime? DateCreation);
public record FournisseurSaveDto(
string RaisonSociale, string? Siret, string? Email, string? Telephone,
string? Adresse, string Statut);
@@ -0,0 +1,709 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using SIFinance.Shared;
using SIFinance.Modules.Achats.Dtos;
using SIFinance.Shared.Entities;
namespace SIFinance.Modules.Achats.Endpoints;
public static class AchatsEndpoints
{
public static IEndpointRouteBuilder MapAchatsEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api").WithTags("Achats").RequireAuthorization();
group.MapGet("/services", async (SIFinanceDbContext db, CancellationToken ct) =>
await db.Services
.AsNoTracking()
.Where(s => s.Actif)
.OrderBy(s => s.Libelle)
.Select(s => new ServiceDto(s.Id, s.Libelle))
.ToListAsync(ct));
group.MapGet("/campus", async (SIFinanceDbContext db, CancellationToken ct) =>
await db.Campus.AsNoTracking().OrderBy(c => c.Libelle)
.Select(c => new { c.Id, c.Libelle }).ToListAsync(ct));
group.MapGet("/types-depense", async (SIFinanceDbContext db, CancellationToken ct) =>
await db.TypesDepenses
.AsNoTracking()
.Where(t => t.Actif)
.OrderBy(t => t.Libelle)
.Select(t => new TypeDepenseDto(t.Id, t.Libelle))
.ToListAsync(ct));
group.MapGet("/modes-paiement", async (SIFinanceDbContext db, CancellationToken ct) =>
await db.ModesPaiements
.AsNoTracking()
.Where(m => m.Actif)
.OrderBy(m => m.Libelle)
.Select(m => new ModePaiementDto(m.Id, m.Libelle, m.NecessiteXml))
.ToListAsync(ct));
group.MapGet("/fournisseurs", async (SIFinanceDbContext db, CancellationToken ct) =>
await db.Fournisseurs
.AsNoTracking()
.Where(f => f.Statut == "ACTIF")
.OrderBy(f => f.RaisonSociale)
.Select(f => new FournisseurDto(f.Id, f.RaisonSociale, f.Siret, f.Statut))
.ToListAsync(ct));
group.MapGet("/fournisseurs/all", async (SIFinanceDbContext db, CancellationToken ct) =>
await db.Fournisseurs
.AsNoTracking()
.OrderBy(f => f.RaisonSociale)
.Select(f => new FournisseurDetailDto(f.Id, f.RaisonSociale, f.Siret, f.Email, f.Telephone, f.Adresse, f.Statut, f.DateCreation))
.ToListAsync(ct));
group.MapPost("/fournisseurs", async (FournisseurSaveDto input, SIFinanceDbContext db, CancellationToken ct) =>
{
if (string.IsNullOrWhiteSpace(input.RaisonSociale))
return Results.BadRequest(new { error = "La raison sociale est requise." });
var fournisseur = new Fournisseur
{
RaisonSociale = input.RaisonSociale.Trim(),
Siret = string.IsNullOrWhiteSpace(input.Siret) ? null : input.Siret.Trim(),
Email = string.IsNullOrWhiteSpace(input.Email) ? null : input.Email.Trim(),
Telephone = string.IsNullOrWhiteSpace(input.Telephone) ? null : input.Telephone.Trim(),
Adresse = string.IsNullOrWhiteSpace(input.Adresse) ? null : input.Adresse.Trim(),
Statut = input.Statut,
DateCreation = DateTime.UtcNow,
};
db.Fournisseurs.Add(fournisseur);
await db.SaveChangesAsync(ct);
return Results.Created($"/api/fournisseurs/{fournisseur.Id}",
new FournisseurDetailDto(fournisseur.Id, fournisseur.RaisonSociale, fournisseur.Siret, fournisseur.Email, fournisseur.Telephone, fournisseur.Adresse, fournisseur.Statut, fournisseur.DateCreation));
});
group.MapPut("/fournisseurs/{id:int}", async (int id, FournisseurSaveDto input, SIFinanceDbContext db, CancellationToken ct) =>
{
if (string.IsNullOrWhiteSpace(input.RaisonSociale))
return Results.BadRequest(new { error = "La raison sociale est requise." });
var fournisseur = await db.Fournisseurs.FirstOrDefaultAsync(f => f.Id == id, ct);
if (fournisseur is null) return Results.NotFound();
fournisseur.RaisonSociale = input.RaisonSociale.Trim();
fournisseur.Siret = string.IsNullOrWhiteSpace(input.Siret) ? null : input.Siret.Trim();
fournisseur.Email = string.IsNullOrWhiteSpace(input.Email) ? null : input.Email.Trim();
fournisseur.Telephone = string.IsNullOrWhiteSpace(input.Telephone) ? null : input.Telephone.Trim();
fournisseur.Adresse = string.IsNullOrWhiteSpace(input.Adresse) ? null : input.Adresse.Trim();
fournisseur.Statut = input.Statut;
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
group.MapDelete("/fournisseurs/{id:int}", async (int id, SIFinanceDbContext db, CancellationToken ct) =>
{
var fournisseur = await db.Fournisseurs.FirstOrDefaultAsync(f => f.Id == id, ct);
if (fournisseur is null) return Results.NotFound();
db.Fournisseurs.Remove(fournisseur);
try
{
await db.SaveChangesAsync(ct);
}
catch (DbUpdateException)
{
return Results.BadRequest(new { error = "Impossible de supprimer ce fournisseur : des demandes, factures, avoirs ou contrats y sont rattachés." });
}
return Results.NoContent();
});
group.MapGet("/demandes", async (string? scope, SIFinanceDbContext db, CurrentUserService currentUser, CancellationToken ct) =>
{
var currentUserId = await currentUser.GetOrCreateCurrentUserIdAsync(ct);
var isFinance = await currentUser.HasRoleAsync("FINANCE", db, ct);
var query = db.DemandesAchats.AsNoTracking().Include(d => d.Demandeur).Include(d => d.Valideur).AsQueryable();
if (scope == "mine")
{
// Forcé explicitement (ex. page Archives, ou un utilisateur Finance+Responsable
// basculé en espace Collaborateur) : strictement ses propres demandes, rien d'autre.
query = query.Where(d => d.DemandeurId == currentUserId);
}
else if (!isFinance)
{
// Par défaut, sans le rôle FINANCE : ses propres demandes + celles de ses
// collaborateurs directs (il en est le responsable) — sans ça, un responsable
// qui n'a pas aussi le rôle FINANCE ne verrait jamais les demandes à valider.
query = query.Where(d => d.DemandeurId == currentUserId || d.Demandeur.ResponsableId == currentUserId);
}
// FINANCE sans scope=mine : visibilité totale (lecture seule — ne valide plus
// rien, cf. PATCH /demandes/{id}/statut).
var demandes = await query
.OrderByDescending(d => d.DateCreation)
.ToListAsync(ct);
var demandeIds = demandes.Select(d => d.Id).ToHashSet();
var justificatifs = await db.Set<PiecesJointe>()
.AsNoTracking()
.Where(p => p.EntiteType == "DEMANDE")
.ToListAsync(ct);
var parDemande = justificatifs
.Where(j => demandeIds.Contains(j.EntiteId))
.ToLookup(j => j.EntiteId);
var factureParDemande = await (from f in db.Factures.AsNoTracking()
join p in db.PiecesJointes.AsNoTracking() on f.Id equals p.EntiteId
where f.DemandeAchatId.HasValue && demandeIds.Contains(f.DemandeAchatId.Value)
&& p.EntiteType == "FACTURE"
orderby p.DateUpload descending
select new { DemandeId = f.DemandeAchatId!.Value, p.SharepointUrl, p.NomFichier })
.GroupBy(x => x.DemandeId)
.ToDictionaryAsync(g => g.Key, g => g.First(), ct);
return demandes.Select(d => factureParDemande.TryGetValue(d.Id, out var facture)
? ToDto(d, parDemande[d.Id], currentUserId, true, facture.SharepointUrl, facture.NomFichier)
: ToDto(d, parDemande[d.Id], currentUserId));
});
group.MapGet("/demandes/{id:int}", async (int id, SIFinanceDbContext db, CurrentUserService currentUser, CancellationToken ct) =>
{
var demande = await db.DemandesAchats.AsNoTracking().Include(d => d.Demandeur).Include(d => d.Valideur).FirstOrDefaultAsync(d => d.Id == id, ct);
if (demande is null) return Results.NotFound();
var currentUserId = await currentUser.GetOrCreateCurrentUserIdAsync(ct);
var isFinance = await currentUser.HasRoleAsync("FINANCE", db, ct);
if (!isFinance && demande.DemandeurId != currentUserId)
return Results.Forbid();
var justificatifs = await db.Set<PiecesJointe>().AsNoTracking().Where(p => p.EntiteType == "DEMANDE" && p.EntiteId == id).ToListAsync(ct);
var facture = await (from f in db.Factures.AsNoTracking()
join p in db.PiecesJointes.AsNoTracking() on f.Id equals p.EntiteId
where f.DemandeAchatId == id && p.EntiteType == "FACTURE"
orderby p.DateUpload descending
select new { p.SharepointUrl, p.NomFichier }).FirstOrDefaultAsync(ct);
return Results.Ok(facture is null
? ToDto(demande, justificatifs, currentUserId)
: ToDto(demande, justificatifs, currentUserId, true, facture.SharepointUrl, facture.NomFichier));
});
// Journal réel des transitions : il permet à Finance comme au demandeur de
// suivre toutes les étapes déjà effectuées, avec leur auteur et commentaire.
group.MapGet("/demandes/{id:int}/historique", async (int id, SIFinanceDbContext db, CurrentUserService currentUser, CancellationToken ct) =>
{
var demande = await db.DemandesAchats.AsNoTracking().FirstOrDefaultAsync(d => d.Id == id, ct);
if (demande is null) return Results.NotFound();
var currentUserId = await currentUser.GetOrCreateCurrentUserIdAsync(ct);
if (!await currentUser.HasRoleAsync("FINANCE", db, ct) && demande.DemandeurId != currentUserId)
return Results.Forbid();
return Results.Ok(await db.HistoriqueActions.AsNoTracking()
.Include(h => h.Utilisateur)
.Where(h => h.EntiteType == "DEMANDE" && h.EntiteId == id)
.OrderBy(h => h.DateAction)
.Select(h => new DemandeWorkflowHistoriqueDto(h.Action, h.Commentaire, h.AncienStatut, h.NouveauStatut,
$"{h.Utilisateur.Prenom} {h.Utilisateur.Nom}".Trim(), h.DateAction))
.ToListAsync(ct));
});
// Le demandeur peut déclarer la facture réellement reçue, quel que soit le
// type d'achat. La facture et son PDF sont créés dans la même transaction.
group.MapPost("/demandes/{id:int}/facture", async (int id, AjouterFactureDemandeDto input, SIFinanceDbContext db, CurrentUserService currentUser, CancellationToken ct) =>
{
var demande = await db.DemandesAchats.FirstOrDefaultAsync(d => d.Id == id, ct);
if (demande is null) return Results.NotFound();
var currentUserId = await currentUser.GetOrCreateCurrentUserIdAsync(ct);
if (demande.DemandeurId != currentUserId) return Results.Forbid();
if (demande.FournisseurId is null) return Results.BadRequest(new { error = "Un fournisseur référencé est requis pour ajouter une facture." });
if (string.IsNullOrWhiteSpace(input.Numero) || input.Montant <= 0 || string.IsNullOrWhiteSpace(input.DocumentNom)
|| string.IsNullOrWhiteSpace(input.DocumentSharepointUrl) || string.IsNullOrWhiteSpace(input.DocumentSharepointItemId) || input.DocumentTailleOctets <= 0)
return Results.BadRequest(new { error = "Les informations et le document de facture sont requis." });
const decimal tauxTva = 20m;
var montantHt = Math.Round(input.Montant / (1 + tauxTva / 100m), 2);
var facture = new Facture
{
SourceType = "MANUEL", DemandeAchatId = demande.Id, FournisseurId = demande.FournisseurId.Value,
NumeroFacture = input.Numero.Trim(), DateFacture = input.DateFacture, DateEcheance = input.DateEcheance,
MontantHt = montantHt, TauxTva = tauxTva, MontantTva = input.Montant - montantHt, MontantTtc = input.Montant,
ServiceId = demande.ServiceId, ModePaiementId = demande.ModePaiementId, Statut = "attente",
Commentaire = $"Ajoutée par le demandeur depuis {demande.Reference}.", SaisieParId = currentUserId, DateCreation = DateTime.UtcNow,
Acomptes = [new Acompte { Montant = input.Montant, DatePrevue = input.DateEcheance, Statut = "PREVU", SaisieParId = currentUserId, DateCreation = DateTime.UtcNow }],
};
db.Factures.Add(facture);
await db.SaveChangesAsync(ct);
db.PiecesJointes.Add(new PiecesJointe
{
EntiteType = "FACTURE", EntiteId = facture.Id, TypePiece = "Facture fournisseur", NomFichier = input.DocumentNom.Trim(),
SharepointUrl = input.DocumentSharepointUrl.Trim(), SharepointItemId = input.DocumentSharepointItemId.Trim(),
TailleOctets = input.DocumentTailleOctets, UploaderId = currentUserId, DateUpload = DateTime.UtcNow,
});
await db.SaveChangesAsync(ct);
return Results.Created($"/api/factures/{facture.Id}", new { facture.Id });
});
group.MapPost("/demandes", async (DemandeAchatCreateDto input, SIFinanceDbContext db, CurrentUserService currentUser, NotificationEmailService notification, CancellationToken ct) =>
{
if (string.IsNullOrWhiteSpace(input.Objet))
return Results.BadRequest(new { error = "L'objet est requis." });
if (input.MontantHt <= 0)
return Results.BadRequest(new { error = "Le montant HT doit être supérieur à 0." });
if (string.IsNullOrWhiteSpace(input.FournisseurLibre) && input.FournisseurId is null)
return Results.BadRequest(new { error = "Un fournisseur (référencé ou en saisie libre) est requis." });
if (!string.IsNullOrWhiteSpace(input.FournisseurLibre) && string.IsNullOrWhiteSpace(input.CategorieFournisseurLibre))
return Results.BadRequest(new { error = "La catégorie du fournisseur est requise en saisie libre." });
var montantTva = Math.Round(input.MontantHt * (input.TauxTva / 100m), 2);
var montantTtc = Math.Round(input.MontantHt + montantTva, 2);
var currentUserId = await currentUser.GetOrCreateCurrentUserIdAsync(ct);
var fournisseurId = input.FournisseurId;
// La saisie libre devient un véritable fournisseur référencé. On évite les
// doublons en réutilisant une raison sociale existante (insensible à la
// casse) et on conserve la catégorie saisie dans la demande pour le suivi.
if (!string.IsNullOrWhiteSpace(input.FournisseurLibre))
{
var nomFournisseur = input.FournisseurLibre.Trim();
var nomNormalise = nomFournisseur.ToUpper();
var fournisseurExistant = await db.Fournisseurs
.FirstOrDefaultAsync(f => f.RaisonSociale.ToUpper() == nomNormalise, ct);
if (fournisseurExistant is null)
{
fournisseurExistant = new Fournisseur
{
RaisonSociale = nomFournisseur,
Statut = "ACTIF",
DateCreation = DateTime.UtcNow,
};
db.Fournisseurs.Add(fournisseurExistant);
await db.SaveChangesAsync(ct);
}
fournisseurId = fournisseurExistant.Id;
}
var demande = new DemandesAchat
{
Reference = await GenererReferenceAsync(db, ct),
ServiceId = input.ServiceId,
Objet = input.Objet.Trim(),
Description = string.IsNullOrWhiteSpace(input.Description) ? null : input.Description.Trim(),
FournisseurId = fournisseurId,
FournisseurLibre = null,
CategorieFournisseurLibre = string.IsNullOrWhiteSpace(input.CategorieFournisseurLibre) ? null : input.CategorieFournisseurLibre.Trim(),
TypeDepenseId = input.TypeDepenseId,
MontantHt = input.MontantHt,
TauxTva = input.TauxTva,
MontantTva = montantTva,
MontantTtc = montantTtc,
DatePrevisionnelle = input.DatePrevisionnelle,
ModePaiementId = input.ModePaiementId,
Statut = "SOUMISE",
DemandeurId = currentUserId,
SeuilApplicable = CalculerSeuil(input.MontantHt),
DateCreation = DateTime.UtcNow,
DateSoumission = DateTime.UtcNow,
};
db.DemandesAchats.Add(demande);
try
{
await db.SaveChangesAsync(ct);
}
catch (DbUpdateException ex)
{
return Results.BadRequest(new
{
error = "Impossible d'enregistrer la demande — vérifiez le service, le fournisseur, le type de dépense et le mode de paiement.",
detail = ex.InnerException?.Message ?? ex.Message,
});
}
var justificatifs = new List<PiecesJointe>();
if (input.Justificatifs is { Count: > 0 })
{
foreach (var j in input.Justificatifs)
{
if (string.IsNullOrWhiteSpace(j.Nom)) continue;
justificatifs.Add(new PiecesJointe
{
EntiteType = "DEMANDE",
EntiteId = demande.Id,
TypePiece = string.IsNullOrWhiteSpace(j.Libelle) ? "Justificatif" : j.Libelle.Trim(),
NomFichier = j.Nom.Trim(),
SharepointUrl = j.SharepointUrl ?? "",
SharepointItemId = j.SharepointItemId ?? "",
TailleOctets = j.TailleOctets ?? 0,
UploaderId = currentUserId,
DateUpload = DateTime.UtcNow,
});
}
if (justificatifs.Count > 0)
{
db.Set<PiecesJointe>().AddRange(justificatifs);
await db.SaveChangesAsync(ct);
}
}
// Notifie le responsable direct du demandeur par email — best-effort : un
// échec d'envoi (mailbox introuvable, Graph indisponible...) ne doit jamais
// faire échouer la création de la demande, déjà enregistrée à ce stade.
var demandeurAvecResponsable = await db.Utilisateurs.AsNoTracking()
.Include(u => u.Responsable)
.FirstOrDefaultAsync(u => u.Id == currentUserId, ct);
if (demandeurAvecResponsable?.Responsable is { Actif: true } responsable && !string.IsNullOrWhiteSpace(responsable.Email))
{
var sujet = $"Nouvelle demande d'achat à valider — {demande.Reference}";
var corps = $"<p>Bonjour {responsable.Prenom},</p>" +
$"<p><strong>{demandeurAvecResponsable.Prenom} {demandeurAvecResponsable.Nom}</strong> a soumis une nouvelle demande d'achat :</p>" +
"<ul>" +
$"<li>Référence : {demande.Reference}</li>" +
$"<li>Objet : {demande.Objet}</li>" +
$"<li>Montant TTC : {demande.MontantTtc:N2} €</li>" +
"</ul>" +
"<p>Merci de la consulter dans SI Finance pour la valider ou la refuser.</p>";
await notification.EnvoyerAsync(demandeurAvecResponsable.Email, responsable.Email, sujet, corps, ct);
}
// Le demandeur est toujours l'utilisateur courant ici : pas besoin de charger
// la navigation Demandeur, ToDto affichera "Moi" via la comparaison d'id.
return Results.Created($"/api/demandes/{demande.Id}", ToDto(demande, justificatifs, currentUserId));
});
group.MapPut("/demandes/{id:int}", async (int id, DemandeAchatCreateDto input, SIFinanceDbContext db, CurrentUserService currentUser, CancellationToken ct) =>
{
var currentUserId = await currentUser.GetOrCreateCurrentUserIdAsync(ct);
var demande = await db.DemandesAchats.FirstOrDefaultAsync(d => d.Id == id, ct);
if (demande is null) return Results.NotFound();
// Seul l'auteur de la demande peut la modifier, et seulement tant qu'elle n'a
// pas encore été tranchée — les mêmes statuts que ceux où un responsable peut
// encore la valider/refuser (cf. PATCH /demandes/{id}/statut).
if (demande.DemandeurId != currentUserId)
return Results.Forbid();
if (demande.Statut is not ("SOUMISE" or "EN_VALIDATION" or "COMPLEMENT_REQUIS"))
return Results.BadRequest(new { error = "Cette demande ne peut plus être modifiée." });
if (string.IsNullOrWhiteSpace(input.Objet))
return Results.BadRequest(new { error = "L'objet est requis." });
if (input.MontantHt <= 0)
return Results.BadRequest(new { error = "Le montant HT doit être supérieur à 0." });
if (string.IsNullOrWhiteSpace(input.FournisseurLibre) && input.FournisseurId is null)
return Results.BadRequest(new { error = "Un fournisseur (référencé ou en saisie libre) est requis." });
if (!string.IsNullOrWhiteSpace(input.FournisseurLibre) && string.IsNullOrWhiteSpace(input.CategorieFournisseurLibre))
return Results.BadRequest(new { error = "La catégorie du fournisseur est requise en saisie libre." });
var montantTva = Math.Round(input.MontantHt * (input.TauxTva / 100m), 2);
var montantTtc = Math.Round(input.MontantHt + montantTva, 2);
demande.ServiceId = input.ServiceId;
demande.Objet = input.Objet.Trim();
demande.Description = string.IsNullOrWhiteSpace(input.Description) ? null : input.Description.Trim();
demande.FournisseurId = input.FournisseurId;
demande.FournisseurLibre = string.IsNullOrWhiteSpace(input.FournisseurLibre) ? null : input.FournisseurLibre.Trim();
demande.CategorieFournisseurLibre = string.IsNullOrWhiteSpace(input.CategorieFournisseurLibre) ? null : input.CategorieFournisseurLibre.Trim();
demande.TypeDepenseId = input.TypeDepenseId;
demande.MontantHt = input.MontantHt;
demande.TauxTva = input.TauxTva;
demande.MontantTva = montantTva;
demande.MontantTtc = montantTtc;
demande.DatePrevisionnelle = input.DatePrevisionnelle;
demande.ModePaiementId = input.ModePaiementId;
demande.SeuilApplicable = CalculerSeuil(input.MontantHt);
// Une modification remet la demande au début du circuit de validation — un
// responsable ayant déjà commencé à l'examiner (EN_VALIDATION) ou demandé un
// complément doit la revoir depuis le début, le contenu ayant changé.
demande.Statut = "SOUMISE";
demande.ValideurId = null;
demande.DateDerniereMaj = DateTime.UtcNow;
try
{
await db.SaveChangesAsync(ct);
}
catch (DbUpdateException ex)
{
return Results.BadRequest(new
{
error = "Impossible d'enregistrer la modification — vérifiez le service, le fournisseur, le type de dépense et le mode de paiement.",
detail = ex.InnerException?.Message ?? ex.Message,
});
}
// Le frontend ne pré-remplit jamais les justificatifs déjà existants dans le
// formulaire d'édition (affichés à part, en lecture seule) — tout ce qui arrive
// ici dans input.Justificatifs est donc forcément une pièce ajoutée pendant
// cette modification, à insérer sans toucher aux justificatifs déjà en base.
if (input.Justificatifs is { Count: > 0 })
{
var nouveaux = input.Justificatifs
.Where(j => !string.IsNullOrWhiteSpace(j.Nom))
.Select(j => new PiecesJointe
{
EntiteType = "DEMANDE",
EntiteId = id,
TypePiece = string.IsNullOrWhiteSpace(j.Libelle) ? "Justificatif" : j.Libelle.Trim(),
NomFichier = j.Nom.Trim(),
SharepointUrl = j.SharepointUrl ?? "",
SharepointItemId = j.SharepointItemId ?? "",
TailleOctets = j.TailleOctets ?? 0,
UploaderId = currentUserId,
DateUpload = DateTime.UtcNow,
})
.ToList();
if (nouveaux.Count > 0)
{
db.Set<PiecesJointe>().AddRange(nouveaux);
await db.SaveChangesAsync(ct);
}
}
var justificatifsActuels = await db.Set<PiecesJointe>().AsNoTracking().Where(p => p.EntiteType == "DEMANDE" && p.EntiteId == id).ToListAsync(ct);
return Results.Ok(ToDto(demande, justificatifsActuels, currentUserId));
});
group.MapPatch("/demandes/{id:int}/annuler", async (int id, SIFinanceDbContext db, CurrentUserService currentUser, CancellationToken ct) =>
{
var currentUserId = await currentUser.GetOrCreateCurrentUserIdAsync(ct);
var demande = await db.DemandesAchats.FirstOrDefaultAsync(d => d.Id == id, ct);
if (demande is null) return Results.NotFound();
if (demande.DemandeurId != currentUserId)
return Results.Forbid();
if (demande.Statut is not ("SOUMISE" or "EN_VALIDATION" or "COMPLEMENT_REQUIS"))
return Results.BadRequest(new { error = "Cette demande ne peut plus être annulée." });
demande.Statut = "ANNULEE";
demande.DateDerniereMaj = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
group.MapPatch("/demandes/{id:int}/statut", async (int id, UpdateDemandeStatutDto input, SIFinanceDbContext db, CurrentUserService currentUser, NotificationEmailService notification, CancellationToken ct) =>
{
if (input.Statut is not ("VALIDEE" or "REFUSEE"))
return Results.BadRequest(new { error = "Statut invalide — seuls VALIDEE et REFUSEE sont autorisés depuis cet endpoint." });
var demande = await db.DemandesAchats.Include(d => d.Demandeur).Include(d => d.Valideur).FirstOrDefaultAsync(d => d.Id == id, ct);
if (demande is null) return Results.NotFound();
var currentUserId = await currentUser.GetOrCreateCurrentUserIdAsync(ct);
// Ce n'est plus le rôle FINANCE qui donne le droit de valider, mais le fait
// d'être le responsable direct (manager Entra ID) du demandeur — Finance garde
// seulement un accès en lecture à toutes les demandes (cf. GET /demandes).
if (demande.Demandeur?.ResponsableId != currentUserId)
return Results.Forbid();
if (demande.Statut is not ("SOUMISE" or "EN_VALIDATION" or "COMPLEMENT_REQUIS"))
return Results.BadRequest(new { error = "Cette demande n'est plus en attente de validation." });
// Compatibilité avec le bouton historique « Valider » : une validation
// manager ouvre désormais le nouveau circuit au lieu de clôturer la demande.
demande.Statut = input.Statut == "VALIDEE" ? "VALIDEE_MANAGER" : input.Statut;
demande.ValideurId = currentUserId;
demande.DateDerniereMaj = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
// Notifie le demandeur de la décision — best-effort, comme pour la notification
// de création : un échec d'envoi ne doit pas faire échouer la décision déjà
// enregistrée à ce stade.
if (demande.Demandeur is { Actif: true } demandeur && !string.IsNullOrWhiteSpace(demandeur.Email))
{
var responsable = await db.Utilisateurs.AsNoTracking().FirstOrDefaultAsync(u => u.Id == currentUserId, ct);
if (responsable is not null && !string.IsNullOrWhiteSpace(responsable.Email))
{
var valide = input.Statut == "VALIDEE";
var sujet = $"Demande d'achat {(valide ? "validée" : "refusée")} — {demande.Reference}";
var corps = $"<p>Bonjour {demandeur.Prenom},</p>" +
$"<p>Votre demande d'achat <strong>{demande.Reference} — {demande.Objet}</strong> a été " +
(valide
? "<strong style=\"color:#2e7d32\">validée</strong>"
: "<strong style=\"color:#c62828\">refusée</strong>") +
$" par {responsable.Prenom} {responsable.Nom}.</p>" +
"<p>Consulte le détail dans SI Finance.</p>";
await notification.EnvoyerAsync(responsable.Email, demandeur.Email, sujet, corps, ct);
}
}
var justificatifs = await db.Set<PiecesJointe>().AsNoTracking().Where(p => p.EntiteType == "DEMANDE" && p.EntiteId == id).ToListAsync(ct);
return Results.Ok(ToDto(demande, justificatifs, currentUserId));
});
// Circuit dédié aux demandes de matériel. Les actions sont volontairement
// contrôlées côté serveur : un utilisateur ne peut pas forcer un statut en
// contournant l'écran, et chaque passage est inscrit dans l'historique.
group.MapPatch("/demandes/{id:int}/workflow", async (int id, WorkflowDemandeDto input, SIFinanceDbContext db, CurrentUserService currentUser, NotificationEmailService notification, CancellationToken ct) =>
{
var demande = await db.DemandesAchats
.Include(d => d.Demandeur)
.Include(d => d.Valideur)
.Include(d => d.TypeDepense)
.FirstOrDefaultAsync(d => d.Id == id, ct);
if (demande is null) return Results.NotFound();
var currentUserId = await currentUser.GetOrCreateCurrentUserIdAsync(ct);
var action = input.Action.Trim().ToUpperInvariant();
var estDemandeur = demande.DemandeurId == currentUserId;
var estManager = demande.Demandeur.ResponsableId == currentUserId;
var estFinance = await currentUser.HasRoleAsync("FINANCE", db, ct);
var estDemandeMateriel = demande.TypeDepense.EstMateriel;
var ancienStatut = demande.Statut;
string? nouveauStatut = action switch
{
"VALIDER_MANAGER" when estManager && demande.Statut is "SOUMISE" or "EN_VALIDATION" or "COMPLEMENT_REQUIS"
=> "VALIDEE_MANAGER",
// Après l'accord du manager, seul le collaborateur à l'origine de la
// demande la transmet au circuit matériel. Finance la consulte alors
// en lecture seule et ne peut pas avancer cette étape.
"TRANSMETTRE_MATERIEL" when estDemandeur && demande.Statut == "VALIDEE_MANAGER" && estDemandeMateriel
=> "EN_VALIDATION_MATERIEL",
"VALIDER_MATERIEL" when (estDemandeur || estFinance) && demande.Statut == "EN_VALIDATION_MATERIEL"
=> "VALIDEE_MATERIEL",
"TRANSMETTRE_FINANCE" when (estDemandeur || estFinance) && demande.Statut is "VALIDEE_MATERIEL" or "VALIDEE_MANAGER"
=> "EN_VALIDATION_FINANCE",
"APPROUVER_FINANCE" when estFinance && demande.Statut == "EN_VALIDATION_FINANCE"
=> "APPROUVEE",
"EMETTRE_COMMANDE" when estDemandeur && demande.Statut == "APPROUVEE"
=> "COMMANDE_EMISE",
"CONFIRMER_COMMANDE" when estDemandeur && demande.Statut == "COMMANDE_EMISE"
=> "COMMANDE_CONFIRMEE",
"MARQUER_EN_LIVRAISON" when estDemandeur && demande.Statut == "COMMANDE_CONFIRMEE"
=> "EN_ATTENTE_LIVRAISON",
"DEMARRER_CONTROLE_RECEPTION" when estDemandeur && demande.Statut == "EN_ATTENTE_LIVRAISON"
=> "EN_CONTROLE_RECEPTION",
"CONFIRMER_RECEPTION_CONFORME" when estDemandeur && demande.Statut == "EN_CONTROLE_RECEPTION"
=> "RECEPTION_CONFORME",
"SIGNALER_NON_CONFORMITE" when estDemandeur && demande.Statut == "EN_CONTROLE_RECEPTION"
=> "VERIFIEE_NON_CONFORME",
"VALIDER_RECEPTION" when estDemandeur && demande.Statut == "RECEPTION_CONFORME"
=> "RECEPTION_VALIDEE",
"ENVOYER_RAPPROCHEMENT" when estDemandeur && demande.Statut == "RECEPTION_VALIDEE"
=> "FACTURE_EN_RAPPROCHEMENT",
"CLOTURER" when estDemandeur && demande.Statut == "FACTURE_EN_RAPPROCHEMENT"
=> "CLOTURE",
"DEMANDER_COMPLEMENT" when PeutTraiterEtape(demande.Statut, estManager, estFinance)
=> "COMPLEMENT_REQUIS",
"REJETER" when PeutTraiterEtape(demande.Statut, estManager, estFinance)
=> "REFUSEE",
_ => null,
};
if (nouveauStatut is null)
return Results.BadRequest(new { error = "Action non autorisée pour votre rôle ou pour le statut actuel de la demande." });
if ((action is "REJETER" or "DEMANDER_COMPLEMENT") && string.IsNullOrWhiteSpace(input.Commentaire))
return Results.BadRequest(new { error = "Un motif est requis pour un refus ou une demande de complément." });
if (action == "VALIDER_MATERIEL" && (string.IsNullOrWhiteSpace(input.DocumentNom)
|| string.IsNullOrWhiteSpace(input.DocumentSharepointUrl)
|| string.IsNullOrWhiteSpace(input.DocumentSharepointItemId)
|| input.DocumentTailleOctets is null))
return Results.BadRequest(new { error = "Un bon de commande ou document matériel est requis pour valider le matériel." });
demande.Statut = nouveauStatut;
demande.ValideurId = currentUserId;
demande.DateDerniereMaj = DateTime.UtcNow;
if (action == "VALIDER_MATERIEL")
{
db.PiecesJointes.Add(new PiecesJointe
{
EntiteType = "DEMANDE",
EntiteId = demande.Id,
TypePiece = "Bon de commande matériel",
NomFichier = input.DocumentNom!.Trim(),
SharepointUrl = input.DocumentSharepointUrl!.Trim(),
SharepointItemId = input.DocumentSharepointItemId!.Trim(),
TailleOctets = input.DocumentTailleOctets!.Value,
UploaderId = currentUserId,
DateUpload = DateTime.UtcNow,
});
}
db.HistoriqueActions.Add(new HistoriqueAction
{
EntiteType = "DEMANDE",
EntiteId = demande.Id,
UtilisateurId = currentUserId,
Action = action,
Commentaire = string.IsNullOrWhiteSpace(input.Commentaire) ? null : input.Commentaire.Trim(),
AncienStatut = ancienStatut,
NouveauStatut = nouveauStatut,
DateAction = DateTime.UtcNow,
});
await db.SaveChangesAsync(ct);
// Finance reste informée de l'avancement opérationnel sans avoir à porter
// les rôles de matériel/réception. L'envoi est best-effort : la transition,
// déjà historisée, ne doit jamais échouer à cause d'un email indisponible.
var auteur = await db.Utilisateurs.AsNoTracking().FirstOrDefaultAsync(u => u.Id == currentUserId, ct);
var finances = await (from u in db.Utilisateurs.AsNoTracking()
join r in db.UtilisateurRoles.AsNoTracking() on u.AzureOid equals r.AzureOid
where u.Actif && r.Role == "FINANCE" && !string.IsNullOrEmpty(u.Email)
select u.Email).Distinct().ToListAsync(ct);
if (auteur is not null && !string.IsNullOrEmpty(auteur.Email))
{
foreach (var emailFinance in finances.Where(email => !string.Equals(email, auteur.Email, StringComparison.OrdinalIgnoreCase)))
{
var sujet = $"Mise à jour de commande — {demande.Reference}";
var corps = $"<p>La demande <strong>{demande.Reference} — {demande.Objet}</strong> est passée au statut <strong>{nouveauStatut}</strong>.</p>" +
$"<p>Action déclarée par {auteur.Prenom} {auteur.Nom}.</p>" +
(string.IsNullOrWhiteSpace(input.Commentaire) ? "" : $"<p>Commentaire : {input.Commentaire.Trim()}</p>");
await notification.EnvoyerAsync(auteur.Email, emailFinance, sujet, corps, ct);
}
}
var justificatifs = await db.Set<PiecesJointe>().AsNoTracking().Where(p => p.EntiteType == "DEMANDE" && p.EntiteId == id).ToListAsync(ct);
return Results.Ok(ToDto(demande, justificatifs, currentUserId));
});
return app;
}
private static bool PeutTraiterEtape(string statut, bool estManager, bool estFinance) => statut switch
{
"SOUMISE" or "EN_VALIDATION" or "COMPLEMENT_REQUIS" => estManager,
"VALIDEE_MANAGER" or "EN_VALIDATION_MATERIEL" or "VALIDEE_MATERIEL" => estFinance,
"EN_VALIDATION_FINANCE" => estFinance,
_ => false,
};
private static DemandeAchatDto ToDto(DemandesAchat d, IEnumerable<PiecesJointe> justificatifs, int currentUserId, bool factureAjoutee = false, string? factureUrl = null, string? factureNom = null) => new(
d.Id, d.Reference, d.ServiceId, d.Objet, d.Description, d.FournisseurId, d.FournisseurLibre,
d.CategorieFournisseurLibre,
d.TypeDepenseId, d.MontantHt, d.TauxTva, d.MontantTva, d.MontantTtc, d.DatePrevisionnelle,
d.ModePaiementId, d.Statut, d.DemandeurId,
$"{d.Demandeur?.Prenom} {d.Demandeur?.Nom}".Trim(),
d.ValideurId,
d.ValideurId is null ? null : $"{d.Valideur?.Prenom} {d.Valideur?.Nom}".Trim(),
d.SeuilApplicable, d.DateCreation,
d.DateSoumission, d.DateDerniereMaj,
justificatifs.Select(j => new DemandeJustificatifDto(j.Id, j.TypePiece, j.NomFichier, j.DateUpload, string.IsNullOrEmpty(j.SharepointUrl) ? null : j.SharepointUrl)).ToList(),
d.Demandeur?.ResponsableId == currentUserId,
factureAjoutee, factureUrl, factureNom);
private static decimal CalculerSeuil(decimal montant) => montant switch
{
< 1000 => 1000,
< 5000 => 5000,
_ => 20000,
};
private static async Task<string> GenererReferenceAsync(SIFinanceDbContext db, CancellationToken ct)
{
var prefix = $"DA-{DateTime.UtcNow.Year}-";
var last = await db.DemandesAchats
.Where(d => d.Reference.StartsWith(prefix))
.OrderByDescending(d => d.Reference)
.Select(d => d.Reference)
.FirstOrDefaultAsync(ct);
var nextSeq = 1;
if (last is not null && int.TryParse(last.AsSpan(prefix.Length), out var lastSeq))
nextSeq = lastSeq + 1;
return $"{prefix}{nextSeq:D4}";
}
}
@@ -0,0 +1,110 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using SIFinance.Modules.Achats.Dtos;
using SIFinance.Shared;
using SIFinance.Shared.Entities;
namespace SIFinance.Modules.Achats.Endpoints;
public static class BudgetsEndpoints
{
public static IEndpointRouteBuilder MapBudgetsEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/budgets").WithTags("Budgets").RequireAuthorization();
// exercice optionnel : sans filtre, remonte tous les exercices connus (utile pour
// un historique) — Pilotage passera l'exercice courant pour ne charger que l'utile.
group.MapGet("", async (int? exercice, SIFinanceDbContext db, CancellationToken ct) =>
{
var query = db.BudgetsServices
.AsNoTracking()
.Include(b => b.Service)
.Include(b => b.Revisions).ThenInclude(r => r.ReviseParUtilisateur)
.AsQueryable();
if (exercice.HasValue)
query = query.Where(b => b.ExerciceAnnee == exercice.Value);
var budgets = await query.OrderBy(b => b.Service.Libelle).ToListAsync(ct);
return budgets.Select(ToDto);
});
group.MapPost("", async (CreateBudgetServiceDto input, SIFinanceDbContext db, CurrentUserService currentUser, CancellationToken ct) =>
{
if (!await currentUser.HasRoleAsync("FINANCE", db, ct))
return Results.Forbid();
if (input.MontantInitial <= 0)
return Results.BadRequest(new { error = "Le montant initial doit être supérieur à 0." });
var existeDeja = await db.BudgetsServices
.AnyAsync(b => b.ServiceId == input.ServiceId && b.ExerciceAnnee == input.ExerciceAnnee, ct);
if (existeDeja)
return Results.BadRequest(new { error = "Un budget existe déjà pour ce service sur cet exercice." });
var budget = new BudgetService
{
ServiceId = input.ServiceId,
ExerciceAnnee = input.ExerciceAnnee,
MontantInitial = input.MontantInitial,
SaisieParId = await currentUser.GetOrCreateCurrentUserIdAsync(ct),
DateCreation = DateTime.UtcNow,
};
db.BudgetsServices.Add(budget);
await db.SaveChangesAsync(ct);
await db.Entry(budget).Reference(b => b.Service).LoadAsync(ct);
return Results.Created($"/api/budgets/{budget.Id}", ToDto(budget));
});
// Réservé à FINANCE : seul le responsable Finance qui gère le budget peut
// l'augmenter en cours d'exercice. Chaque appel ajoute une ligne à l'historique
// (budgets_service_revisions) plutôt que d'écraser une valeur unique.
group.MapPost("/{id:int}/revisions", async (int id, CreateBudgetRevisionDto input, SIFinanceDbContext db, CurrentUserService currentUser, CancellationToken ct) =>
{
if (!await currentUser.HasRoleAsync("FINANCE", db, ct))
return Results.Forbid();
var budget = await db.BudgetsServices
.Include(b => b.Service)
.Include(b => b.Revisions).ThenInclude(r => r.ReviseParUtilisateur)
.FirstOrDefaultAsync(b => b.Id == id, ct);
if (budget is null) return Results.NotFound();
if (input.MontantRevise <= 0)
return Results.BadRequest(new { error = "Le montant révisé doit être supérieur à 0." });
var revision = new BudgetServiceRevision
{
BudgetServiceId = id,
MontantRevise = input.MontantRevise,
Commentaire = string.IsNullOrWhiteSpace(input.Commentaire) ? null : input.Commentaire.Trim(),
RevisePar = await currentUser.GetOrCreateCurrentUserIdAsync(ct),
DateRevision = DateTime.UtcNow,
};
db.BudgetServiceRevisions.Add(revision);
budget.DateDerniereMaj = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
await db.Entry(revision).Reference(r => r.ReviseParUtilisateur).LoadAsync(ct);
budget.Revisions.Add(revision);
return Results.Created($"/api/budgets/{id}/revisions/{revision.Id}", ToDto(budget));
});
return app;
}
private static BudgetServiceDto ToDto(BudgetService b)
{
var revisions = b.Revisions
.OrderByDescending(r => r.DateRevision)
.Select(r => new BudgetRevisionDto(
r.Id, r.MontantRevise, r.Commentaire,
$"{r.ReviseParUtilisateur.Prenom} {r.ReviseParUtilisateur.Nom}".Trim(),
r.DateRevision))
.ToList();
return new BudgetServiceDto(
b.Id, b.ServiceId, b.Service.Libelle, b.ExerciceAnnee,
b.MontantInitial, revisions.Count > 0 ? revisions[0].MontantRevise : null,
revisions);
}
}
@@ -0,0 +1,389 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SIFinance.Shared;
using SIFinance.Modules.Achats.Dtos;
using SIFinance.Shared.Entities;
namespace SIFinance.Modules.Achats.Endpoints;
public record ContratAlerteDto(int ContratId, string Fournisseur, string Objet, string Type, string Severite, string Message, DateTime DateCalcul);
public static class ContratsEndpoints
{
private const int SeuilRenouvellementJours = 60;
public static IEndpointRouteBuilder MapContratsEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/contrats").WithTags("Contrats").RequireAuthorization();
// Les contrats sont gérés par les responsables de service. Finance conserve
// une visibilité transverse en lecture seule, sans droit de modification.
group.AddEndpointFilter(async (context, next) =>
{
var db = context.HttpContext.RequestServices.GetRequiredService<SIFinanceDbContext>();
var currentUser = context.HttpContext.RequestServices.GetRequiredService<CurrentUserService>();
var isFinance = await currentUser.HasRoleAsync("FINANCE", db, context.HttpContext.RequestAborted);
var userId = await currentUser.GetOrCreateCurrentUserIdAsync(context.HttpContext.RequestAborted);
var hasResponsableRole = await currentUser.HasRoleAsync("RESPONSABLE", db, context.HttpContext.RequestAborted);
var aDesCollaborateurs = await db.Utilisateurs.AsNoTracking()
.AnyAsync(u => u.Actif && u.ResponsableId == userId, context.HttpContext.RequestAborted);
var estResponsable = aDesCollaborateurs || (hasResponsableRole && await db.Utilisateurs.AsNoTracking()
.AnyAsync(u => u.Actif && u.Id == userId && u.ServiceId != null, context.HttpContext.RequestAborted));
if (!isFinance && !estResponsable) return Results.Forbid();
// Un profil Finance seul consulte uniquement. Un responsable qui possède aussi
// ce rôle conserve la gestion de ses propres contrats de service.
if (isFinance && !estResponsable && !HttpMethods.IsGet(context.HttpContext.Request.Method)) return Results.Forbid();
if (!HttpMethods.IsGet(context.HttpContext.Request.Method)
&& context.HttpContext.Request.RouteValues.TryGetValue("id", out var routeId)
&& int.TryParse(routeId?.ToString(), out var contratId))
{
var serviceId = await db.ContratsFournisseurs.AsNoTracking()
.Where(c => c.Id == contratId).Select(c => c.ServiceId).FirstOrDefaultAsync(context.HttpContext.RequestAborted);
var peutGerer = serviceId.HasValue && await db.Utilisateurs.AsNoTracking()
.AnyAsync(u => u.Actif && u.ServiceId == serviceId && (u.ResponsableId == userId || (hasResponsableRole && u.Id == userId)), context.HttpContext.RequestAborted);
if (!peutGerer) return Results.Forbid();
}
return await next(context);
});
group.MapGet("", async (SIFinanceDbContext db, CurrentUserService currentUser, CancellationToken ct) =>
{
var isFinance = await currentUser.HasRoleAsync("FINANCE", db, ct);
var currentUserId = await currentUser.GetOrCreateCurrentUserIdAsync(ct);
var query = db.ContratsFournisseurs.AsNoTracking().AsQueryable();
if (!isFinance)
{
var servicesGerés = db.Utilisateurs.Where(u => u.ResponsableId == currentUserId).Select(u => u.ServiceId);
query = query.Where(c => c.ServiceId.HasValue && servicesGerés.Contains(c.ServiceId));
}
if (!isFinance && await currentUser.HasRoleAsync("RESPONSABLE", db, ct))
{
var monService = db.Utilisateurs.Where(u => u.Id == currentUserId && u.Actif).Select(u => u.ServiceId);
query = db.ContratsFournisseurs.AsNoTracking()
.Where(c => c.ServiceId.HasValue && monService.Contains(c.ServiceId));
}
var contrats = await query
// Plusieurs Include de collections dans une seule requête produisent un
// produit cartésien (lignes dupliquées) : on découpe en requêtes séparées.
.AsSplitQuery()
.Include(c => c.Fournisseur)
.Include(c => c.Service)
.Include(c => c.Campus)
.Include(c => c.Echeances).ThenInclude(e => e.Justificatif)
.Include(c => c.Factures)
.OrderByDescending(c => c.DateCreation)
.ToListAsync(ct);
// Filtrer par EntiteType suffit (cf. même correctif sur /api/demandes) :
// un filtre .Contains() sur une liste d'ids en mémoire forcerait EF à générer
// un paramètre SQL par contrat, ce qui grossit avec la table et empêche
// SQL Server de réutiliser un plan de requête en cache.
var documents = await db.Set<PiecesJointe>()
.AsNoTracking()
.Where(p => p.EntiteType == "CONTRAT")
.ToListAsync(ct);
// Lookup groupé par contrat : accès O(1) au lieu d'un Where qui re-parcourt
// toute la liste des documents pour chaque contrat.
var documentsParContrat = documents.ToLookup(d => d.EntiteId);
return contrats.Select(c => ToDto(c, documentsParContrat[c.Id]));
});
// Alertes calculées à la volée (pas de table dédiée, pas de job planifié) :
// même filtrage par service que MapGet("") ci-dessus, réappliqué ici plutôt
// que factorisé, pour ne pas modifier la signature de la requête existante.
group.MapGet("/alertes", async (SIFinanceDbContext db, CurrentUserService currentUser, CancellationToken ct) =>
{
var isFinance = await currentUser.HasRoleAsync("FINANCE", db, ct);
var currentUserId = await currentUser.GetOrCreateCurrentUserIdAsync(ct);
var query = db.ContratsFournisseurs.AsNoTracking().AsQueryable();
if (!isFinance)
{
var servicesGerés = db.Utilisateurs.Where(u => u.ResponsableId == currentUserId).Select(u => u.ServiceId);
query = query.Where(c => c.ServiceId.HasValue && servicesGerés.Contains(c.ServiceId));
}
if (!isFinance && await currentUser.HasRoleAsync("RESPONSABLE", db, ct))
{
var monService = db.Utilisateurs.Where(u => u.Id == currentUserId && u.Actif).Select(u => u.ServiceId);
query = db.ContratsFournisseurs.AsNoTracking()
.Where(c => c.ServiceId.HasValue && monService.Contains(c.ServiceId));
}
var contrats = await query
.Where(c => c.Statut != "RESILIE" && c.Statut != "EXPIRE" && c.Statut != "ARCHIVE")
.Include(c => c.Fournisseur)
.Include(c => c.Echeances)
.ToListAsync(ct);
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var alertes = new List<ContratAlerteDto>();
var maintenant = DateTime.UtcNow;
foreach (var c in contrats)
{
var joursAvantFin = c.DateFin.DayNumber - today.DayNumber;
if (joursAvantFin <= SeuilRenouvellementJours)
{
var expire = joursAvantFin < 0;
alertes.Add(new ContratAlerteDto(
c.Id, c.Fournisseur.RaisonSociale, c.Objet,
expire ? "CONTRAT_EXPIRE" : "CONTRAT_A_RENOUVELER",
expire ? "danger" : joursAvantFin <= 30 ? "warning" : "info",
expire
? $"Expiré depuis le {c.DateFin:dd/MM/yyyy}"
: $"Échéance dans {joursAvantFin} j ({c.DateFin:dd/MM/yyyy})",
maintenant));
}
var echeancesEnRetard = c.Echeances.Where(e => e.Statut == "A_VENIR" && e.DateEcheance < today).ToList();
if (echeancesEnRetard.Count > 0)
{
var montant = echeancesEnRetard.Sum(e => e.Montant);
alertes.Add(new ContratAlerteDto(
c.Id, c.Fournisseur.RaisonSociale, c.Objet,
"ECHEANCE_CONTRAT_RETARD", "danger",
$"{echeancesEnRetard.Count} échéance(s) en retard · {montant:N0} €",
maintenant));
}
}
return Results.Ok(alertes.OrderBy(a => a.Severite == "danger" ? 0 : a.Severite == "warning" ? 1 : 2));
});
group.MapPost("", async (CreateContratDto input, SIFinanceDbContext db, CurrentUserService currentUser, CancellationToken ct) =>
{
if (string.IsNullOrWhiteSpace(input.Objet))
return Results.BadRequest(new { error = "L'objet du contrat est requis." });
if (input.ServiceId is null || input.CampusId is null)
return Results.BadRequest(new { error = "Le service responsable et le campus sont requis." });
if (input.Echeances.Count == 0)
return Results.BadRequest(new { error = "Renseigner au moins une échéance." });
var saisieParId = await currentUser.GetOrCreateCurrentUserIdAsync(ct);
var hasResponsableRole = await currentUser.HasRoleAsync("RESPONSABLE", db, ct);
var estResponsableDuService = await db.Utilisateurs.AsNoTracking()
.AnyAsync(u => u.Actif && u.ServiceId == input.ServiceId &&
(u.ResponsableId == saisieParId || (hasResponsableRole && u.Id == saisieParId)), ct);
if (!estResponsableDuService) return Results.Forbid();
var contrat = new ContratFournisseur
{
FournisseurId = input.FournisseurId,
ServiceId = input.ServiceId,
CampusId = input.CampusId,
Objet = input.Objet.Trim(),
Reference = string.IsNullOrWhiteSpace(input.Reference) ? null : input.Reference.Trim(),
DateSignature = input.DateSignature,
DateDebut = input.DateDebut,
DateFin = input.DateFin,
MontantTotal = input.MontantTotal,
Periodicite = input.Periodicite,
Statut = "ACTIF",
SaisieParId = saisieParId,
DateCreation = DateTime.UtcNow,
Echeances = input.Echeances.Select(e => new ContratEcheance
{
DateEcheance = e.Date,
Montant = e.Montant,
Statut = "A_VENIR",
DateCreation = DateTime.UtcNow,
}).ToList(),
};
db.ContratsFournisseurs.Add(contrat);
try
{
await db.SaveChangesAsync(ct);
}
catch (DbUpdateException ex)
{
return Results.BadRequest(new { error = "Impossible d'enregistrer le contrat — vérifiez le fournisseur.", detail = ex.InnerException?.Message ?? ex.Message });
}
db.ContratsFournisseursHistorique.Add(new ContratFournisseurHistorique
{
ContratId = contrat.Id,
NouveauStatut = "ACTIF",
Commentaire = "Contrat cr\u00e9\u00e9",
UtilisateurId = contrat.SaisieParId,
DateAction = DateTime.UtcNow,
});
await db.SaveChangesAsync(ct);
await db.Entry(contrat).Reference(c => c.Fournisseur).LoadAsync(ct);
return Results.Created($"/api/contrats/{contrat.Id}", ToDto(contrat, []));
});
group.MapGet("/{id:int}/historique", async (int id, SIFinanceDbContext db, CurrentUserService currentUser, CancellationToken ct) =>
{
var contrat = await db.ContratsFournisseurs.AsNoTracking().FirstOrDefaultAsync(c => c.Id == id, ct);
if (contrat is null) return Results.NotFound();
var isFinance = await currentUser.HasRoleAsync("FINANCE", db, ct);
if (!isFinance)
{
var userId = await currentUser.GetOrCreateCurrentUserIdAsync(ct);
var hasResponsableRole = await currentUser.HasRoleAsync("RESPONSABLE", db, ct);
var estResponsableDuService = await db.Utilisateurs.AsNoTracking()
.AnyAsync(u => u.Actif && u.ServiceId == contrat.ServiceId &&
(u.ResponsableId == userId || (hasResponsableRole && u.Id == userId)), ct);
if (!estResponsableDuService) return Results.Forbid();
}
var historique = await db.ContratsFournisseursHistorique.AsNoTracking()
.Where(h => h.ContratId == id).Include(h => h.Utilisateur)
.OrderBy(h => h.DateAction).Select(h => new ContratHistoriqueDto(h.Id, h.AncienStatut, h.NouveauStatut, h.Commentaire,
h.Utilisateur.Prenom + " " + h.Utilisateur.Nom, h.DateAction)).ToListAsync(ct);
return Results.Ok(historique);
});
group.MapPatch("/{id:int}/statut", async (int id, ChangerStatutContratDto input, SIFinanceDbContext db, CurrentUserService currentUser, CancellationToken ct) =>
{
var contrat = await db.ContratsFournisseurs.FirstOrDefaultAsync(c => c.Id == id, ct);
if (contrat is null) return Results.NotFound();
var userId = await currentUser.GetOrCreateCurrentUserIdAsync(ct);
var hasResponsableRole = await currentUser.HasRoleAsync("RESPONSABLE", db, ct);
var estResponsableDuService = await db.Utilisateurs.AsNoTracking()
.AnyAsync(u => u.Actif && u.ServiceId == contrat.ServiceId &&
(u.ResponsableId == userId || (hasResponsableRole && u.Id == userId)), ct);
if (!estResponsableDuService) return Results.Forbid();
var statut = input.Statut.Trim().ToUpperInvariant();
if (statut is not ("BROUILLON" or "ACTIF" or "A_RENOUVELER" or "SUSPENDU" or "RESILIE" or "EXPIRE" or "ARCHIVE"))
return Results.BadRequest(new { error = "Statut de contrat invalide." });
if (statut == "RESILIE" && (input.DateResiliation is null || string.IsNullOrWhiteSpace(input.MotifResiliation)))
return Results.BadRequest(new { error = "La date et le motif de résiliation sont requis." });
var ancienStatut = contrat.Statut;
contrat.Statut = statut;
contrat.DateResiliation = statut == "RESILIE" ? input.DateResiliation : null;
contrat.MotifResiliation = statut == "RESILIE" ? input.MotifResiliation!.Trim() : null;
contrat.DateDerniereMaj = DateTime.UtcNow;
db.ContratsFournisseursHistorique.Add(new ContratFournisseurHistorique
{
ContratId = contrat.Id,
AncienStatut = ancienStatut,
NouveauStatut = statut,
Commentaire = string.IsNullOrWhiteSpace(input.Commentaire) ? null : input.Commentaire.Trim(),
UtilisateurId = await currentUser.GetOrCreateCurrentUserIdAsync(ct),
DateAction = DateTime.UtcNow,
});
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
group.MapPost("/{id:int}/echeances", async (int id, CreateContratEcheanceDto input, SIFinanceDbContext db, CancellationToken ct) =>
{
var contrat = await db.ContratsFournisseurs.FindAsync([id], ct);
if (contrat is null) return Results.NotFound();
var echeance = new ContratEcheance { ContratId = id, DateEcheance = input.Date, Montant = input.Montant, Statut = "A_VENIR", DateCreation = DateTime.UtcNow };
db.ContratEcheances.Add(echeance);
await db.SaveChangesAsync(ct);
return Results.Created($"/api/contrats/{id}/echeances/{echeance.Id}", new ContratEcheanceDto(echeance.Id, echeance.DateEcheance, echeance.Montant, echeance.Statut, null));
});
group.MapPut("/{id:int}/echeances/{echeanceId:int}", async (int id, int echeanceId, UpdateEcheanceDto input, SIFinanceDbContext db, CurrentUserService currentUser, CancellationToken ct) =>
{
var echeance = await db.ContratEcheances.Include(e => e.Justificatif)
.FirstOrDefaultAsync(e => e.Id == echeanceId && e.ContratId == id, ct);
if (echeance is null) return Results.NotFound();
echeance.DateEcheance = input.Date;
echeance.Montant = input.Montant;
echeance.Statut = input.Statut;
if (!string.IsNullOrWhiteSpace(input.JustificatifNom))
{
if (echeance.Justificatif is not null)
{
echeance.Justificatif.NomFichier = input.JustificatifNom;
}
else
{
// Pas encore de stockage SharePoint branché : on garde une trace du nom de
// fichier, l'URL/ID SharePoint seront renseignés quand l'upload sera en place.
var justificatif = new PiecesJointe
{
EntiteType = "CONTRAT_ECHEANCE",
EntiteId = echeance.Id,
TypePiece = "Justificatif",
NomFichier = input.JustificatifNom,
SharepointUrl = "",
SharepointItemId = "",
TailleOctets = 0,
UploaderId = await currentUser.GetOrCreateCurrentUserIdAsync(ct),
DateUpload = DateTime.UtcNow,
};
db.Set<PiecesJointe>().Add(justificatif);
echeance.Justificatif = justificatif;
}
}
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
group.MapDelete("/{id:int}/echeances/{echeanceId:int}", async (int id, int echeanceId, SIFinanceDbContext db, CancellationToken ct) =>
{
var echeance = await db.ContratEcheances.FirstOrDefaultAsync(e => e.Id == echeanceId && e.ContratId == id, ct);
if (echeance is null) return Results.NotFound();
db.ContratEcheances.Remove(echeance);
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
group.MapPost("/{id:int}/documents", async (int id, AddDocumentDto input, SIFinanceDbContext db, CurrentUserService currentUser, CancellationToken ct) =>
{
var contrat = await db.ContratsFournisseurs.FindAsync([id], ct);
if (contrat is null) return Results.NotFound();
var document = new PiecesJointe
{
EntiteType = "CONTRAT",
EntiteId = id,
TypePiece = input.Tag,
NomFichier = input.Nom,
SharepointUrl = "",
SharepointItemId = "",
TailleOctets = 0,
UploaderId = await currentUser.GetOrCreateCurrentUserIdAsync(ct),
DateUpload = input.Date.HasValue ? input.Date.Value.ToDateTime(TimeOnly.MinValue) : DateTime.UtcNow,
};
db.Set<PiecesJointe>().Add(document);
await db.SaveChangesAsync(ct);
return Results.Created($"/api/contrats/{id}/documents/{document.Id}", ToDocumentDto(document));
});
group.MapPut("/{id:int}/documents/{documentId:int}", async (int id, int documentId, UpdateDocumentDto input, SIFinanceDbContext db, CancellationToken ct) =>
{
var document = await db.Set<PiecesJointe>().FirstOrDefaultAsync(d => d.Id == documentId && d.EntiteId == id && d.EntiteType == "CONTRAT", ct);
if (document is null) return Results.NotFound();
document.TypePiece = input.Tag;
document.DateUpload = input.Date.ToDateTime(TimeOnly.MinValue);
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
group.MapDelete("/{id:int}/documents/{documentId:int}", async (int id, int documentId, SIFinanceDbContext db, CancellationToken ct) =>
{
var document = await db.Set<PiecesJointe>().FirstOrDefaultAsync(d => d.Id == documentId && d.EntiteId == id && d.EntiteType == "CONTRAT", ct);
if (document is null) return Results.NotFound();
db.Set<PiecesJointe>().Remove(document);
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
return app;
}
private static ContratDocumentDto ToDocumentDto(PiecesJointe p) =>
new(p.Id, DateOnly.FromDateTime(p.DateUpload ?? DateTime.UtcNow), p.TypePiece, p.NomFichier);
private static ContratDto ToDto(ContratFournisseur c, IEnumerable<PiecesJointe> documents) => new(
c.Id, c.FournisseurId, c.Fournisseur.RaisonSociale, c.ServiceId, c.Service?.Libelle, c.CampusId, c.Campus?.Libelle, c.Reference, c.Objet,
c.DateSignature, c.DateDebut, c.DateFin, c.DateResiliation, c.MotifResiliation, c.MontantTotal, c.Periodicite, c.Statut,
c.Echeances.Select(e => new ContratEcheanceDto(e.Id, e.DateEcheance, e.Montant, e.Statut, e.Justificatif?.NomFichier)).ToList(),
documents.Select(ToDocumentDto).ToList(),
c.Factures.Select(f => new ContratFactureLieeDto(f.Id, f.NumeroFacture, f.MontantTtc, f.DateFacture, f.Statut)).ToList());
}
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\SIFinance.Shared\SIFinance.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
@@ -0,0 +1,14 @@
namespace SIFinance.Modules.Finance.Dtos;
public record AvoirDto(
int Id,
string Numero,
int FournisseurId,
string Fournisseur,
decimal Montant,
decimal SoldeDisponible,
DateOnly Date,
int? FactureLieeId,
string? FactureLieeNumero);
public record CreateAvoirDto(string Numero, int FournisseurId, decimal Montant, DateOnly Date);
@@ -0,0 +1,53 @@
namespace SIFinance.Modules.Finance.Dtos;
public record FactureEcheanceDto(int Id, DateOnly Date, decimal Montant, string Statut);
public record FactureJustificatifDto(string Nom, string Url);
public record FactureDto(
int Id,
string Numero,
int FournisseurId,
string Fournisseur,
decimal MontantHt,
decimal TauxTva,
decimal MontantTva,
decimal MontantTtc,
DateOnly DateFacture,
DateOnly DateEcheance,
string Statut,
int? DemandeAchatId,
int? AvoirLieId,
string? AvoirLieNumero,
decimal? AvoirLieMontant,
string? Commentaire,
bool EstDoublonSignale,
int? ContratId,
string? ContratObjet,
List<FactureEcheanceDto> Echeances,
List<FactureJustificatifDto>? Justificatifs = null);
// Le formulaire ne saisit qu'un montant TTC global (pas de ventilation HT/TVA à la création) —
// on applique un taux de TVA par défaut de 20% et on recalcule HT/TVA à partir du TTC saisi.
public record CreateFactureDto(string Numero, int FournisseurId, decimal Montant, DateOnly DateFacture, DateOnly DateEcheance, int? DemandeAchatId, string? Commentaire, int? ContratId);
public record UpdateFactureCommentaireDto(string? Commentaire);
public record UpdateFactureStatutDto(string Statut);
public record CreateEcheanceDto(DateOnly Date, decimal Montant);
public record UpdateEcheanceDto(DateOnly Date, decimal Montant);
// Résultat de l'analyse d'un PDF Factur-X/ZUGFeRD uploadé (réception de facture
// électronique) — Conforme=false si le PDF n'a pas de XML EN16931 embarqué, auquel cas
// les autres champs sont null et la saisie reste manuelle.
public record FacturXAnalyseDto(
bool Conforme,
string? Numero,
string? FournisseurNom,
decimal? MontantHt,
decimal? MontantTva,
decimal? MontantTtc,
DateOnly? DateFacture,
DateOnly? DateEcheance,
string? Erreur);
@@ -0,0 +1,22 @@
namespace SIFinance.Modules.Finance.Dtos;
// Un "paiement" côté UI peut regrouper plusieurs factures d'un même fournisseur.
// La table fin.paiements est 1 ligne par facture : on regroupe par Reference pour l'affichage.
public record PaiementGroupeDto(
string Reference,
int FournisseurId,
string Fournisseur,
List<int> FactureIds,
List<string> FactureNumeros,
decimal MontantTotal,
int ModePaiementId,
string ModePaiement,
DateOnly DatePaiement,
string Statut,
bool Rapproche);
public record FactureDisponibleDto(int Id, int FournisseurId, string Fournisseur, string Numero, decimal Montant, DateOnly DateFacture);
public record CreatePaiementDto(List<int> FactureIds, int ModePaiementId, DateOnly DatePaiement, string Reference, string Statut, bool Rapproche);
public record UpdatePaiementDto(int ModePaiementId, DateOnly DatePaiement, string Reference, string Statut, bool Rapproche);
@@ -0,0 +1,78 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using SIFinance.Modules.Finance.Dtos;
using SIFinance.Shared;
using SIFinance.Shared.Entities;
namespace SIFinance.Modules.Finance.Endpoints;
public static class AvoirsEndpoints
{
public static IEndpointRouteBuilder MapAvoirsEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/avoirs").WithTags("Avoirs").RequireAuthorization();
group.MapGet("", async (SIFinanceDbContext db, CancellationToken ct) =>
// Projection directe en DTO : EF ne récupère que les colonnes utiles et
// ne remonte qu'une seule affectation, au lieu de charger toute la collection.
await db.Avoirs
.AsNoTracking()
.OrderByDescending(a => a.DateCreation)
.Select(a => new AvoirDto(
a.Id,
a.NumeroAvoir,
a.FournisseurId,
a.Fournisseur.RaisonSociale,
a.MontantInitial,
a.SoldeDisponible,
a.DateAvoir,
a.AffectationsAvoirs.Select(af => (int?)af.FactureId).FirstOrDefault(),
a.AffectationsAvoirs.Select(af => af.Facture.NumeroFacture).FirstOrDefault()))
.ToListAsync(ct));
group.MapPost("", async (CreateAvoirDto input, SIFinanceDbContext db, CurrentUserService currentUser, CancellationToken ct) =>
{
if (string.IsNullOrWhiteSpace(input.Numero))
return Results.BadRequest(new { error = "Le numéro d'avoir est requis." });
if (input.Montant <= 0)
return Results.BadRequest(new { error = "Le montant doit être supérieur à 0." });
var avoir = new Avoir
{
FournisseurId = input.FournisseurId,
NumeroAvoir = input.Numero.Trim(),
DateAvoir = input.Date,
MontantInitial = input.Montant,
SoldeDisponible = input.Montant,
SaisieParId = await currentUser.GetOrCreateCurrentUserIdAsync(ct),
DateCreation = DateTime.UtcNow,
};
db.Avoirs.Add(avoir);
try
{
await db.SaveChangesAsync(ct);
}
catch (DbUpdateException ex)
{
return Results.BadRequest(new { error = "Impossible d'enregistrer l'avoir — vérifiez le fournisseur.", detail = ex.InnerException?.Message ?? ex.Message });
}
await db.Entry(avoir).Reference(a => a.Fournisseur).LoadAsync(ct);
return Results.Created($"/api/avoirs/{avoir.Id}", ToDto(avoir));
});
return app;
}
private static AvoirDto ToDto(Avoir a)
{
var affectation = a.AffectationsAvoirs.FirstOrDefault();
return new AvoirDto(
a.Id, a.NumeroAvoir, a.FournisseurId, a.Fournisseur.RaisonSociale,
a.MontantInitial, a.SoldeDisponible, a.DateAvoir,
affectation?.FactureId, affectation?.Facture.NumeroFacture);
}
}
@@ -0,0 +1,322 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using s2industries.ZUGFeRD;
using SIFinance.Modules.Finance.Dtos;
using SIFinance.Shared;
using SIFinance.Shared.Entities;
namespace SIFinance.Modules.Finance.Endpoints;
public static class FacturesEndpoints
{
public static IEndpointRouteBuilder MapFacturesEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/factures").WithTags("Factures").RequireAuthorization();
// Réception de factures électroniques (Factur-X/ZUGFeRD, échéance réglementaire du
// 1er septembre) : le PDF fourni par le fournisseur embarque un XML EN16931 — on le
// détecte et on en extrait les données pour pré-remplir/vérifier le formulaire de
// saisie, plutôt que de forcer une ressaisie manuelle sujette aux erreurs.
group.MapPost("/analyser-facturx", async (HttpRequest request, CancellationToken ct) =>
{
if (!request.HasFormContentType)
return Results.BadRequest(new { error = "Contenu multipart attendu." });
var form = await request.ReadFormAsync(ct);
var file = form.Files.GetFile("file");
if (file is null || file.Length == 0)
return Results.BadRequest(new { error = "Fichier manquant." });
try
{
await using var stream = file.OpenReadStream();
var desc = InvoiceDescriptor.Load(stream);
var echeance = desc.GetTradePaymentTerms().FirstOrDefault()?.DueDate;
return Results.Ok(new FacturXAnalyseDto(
Conforme: true,
Numero: desc.InvoiceNo,
FournisseurNom: desc.Seller?.Name,
MontantHt: desc.TaxBasisAmount ?? desc.LineTotalAmount,
MontantTva: desc.TaxTotalAmount,
MontantTtc: desc.GrandTotalAmount,
DateFacture: desc.InvoiceDate.HasValue ? DateOnly.FromDateTime(desc.InvoiceDate.Value) : null,
DateEcheance: echeance.HasValue ? DateOnly.FromDateTime(echeance.Value) : null,
Erreur: null));
}
catch (Exception)
{
// Cas normal pour un PDF "papier scanné" sans XML embarqué — pas une erreur
// serveur, juste une facture non électronique à saisir manuellement.
return Results.Ok(new FacturXAnalyseDto(
Conforme: false, Numero: null, FournisseurNom: null,
MontantHt: null, MontantTva: null, MontantTtc: null,
DateFacture: null, DateEcheance: null,
Erreur: "Ce PDF ne contient pas de facture électronique Factur-X/ZUGFeRD reconnue — saisie manuelle requise."));
}
});
group.MapGet("", async (SIFinanceDbContext db, CancellationToken ct) =>
// Projection directe en DTO : SQL Server ne renvoie que les colonnes utilisées,
// et une seule affectation d'avoir est remontée (OUTER APPLY TOP 1) au lieu de
// charger toute la collection avec l'entité Avoir complète.
{
var factures = await db.Factures
.AsNoTracking()
.OrderByDescending(f => f.DateCreation)
.Select(f => new FactureDto(
f.Id, f.NumeroFacture, f.FournisseurId, f.Fournisseur.RaisonSociale,
f.MontantHt, f.TauxTva, f.MontantTva, f.MontantTtc,
f.DateFacture, f.DateEcheance, f.Statut, f.DemandeAchatId,
f.AffectationsAvoirs.Select(a => (int?)a.AvoirId).FirstOrDefault(),
f.AffectationsAvoirs.Select(a => a.Avoir.NumeroAvoir).FirstOrDefault(),
f.AffectationsAvoirs.Select(a => (decimal?)a.MontantAffecte).FirstOrDefault(),
f.Commentaire,
f.EstDoublonSignale ?? false,
f.ContratId,
f.Contrat != null ? f.Contrat.Objet : null,
// ToEcheanceDto n'est pas utilisable ici : EF ne sait pas traduire
// un appel de méthode C# en SQL, le constructeur est donc écrit inline.
f.Acomptes.Select(a => new FactureEcheanceDto(a.Id, a.DatePrevue, a.Montant, a.Statut)).ToList()))
.ToListAsync(ct);
var factureIds = factures.Select(f => f.Id).ToArray();
var justificatifs = await db.PiecesJointes.AsNoTracking()
.Where(p => p.EntiteType == "FACTURE" && factureIds.Contains(p.EntiteId))
.OrderBy(p => p.DateUpload)
.Select(p => new { p.EntiteId, p.NomFichier, p.SharepointUrl })
.ToListAsync(ct);
var parFacture = justificatifs.ToLookup(p => p.EntiteId);
return factures.Select(f => f with
{
Justificatifs = parFacture[f.Id]
.Where(p => !string.IsNullOrWhiteSpace(p.SharepointUrl))
.Select(p => new FactureJustificatifDto(p.NomFichier, p.SharepointUrl))
.ToList(),
});
});
group.MapPost("", async (CreateFactureDto input, SIFinanceDbContext db, CurrentUserService currentUser, CancellationToken ct) =>
{
if (string.IsNullOrWhiteSpace(input.Numero))
return Results.BadRequest(new { error = "Le numéro de facture est requis." });
if (input.Montant <= 0)
return Results.BadRequest(new { error = "Le montant doit être supérieur à 0." });
if (input.ContratId.HasValue)
{
var contratExiste = await db.ContratsFournisseurs
.AnyAsync(c => c.Id == input.ContratId.Value && c.FournisseurId == input.FournisseurId, ct);
if (!contratExiste)
return Results.BadRequest(new { error = "Le contrat sélectionné n'appartient pas à ce fournisseur." });
}
var montantTtc = input.Montant;
var montantHt = Math.Round(montantTtc / (1 + FinanceDefaults.DefaultTauxTva / 100m), 2);
var montantTva = Math.Round(montantTtc - montantHt, 2);
var currentUserId = await currentUser.GetOrCreateCurrentUserIdAsync(ct);
// Doublon potentiel : même fournisseur + même montant TTC dans une fenêtre de 30
// jours autour de la date de facture, indépendamment du numéro (souvent mal
// ressaisi) — signalement non bloquant, l'utilisateur peut le lever ensuite.
var fenetreDebut = input.DateFacture.AddDays(-30);
var fenetreFin = input.DateFacture.AddDays(30);
var doublonPotentiel = await db.Factures.AnyAsync(f =>
f.FournisseurId == input.FournisseurId &&
f.MontantTtc == montantTtc &&
f.DateFacture >= fenetreDebut && f.DateFacture <= fenetreFin, ct);
var facture = new Facture
{
SourceType = "MANUEL",
DemandeAchatId = input.DemandeAchatId,
ContratId = input.ContratId,
FournisseurId = input.FournisseurId,
NumeroFacture = input.Numero.Trim(),
DateFacture = input.DateFacture,
DateEcheance = input.DateEcheance,
MontantHt = montantHt,
TauxTva = FinanceDefaults.DefaultTauxTva,
MontantTva = montantTva,
MontantTtc = montantTtc,
ServiceId = FinanceDefaults.DefaultServiceId,
ModePaiementId = FinanceDefaults.DefaultModePaiementId,
Statut = "attente",
Commentaire = string.IsNullOrWhiteSpace(input.Commentaire) ? null : input.Commentaire.Trim(),
EstDoublonSignale = doublonPotentiel,
SaisieParId = currentUserId,
DateCreation = DateTime.UtcNow,
Acomptes = [new Acompte
{
Montant = montantTtc,
DatePrevue = input.DateEcheance,
Statut = "PREVU",
SaisieParId = currentUserId,
DateCreation = DateTime.UtcNow,
}],
};
db.Factures.Add(facture);
try
{
await db.SaveChangesAsync(ct);
}
catch (DbUpdateException ex)
{
return Results.BadRequest(new { error = "Impossible d'enregistrer la facture — vérifiez le fournisseur.", detail = ex.InnerException?.Message ?? ex.Message });
}
await db.Entry(facture).Reference(f => f.Fournisseur).LoadAsync(ct);
if (facture.ContratId.HasValue)
await db.Entry(facture).Reference(f => f.Contrat).LoadAsync(ct);
return Results.Created($"/api/factures/{facture.Id}", ToDto(facture));
});
group.MapPatch("/{id:int}/statut", async (int id, UpdateFactureStatutDto input, SIFinanceDbContext db, CancellationToken ct) =>
{
var facture = await db.Factures.FindAsync([id], ct);
if (facture is null) return Results.NotFound();
facture.Statut = input.Statut;
facture.DateDerniereMaj = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
group.MapPatch("/{id:int}/commentaire", async (int id, UpdateFactureCommentaireDto input, SIFinanceDbContext db, CancellationToken ct) =>
{
var facture = await db.Factures.FindAsync([id], ct);
if (facture is null) return Results.NotFound();
facture.Commentaire = string.IsNullOrWhiteSpace(input.Commentaire) ? null : input.Commentaire.Trim();
facture.DateDerniereMaj = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
// Permet à l'utilisateur de lever un signalement de doublon potentiel une fois
// vérifié qu'il s'agit bien de deux factures distinctes (cf. demande métier :
// avertissement désactivable, pas un blocage).
group.MapPatch("/{id:int}/doublon", async (int id, SIFinanceDbContext db, CancellationToken ct) =>
{
var facture = await db.Factures.FindAsync([id], ct);
if (facture is null) return Results.NotFound();
facture.EstDoublonSignale = false;
facture.DateDerniereMaj = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
group.MapDelete("/{id:int}", async (int id, SIFinanceDbContext db, CancellationToken ct) =>
{
var facture = await db.Factures
.Include(f => f.Acomptes)
.Include(f => f.AffectationsAvoirs).ThenInclude(a => a.Avoir)
.FirstOrDefaultAsync(f => f.Id == id, ct);
if (facture is null) return Results.NotFound();
foreach (var affectation in facture.AffectationsAvoirs)
{
affectation.Avoir.SoldeDisponible += affectation.MontantAffecte;
}
db.AffectationsAvoirs.RemoveRange(facture.AffectationsAvoirs);
db.Acomptes.RemoveRange(facture.Acomptes);
db.Factures.Remove(facture);
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
group.MapPost("/{id:int}/echeances", async (int id, CreateEcheanceDto input, SIFinanceDbContext db, CurrentUserService currentUser, CancellationToken ct) =>
{
var facture = await db.Factures.FindAsync([id], ct);
if (facture is null) return Results.NotFound();
var echeance = new Acompte
{
FactureId = id,
Montant = input.Montant,
DatePrevue = input.Date,
Statut = "PREVU",
SaisieParId = await currentUser.GetOrCreateCurrentUserIdAsync(ct),
DateCreation = DateTime.UtcNow,
};
db.Acomptes.Add(echeance);
await db.SaveChangesAsync(ct);
return Results.Created($"/api/factures/{id}/echeances/{echeance.Id}", ToEcheanceDto(echeance));
});
group.MapPut("/{id:int}/echeances/{echeanceId:int}", async (int id, int echeanceId, UpdateEcheanceDto input, SIFinanceDbContext db, CancellationToken ct) =>
{
var echeance = await db.Acomptes.FirstOrDefaultAsync(a => a.Id == echeanceId && a.FactureId == id, ct);
if (echeance is null) return Results.NotFound();
echeance.DatePrevue = input.Date;
echeance.Montant = input.Montant;
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
group.MapDelete("/{id:int}/echeances/{echeanceId:int}", async (int id, int echeanceId, SIFinanceDbContext db, CancellationToken ct) =>
{
var echeance = await db.Acomptes.FirstOrDefaultAsync(a => a.Id == echeanceId && a.FactureId == id, ct);
if (echeance is null) return Results.NotFound();
db.Acomptes.Remove(echeance);
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
group.MapPost("/{id:int}/avoir/{avoirId:int}", async (int id, int avoirId, SIFinanceDbContext db, CurrentUserService currentUser, CancellationToken ct) =>
{
var facture = await db.Factures.FindAsync([id], ct);
var avoir = await db.Avoirs.FindAsync([avoirId], ct);
if (facture is null || avoir is null) return Results.NotFound();
if (avoir.FournisseurId != facture.FournisseurId)
return Results.BadRequest(new { error = "L'avoir doit être du même fournisseur que la facture." });
if (avoir.SoldeDisponible <= 0)
return Results.BadRequest(new { error = "Cet avoir n'a plus de solde disponible." });
var montantAffecte = Math.Min(avoir.SoldeDisponible, facture.MontantTtc);
db.AffectationsAvoirs.Add(new AffectationsAvoir
{
AvoirId = avoirId,
FactureId = id,
MontantAffecte = montantAffecte,
AffecteParId = await currentUser.GetOrCreateCurrentUserIdAsync(ct),
DateAffectation = DateTime.UtcNow,
});
avoir.SoldeDisponible -= montantAffecte;
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
group.MapDelete("/{id:int}/avoir/{avoirId:int}", async (int id, int avoirId, SIFinanceDbContext db, CancellationToken ct) =>
{
var affectation = await db.AffectationsAvoirs.Include(a => a.Avoir)
.FirstOrDefaultAsync(a => a.FactureId == id && a.AvoirId == avoirId, ct);
if (affectation is null) return Results.NotFound();
affectation.Avoir.SoldeDisponible += affectation.MontantAffecte;
db.AffectationsAvoirs.Remove(affectation);
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
return app;
}
private static FactureEcheanceDto ToEcheanceDto(Acompte a) =>
new(a.Id, a.DatePrevue, a.Montant, a.Statut);
// Conservé pour le MapPost, qui construit le DTO à partir de l'entité fraîchement
// créée (sans affectation d'avoir à ce stade).
private static FactureDto ToDto(Facture f)
{
var affectation = f.AffectationsAvoirs.FirstOrDefault();
return new FactureDto(
f.Id, f.NumeroFacture, f.FournisseurId, f.Fournisseur.RaisonSociale,
f.MontantHt, f.TauxTva, f.MontantTva, f.MontantTtc,
f.DateFacture, f.DateEcheance, f.Statut, f.DemandeAchatId,
affectation?.AvoirId, affectation?.Avoir.NumeroAvoir, affectation?.MontantAffecte,
f.Commentaire, f.EstDoublonSignale ?? false,
f.ContratId, f.Contrat?.Objet,
f.Acomptes.Select(ToEcheanceDto).ToList());
}
}
@@ -0,0 +1,10 @@
namespace SIFinance.Modules.Finance.Endpoints;
internal static class FinanceDefaults
{
// Le formulaire de saisie facture ne demande pas encore de service/mode de paiement —
// valeurs par défaut en attendant l'ajout de ces champs côté UI.
public const int DefaultServiceId = 1;
public const int DefaultModePaiementId = 1;
public const decimal DefaultTauxTva = 20m;
}
@@ -0,0 +1,140 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using SIFinance.Modules.Finance.Dtos;
using SIFinance.Shared;
using SIFinance.Shared.Entities;
namespace SIFinance.Modules.Finance.Endpoints;
public static class PaiementsEndpoints
{
public static IEndpointRouteBuilder MapPaiementsEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/paiements").WithTags("Paiements").RequireAuthorization();
group.MapGet("/factures-disponibles", async (SIFinanceDbContext db, CancellationToken ct) =>
{
var facturesPayeesIds = db.Paiements.Select(p => p.FactureId);
return await db.Factures
.AsNoTracking()
.Include(f => f.Fournisseur)
.Where(f => f.Statut == "validee" && !facturesPayeesIds.Contains(f.Id))
.OrderBy(f => f.DateFacture)
.Select(f => new FactureDisponibleDto(f.Id, f.FournisseurId, f.Fournisseur.RaisonSociale, f.NumeroFacture, f.MontantTtc, f.DateFacture))
.ToListAsync(ct);
});
group.MapGet("", async (SIFinanceDbContext db, CancellationToken ct) =>
{
var paiements = await db.Paiements
.AsNoTracking()
.Include(p => p.Facture).ThenInclude(f => f.Fournisseur)
.Include(p => p.ModeReglement)
.ToListAsync(ct);
return paiements
.GroupBy(p => p.ReferencePaiement ?? $"PAI-{p.Id}")
.Select(g =>
{
var first = g.First();
return new PaiementGroupeDto(
g.Key, first.Facture.FournisseurId, first.Facture.Fournisseur.RaisonSociale,
g.Select(p => p.FactureId).ToList(),
g.Select(p => p.Facture.NumeroFacture).ToList(),
g.Sum(p => p.MontantPaye),
first.ModeReglementId, first.ModeReglement.Libelle,
first.DatePaiement, first.Statut, first.Rapproche);
})
.OrderByDescending(p => p.DatePaiement);
});
group.MapPost("", async (CreatePaiementDto input, SIFinanceDbContext db, CurrentUserService currentUser, CancellationToken ct) =>
{
if (input.FactureIds.Count == 0)
return Results.BadRequest(new { error = "Sélectionner au moins une facture." });
if (string.IsNullOrWhiteSpace(input.Reference))
return Results.BadRequest(new { error = "La référence de paiement est requise." });
var factures = await db.Factures.Where(f => input.FactureIds.Contains(f.Id)).ToListAsync(ct);
if (factures.Count != input.FactureIds.Count)
return Results.BadRequest(new { error = "Une ou plusieurs factures sont introuvables." });
if (factures.Select(f => f.FournisseurId).Distinct().Count() > 1)
return Results.BadRequest(new { error = "Un paiement regroupe uniquement des factures d'un même fournisseur." });
var dejaCouvertes = await db.Paiements.AnyAsync(p => input.FactureIds.Contains(p.FactureId), ct);
if (dejaCouvertes)
return Results.BadRequest(new { error = "Une ou plusieurs factures sont déjà couvertes par un paiement." });
var referenceExistante = await db.Paiements.AnyAsync(p => p.ReferencePaiement == input.Reference, ct);
if (referenceExistante)
return Results.BadRequest(new { error = "Cette référence de paiement existe déjà." });
var currentUserId = await currentUser.GetOrCreateCurrentUserIdAsync(ct);
var lignes = factures.Select(f => new Paiement
{
FactureId = f.Id,
MontantPaye = f.MontantTtc,
DatePaiement = input.DatePaiement,
ModeReglementId = input.ModePaiementId,
ReferencePaiement = input.Reference,
EnregistreParId = currentUserId,
DateSaisie = DateTime.UtcNow,
Statut = input.Statut,
Rapproche = input.Rapproche,
}).ToList();
db.Paiements.AddRange(lignes);
try
{
await db.SaveChangesAsync(ct);
}
catch (DbUpdateException ex)
{
return Results.BadRequest(new { error = "Impossible d'enregistrer le paiement.", detail = ex.InnerException?.Message ?? ex.Message });
}
return Results.Created($"/api/paiements/{Uri.EscapeDataString(input.Reference)}", (object?)null);
});
group.MapPatch("/{reference}", async (string reference, UpdatePaiementDto input, SIFinanceDbContext db, CancellationToken ct) =>
{
var lignes = await db.Paiements.Where(p => p.ReferencePaiement == reference).ToListAsync(ct);
if (lignes.Count == 0) return Results.NotFound();
foreach (var p in lignes)
{
p.ModeReglementId = input.ModePaiementId;
p.DatePaiement = input.DatePaiement;
p.ReferencePaiement = input.Reference;
p.Statut = input.Statut;
p.Rapproche = input.Rapproche;
}
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
group.MapPatch("/{reference}/rapproche", async (string reference, SIFinanceDbContext db, CancellationToken ct) =>
{
var lignes = await db.Paiements.Where(p => p.ReferencePaiement == reference).ToListAsync(ct);
if (lignes.Count == 0) return Results.NotFound();
var nouvelEtat = !lignes[0].Rapproche;
foreach (var p in lignes) p.Rapproche = nouvelEtat;
await db.SaveChangesAsync(ct);
return Results.Ok(new { rapproche = nouvelEtat });
});
group.MapDelete("/{reference}", async (string reference, SIFinanceDbContext db, CancellationToken ct) =>
{
var lignes = await db.Paiements.Where(p => p.ReferencePaiement == reference).ToListAsync(ct);
if (lignes.Count == 0) return Results.NotFound();
db.Paiements.RemoveRange(lignes);
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
return app;
}
}
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\SIFinance.Shared\SIFinance.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="ZUGFeRD-csharp" Version="18.0.0" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
@@ -0,0 +1,10 @@
namespace SIFinance.Modules.SousTraitants.Dtos;
public record ContratPrestataireDto(
int Id, int OrganismeId, string Organisme, string Reference, string Objet,
DateOnly? DateSignature, DateOnly DateDebut, DateOnly DateFin,
decimal MontantTotal, string Statut);
public record ContratPrestataireCreateDto(
int OrganismeId, string Objet, DateOnly? DateSignature,
DateOnly DateDebut, DateOnly DateFin, decimal MontantTotal);
@@ -0,0 +1,11 @@
namespace SIFinance.Modules.SousTraitants.Dtos;
public record OrganismeDto(int Id, string RaisonSociale, string? Siret, string Statut);
public record OrganismeDetailDto(
int Id, string RaisonSociale, string? Siret, string? Email, string? Telephone,
string? Adresse, string Statut, DateTime? DateCreation);
public record OrganismeSaveDto(
string RaisonSociale, string? Siret, string? Email, string? Telephone,
string? Adresse, string Statut);
@@ -0,0 +1,82 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using SIFinance.Modules.SousTraitants.Dtos;
using SIFinance.Shared;
using SIFinance.Shared.Entities;
namespace SIFinance.Modules.SousTraitants.Endpoints;
public static class ContratsPrestatairesEndpoints
{
public static IEndpointRouteBuilder MapContratsPrestatairesEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/soustraitants/contrats").WithTags("SousTraitants-Contrats").RequireAuthorization();
group.MapGet("", async (SIFinanceDbContext db, CancellationToken ct) =>
await db.ContratsPrestataires
.AsNoTracking()
.Include(c => c.Organisme)
.OrderByDescending(c => c.DateCreation)
.Select(c => ToDto(c))
.ToListAsync(ct));
group.MapPost("", async (ContratPrestataireCreateDto input, SIFinanceDbContext db, CurrentUserService currentUser, CancellationToken ct) =>
{
if (string.IsNullOrWhiteSpace(input.Objet))
return Results.BadRequest(new { error = "L'objet du contrat est requis." });
if (input.DateFin < input.DateDebut)
return Results.BadRequest(new { error = "La date de fin doit être postérieure à la date de début." });
var contrat = new ContratPrestataire
{
OrganismeId = input.OrganismeId,
Reference = await GenererReferenceAsync(db, ct),
Objet = input.Objet.Trim(),
DateSignature = input.DateSignature,
DateDebut = input.DateDebut,
DateFin = input.DateFin,
MontantTotal = input.MontantTotal,
Statut = "ACTIF",
SaisieParId = await currentUser.GetOrCreateCurrentUserIdAsync(ct),
DateCreation = DateTime.UtcNow,
};
db.ContratsPrestataires.Add(contrat);
try
{
await db.SaveChangesAsync(ct);
}
catch (DbUpdateException ex)
{
return Results.BadRequest(new { error = "Impossible d'enregistrer le contrat — vérifiez l'organisme.", detail = ex.InnerException?.Message ?? ex.Message });
}
await db.Entry(contrat).Reference(c => c.Organisme).LoadAsync(ct);
return Results.Created($"/api/soustraitants/contrats/{contrat.Id}", ToDto(contrat));
});
return app;
}
private static async Task<string> GenererReferenceAsync(SIFinanceDbContext db, CancellationToken ct)
{
var prefix = $"ST-CONT-{DateTime.UtcNow.Year}-";
var last = await db.ContratsPrestataires
.Where(c => c.Reference.StartsWith(prefix))
.OrderByDescending(c => c.Reference)
.Select(c => c.Reference)
.FirstOrDefaultAsync(ct);
var nextSeq = 1;
if (last is not null && int.TryParse(last.AsSpan(prefix.Length), out var lastSeq))
nextSeq = lastSeq + 1;
return $"{prefix}{nextSeq:D3}";
}
private static ContratPrestataireDto ToDto(ContratPrestataire c) => new(
c.Id, c.OrganismeId, c.Organisme.RaisonSociale, c.Reference, c.Objet,
c.DateSignature, c.DateDebut, c.DateFin, c.MontantTotal, c.Statut);
}
@@ -0,0 +1,93 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using SIFinance.Modules.SousTraitants.Dtos;
using SIFinance.Shared.Entities;
namespace SIFinance.Modules.SousTraitants.Endpoints;
public static class OrganismesEndpoints
{
public static IEndpointRouteBuilder MapOrganismesEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/soustraitants/organismes").WithTags("SousTraitants-Organismes").RequireAuthorization();
group.MapGet("", async (SIFinanceDbContext db, CancellationToken ct) =>
await db.Organismes
.AsNoTracking()
.Where(o => o.Statut == "ACTIF")
.OrderBy(o => o.RaisonSociale)
.Select(o => new OrganismeDto(o.Id, o.RaisonSociale, o.Siret, o.Statut))
.ToListAsync(ct));
group.MapGet("/all", async (SIFinanceDbContext db, CancellationToken ct) =>
await db.Organismes
.AsNoTracking()
.OrderBy(o => o.RaisonSociale)
.Select(o => new OrganismeDetailDto(o.Id, o.RaisonSociale, o.Siret, o.Email, o.Telephone, o.Adresse, o.Statut, o.DateCreation))
.ToListAsync(ct));
group.MapPost("", async (OrganismeSaveDto input, SIFinanceDbContext db, CancellationToken ct) =>
{
if (string.IsNullOrWhiteSpace(input.RaisonSociale))
return Results.BadRequest(new { error = "La raison sociale est requise." });
var organisme = new Organisme
{
RaisonSociale = input.RaisonSociale.Trim(),
Siret = string.IsNullOrWhiteSpace(input.Siret) ? null : input.Siret.Trim(),
Email = string.IsNullOrWhiteSpace(input.Email) ? null : input.Email.Trim(),
Telephone = string.IsNullOrWhiteSpace(input.Telephone) ? null : input.Telephone.Trim(),
Adresse = string.IsNullOrWhiteSpace(input.Adresse) ? null : input.Adresse.Trim(),
Statut = input.Statut,
DateCreation = DateTime.UtcNow,
};
db.Organismes.Add(organisme);
await db.SaveChangesAsync(ct);
return Results.Created($"/api/soustraitants/organismes/{organisme.Id}",
new OrganismeDetailDto(organisme.Id, organisme.RaisonSociale, organisme.Siret, organisme.Email, organisme.Telephone, organisme.Adresse, organisme.Statut, organisme.DateCreation));
});
group.MapPut("/{id:int}", async (int id, OrganismeSaveDto input, SIFinanceDbContext db, CancellationToken ct) =>
{
if (string.IsNullOrWhiteSpace(input.RaisonSociale))
return Results.BadRequest(new { error = "La raison sociale est requise." });
var organisme = await db.Organismes.FirstOrDefaultAsync(o => o.Id == id, ct);
if (organisme is null) return Results.NotFound();
organisme.RaisonSociale = input.RaisonSociale.Trim();
organisme.Siret = string.IsNullOrWhiteSpace(input.Siret) ? null : input.Siret.Trim();
organisme.Email = string.IsNullOrWhiteSpace(input.Email) ? null : input.Email.Trim();
organisme.Telephone = string.IsNullOrWhiteSpace(input.Telephone) ? null : input.Telephone.Trim();
organisme.Adresse = string.IsNullOrWhiteSpace(input.Adresse) ? null : input.Adresse.Trim();
organisme.Statut = input.Statut;
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
group.MapDelete("/{id:int}", async (int id, SIFinanceDbContext db, CancellationToken ct) =>
{
var organisme = await db.Organismes.FirstOrDefaultAsync(o => o.Id == id, ct);
if (organisme is null) return Results.NotFound();
db.Organismes.Remove(organisme);
try
{
await db.SaveChangesAsync(ct);
}
catch (DbUpdateException)
{
return Results.BadRequest(new { error = "Impossible de supprimer cet organisme : des contrats y sont rattachés." });
}
return Results.NoContent();
});
return app;
}
}
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\SIFinance.Shared\SIFinance.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
+17
View File
@@ -0,0 +1,17 @@
namespace SIFinance.Shared;
// Section "AzureAd" de appsettings*.json — App registration Azure AD "SI Finance",
// utilisée à la fois pour le flux OAuth2 (authorization code + PKCE, connexion
// utilisateur) dans EntraAuthEndpoints et pour l'appel Graph "app-only" (client
// credentials) dans EntraSyncService/EntraGraphTokenProvider. ClientSecret et GroupId
// sont renseignés dans appsettings.Development.json (secrets, non commités).
public class AzureAdOptions
{
public const string SectionName = "AzureAd";
public string TenantId { get; set; } = "";
public string ClientId { get; set; } = "";
public string ClientSecret { get; set; } = "";
public string RedirectUri { get; set; } = "";
public string GroupId { get; set; } = "";
}
+38
View File
@@ -0,0 +1,38 @@
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using SIFinance.Shared.Entities;
namespace SIFinance.Shared;
public class CurrentUserService(IHttpContextAccessor httpContextAccessor)
{
public Task<int> GetOrCreateCurrentUserIdAsync(CancellationToken ct = default)
{
var principal = httpContextAccessor.HttpContext?.User
?? throw new InvalidOperationException("Aucun contexte HTTP disponible pour résoudre l'utilisateur courant.");
var idClaim = principal.FindFirst("id")?.Value
?? throw new UnauthorizedAccessException("Le jeton ne contient pas de claim 'id' — utilisateur non identifiable.");
return Task.FromResult(int.Parse(idClaim));
}
// Vérifie si l'utilisateur courant possède le rôle donné (ex. "FINANCE").
// Passe par une jointure utilisateurs <-> utilisateur_roles via azure_oid, car
// le JWT ne porte que l'Id interne, pas l'azure_oid.
public async Task<bool> HasRoleAsync(string role, SIFinanceDbContext db, CancellationToken ct = default)
{
var userId = await GetOrCreateCurrentUserIdAsync(ct);
// Les habilitations fonctionnelles viennent exclusivement de
// core.utilisateur_roles. Un profil Entra ou le statut Responsable ne donne
// jamais à lui seul l'accès aux écrans ou actions Finance.
return await db.UtilisateurRoles
.AsNoTracking()
.Join(db.Utilisateurs.AsNoTracking(),
ur => ur.AzureOid,
u => u.AzureOid,
(ur, u) => new { u.Id, ur.Role })
.AnyAsync(x => x.Id == userId && x.Role == role, ct);
}
}
+33
View File
@@ -0,0 +1,33 @@
using System;
using System.Collections.Generic;
namespace SIFinance.Shared.Entities;
public partial class Acompte
{
public int Id { get; set; }
public int FactureId { get; set; }
public decimal Montant { get; set; }
public DateOnly DatePrevue { get; set; }
public DateOnly? DateVerse { get; set; }
public string Statut { get; set; } = null!;
public int? JustificatifId { get; set; }
public string? Commentaire { get; set; }
public int SaisieParId { get; set; }
public DateTime? DateCreation { get; set; }
public virtual Facture Facture { get; set; } = null!;
public virtual PiecesJointe? Justificatif { get; set; }
public virtual Utilisateur SaisiePar { get; set; } = null!;
}
@@ -0,0 +1,25 @@
using System;
using System.Collections.Generic;
namespace SIFinance.Shared.Entities;
public partial class AffectationsAvoir
{
public int Id { get; set; }
public int AvoirId { get; set; }
public int FactureId { get; set; }
public decimal MontantAffecte { get; set; }
public int AffecteParId { get; set; }
public DateTime? DateAffectation { get; set; }
public virtual Utilisateur AffectePar { get; set; } = null!;
public virtual Avoir Avoir { get; set; } = null!;
public virtual Facture Facture { get; set; } = null!;
}
+25
View File
@@ -0,0 +1,25 @@
using System;
using System.Collections.Generic;
namespace SIFinance.Shared.Entities;
public partial class Audit
{
public long Id { get; set; }
public string EntiteType { get; set; } = null!;
public int EntiteId { get; set; }
public int UtilisateurId { get; set; }
public string Action { get; set; } = null!;
public string? DetailJson { get; set; }
public string? IpAdresse { get; set; }
public DateTime DateAction { get; set; }
public virtual Utilisateur Utilisateur { get; set; } = null!;
}
+37
View File
@@ -0,0 +1,37 @@
using System;
using System.Collections.Generic;
namespace SIFinance.Shared.Entities;
public partial class Avoir
{
public int Id { get; set; }
public int FournisseurId { get; set; }
public string NumeroAvoir { get; set; } = null!;
public DateOnly DateAvoir { get; set; }
public decimal MontantInitial { get; set; }
public decimal SoldeDisponible { get; set; }
public int? FactureId { get; set; }
public int? JustificatifId { get; set; }
public int SaisieParId { get; set; }
public DateTime? DateCreation { get; set; }
public virtual ICollection<AffectationsAvoir> AffectationsAvoirs { get; set; } = new List<AffectationsAvoir>();
public virtual Facture? Facture { get; set; }
public virtual Fournisseur Fournisseur { get; set; } = null!;
public virtual PiecesJointe? Justificatif { get; set; }
public virtual Utilisateur SaisiePar { get; set; } = null!;
}
@@ -0,0 +1,32 @@
using System;
using System.Collections.Generic;
namespace SIFinance.Shared.Entities;
// Budget annuel d'un service pour le module Achats. Exercice non calendaire (1er
// septembre au 31 août) — ExerciceAnnee porte l'année de démarrage (ex. 2026 = exercice
// du 01/09/2026 au 31/08/2027). Le montant "actif" du budget est MontantInitial tant
// qu'aucune révision n'existe, sinon la révision la plus récente (cf. Revisions) —
// chaque révision est conservée pour l'historique, jamais écrasée.
public partial class BudgetService
{
public int Id { get; set; }
public int ServiceId { get; set; }
public int ExerciceAnnee { get; set; }
public decimal MontantInitial { get; set; }
public int SaisieParId { get; set; }
public DateTime? DateCreation { get; set; }
public DateTime? DateDerniereMaj { get; set; }
public virtual Service Service { get; set; } = null!;
public virtual Utilisateur SaisiePar { get; set; } = null!;
public virtual ICollection<BudgetServiceRevision> Revisions { get; set; } = new List<BudgetServiceRevision>();
}
@@ -0,0 +1,25 @@
using System;
namespace SIFinance.Shared.Entities;
// Historique des révisions d'un budget de service (augmentation en cours d'exercice,
// décidée par un responsable Finance) — une ligne par révision, jamais écrasée, pour
// garder la traçabilité complète de qui a changé le budget et pourquoi.
public partial class BudgetServiceRevision
{
public int Id { get; set; }
public int BudgetServiceId { get; set; }
public decimal MontantRevise { get; set; }
public string? Commentaire { get; set; }
public int RevisePar { get; set; }
public DateTime DateRevision { get; set; }
public virtual BudgetService BudgetService { get; set; } = null!;
public virtual Utilisateur ReviseParUtilisateur { get; set; } = null!;
}
+9
View File
@@ -0,0 +1,9 @@
namespace SIFinance.Shared.Entities;
public partial class Campus
{
public int Id { get; set; }
public string Libelle { get; set; } = null!;
public bool Actif { get; set; }
public virtual ICollection<ContratFournisseur> ContratsFournisseurs { get; set; } = new List<ContratFournisseur>();
}
@@ -0,0 +1,24 @@
using System;
namespace SIFinance.Shared.Entities;
public partial class ContratEcheance
{
public int Id { get; set; }
public int ContratId { get; set; }
public DateOnly DateEcheance { get; set; }
public decimal Montant { get; set; }
public string Statut { get; set; } = null!;
public int? JustificatifId { get; set; }
public DateTime? DateCreation { get; set; }
public virtual ContratFournisseur Contrat { get; set; } = null!;
public virtual PiecesJointe? Justificatif { get; set; }
}
@@ -0,0 +1,55 @@
using System;
using System.Collections.Generic;
namespace SIFinance.Shared.Entities;
public partial class ContratFournisseur
{
public int Id { get; set; }
public int FournisseurId { get; set; }
public int? ServiceId { get; set; }
public int? CampusId { get; set; }
public string? Reference { get; set; }
public string Objet { get; set; } = null!;
public DateOnly? DateSignature { get; set; }
public DateOnly DateDebut { get; set; }
public DateOnly DateFin { get; set; }
public DateOnly? DateResiliation { get; set; }
public string? MotifResiliation { get; set; }
public decimal MontantTotal { get; set; }
public string Periodicite { get; set; } = null!;
public string Statut { get; set; } = null!;
public int SaisieParId { get; set; }
public DateTime? DateCreation { get; set; }
public DateTime? DateDerniereMaj { get; set; }
public virtual Fournisseur Fournisseur { get; set; } = null!;
public virtual Service? Service { get; set; }
public virtual Campus? Campus { get; set; }
public virtual Utilisateur SaisiePar { get; set; } = null!;
public virtual ICollection<ContratEcheance> Echeances { get; set; } = new List<ContratEcheance>();
public virtual ICollection<Facture> Factures { get; set; } = new List<Facture>();
public virtual ICollection<ContratFournisseurHistorique> Historique { get; set; } = new List<ContratFournisseurHistorique>();
}
@@ -0,0 +1,14 @@
namespace SIFinance.Shared.Entities;
public partial class ContratFournisseurHistorique
{
public int Id { get; set; }
public int ContratId { get; set; }
public string? AncienStatut { get; set; }
public string NouveauStatut { get; set; } = null!;
public string? Commentaire { get; set; }
public int UtilisateurId { get; set; }
public DateTime DateAction { get; set; }
public virtual ContratFournisseur Contrat { get; set; } = null!;
public virtual Utilisateur Utilisateur { get; set; } = null!;
}
@@ -0,0 +1,32 @@
using System;
namespace SIFinance.Shared.Entities;
public partial class ContratPrestataire
{
public int Id { get; set; }
public int OrganismeId { get; set; }
public string Reference { get; set; } = null!;
public string Objet { get; set; } = null!;
public DateOnly? DateSignature { get; set; }
public DateOnly DateDebut { get; set; }
public DateOnly DateFin { get; set; }
public decimal MontantTotal { get; set; }
public string Statut { get; set; } = null!;
public int SaisieParId { get; set; }
public DateTime? DateCreation { get; set; }
public virtual Organisme Organisme { get; set; } = null!;
public virtual Utilisateur SaisiePar { get; set; } = null!;
}
@@ -0,0 +1,65 @@
using System;
using System.Collections.Generic;
namespace SIFinance.Shared.Entities;
public partial class DemandesAchat
{
public int Id { get; set; }
public string Reference { get; set; } = null!;
public int ServiceId { get; set; }
public string Objet { get; set; } = null!;
public string? Description { get; set; }
public int? FournisseurId { get; set; }
public string? FournisseurLibre { get; set; }
public string? CategorieFournisseurLibre { get; set; }
public int TypeDepenseId { get; set; }
public decimal MontantHt { get; set; }
public decimal TauxTva { get; set; }
public decimal MontantTva { get; set; }
public decimal MontantTtc { get; set; }
public DateOnly DatePrevisionnelle { get; set; }
public int ModePaiementId { get; set; }
public string Statut { get; set; } = null!;
public int DemandeurId { get; set; }
public int? ValideurId { get; set; }
public decimal SeuilApplicable { get; set; }
public DateTime? DateCreation { get; set; }
public DateTime? DateSoumission { get; set; }
public DateTime? DateDerniereMaj { get; set; }
public virtual Utilisateur Demandeur { get; set; } = null!;
public virtual ICollection<Facture> Factures { get; set; } = new List<Facture>();
public virtual Fournisseur? Fournisseur { get; set; }
public virtual ModesPaiement ModePaiement { get; set; } = null!;
public virtual Service Service { get; set; } = null!;
public virtual TypesDepense TypeDepense { get; set; } = null!;
public virtual Utilisateur? Valideur { get; set; }
}
+77
View File
@@ -0,0 +1,77 @@
using System;
using System.Collections.Generic;
namespace SIFinance.Shared.Entities;
public partial class Facture
{
public int Id { get; set; }
public string SourceType { get; set; } = null!;
public int? DemandeAchatId { get; set; }
public int? ContratId { get; set; }
public int FournisseurId { get; set; }
public string NumeroFacture { get; set; } = null!;
public DateOnly DateFacture { get; set; }
public DateOnly DateEcheance { get; set; }
public decimal MontantHt { get; set; }
public decimal TauxTva { get; set; }
public decimal MontantTva { get; set; }
public decimal MontantTtc { get; set; }
public int? CategorieDepenseId { get; set; }
public int ServiceId { get; set; }
public int ModePaiementId { get; set; }
public string Statut { get; set; } = null!;
public bool? EstDoublonSignale { get; set; }
public string? Commentaire { get; set; }
public int SaisieParId { get; set; }
public int? ValideBapParId { get; set; }
public DateTime? DateValidationBap { get; set; }
public DateTime? DateCreation { get; set; }
public DateTime? DateDerniereMaj { get; set; }
public virtual ICollection<Acompte> Acomptes { get; set; } = new List<Acompte>();
public virtual ICollection<AffectationsAvoir> AffectationsAvoirs { get; set; } = new List<AffectationsAvoir>();
public virtual ICollection<Avoir> Avoirs { get; set; } = new List<Avoir>();
public virtual TypesDepense? CategorieDepense { get; set; }
public virtual DemandesAchat? DemandeAchat { get; set; }
public virtual ContratFournisseur? Contrat { get; set; }
public virtual Fournisseur Fournisseur { get; set; } = null!;
public virtual ModesPaiement ModePaiement { get; set; } = null!;
public virtual ICollection<Paiement> Paiements { get; set; } = new List<Paiement>();
public virtual Utilisateur SaisiePar { get; set; } = null!;
public virtual Service Service { get; set; } = null!;
public virtual Utilisateur? ValideBapPar { get; set; }
}
+25
View File
@@ -0,0 +1,25 @@
using System;
using System.Collections.Generic;
namespace SIFinance.Shared.Entities;
public partial class FichiersXml
{
public int Id { get; set; }
public string NomFichier { get; set; } = null!;
public string SharepointUrl { get; set; } = null!;
public DateTime? DateGeneration { get; set; }
public int GenereParId { get; set; }
public int NombreVirements { get; set; }
public decimal MontantTotal { get; set; }
public virtual Utilisateur GenerePar { get; set; } = null!;
public virtual ICollection<Paiement> Paiements { get; set; } = new List<Paiement>();
}
+37
View File
@@ -0,0 +1,37 @@
using System;
using System.Collections.Generic;
namespace SIFinance.Shared.Entities;
public partial class Fournisseur
{
public int Id { get; set; }
public string RaisonSociale { get; set; } = null!;
public string? Siret { get; set; }
public string? Email { get; set; }
public string? Telephone { get; set; }
public string? Adresse { get; set; }
public byte[]? IbanChiffre { get; set; }
public string? Bic { get; set; }
public string? TitulaireCompte { get; set; }
public string Statut { get; set; } = null!;
public DateTime? DateCreation { get; set; }
public virtual ICollection<Avoir> Avoirs { get; set; } = new List<Avoir>();
public virtual ICollection<ContratFournisseur> ContratsFournisseurs { get; set; } = new List<ContratFournisseur>();
public virtual ICollection<DemandesAchat> DemandesAchats { get; set; } = new List<DemandesAchat>();
public virtual ICollection<Facture> Factures { get; set; } = new List<Facture>();
}
@@ -0,0 +1,27 @@
using System;
using System.Collections.Generic;
namespace SIFinance.Shared.Entities;
public partial class HistoriqueAction
{
public int Id { get; set; }
public string EntiteType { get; set; } = null!;
public int EntiteId { get; set; }
public int UtilisateurId { get; set; }
public string Action { get; set; } = null!;
public string? Commentaire { get; set; }
public string? AncienStatut { get; set; }
public string? NouveauStatut { get; set; }
public DateTime? DateAction { get; set; }
public virtual Utilisateur Utilisateur { get; set; } = null!;
}
@@ -0,0 +1,21 @@
using System;
using System.Collections.Generic;
namespace SIFinance.Shared.Entities;
public partial class ModesPaiement
{
public int Id { get; set; }
public string Libelle { get; set; } = null!;
public bool NecessiteXml { get; set; }
public bool Actif { get; set; }
public virtual ICollection<DemandesAchat> DemandesAchats { get; set; } = new List<DemandesAchat>();
public virtual ICollection<Facture> Factures { get; set; } = new List<Facture>();
public virtual ICollection<Paiement> Paiements { get; set; } = new List<Paiement>();
}
+25
View File
@@ -0,0 +1,25 @@
using System;
using System.Collections.Generic;
namespace SIFinance.Shared.Entities;
public partial class Organisme
{
public int Id { get; set; }
public string RaisonSociale { get; set; } = null!;
public string? Siret { get; set; }
public string? Email { get; set; }
public string? Telephone { get; set; }
public string? Adresse { get; set; }
public string Statut { get; set; } = null!;
public DateTime? DateCreation { get; set; }
public virtual ICollection<ContratPrestataire> ContratsPrestataires { get; set; } = new List<ContratPrestataire>();
}
+39
View File
@@ -0,0 +1,39 @@
using System;
using System.Collections.Generic;
namespace SIFinance.Shared.Entities;
public partial class Paiement
{
public int Id { get; set; }
public int FactureId { get; set; }
public decimal MontantPaye { get; set; }
public DateOnly DatePaiement { get; set; }
public int ModeReglementId { get; set; }
public string? ReferencePaiement { get; set; }
public int? FichierXmlId { get; set; }
public int EnregistreParId { get; set; }
public DateTime? DateSaisie { get; set; }
public string Statut { get; set; } = null!;
public bool Rapproche { get; set; }
public virtual Utilisateur EnregistrePar { get; set; } = null!;
public virtual Facture Facture { get; set; } = null!;
public virtual FichiersXml? FichierXml { get; set; }
public virtual ModesPaiement ModeReglement { get; set; } = null!;
public virtual ICollection<Rapprochement> Rapprochements { get; set; } = new List<Rapprochement>();
}
+19
View File
@@ -0,0 +1,19 @@
using System;
using System.Collections.Generic;
namespace SIFinance.Shared.Entities;
public partial class Parametre
{
public string Cle { get; set; } = null!;
public string Valeur { get; set; } = null!;
public string? Description { get; set; }
public int? ModifiePar { get; set; }
public DateTime? DateModif { get; set; }
public virtual Utilisateur? ModifieParNavigation { get; set; }
}
+35
View File
@@ -0,0 +1,35 @@
using System;
using System.Collections.Generic;
namespace SIFinance.Shared.Entities;
public partial class PiecesJointe
{
public int Id { get; set; }
public string EntiteType { get; set; } = null!;
public int EntiteId { get; set; }
public string TypePiece { get; set; } = null!;
public string NomFichier { get; set; } = null!;
public string SharepointUrl { get; set; } = null!;
public string SharepointItemId { get; set; } = null!;
public long TailleOctets { get; set; }
public int UploaderId { get; set; }
public DateTime? DateUpload { get; set; }
public virtual ICollection<Acompte> Acomptes { get; set; } = new List<Acompte>();
public virtual ICollection<Avoir> Avoirs { get; set; } = new List<Avoir>();
public virtual ICollection<ContratEcheance> ContratEcheances { get; set; } = new List<ContratEcheance>();
public virtual Utilisateur Uploader { get; set; } = null!;
}
@@ -0,0 +1,31 @@
using System;
using System.Collections.Generic;
namespace SIFinance.Shared.Entities;
public partial class Rapprochement
{
public int Id { get; set; }
public int? PaiementId { get; set; }
public string? ReferenceBancaire { get; set; }
public DateOnly DateOperationBanq { get; set; }
public string? LibelleBanque { get; set; }
public decimal MontantBanque { get; set; }
public string Statut { get; set; } = null!;
public string? Commentaire { get; set; }
public int? TraiteParId { get; set; }
public DateTime? DateTraitement { get; set; }
public virtual Paiement? Paiement { get; set; }
public virtual Utilisateur? TraitePar { get; set; }
}
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
using System;
using System.Collections.Generic;
namespace SIFinance.Shared.Entities;
public partial class Service
{
public int Id { get; set; }
public string Libelle { get; set; } = null!;
public bool Actif { get; set; }
public virtual ICollection<DemandesAchat> DemandesAchats { get; set; } = new List<DemandesAchat>();
public virtual ICollection<Facture> Factures { get; set; } = new List<Facture>();
public virtual ICollection<Utilisateur> Utilisateurs { get; set; } = new List<Utilisateur>();
}
+19
View File
@@ -0,0 +1,19 @@
using System;
using System.Collections.Generic;
namespace SIFinance.Shared.Entities;
public partial class TypesDepense
{
public int Id { get; set; }
public string Libelle { get; set; } = null!;
public bool Actif { get; set; }
public bool EstMateriel { get; set; }
public virtual ICollection<DemandesAchat> DemandesAchats { get; set; } = new List<DemandesAchat>();
public virtual ICollection<Facture> Factures { get; set; } = new List<Facture>();
}
+70
View File
@@ -0,0 +1,70 @@
using System;
using System.Collections.Generic;
namespace SIFinance.Shared.Entities;
public partial class Utilisateur
{
public int Id { get; set; }
public string AzureOid { get; set; } = null!;
public string Nom { get; set; } = null!;
public string Prenom { get; set; } = null!;
public string Email { get; set; } = null!;
public string Profil { get; set; } = null!;
public int? ServiceId { get; set; }
// Manager direct, synchronisé depuis Entra ID (EntraSyncService) — c'est cette
// personne, et non le rôle FINANCE, qui a le droit de valider/refuser les demandes
// d'achat du collaborateur.
public int? ResponsableId { get; set; }
public bool Actif { get; set; }
public DateTime? DateCreation { get; set; }
public DateTime? DateDerniereConnexion { get; set; }
public virtual ICollection<Acompte> Acomptes { get; set; } = new List<Acompte>();
public virtual ICollection<AffectationsAvoir> AffectationsAvoirs { get; set; } = new List<AffectationsAvoir>();
public virtual ICollection<Audit> Audits { get; set; } = new List<Audit>();
public virtual ICollection<Avoir> Avoirs { get; set; } = new List<Avoir>();
public virtual ICollection<ContratFournisseur> ContratsFournisseurs { get; set; } = new List<ContratFournisseur>();
public virtual ICollection<ContratPrestataire> ContratsPrestataires { get; set; } = new List<ContratPrestataire>();
public virtual ICollection<DemandesAchat> DemandesAchatDemandeurs { get; set; } = new List<DemandesAchat>();
public virtual ICollection<DemandesAchat> DemandesAchatValideurs { get; set; } = new List<DemandesAchat>();
public virtual ICollection<Facture> FactureSaisiePars { get; set; } = new List<Facture>();
public virtual ICollection<Facture> FactureValideBapPars { get; set; } = new List<Facture>();
public virtual ICollection<FichiersXml> FichiersXmls { get; set; } = new List<FichiersXml>();
public virtual ICollection<HistoriqueAction> HistoriqueActions { get; set; } = new List<HistoriqueAction>();
public virtual ICollection<Paiement> Paiements { get; set; } = new List<Paiement>();
public virtual ICollection<Parametre> Parametres { get; set; } = new List<Parametre>();
public virtual ICollection<PiecesJointe> PiecesJointes { get; set; } = new List<PiecesJointe>();
public virtual ICollection<Rapprochement> Rapprochements { get; set; } = new List<Rapprochement>();
public virtual Service? Service { get; set; }
public virtual Utilisateur? Responsable { get; set; }
public virtual ICollection<Utilisateur> Collaborateurs { get; set; } = new List<Utilisateur>();
}
@@ -0,0 +1,12 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace SIFinance.Shared.Entities
{
public partial class UtilisateurRole
{
public string AzureOid { get; set; } = null!;
public string Role { get; set; } = null!;
}
}
@@ -0,0 +1,48 @@
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Options;
namespace SIFinance.Shared;
// Jeton Graph "app-only" (client_credentials) pour les appels serveur-à-serveur
// (synchro du groupe Entra ID, envoi d'emails de notification, etc.). Mis en cache 55
// minutes pour éviter un aller-retour Azure AD à chaque appel. Vit dans SIFinance.Shared
// (et non SIFinance.Api) car les modules en ont besoin eux aussi (ex. notification par
// email à la création d'une demande), et référencer SIFinance.Api depuis un module
// créerait un cycle.
public class EntraGraphTokenProvider(IHttpClientFactory httpClientFactory, IOptions<AzureAdOptions> options)
{
private string? _token;
private DateTime _expiresAt;
public async Task<string> GetTokenAsync(CancellationToken ct)
{
if (_token is not null && DateTime.UtcNow < _expiresAt) return _token;
var opts = options.Value;
var http = httpClientFactory.CreateClient();
var response = await http.PostAsync(
$"https://login.microsoftonline.com/{opts.TenantId}/oauth2/v2.0/token",
new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "client_credentials",
["client_id"] = opts.ClientId,
["client_secret"] = opts.ClientSecret,
["scope"] = "https://graph.microsoft.com/.default",
}), ct);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<TokenResponse>(cancellationToken: ct)
?? throw new InvalidOperationException("Réponse de jeton Graph vide.");
_token = payload.AccessToken;
_expiresAt = DateTime.UtcNow.AddMinutes(55);
return _token;
}
private class TokenResponse
{
[JsonPropertyName("access_token")]
public string AccessToken { get; set; } = "";
}
}
+145
View File
@@ -0,0 +1,145 @@
using System.Text;
using iText.Kernel.Colors;
using iText.Kernel.Pdf;
using iText.Kernel.Pdf.Filespec;
using iText.Layout;
using iText.Layout.Element;
using iText.Pdfa;
using Microsoft.Extensions.Configuration;
using s2industries.ZUGFeRD;
namespace SIFinance.Shared;
public record FacturXLigne(string Designation, decimal Quantite, decimal PrixUnitaireHt);
public record FacturXPartie(string Nom, string? Rue, string? CodePostal, string? Ville, string PaysCode, string? NumeroTva);
public record FacturXEmissionRequest(
string NumeroFacture,
DateOnly DateFacture,
FacturXPartie Vendeur,
FacturXPartie Acheteur,
List<FacturXLigne> Lignes,
decimal TauxTva);
// Moteur réutilisable d'émission de factures électroniques Factur-X/ZUGFeRD (norme
// EN16931) : XML structuré via ZUGFeRD-csharp, assemblage PDF/A-3 + attachement du XML
// via iText7. Vit dans SIFinance.Shared pour être appelable depuis n'importe quel
// module (Achats, Sous-Traitants, futur Contrat Étudiant) une fois qu'un cas d'usage
// concret d'émission est identifié — voir appels dans les endpoints des modules.
//
// Nécessite un profil colorimétrique ICC sRGB valide (obligatoire pour la conformité
// PDF/A-3, "PdfOutputIntent") — fichier binaire non fourni ici (voir
// SIFinance.Shared/Resources/README.md pour comment se le procurer et où le déposer).
public class FacturXEmissionService(IConfiguration configuration)
{
private string CheminProfilIcc =>
configuration["FacturX:ProfilIccSrgb"] ?? Path.Combine(AppContext.BaseDirectory, "Resources", "sRGB.icc");
public byte[] Generer(FacturXEmissionRequest request)
{
if (request.Lignes.Count == 0)
throw new ArgumentException("Une facture électronique doit contenir au moins une ligne.", nameof(request));
var montantHt = request.Lignes.Sum(l => Math.Round(l.Quantite * l.PrixUnitaireHt, 2));
var montantTva = Math.Round(montantHt * request.TauxTva / 100m, 2);
var montantTtc = montantHt + montantTva;
var xmlBytes = GenererXml(request, montantHt, montantTva, montantTtc);
return AssemblerPdfA3(request, xmlBytes, montantHt, montantTva, montantTtc);
}
private static byte[] GenererXml(FacturXEmissionRequest request, decimal montantHt, decimal montantTva, decimal montantTtc)
{
var desc = InvoiceDescriptor.CreateInvoice(request.NumeroFacture, request.DateFacture.ToDateTime(TimeOnly.MinValue), CurrencyCodes.EUR);
desc.SetSeller(
request.Vendeur.Nom, request.Vendeur.CodePostal ?? "", request.Vendeur.Ville ?? "", request.Vendeur.Rue ?? "",
ParsePays(request.Vendeur.PaysCode), request.Vendeur.NumeroTva);
desc.SetBuyer(
request.Acheteur.Nom, request.Acheteur.CodePostal ?? "", request.Acheteur.Ville ?? "", request.Acheteur.Rue ?? "",
ParsePays(request.Acheteur.PaysCode), request.Acheteur.NumeroTva);
foreach (var ligne in request.Lignes)
{
desc.AddTradeLineItem(
name: ligne.Designation,
netUnitPrice: ligne.PrixUnitaireHt,
unitCode: QuantityCodes.C62,
billedQuantity: ligne.Quantite,
taxType: TaxTypes.VAT,
categoryCode: TaxCategoryCodes.S,
taxPercent: request.TauxTva);
}
desc.AddApplicableTradeTax(montantHt, request.TauxTva, montantTva, TaxTypes.VAT, TaxCategoryCodes.S);
desc.SetTotals(
lineTotalAmount: montantHt, taxBasisAmount: montantHt, taxTotalAmount: montantTva,
grandTotalAmount: montantTtc, duePayableAmount: montantTtc);
using var xmlStream = new MemoryStream();
desc.Save(xmlStream, ZUGFeRDVersion.Version23, Profile.Comfort);
return xmlStream.ToArray();
}
private byte[] AssemblerPdfA3(FacturXEmissionRequest request, byte[] xmlBytes, decimal montantHt, decimal montantTva, decimal montantTtc)
{
var cheminIcc = CheminProfilIcc;
if (!File.Exists(cheminIcc))
throw new InvalidOperationException(
$"Profil ICC sRGB introuvable ({cheminIcc}) — requis pour générer un PDF/A-3 conforme. " +
"Voir SIFinance.Shared/Resources/README.md.");
using var pdfMemStream = new MemoryStream();
using (var iccStream = File.OpenRead(cheminIcc))
{
var outputIntent = new PdfOutputIntent("Custom", "", "http://www.color.org", "sRGB IEC61966-2.1", iccStream);
using var writer = new PdfWriter(pdfMemStream);
using var pdfDoc = new PdfADocument(writer, PdfAConformance.PDF_A_3B, outputIntent);
pdfDoc.SetTagged();
using (var document = new Document(pdfDoc))
{
document.Add(new Paragraph($"Facture {request.NumeroFacture}").SetFontSize(18).SimulateBold());
document.Add(new Paragraph($"Date : {request.DateFacture:dd/MM/yyyy}"));
document.Add(new Paragraph(" "));
document.Add(new Paragraph("Émetteur").SimulateBold());
document.Add(new Paragraph($"{request.Vendeur.Nom}\n{request.Vendeur.Rue}\n{request.Vendeur.CodePostal} {request.Vendeur.Ville}"));
document.Add(new Paragraph(" "));
document.Add(new Paragraph("Destinataire").SimulateBold());
document.Add(new Paragraph($"{request.Acheteur.Nom}\n{request.Acheteur.Rue}\n{request.Acheteur.CodePostal} {request.Acheteur.Ville}"));
document.Add(new Paragraph(" "));
var table = new Table(4).UseAllAvailableWidth();
table.AddHeaderCell("Désignation");
table.AddHeaderCell("Qté");
table.AddHeaderCell("PU HT");
table.AddHeaderCell("Total HT");
foreach (var ligne in request.Lignes)
{
table.AddCell(ligne.Designation);
table.AddCell(ligne.Quantite.ToString("N2"));
table.AddCell($"{ligne.PrixUnitaireHt:N2} €");
table.AddCell($"{(ligne.Quantite * ligne.PrixUnitaireHt):N2} €");
}
document.Add(table);
document.Add(new Paragraph(" "));
document.Add(new Paragraph($"Total HT : {montantHt:N2} €"));
document.Add(new Paragraph($"TVA ({request.TauxTva:N0}%) : {montantTva:N2} €"));
document.Add(new Paragraph($"Total TTC : {montantTtc:N2} €").SimulateBold());
}
var fileSpec = PdfFileSpec.CreateEmbeddedFileSpec(
pdfDoc, xmlBytes, "Facture électronique Factur-X", "factur-x.xml", PdfName.ApplicationXml, null, PdfName.Data);
pdfDoc.AddFileAttachment("factur-x.xml", fileSpec);
}
return pdfMemStream.ToArray();
}
private static s2industries.ZUGFeRD.CountryCodes ParsePays(string code) =>
Enum.TryParse<s2industries.ZUGFeRD.CountryCodes>(code, true, out var parsed) ? parsed : s2industries.ZUGFeRD.CountryCodes.FR;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,148 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SIFinance.Shared.Migrations
{
/// <inheritdoc />
public partial class AddContratsAndPaiementStatut : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "statut",
schema: "fin",
table: "paiements",
type: "varchar(20)",
unicode: false,
maxLength: 20,
nullable: false,
defaultValue: "PROGRAMME");
migrationBuilder.AddColumn<bool>(
name: "rapproche",
schema: "fin",
table: "paiements",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.CreateTable(
name: "contrats_fournisseurs",
schema: "achats",
columns: table => new
{
id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
fournisseur_id = table.Column<int>(type: "int", nullable: false),
reference = table.Column<string>(type: "varchar(50)", unicode: false, maxLength: 50, nullable: true),
objet = table.Column<string>(type: "varchar(255)", unicode: false, maxLength: 255, nullable: false),
date_signature = table.Column<DateOnly>(type: "date", nullable: true),
date_debut = table.Column<DateOnly>(type: "date", nullable: false),
date_fin = table.Column<DateOnly>(type: "date", nullable: false),
montant_total = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
periodicite = table.Column<string>(type: "varchar(20)", unicode: false, maxLength: 20, nullable: false, defaultValue: "mensuelle"),
statut = table.Column<string>(type: "varchar(20)", unicode: false, maxLength: 20, nullable: false, defaultValue: "ACTIF"),
saisie_par_id = table.Column<int>(type: "int", nullable: false),
date_creation = table.Column<DateTime>(type: "datetime", nullable: true, defaultValueSql: "(getdate())"),
date_derniere_maj = table.Column<DateTime>(type: "datetime", nullable: true, defaultValueSql: "(getdate())")
},
constraints: table =>
{
table.PrimaryKey("PK__contrats_fournisseurs", x => x.id);
table.ForeignKey(
name: "FK__contrats_fournisseurs__fournisseur",
column: x => x.fournisseur_id,
principalSchema: "ref",
principalTable: "fournisseurs",
principalColumn: "id");
table.ForeignKey(
name: "FK__contrats_fournisseurs__saisie_par",
column: x => x.saisie_par_id,
principalSchema: "core",
principalTable: "utilisateurs",
principalColumn: "id");
});
migrationBuilder.CreateTable(
name: "contrats_echeances",
schema: "achats",
columns: table => new
{
id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
contrat_id = table.Column<int>(type: "int", nullable: false),
date_echeance = table.Column<DateOnly>(type: "date", nullable: false),
montant = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
statut = table.Column<string>(type: "varchar(20)", unicode: false, maxLength: 20, nullable: false, defaultValue: "A_VENIR"),
justificatif_id = table.Column<int>(type: "int", nullable: true),
date_creation = table.Column<DateTime>(type: "datetime", nullable: true, defaultValueSql: "(getdate())")
},
constraints: table =>
{
table.PrimaryKey("PK__contrats_echeances", x => x.id);
table.ForeignKey(
name: "FK__contrats_echeances__contrat",
column: x => x.contrat_id,
principalSchema: "achats",
principalTable: "contrats_fournisseurs",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK__contrats_echeances__justificatif",
column: x => x.justificatif_id,
principalSchema: "core",
principalTable: "pieces_jointes",
principalColumn: "id");
});
migrationBuilder.CreateIndex(
name: "IX_ctf_fournisseur",
schema: "achats",
table: "contrats_fournisseurs",
column: "fournisseur_id");
migrationBuilder.CreateIndex(
name: "IX_ctf_saisie_par",
schema: "achats",
table: "contrats_fournisseurs",
column: "saisie_par_id");
migrationBuilder.CreateIndex(
name: "IX_cte_contrat",
schema: "achats",
table: "contrats_echeances",
column: "contrat_id");
migrationBuilder.CreateIndex(
name: "IX_cte_justif",
schema: "achats",
table: "contrats_echeances",
column: "justificatif_id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "contrats_echeances",
schema: "achats");
migrationBuilder.DropTable(
name: "contrats_fournisseurs",
schema: "achats");
migrationBuilder.DropColumn(
name: "rapproche",
schema: "fin",
table: "paiements");
migrationBuilder.DropColumn(
name: "statut",
schema: "fin",
table: "paiements");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SIFinance.Shared.Migrations
{
/// <inheritdoc />
public partial class AddCategorieFournisseurLibre : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "categorie_fournisseur_libre",
schema: "achats",
table: "demandes_achat",
type: "nvarchar(100)",
maxLength: 100,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "categorie_fournisseur_libre",
schema: "achats",
table: "demandes_achat");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,104 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SIFinance.Shared.Migrations
{
/// <inheritdoc />
public partial class AddSousTraitantsOrganismesContrats : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "st");
migrationBuilder.CreateTable(
name: "organismes",
schema: "st",
columns: table => new
{
id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
raison_sociale = table.Column<string>(type: "nvarchar(255)", maxLength: 255, nullable: false),
siret = table.Column<string>(type: "varchar(20)", unicode: false, maxLength: 20, nullable: true),
email = table.Column<string>(type: "varchar(255)", unicode: false, maxLength: 255, nullable: true),
telephone = table.Column<string>(type: "varchar(30)", unicode: false, maxLength: 30, nullable: true),
adresse = table.Column<string>(type: "nvarchar(255)", maxLength: 255, nullable: true),
statut = table.Column<string>(type: "varchar(20)", unicode: false, maxLength: 20, nullable: false, defaultValue: "ACTIF"),
date_creation = table.Column<DateTime>(type: "datetime", nullable: true, defaultValueSql: "(getdate())")
},
constraints: table =>
{
table.PrimaryKey("PK__organismes", x => x.id);
});
migrationBuilder.CreateTable(
name: "contrats_prestataires",
schema: "st",
columns: table => new
{
id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
organisme_id = table.Column<int>(type: "int", nullable: false),
reference = table.Column<string>(type: "varchar(50)", unicode: false, maxLength: 50, nullable: false),
objet = table.Column<string>(type: "nvarchar(255)", maxLength: 255, nullable: false),
date_signature = table.Column<DateOnly>(type: "date", nullable: true),
date_debut = table.Column<DateOnly>(type: "date", nullable: false),
date_fin = table.Column<DateOnly>(type: "date", nullable: false),
montant_total = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
statut = table.Column<string>(type: "varchar(20)", unicode: false, maxLength: 20, nullable: false, defaultValue: "ACTIF"),
saisie_par_id = table.Column<int>(type: "int", nullable: false),
date_creation = table.Column<DateTime>(type: "datetime", nullable: true, defaultValueSql: "(getdate())")
},
constraints: table =>
{
table.PrimaryKey("PK__contrats_prestataires", x => x.id);
table.ForeignKey(
name: "FK__contrats_prestataires__organisme",
column: x => x.organisme_id,
principalSchema: "st",
principalTable: "organismes",
principalColumn: "id");
table.ForeignKey(
name: "FK__contrats_prestataires__saisie_par",
column: x => x.saisie_par_id,
principalSchema: "core",
principalTable: "utilisateurs",
principalColumn: "id");
});
migrationBuilder.CreateIndex(
name: "IX_ctp_organisme",
schema: "st",
table: "contrats_prestataires",
column: "organisme_id");
migrationBuilder.CreateIndex(
name: "IX_ctp_saisie_par",
schema: "st",
table: "contrats_prestataires",
column: "saisie_par_id");
migrationBuilder.CreateIndex(
name: "UQ_ctp_reference",
schema: "st",
table: "contrats_prestataires",
column: "reference",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "contrats_prestataires",
schema: "st");
migrationBuilder.DropTable(
name: "organismes",
schema: "st");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,55 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SIFinance.Shared.Migrations
{
/// <inheritdoc />
public partial class AddResponsableToUtilisateurs : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "responsable_id",
schema: "core",
table: "utilisateurs",
type: "int",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_utilisateurs_responsable_id",
schema: "core",
table: "utilisateurs",
column: "responsable_id");
migrationBuilder.AddForeignKey(
name: "FK__utilisateurs__responsable",
schema: "core",
table: "utilisateurs",
column: "responsable_id",
principalSchema: "core",
principalTable: "utilisateurs",
principalColumn: "id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK__utilisateurs__responsable",
schema: "core",
table: "utilisateurs");
migrationBuilder.DropIndex(
name: "IX_utilisateurs_responsable_id",
schema: "core",
table: "utilisateurs");
migrationBuilder.DropColumn(
name: "responsable_id",
schema: "core",
table: "utilisateurs");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SIFinance.Shared.Migrations
{
/// <inheritdoc />
public partial class AddCommentaireToFactures : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "commentaire",
schema: "fin",
table: "factures",
type: "nvarchar(1000)",
maxLength: 1000,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "commentaire",
schema: "fin",
table: "factures");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,56 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SIFinance.Shared.Migrations
{
/// <inheritdoc />
public partial class AddContratIdToFactures : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "contrat_id",
schema: "fin",
table: "factures",
type: "int",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_factures_contrat_id",
schema: "fin",
table: "factures",
column: "contrat_id");
migrationBuilder.AddForeignKey(
name: "FK_factures_contrat",
schema: "fin",
table: "factures",
column: "contrat_id",
principalSchema: "achats",
principalTable: "contrats_fournisseurs",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_factures_contrat",
schema: "fin",
table: "factures");
migrationBuilder.DropIndex(
name: "IX_factures_contrat_id",
schema: "fin",
table: "factures");
migrationBuilder.DropColumn(
name: "contrat_id",
schema: "fin",
table: "factures");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,84 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SIFinance.Shared.Migrations
{
/// <inheritdoc />
public partial class AddBudgetsService : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "budgets_service",
schema: "achats",
columns: table => new
{
id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
service_id = table.Column<int>(type: "int", nullable: false),
exercice_annee = table.Column<int>(type: "int", nullable: false),
montant_initial = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
montant_revise = table.Column<decimal>(type: "decimal(18,2)", nullable: true),
date_revision = table.Column<DateTime>(type: "datetime", nullable: true),
revise_par = table.Column<int>(type: "int", nullable: true),
commentaire_revision = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
saisie_par_id = table.Column<int>(type: "int", nullable: false),
date_creation = table.Column<DateTime>(type: "datetime", nullable: true, defaultValueSql: "(getdate())"),
date_derniere_maj = table.Column<DateTime>(type: "datetime", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_budgets_service", x => x.id);
table.ForeignKey(
name: "FK_budgets_service_revise_par",
column: x => x.revise_par,
principalSchema: "core",
principalTable: "utilisateurs",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_budgets_service_saisie_par",
column: x => x.saisie_par_id,
principalSchema: "core",
principalTable: "utilisateurs",
principalColumn: "id");
table.ForeignKey(
name: "FK_budgets_service_service",
column: x => x.service_id,
principalSchema: "ref",
principalTable: "services",
principalColumn: "id");
});
migrationBuilder.CreateIndex(
name: "IX_budgets_service_revise_par",
schema: "achats",
table: "budgets_service",
column: "revise_par");
migrationBuilder.CreateIndex(
name: "IX_budgets_service_saisie_par_id",
schema: "achats",
table: "budgets_service",
column: "saisie_par_id");
migrationBuilder.CreateIndex(
name: "IX_budgets_service_unique",
schema: "achats",
table: "budgets_service",
columns: new[] { "service_id", "exercice_annee" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "budgets_service",
schema: "achats");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,141 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SIFinance.Shared.Migrations
{
/// <inheritdoc />
public partial class SplitBudgetRevisionsHistory : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_budgets_service_revise_par",
schema: "achats",
table: "budgets_service");
migrationBuilder.DropIndex(
name: "IX_budgets_service_revise_par",
schema: "achats",
table: "budgets_service");
migrationBuilder.DropColumn(
name: "commentaire_revision",
schema: "achats",
table: "budgets_service");
migrationBuilder.DropColumn(
name: "date_revision",
schema: "achats",
table: "budgets_service");
migrationBuilder.DropColumn(
name: "montant_revise",
schema: "achats",
table: "budgets_service");
migrationBuilder.DropColumn(
name: "revise_par",
schema: "achats",
table: "budgets_service");
migrationBuilder.CreateTable(
name: "budgets_service_revisions",
schema: "achats",
columns: table => new
{
id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
budget_service_id = table.Column<int>(type: "int", nullable: false),
montant_revise = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
commentaire = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
revise_par = table.Column<int>(type: "int", nullable: false),
date_revision = table.Column<DateTime>(type: "datetime", nullable: false, defaultValueSql: "(getdate())")
},
constraints: table =>
{
table.PrimaryKey("PK_budgets_service_revisions", x => x.id);
table.ForeignKey(
name: "FK_budgets_service_revisions_budget",
column: x => x.budget_service_id,
principalSchema: "achats",
principalTable: "budgets_service",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_budgets_service_revisions_revise_par",
column: x => x.revise_par,
principalSchema: "core",
principalTable: "utilisateurs",
principalColumn: "id");
});
migrationBuilder.CreateIndex(
name: "IX_budgets_service_revisions_budget_service_id",
schema: "achats",
table: "budgets_service_revisions",
column: "budget_service_id");
migrationBuilder.CreateIndex(
name: "IX_budgets_service_revisions_revise_par",
schema: "achats",
table: "budgets_service_revisions",
column: "revise_par");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "budgets_service_revisions",
schema: "achats");
migrationBuilder.AddColumn<string>(
name: "commentaire_revision",
schema: "achats",
table: "budgets_service",
type: "nvarchar(1000)",
maxLength: 1000,
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "date_revision",
schema: "achats",
table: "budgets_service",
type: "datetime",
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "montant_revise",
schema: "achats",
table: "budgets_service",
type: "decimal(18,2)",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "revise_par",
schema: "achats",
table: "budgets_service",
type: "int",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_budgets_service_revise_par",
schema: "achats",
table: "budgets_service",
column: "revise_par");
migrationBuilder.AddForeignKey(
name: "FK_budgets_service_revise_par",
schema: "achats",
table: "budgets_service",
column: "revise_par",
principalSchema: "core",
principalTable: "utilisateurs",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
}
}
}
@@ -0,0 +1,18 @@
-- À exécuter une seule fois sur la base SIFinance.
-- Le flag permet de rattacher n'importe quelle catégorie au circuit matériel,
-- sans dépendre de son libellé.
ALTER TABLE ref.types_depense
ADD est_materiel bit NOT NULL
CONSTRAINT DF_types_depense_est_materiel DEFAULT (0);
GO
-- Les nouveaux statuts du workflow dépassent parfois la taille historique de 30.
ALTER TABLE achats.demandes_achat
ALTER COLUMN statut varchar(50) NOT NULL;
GO
-- Catégories à traiter comme du matériel : adapte cette liste à tes règles métier.
UPDATE ref.types_depense
SET est_materiel = 1
WHERE libelle IN ('Fournitures', 'Informatique', 'Mobilier');
GO
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,50 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Microsoft.Extensions.Logging;
namespace SIFinance.Shared;
// Envoie des notifications par email via Microsoft Graph (permission application
// Mail.Send, déjà accordée) — envoie "au nom de" une boîte du tenant (ex. le
// demandeur), pas besoin de boîte de service dédiée. Les échecs sont journalisés mais
// n'interrompent jamais le flux appelant (une notification ratée ne doit pas faire
// échouer la création d'une demande). Vit dans SIFinance.Shared car les modules
// (Achats, Sous-Traitants...) doivent pouvoir l'appeler directement.
public class NotificationEmailService(
IHttpClientFactory httpClientFactory,
EntraGraphTokenProvider tokenProvider,
ILogger<NotificationEmailService> logger)
{
public async Task EnvoyerAsync(string expediteurEmail, string destinataireEmail, string sujet, string corpsHtml, CancellationToken ct)
{
try
{
var token = await tokenProvider.GetTokenAsync(ct);
var http = httpClientFactory.CreateClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var payload = new
{
message = new
{
subject = sujet,
body = new { contentType = "HTML", content = corpsHtml },
toRecipients = new[] { new { emailAddress = new { address = destinataireEmail } } },
},
saveToSentItems = false,
};
var response = await http.PostAsJsonAsync(
$"https://graph.microsoft.com/v1.0/users/{Uri.EscapeDataString(expediteurEmail)}/sendMail", payload, ct);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync(ct);
logger.LogWarning("Échec d'envoi d'email de notification ({Status}) : {Body}", response.StatusCode, body);
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogWarning(ex, "Échec d'envoi d'email de notification à {Destinataire}.", destinataireEmail);
}
}
}
+28
View File
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="itext7" Version="9.7.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="ZUGFeRD-csharp" Version="18.0.0" />
</ItemGroup>
<ItemGroup>
<!-- Nécessaire pour IHttpContextAccessor (CurrentUserService), partagé par l'API et les modules. -->
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
</Project>
+7
View File
@@ -0,0 +1,7 @@
<Solution>
<Project Path="SIFinance.Api/SIFinance.Api.csproj" />
<Project Path="SIFinance.Modules.Achats/SIFinance.Modules.Achats.csproj" />
<Project Path="SIFinance.Modules.Finance/SIFinance.Modules.Finance.csproj" />
<Project Path="SIFinance.Modules.SousTraitants/SIFinance.Modules.SousTraitants.csproj" />
<Project Path="SIFinance.Shared/SIFinance.Shared.csproj" />
</Solution>
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+75
View File
@@ -0,0 +1,75 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
+22
View File
@@ -0,0 +1,22 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SI Finance</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3611
View File
File diff suppressed because it is too large Load Diff
+36
View File
@@ -0,0 +1,36 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^9.2.0",
"@mui/material": "^9.2.0",
"@tanstack/react-query": "^5.101.4",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router-dom": "^7.18.1"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^24.13.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"eslint": "^10.6.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.3",
"globals": "^17.7.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.62.0",
"vite": "^8.1.1"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

Some files were not shown because too many files have changed in this diff Show More