Premier commit
This commit is contained in:
+248
@@ -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
@@ -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
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 _);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,6 @@
|
||||
@SIFinance.Api_HostAddress = http://localhost:5272
|
||||
|
||||
GET {{SIFinance.Api_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -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; } = "";
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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; } = "";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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!;
|
||||
}
|
||||
@@ -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!;
|
||||
}
|
||||
@@ -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!;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
@@ -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>();
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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; } = "";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+1716
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1721
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1899
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1914
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1919
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1935
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+2065
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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?
|
||||
@@ -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...
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
```
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -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>
|
||||
Generated
+3611
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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
Reference in New Issue
Block a user