diff --git a/TrafagSalesExporter/Components/App.razor b/TrafagSalesExporter/Components/App.razor new file mode 100644 index 0000000..29bb7e9 --- /dev/null +++ b/TrafagSalesExporter/Components/App.razor @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/TrafagSalesExporter/Components/Layout/MainLayout.razor b/TrafagSalesExporter/Components/Layout/MainLayout.razor new file mode 100644 index 0000000..7fe2989 --- /dev/null +++ b/TrafagSalesExporter/Components/Layout/MainLayout.razor @@ -0,0 +1,19 @@ +@inherits LayoutComponentBase + + + + + + + + Trafag Sales Exporter + + + + + + + @Body + + + diff --git a/TrafagSalesExporter/Components/Layout/NavMenu.razor b/TrafagSalesExporter/Components/Layout/NavMenu.razor new file mode 100644 index 0000000..a820d8c --- /dev/null +++ b/TrafagSalesExporter/Components/Layout/NavMenu.razor @@ -0,0 +1,6 @@ + + Dashboard + Standorte + Settings + Logs + diff --git a/TrafagSalesExporter/Components/Pages/Dashboard.razor b/TrafagSalesExporter/Components/Pages/Dashboard.razor new file mode 100644 index 0000000..31dc3fc --- /dev/null +++ b/TrafagSalesExporter/Components/Pages/Dashboard.razor @@ -0,0 +1,117 @@ +@page "/" +@using Microsoft.EntityFrameworkCore +@inject IDbContextFactory DbFactory +@inject ExportOrchestrationService ExportService + +Dashboard + +Dashboard + + + Alle exportieren + Nächster automatischer Lauf: @nextRunText + + + + + Land + TSC + Schema + Server + Letzter Status + Row Count + Letzter Lauf + Dauer + Aktion + + + @context.Land + @context.TSC + @context.Schema + @context.HanaServer?.Name + @GetStatusIcon(context.Id) + @GetRows(context.Id) + @GetLastRun(context.Id) + @GetDuration(context.Id) + + @if (runningSiteIds.Contains(context.Id)) + { + + } + else + { + Einzeln exportieren + } + + + + +@code { + private List sites = []; + private Dictionary latestLogs = new(); + private HashSet runningSiteIds = []; + private bool isRunningAll; + private string nextRunText = "-"; + + protected override async Task OnInitializedAsync() + { + await LoadAsync(); + } + + private async Task LoadAsync() + { + await using var db = await DbFactory.CreateDbContextAsync(); + sites = await db.Sites + .Include(x => x.HanaServer) + .Where(x => x.IsActive) + .OrderBy(x => x.Land) + .ToListAsync(); + + latestLogs = await ExportService.GetLatestLogsPerSiteAsync(); + var nextRun = await ExportService.GetNextRunAsync(); + nextRunText = nextRun.HasValue ? nextRun.Value.ToString("dd.MM.yyyy HH:mm") : "Deaktiviert"; + } + + private async Task ExportAllAsync() + { + isRunningAll = true; + foreach (var site in sites) + { + runningSiteIds.Add(site.Id); + } + + StateHasChanged(); + await ExportService.ExportAllActiveSitesAsync(); + runningSiteIds.Clear(); + isRunningAll = false; + await LoadAsync(); + } + + private async Task ExportSingleAsync(int siteId) + { + runningSiteIds.Add(siteId); + StateHasChanged(); + await ExportService.ExportSiteAsync(siteId); + runningSiteIds.Remove(siteId); + await LoadAsync(); + } + + private string GetStatusIcon(int siteId) + { + if (!latestLogs.TryGetValue(siteId, out var log) || log is null) + { + return "-"; + } + + return log.Status == "OK" ? "✅" : "❌"; + } + + private string GetRows(int siteId) => + latestLogs.TryGetValue(siteId, out var log) && log is not null ? log.RowCount.ToString() : "-"; + + private string GetLastRun(int siteId) => + latestLogs.TryGetValue(siteId, out var log) && log is not null ? log.Timestamp.ToLocalTime().ToString("dd.MM.yyyy HH:mm:ss") : "-"; + + private string GetDuration(int siteId) => + latestLogs.TryGetValue(siteId, out var log) && log is not null ? $"{log.DurationSeconds:F1}s" : "-"; +} diff --git a/TrafagSalesExporter/Components/Pages/Logs.razor b/TrafagSalesExporter/Components/Pages/Logs.razor new file mode 100644 index 0000000..a5cd9a2 --- /dev/null +++ b/TrafagSalesExporter/Components/Pages/Logs.razor @@ -0,0 +1,94 @@ +@page "/logs" +@using Microsoft.EntityFrameworkCore +@inject IDbContextFactory DbFactory + +Logs + +Logs + + + + + + Alle + OK + Error + + + + Filtern + + + + + Logs löschen + + + + + Timestamp + Land + TSC + Status + Rows + Dauer + Fehler + Dateiname + + + @context.Timestamp.ToLocalTime().ToString("dd.MM.yyyy HH:mm:ss") + @context.Land + @context.TSC + @context.Status + @context.RowCount + @($"{context.DurationSeconds:F1}s") + @context.ErrorMessage + @context.FileName + + + +@code { + private List logs = []; + private string filterLand = string.Empty; + private string filterStatus = string.Empty; + private DateTime? filterFromDate; + private int deleteOlderThanDays = 30; + + protected override async Task OnInitializedAsync() => await LoadAsync(); + + private async Task LoadAsync() + { + await using var db = await DbFactory.CreateDbContextAsync(); + var query = db.ExportLogs.AsQueryable(); + + if (!string.IsNullOrWhiteSpace(filterLand)) + { + query = query.Where(x => x.Land.Contains(filterLand)); + } + + if (!string.IsNullOrWhiteSpace(filterStatus)) + { + query = query.Where(x => x.Status == filterStatus); + } + + if (filterFromDate.HasValue) + { + var fromUtc = filterFromDate.Value.Date.ToUniversalTime(); + query = query.Where(x => x.Timestamp >= fromUtc); + } + + logs = await query.OrderByDescending(x => x.Timestamp).ToListAsync(); + } + + private string GetRowClass(ExportLog log, int _) => log.Status == "Error" ? "mud-theme-error" : string.Empty; + + private async Task DeleteOlderAsync() + { + var threshold = DateTime.UtcNow.AddDays(-deleteOlderThanDays); + await using var db = await DbFactory.CreateDbContextAsync(); + var oldLogs = await db.ExportLogs.Where(x => x.Timestamp < threshold).ToListAsync(); + db.ExportLogs.RemoveRange(oldLogs); + await db.SaveChangesAsync(); + await LoadAsync(); + } +} diff --git a/TrafagSalesExporter/Components/Pages/Settings.razor b/TrafagSalesExporter/Components/Pages/Settings.razor new file mode 100644 index 0000000..03b193b --- /dev/null +++ b/TrafagSalesExporter/Components/Pages/Settings.razor @@ -0,0 +1,93 @@ +@page "/settings" +@using Microsoft.EntityFrameworkCore +@inject IDbContextFactory DbFactory +@inject CryptoService CryptoService +@inject SharePointUploadService SharePointUploadService + +Settings + +Settings + + + SharePoint + + + + + + + + + Speichern + SharePoint Verbindung testen + + + + + Export & Timer + + + + + + + Dateiname-Vorschau: @PreviewFileName + + +@message + +@code { + private SharePointConfig sharePointConfig = new(); + private ExportSettings settings = new(); + private string sharePointClientSecret = string.Empty; + private string message = "Bereit."; + + private string PreviewFileName => $"Sales_{{TSC}}_{DateTime.UtcNow:yyyy-MM-dd}.xlsx"; + + protected override async Task OnInitializedAsync() + { + await using var db = await DbFactory.CreateDbContextAsync(); + sharePointConfig = await db.SharePointConfigs.OrderBy(x => x.Id).FirstAsync(); + settings = await db.ExportSettings.OrderBy(x => x.Id).FirstAsync(); + sharePointClientSecret = CryptoService.Decrypt(sharePointConfig.EncryptedClientSecret); + } + + private async Task SaveAsync() + { + await using var db = await DbFactory.CreateDbContextAsync(); + var sp = await db.SharePointConfigs.SingleAsync(x => x.Id == sharePointConfig.Id); + var es = await db.ExportSettings.SingleAsync(x => x.Id == settings.Id); + + sp.SiteUrl = sharePointConfig.SiteUrl; + sp.ExportFolder = sharePointConfig.ExportFolder; + sp.TenantId = sharePointConfig.TenantId; + sp.ClientId = sharePointConfig.ClientId; + sp.EncryptedClientSecret = CryptoService.Encrypt(sharePointClientSecret); + + es.DateFilter = settings.DateFilter; + es.TimerHour = settings.TimerHour; + es.TimerMinute = settings.TimerMinute; + es.TimerEnabled = settings.TimerEnabled; + + await db.SaveChangesAsync(); + message = "Settings gespeichert."; + } + + private async Task TestSharePointAsync() + { + try + { + var ok = await SharePointUploadService.TestConnectionAsync( + sharePointConfig.SiteUrl, + sharePointConfig.TenantId, + sharePointConfig.ClientId, + sharePointClientSecret); + + message = ok ? "SharePoint Verbindung OK." : "SharePoint Verbindung fehlgeschlagen."; + } + catch (Exception ex) + { + message = $"SharePoint Test fehlgeschlagen: {ex.Message}"; + } + } +} diff --git a/TrafagSalesExporter/Components/Pages/Standorte.razor b/TrafagSalesExporter/Components/Pages/Standorte.razor new file mode 100644 index 0000000..407a196 --- /dev/null +++ b/TrafagSalesExporter/Components/Pages/Standorte.razor @@ -0,0 +1,215 @@ +@page "/standorte" +@using Microsoft.EntityFrameworkCore +@inject IDbContextFactory DbFactory +@inject HanaQueryService HanaQueryService +@inject CryptoService CryptoService + +Standorte + +Standorte + + + Neuen Standort hinzufügen + + @foreach (var srv in servers) { @srv.Name } + + + + + Speichern + + + + + + LandTSCSchemaServerAktivAktion + + + @context.Land + @context.TSC + @context.Schema + @context.HanaServer?.Name + @(context.IsActive ? "Ja" : "Nein") + + Edit + Delete + + + + +@if (editingSite is not null) +{ + + Standort bearbeiten + + @foreach (var srv in servers) { @srv.Name } + + + + + Update + + +} + + +HANA Server + + + + + + + + + Server speichern + + + + + + NameHostPortUsernameAktion + + + @context.Name + @context.Host + @context.Port + @context.Username + + Verbindung testen + Delete + + + + +@message + +@code { + private List sites = []; + private List servers = []; + private Site newSite = new() { IsActive = true }; + private Site? editingSite; + private HanaServer newServer = new() { Port = 30015 }; + private string newServerPassword = string.Empty; + private string message = "Bereit."; + + protected override async Task OnInitializedAsync() => await LoadAsync(); + + private async Task LoadAsync() + { + await using var db = await DbFactory.CreateDbContextAsync(); + servers = await db.HanaServers.OrderBy(x => x.Name).ToListAsync(); + sites = await db.Sites.Include(x => x.HanaServer).OrderBy(x => x.Land).ToListAsync(); + + if (servers.Count > 0 && newSite.HanaServerId == 0) + { + newSite.HanaServerId = servers[0].Id; + } + } + + private async Task AddSiteAsync() + { + await using var db = await DbFactory.CreateDbContextAsync(); + db.Sites.Add(new Site + { + HanaServerId = newSite.HanaServerId, + Schema = newSite.Schema, + TSC = newSite.TSC, + Land = newSite.Land, + IsActive = newSite.IsActive + }); + await db.SaveChangesAsync(); + newSite = new Site { IsActive = true, HanaServerId = servers.FirstOrDefault()?.Id ?? 0 }; + await LoadAsync(); + } + + private void EditSite(Site site) + { + editingSite = new Site + { + Id = site.Id, + HanaServerId = site.HanaServerId, + Schema = site.Schema, + TSC = site.TSC, + Land = site.Land, + IsActive = site.IsActive + }; + } + + private async Task SaveSiteAsync() + { + if (editingSite is null) + { + return; + } + + await using var db = await DbFactory.CreateDbContextAsync(); + var entity = await db.Sites.SingleAsync(x => x.Id == editingSite.Id); + entity.HanaServerId = editingSite.HanaServerId; + entity.Schema = editingSite.Schema; + entity.TSC = editingSite.TSC; + entity.Land = editingSite.Land; + entity.IsActive = editingSite.IsActive; + await db.SaveChangesAsync(); + editingSite = null; + await LoadAsync(); + } + + private async Task DeleteSiteAsync(int id) + { + await using var db = await DbFactory.CreateDbContextAsync(); + var site = await db.Sites.SingleAsync(x => x.Id == id); + db.Sites.Remove(site); + await db.SaveChangesAsync(); + await LoadAsync(); + } + + private async Task AddServerAsync() + { + await using var db = await DbFactory.CreateDbContextAsync(); + db.HanaServers.Add(new HanaServer + { + Name = newServer.Name, + Host = newServer.Host, + Port = newServer.Port, + Username = newServer.Username, + EncryptedPassword = CryptoService.Encrypt(newServerPassword) + }); + await db.SaveChangesAsync(); + + newServer = new HanaServer { Port = 30015 }; + newServerPassword = string.Empty; + await LoadAsync(); + } + + private async Task DeleteServerAsync(int id) + { + await using var db = await DbFactory.CreateDbContextAsync(); + var isUsed = await db.Sites.AnyAsync(x => x.HanaServerId == id); + if (isUsed) + { + message = "Server kann nicht gelöscht werden, solange Sites darauf zeigen."; + return; + } + + var server = await db.HanaServers.SingleAsync(x => x.Id == id); + db.HanaServers.Remove(server); + await db.SaveChangesAsync(); + await LoadAsync(); + } + + private async Task TestServerAsync(HanaServer server) + { + try + { + var ok = HanaQueryService.TestConnection(server.Host, server.Port, server.Username, CryptoService.Decrypt(server.EncryptedPassword)); + message = ok ? $"Verbindung OK: {server.Name}" : $"Verbindung fehlgeschlagen: {server.Name}"; + } + catch (Exception ex) + { + message = $"Verbindung fehlgeschlagen: {ex.Message}"; + } + + await InvokeAsync(StateHasChanged); + } +} diff --git a/TrafagSalesExporter/Components/Routes.razor b/TrafagSalesExporter/Components/Routes.razor new file mode 100644 index 0000000..ec35a12 --- /dev/null +++ b/TrafagSalesExporter/Components/Routes.razor @@ -0,0 +1,8 @@ +@using TrafagSalesExporter.Components.Layout + + + + + + + diff --git a/TrafagSalesExporter/Components/_Imports.razor b/TrafagSalesExporter/Components/_Imports.razor new file mode 100644 index 0000000..a19158f --- /dev/null +++ b/TrafagSalesExporter/Components/_Imports.razor @@ -0,0 +1,13 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.JSInterop +@using MudBlazor +@using TrafagSalesExporter +@using TrafagSalesExporter.Components +@using TrafagSalesExporter.Models +@using TrafagSalesExporter.Services +@using TrafagSalesExporter.Data diff --git a/TrafagSalesExporter/Data/AppDbContext.cs b/TrafagSalesExporter/Data/AppDbContext.cs new file mode 100644 index 0000000..88b3de0 --- /dev/null +++ b/TrafagSalesExporter/Data/AppDbContext.cs @@ -0,0 +1,99 @@ +using Microsoft.EntityFrameworkCore; +using TrafagSalesExporter.Models; +using TrafagSalesExporter.Services; + +namespace TrafagSalesExporter.Data; + +public class AppDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet HanaServers => Set(); + public DbSet Sites => Set(); + public DbSet SharePointConfigs => Set(); + public DbSet ExportSettings => Set(); + public DbSet ExportLogs => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().HasIndex(x => x.Name).IsUnique(); + + modelBuilder.Entity() + .HasOne(x => x.HanaServer) + .WithMany(x => x.Sites) + .HasForeignKey(x => x.HanaServerId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasOne(x => x.Site) + .WithMany() + .HasForeignKey(x => x.SiteId) + .OnDelete(DeleteBehavior.SetNull); + } +} + +public static class DbInitializer +{ + public static async Task SeedDefaultsAsync(AppDbContext db, CryptoService cryptoService) + { + if (!await db.HanaServers.AnyAsync()) + { + db.HanaServers.AddRange( + new HanaServer + { + Name = "Internal", + Host = "travtrp0", + Port = 30015, + Username = string.Empty, + EncryptedPassword = cryptoService.Encrypt(string.Empty) + }, + new HanaServer + { + Name = "India", + Host = "20.197.20.60", + Port = 30015, + Username = string.Empty, + EncryptedPassword = cryptoService.Encrypt(string.Empty) + }); + + await db.SaveChangesAsync(); + } + + if (!await db.Sites.AnyAsync()) + { + var internalServer = await db.HanaServers.SingleAsync(x => x.Name == "Internal"); + var indiaServer = await db.HanaServers.SingleAsync(x => x.Name == "India"); + + db.Sites.AddRange( + new Site { HanaServerId = internalServer.Id, Schema = "fr01_p", TSC = "TRFR", Land = "Frankreich", IsActive = true }, + new Site { HanaServerId = internalServer.Id, Schema = "it01_p", TSC = "TRIT", Land = "Italien", IsActive = true }, + new Site { HanaServerId = internalServer.Id, Schema = "us01_p", TSC = "TRUS", Land = "USA", IsActive = true }, + new Site { HanaServerId = indiaServer.Id, Schema = "TRAFAG_LIVE", TSC = "TRIN", Land = "Indien", IsActive = true }); + + await db.SaveChangesAsync(); + } + + if (!await db.SharePointConfigs.AnyAsync()) + { + db.SharePointConfigs.Add(new SharePointConfig + { + SiteUrl = "https://trafagag.sharepoint.com/sites/WorldwideBIPlatform", + ExportFolder = "/Shared Documents/Exports/", + TenantId = string.Empty, + ClientId = string.Empty, + EncryptedClientSecret = cryptoService.Encrypt(string.Empty) + }); + await db.SaveChangesAsync(); + } + + if (!await db.ExportSettings.AnyAsync()) + { + db.ExportSettings.Add(new ExportSettings + { + DateFilter = "2025-01-01", + TimerHour = 3, + TimerMinute = 0, + TimerEnabled = true + }); + await db.SaveChangesAsync(); + } + } +} diff --git a/TrafagSalesExporter/Models/ExportLog.cs b/TrafagSalesExporter/Models/ExportLog.cs new file mode 100644 index 0000000..4a47b9a --- /dev/null +++ b/TrafagSalesExporter/Models/ExportLog.cs @@ -0,0 +1,16 @@ +namespace TrafagSalesExporter.Models; + +public class ExportLog +{ + public int Id { get; set; } + public DateTime Timestamp { get; set; } + public int? SiteId { get; set; } + public Site? Site { get; set; } + public string Land { get; set; } = string.Empty; + public string TSC { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public int RowCount { get; set; } + public string? ErrorMessage { get; set; } + public string FileName { get; set; } = string.Empty; + public double DurationSeconds { get; set; } +} diff --git a/TrafagSalesExporter/Models/ExportSettings.cs b/TrafagSalesExporter/Models/ExportSettings.cs new file mode 100644 index 0000000..9596d10 --- /dev/null +++ b/TrafagSalesExporter/Models/ExportSettings.cs @@ -0,0 +1,10 @@ +namespace TrafagSalesExporter.Models; + +public class ExportSettings +{ + public int Id { get; set; } + public string DateFilter { get; set; } = "2025-01-01"; + public int TimerHour { get; set; } = 3; + public int TimerMinute { get; set; } = 0; + public bool TimerEnabled { get; set; } = true; +} diff --git a/TrafagSalesExporter/Models/HanaServer.cs b/TrafagSalesExporter/Models/HanaServer.cs new file mode 100644 index 0000000..71a8b97 --- /dev/null +++ b/TrafagSalesExporter/Models/HanaServer.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations; + +namespace TrafagSalesExporter.Models; + +public class HanaServer +{ + public int Id { get; set; } + + [Required] + public string Name { get; set; } = string.Empty; + + [Required] + public string Host { get; set; } = string.Empty; + + public int Port { get; set; } + + public string Username { get; set; } = string.Empty; + + public string EncryptedPassword { get; set; } = string.Empty; + + public List Sites { get; set; } = []; +} diff --git a/TrafagSalesExporter/Models/SalesRecord.cs b/TrafagSalesExporter/Models/SalesRecord.cs new file mode 100644 index 0000000..5efc3d1 --- /dev/null +++ b/TrafagSalesExporter/Models/SalesRecord.cs @@ -0,0 +1,31 @@ +namespace TrafagSalesExporter.Models; + +public class SalesRecord +{ + public DateTime ExtractionDate { get; set; } + public string TSC { get; set; } = string.Empty; + public string InvoiceNumber { get; set; } = string.Empty; + public int PositionOnInvoice { get; set; } + public string Material { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string ProductGroup { get; set; } = string.Empty; + public decimal Quantity { get; set; } + public string SupplierNumber { get; set; } = string.Empty; + public string SupplierName { get; set; } = string.Empty; + public string SupplierCountry { get; set; } = string.Empty; + public string CustomerNumber { get; set; } = string.Empty; + public string CustomerName { get; set; } = string.Empty; + public string CustomerCountry { get; set; } = string.Empty; + public string CustomerIndustry { get; set; } = string.Empty; + public decimal StandardCost { get; set; } + public string StandardCostCurrency { get; set; } = string.Empty; + public string PurchaseOrderNumber { get; set; } = string.Empty; + public decimal SalesPriceValue { get; set; } + public string SalesCurrency { get; set; } = string.Empty; + public string Incoterms2020 { get; set; } = string.Empty; + public string SalesResponsibleEmployee { get; set; } = string.Empty; + public DateTime? InvoiceDate { get; set; } + public DateTime? OrderDate { get; set; } + public string Land { get; set; } = string.Empty; + public string DocumentType { get; set; } = string.Empty; +} diff --git a/TrafagSalesExporter/Models/SharePointConfig.cs b/TrafagSalesExporter/Models/SharePointConfig.cs new file mode 100644 index 0000000..bb2b0a0 --- /dev/null +++ b/TrafagSalesExporter/Models/SharePointConfig.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations; + +namespace TrafagSalesExporter.Models; + +public class SharePointConfig +{ + public int Id { get; set; } + + [Required] + public string SiteUrl { get; set; } = string.Empty; + + [Required] + public string ExportFolder { get; set; } = "/Shared Documents/Exports/"; + + [Required] + public string TenantId { get; set; } = string.Empty; + + [Required] + public string ClientId { get; set; } = string.Empty; + + public string EncryptedClientSecret { get; set; } = string.Empty; +} diff --git a/TrafagSalesExporter/Models/Site.cs b/TrafagSalesExporter/Models/Site.cs new file mode 100644 index 0000000..90f6910 --- /dev/null +++ b/TrafagSalesExporter/Models/Site.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations; + +namespace TrafagSalesExporter.Models; + +public class Site +{ + public int Id { get; set; } + public int HanaServerId { get; set; } + + public HanaServer? HanaServer { get; set; } + + [Required] + public string Schema { get; set; } = string.Empty; + + [Required] + public string TSC { get; set; } = string.Empty; + + [Required] + public string Land { get; set; } = string.Empty; + + public bool IsActive { get; set; } = true; +} diff --git a/TrafagSalesExporter/Program.cs b/TrafagSalesExporter/Program.cs new file mode 100644 index 0000000..1ff2a06 --- /dev/null +++ b/TrafagSalesExporter/Program.cs @@ -0,0 +1,47 @@ +using Microsoft.EntityFrameworkCore; +using MudBlazor.Services; +using TrafagSalesExporter.Data; +using TrafagSalesExporter.Services; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddRazorComponents() + .AddInteractiveServerComponents(); + +builder.Services.AddMudServices(); + +builder.Services.AddDbContextFactory(options => + options.UseSqlite("Data Source=trafag_exporter.db")); + +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddHostedService(); + +var app = builder.Build(); + +using (var scope = app.Services.CreateScope()) +{ + var dbFactory = scope.ServiceProvider.GetRequiredService>(); + var cryptoService = scope.ServiceProvider.GetRequiredService(); + await using var db = await dbFactory.CreateDbContextAsync(); + await db.Database.EnsureCreatedAsync(); + await DbInitializer.SeedDefaultsAsync(db, cryptoService); +} + +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler("/Error", createScopeForErrors: true); + app.UseHsts(); +} + +app.UseHttpsRedirection(); +app.UseStaticFiles(); +app.UseAntiforgery(); + +app.MapRazorComponents() + .AddInteractiveServerRenderMode(); + +app.Run(); diff --git a/TrafagSalesExporter/Services/CryptoService.cs b/TrafagSalesExporter/Services/CryptoService.cs new file mode 100644 index 0000000..4e1f762 --- /dev/null +++ b/TrafagSalesExporter/Services/CryptoService.cs @@ -0,0 +1,26 @@ +using System.Security.Cryptography; +using System.Text; + +namespace TrafagSalesExporter.Services; + +public class CryptoService +{ + public string Encrypt(string plainText) + { + var input = Encoding.UTF8.GetBytes(plainText ?? string.Empty); + var protectedBytes = ProtectedData.Protect(input, null, DataProtectionScope.CurrentUser); + return Convert.ToBase64String(protectedBytes); + } + + public string Decrypt(string cipherText) + { + if (string.IsNullOrWhiteSpace(cipherText)) + { + return string.Empty; + } + + var input = Convert.FromBase64String(cipherText); + var unprotectedBytes = ProtectedData.Unprotect(input, null, DataProtectionScope.CurrentUser); + return Encoding.UTF8.GetString(unprotectedBytes); + } +} diff --git a/TrafagSalesExporter/Services/ExcelExportService.cs b/TrafagSalesExporter/Services/ExcelExportService.cs new file mode 100644 index 0000000..20b44fe --- /dev/null +++ b/TrafagSalesExporter/Services/ExcelExportService.cs @@ -0,0 +1,91 @@ +using ClosedXML.Excel; +using TrafagSalesExporter.Models; + +namespace TrafagSalesExporter.Services; + +public class ExcelExportService +{ + public string CreateFile(string baseDirectory, string land, string tsc, List records) + { + var outputDirectory = Path.Combine(baseDirectory, "exports", land); + Directory.CreateDirectory(outputDirectory); + + var fileName = $"Sales_{tsc}_{DateTime.UtcNow:yyyy-MM-dd}.xlsx"; + var filePath = Path.Combine(outputDirectory, fileName); + + using var workbook = new XLWorkbook(); + var ws = workbook.AddWorksheet("Sales"); + + var headers = new[] + { + "extraction date", + "TSC", + "Invoice Number", + "Position on invoice", + "Material", + "Name", + "Product Group", + "Quantity", + "Supplier number", + "Supplier name", + "Supplier country", + "Customer number", + "Customer name", + "Customer country", + "Customer Industry", + "Standard cost", + "Standard Cost Currency", + "Purchase Order number", + "Sales Price/Value", + "Sales Currency", + "Incoterms 2020", + "Sales responsible employee", + "invoice date", + "order date", + "Land", + "Document Type" + }; + + for (var i = 0; i < headers.Length; i++) + { + ws.Cell(1, i + 1).Value = headers[i]; + ws.Cell(1, i + 1).Style.Font.Bold = true; + } + + var row = 2; + foreach (var r in records) + { + ws.Cell(row, 1).Value = r.ExtractionDate.ToString("dd.MM.yyyy HH:mm:ss"); + ws.Cell(row, 2).Value = r.TSC; + ws.Cell(row, 3).Value = r.InvoiceNumber; + ws.Cell(row, 4).Value = r.PositionOnInvoice; + ws.Cell(row, 5).Value = r.Material; + ws.Cell(row, 6).Value = r.Name; + ws.Cell(row, 7).Value = r.ProductGroup; + ws.Cell(row, 8).Value = r.Quantity; + ws.Cell(row, 9).Value = r.SupplierNumber; + ws.Cell(row, 10).Value = r.SupplierName; + ws.Cell(row, 11).Value = r.SupplierCountry; + ws.Cell(row, 12).Value = r.CustomerNumber; + ws.Cell(row, 13).Value = r.CustomerName; + ws.Cell(row, 14).Value = r.CustomerCountry; + ws.Cell(row, 15).Value = r.CustomerIndustry; + ws.Cell(row, 16).Value = r.StandardCost; + ws.Cell(row, 17).Value = r.StandardCostCurrency; + ws.Cell(row, 18).Value = r.PurchaseOrderNumber; + ws.Cell(row, 19).Value = r.SalesPriceValue; + ws.Cell(row, 20).Value = r.SalesCurrency; + ws.Cell(row, 21).Value = r.Incoterms2020; + ws.Cell(row, 22).Value = r.SalesResponsibleEmployee; + ws.Cell(row, 23).Value = r.InvoiceDate?.ToString("dd.MM.yyyy") ?? string.Empty; + ws.Cell(row, 24).Value = r.OrderDate?.ToString("dd.MM.yyyy") ?? string.Empty; + ws.Cell(row, 25).Value = r.Land; + ws.Cell(row, 26).Value = r.DocumentType; + row++; + } + + ws.Columns().AdjustToContents(); + workbook.SaveAs(filePath); + return filePath; + } +} diff --git a/TrafagSalesExporter/Services/ExportOrchestrationService.cs b/TrafagSalesExporter/Services/ExportOrchestrationService.cs new file mode 100644 index 0000000..3cc0a1e --- /dev/null +++ b/TrafagSalesExporter/Services/ExportOrchestrationService.cs @@ -0,0 +1,120 @@ +using Microsoft.EntityFrameworkCore; +using TrafagSalesExporter.Data; +using TrafagSalesExporter.Models; + +namespace TrafagSalesExporter.Services; + +public class ExportOrchestrationService( + IDbContextFactory dbFactory, + CryptoService cryptoService, + HanaQueryService hanaQueryService, + ExcelExportService excelExportService, + SharePointUploadService sharePointUploadService) +{ + public async Task ExportAllActiveSitesAsync(CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + var siteIds = await db.Sites.Where(x => x.IsActive).Select(x => x.Id).ToListAsync(ct); + + foreach (var siteId in siteIds) + { + await ExportSiteAsync(siteId, ct); + } + } + + public async Task ExportSiteAsync(int siteId, CancellationToken ct = default) + { + var started = DateTime.UtcNow; + + await using var db = await dbFactory.CreateDbContextAsync(ct); + var site = await db.Sites.Include(x => x.HanaServer).SingleAsync(x => x.Id == siteId, ct); + var settings = await db.ExportSettings.OrderBy(x => x.Id).FirstAsync(ct); + var sp = await db.SharePointConfigs.OrderBy(x => x.Id).FirstAsync(ct); + + var log = new ExportLog + { + Timestamp = DateTime.UtcNow, + SiteId = site.Id, + Land = site.Land, + TSC = site.TSC, + Status = "Error", + RowCount = 0, + FileName = string.Empty, + DurationSeconds = 0 + }; + + try + { + var hanaServer = site.HanaServer ?? throw new InvalidOperationException("HANA Server fehlt."); + var hanaPassword = cryptoService.Decrypt(hanaServer.EncryptedPassword); + var clientSecret = cryptoService.Decrypt(sp.EncryptedClientSecret); + + var records = hanaQueryService.QuerySales( + hanaServer.Host, + hanaServer.Port, + hanaServer.Username, + hanaPassword, + site.Schema, + site.TSC, + site.Land, + settings.DateFilter); + + var filePath = excelExportService.CreateFile(AppContext.BaseDirectory, site.Land, site.TSC, records); + + await sharePointUploadService.UploadAsync( + sp.SiteUrl, + sp.ExportFolder, + sp.TenantId, + sp.ClientId, + clientSecret, + site.Land, + filePath); + + log.Status = "OK"; + log.RowCount = records.Count; + log.FileName = Path.GetFileName(filePath); + log.ErrorMessage = null; + } + catch (Exception ex) + { + log.ErrorMessage = ex.Message; + } + finally + { + log.DurationSeconds = (DateTime.UtcNow - started).TotalSeconds; + db.ExportLogs.Add(log); + await db.SaveChangesAsync(ct); + } + } + + public async Task GetNextRunAsync(CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + var settings = await db.ExportSettings.OrderBy(x => x.Id).FirstOrDefaultAsync(ct); + if (settings is null || !settings.TimerEnabled) + { + return null; + } + + var now = DateTime.Now; + var next = new DateTime(now.Year, now.Month, now.Day, settings.TimerHour, settings.TimerMinute, 0); + if (next <= now) + { + next = next.AddDays(1); + } + + return next; + } + + public async Task> GetLatestLogsPerSiteAsync(CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + var grouped = await db.ExportLogs + .OrderByDescending(x => x.Timestamp) + .ToListAsync(ct); + + return grouped + .GroupBy(x => x.SiteId ?? 0) + .ToDictionary(g => g.Key, g => g.FirstOrDefault()); + } +} diff --git a/TrafagSalesExporter/Services/HanaQueryService.cs b/TrafagSalesExporter/Services/HanaQueryService.cs new file mode 100644 index 0000000..9a836f7 --- /dev/null +++ b/TrafagSalesExporter/Services/HanaQueryService.cs @@ -0,0 +1,174 @@ +using Sap.Data.Hana; +using TrafagSalesExporter.Models; + +namespace TrafagSalesExporter.Services; + +public class HanaQueryService +{ + public List QuerySales(string host, int port, string username, string password, string schema, string tsc, string land, string dateFilter) + { + var connectionString = $"ServerNode={host}:{port};UserName={username};Password={password}"; + var result = new List(); + + using var connection = new HanaConnection(connectionString); + connection.Open(); + + var invoiceQuery = GetInvoiceQuery(schema, tsc, dateFilter); + var creditQuery = GetCreditNoteQuery(schema, tsc, dateFilter); + + result.AddRange(Read(connection, invoiceQuery, land)); + result.AddRange(Read(connection, creditQuery, land)); + + foreach (var record in result) + { + if (record.Material.Contains('/')) + { + var parts = record.Material.Split('/'); + record.Material = parts[^1]; + } + } + + return result; + } + + public bool TestConnection(string host, int port, string username, string password) + { + var connectionString = $"ServerNode={host}:{port};UserName={username};Password={password}"; + using var connection = new HanaConnection(connectionString); + connection.Open(); + return connection.State == System.Data.ConnectionState.Open; + } + + private static List Read(HanaConnection connection, string query, string land) + { + var records = new List(); + using var cmd = new HanaCommand(query, connection); + using var reader = cmd.ExecuteReader(); + + while (reader.Read()) + { + records.Add(new SalesRecord + { + ExtractionDate = reader.GetDateTime(reader.GetOrdinal("extraction_date")), + TSC = reader["tsc"]?.ToString() ?? string.Empty, + InvoiceNumber = reader["invoice_number"]?.ToString() ?? string.Empty, + PositionOnInvoice = Convert.ToInt32(reader["invoice_position"]), + InvoiceDate = reader.IsDBNull(reader.GetOrdinal("invoice_date")) ? null : reader.GetDateTime(reader.GetOrdinal("invoice_date")), + Material = reader["material"]?.ToString() ?? string.Empty, + Name = reader["material_name"]?.ToString() ?? string.Empty, + ProductGroup = reader["product_group"]?.ToString() ?? string.Empty, + Quantity = Convert.ToDecimal(reader["quantity"]), + SupplierNumber = reader["supplier_number"]?.ToString() ?? string.Empty, + SupplierName = reader["supplier_name"]?.ToString() ?? string.Empty, + SupplierCountry = reader["supplier_country"]?.ToString() ?? string.Empty, + CustomerNumber = reader["customer_number"]?.ToString() ?? string.Empty, + CustomerName = reader["customer_name"]?.ToString() ?? string.Empty, + CustomerCountry = reader["customer_country"]?.ToString() ?? string.Empty, + CustomerIndustry = reader["customer_industry"]?.ToString() ?? string.Empty, + StandardCost = Convert.ToDecimal(reader["standard_cost"]), + StandardCostCurrency = reader["standard_cost_currency"]?.ToString() ?? string.Empty, + PurchaseOrderNumber = reader["purchase_order_number"]?.ToString() ?? string.Empty, + SalesPriceValue = Convert.ToDecimal(reader["sales_value"]), + SalesCurrency = reader["sales_currency"]?.ToString() ?? string.Empty, + Incoterms2020 = reader["incoterms_2020"]?.ToString() ?? string.Empty, + SalesResponsibleEmployee = reader["sales_responsible"]?.ToString() ?? string.Empty, + OrderDate = reader.IsDBNull(reader.GetOrdinal("order_date")) ? null : reader.GetDateTime(reader.GetOrdinal("order_date")), + Land = land, + DocumentType = reader["doc_type"]?.ToString() ?? string.Empty + }); + } + + return records; + } + + private static string GetInvoiceQuery(string schema, string tsc, string dateFilter) => $@" +SELECT + CURRENT_TIMESTAMP AS extraction_date, + '{tsc}' AS tsc, + h.""DocNum"" AS invoice_number, + p.""LineNum"" AS invoice_position, + h.""DocDate"" AS invoice_date, + p.""ItemCode"" AS material, + p.""Dscription"" AS material_name, + COALESCE(grp.""ItmsGrpNam"", '') AS product_group, + p.""Quantity"" AS quantity, + COALESCE(itm.""CardCode"", '') AS supplier_number, + COALESCE(sup.""CardName"", '') AS supplier_name, + COALESCE(sup_adr.""Country"", '') AS supplier_country, + h.""CardCode"" AS customer_number, + h.""CardName"" AS customer_name, + COALESCE(cust_adr.""Country"", '') AS customer_country, + COALESCE(ind.""IndName"", '') AS customer_industry, + p.""StockPrice"" AS standard_cost, + COALESCE(p.""Currency"", h.""DocCur"") AS standard_cost_currency, + CASE WHEN p.""BaseType"" = 22 + THEN CAST(p.""BaseRef"" AS NVARCHAR(20)) + ELSE '' END AS purchase_order_number, + p.""LineTotal"" AS sales_value, + COALESCE(p.""Currency"", h.""DocCur"") AS sales_currency, + '' AS incoterms_2020, + COALESCE(emp.""SlpName"", '') AS sales_responsible, + CASE WHEN p.""BaseType"" = 17 + THEN (SELECT o.""DocDate"" FROM {schema}.""ORDR"" o + WHERE o.""DocEntry"" = p.""BaseEntry"") + ELSE NULL END AS order_date, + 'INV' AS doc_type +FROM {schema}.""OINV"" h +INNER JOIN {schema}.""INV1"" p ON h.""DocEntry"" = p.""DocEntry"" +LEFT JOIN {schema}.""OITM"" itm ON p.""ItemCode"" = itm.""ItemCode"" +LEFT JOIN {schema}.""OITB"" grp ON itm.""ItmsGrpCod"" = grp.""ItmsGrpCod"" +LEFT JOIN {schema}.""OCRD"" cust ON h.""CardCode"" = cust.""CardCode"" +LEFT JOIN {schema}.""CRD1"" cust_adr ON h.""CardCode"" = cust_adr.""CardCode"" + AND cust_adr.""AdresType"" = 'B' AND cust_adr.""Address"" = h.""PayToCode"" +LEFT JOIN {schema}.""OOND"" ind ON cust.""IndustryC"" = ind.""IndCode"" +LEFT JOIN {schema}.""OCRD"" sup ON itm.""CardCode"" = sup.""CardCode"" + AND sup.""CardType"" = 'S' +LEFT JOIN {schema}.""CRD1"" sup_adr ON itm.""CardCode"" = sup_adr.""CardCode"" + AND sup_adr.""AdresType"" = 'B' +LEFT JOIN {schema}.""OSLP"" emp ON h.""SlpCode"" = emp.""SlpCode"" +WHERE h.""CANCELED"" = 'N' AND h.""DocDate"" >= '{dateFilter}' +ORDER BY h.""DocDate"" DESC, h.""DocNum"", p.""LineNum"""; + + private static string GetCreditNoteQuery(string schema, string tsc, string dateFilter) => $@" +SELECT + CURRENT_TIMESTAMP AS extraction_date, + '{tsc}' AS tsc, + h.""DocNum"" AS invoice_number, + p.""LineNum"" AS invoice_position, + h.""DocDate"" AS invoice_date, + p.""ItemCode"" AS material, + p.""Dscription"" AS material_name, + COALESCE(grp.""ItmsGrpNam"", '') AS product_group, + p.""Quantity"" * -1 AS quantity, + COALESCE(itm.""CardCode"", '') AS supplier_number, + COALESCE(sup.""CardName"", '') AS supplier_name, + COALESCE(sup_adr.""Country"", '') AS supplier_country, + h.""CardCode"" AS customer_number, + h.""CardName"" AS customer_name, + COALESCE(cust_adr.""Country"", '') AS customer_country, + COALESCE(ind.""IndName"", '') AS customer_industry, + p.""StockPrice"" AS standard_cost, + COALESCE(p.""Currency"", h.""DocCur"") AS standard_cost_currency, + '' AS purchase_order_number, + p.""LineTotal"" * -1 AS sales_value, + COALESCE(p.""Currency"", h.""DocCur"") AS sales_currency, + '' AS incoterms_2020, + COALESCE(emp.""SlpName"", '') AS sales_responsible, + NULL AS order_date, + 'CRN' AS doc_type +FROM {schema}.""ORIN"" h +INNER JOIN {schema}.""RIN1"" p ON h.""DocEntry"" = p.""DocEntry"" +LEFT JOIN {schema}.""OITM"" itm ON p.""ItemCode"" = itm.""ItemCode"" +LEFT JOIN {schema}.""OITB"" grp ON itm.""ItmsGrpCod"" = grp.""ItmsGrpCod"" +LEFT JOIN {schema}.""OCRD"" cust ON h.""CardCode"" = cust.""CardCode"" +LEFT JOIN {schema}.""CRD1"" cust_adr ON h.""CardCode"" = cust_adr.""CardCode"" + AND cust_adr.""AdresType"" = 'B' AND cust_adr.""Address"" = h.""PayToCode"" +LEFT JOIN {schema}.""OOND"" ind ON cust.""IndustryC"" = ind.""IndCode"" +LEFT JOIN {schema}.""OCRD"" sup ON itm.""CardCode"" = sup.""CardCode"" + AND sup.""CardType"" = 'S' +LEFT JOIN {schema}.""CRD1"" sup_adr ON itm.""CardCode"" = sup_adr.""CardCode"" + AND sup_adr.""AdresType"" = 'B' +LEFT JOIN {schema}.""OSLP"" emp ON h.""SlpCode"" = emp.""SlpCode"" +WHERE h.""CANCELED"" = 'N' AND h.""DocDate"" >= '{dateFilter}' +ORDER BY h.""DocDate"" DESC, h.""DocNum"", p.""LineNum"""; +} diff --git a/TrafagSalesExporter/Services/SharePointUploadService.cs b/TrafagSalesExporter/Services/SharePointUploadService.cs new file mode 100644 index 0000000..076ebd9 --- /dev/null +++ b/TrafagSalesExporter/Services/SharePointUploadService.cs @@ -0,0 +1,95 @@ +using Azure.Identity; +using Microsoft.Graph; +using Microsoft.Graph.Models; + +namespace TrafagSalesExporter.Services; + +public class SharePointUploadService +{ + public async Task UploadAsync(string siteUrl, string exportFolder, string tenantId, string clientId, string clientSecret, string land, string localFilePath) + { + var graph = CreateGraphClient(tenantId, clientId, clientSecret); + var (siteId, driveId) = await ResolveSiteAndDriveAsync(graph, siteUrl); + + var folderPath = $"{exportFolder.Trim('/')}/{land}"; + await EnsureFolderPathAsync(graph, driveId, folderPath); + + var fileName = Path.GetFileName(localFilePath); + var remotePath = $"{folderPath}/{fileName}"; + + await using var stream = File.OpenRead(localFilePath); + await graph.Drives[driveId].Root.ItemWithPath(remotePath).Content.PutAsync(stream); + } + + public async Task TestConnectionAsync(string siteUrl, string tenantId, string clientId, string clientSecret) + { + var graph = CreateGraphClient(tenantId, clientId, clientSecret); + var (siteId, _) = await ResolveSiteAndDriveAsync(graph, siteUrl); + return !string.IsNullOrWhiteSpace(siteId); + } + + private static GraphServiceClient CreateGraphClient(string tenantId, string clientId, string clientSecret) + { + var credential = new ClientSecretCredential(tenantId, clientId, clientSecret); + return new GraphServiceClient(credential, ["https://graph.microsoft.com/.default"]); + } + + private static async Task<(string siteId, string driveId)> ResolveSiteAndDriveAsync(GraphServiceClient graph, string siteUrl) + { + var uri = new Uri(siteUrl); + var site = await graph.Sites[$"{uri.Host}:{uri.AbsolutePath}"].GetAsync(); + if (site?.Id is null) + { + throw new InvalidOperationException("SharePoint Site nicht gefunden."); + } + + var drive = await graph.Sites[site.Id].Drive.GetAsync(); + if (drive?.Id is null) + { + throw new InvalidOperationException("SharePoint Dokumentenbibliothek nicht gefunden."); + } + + return (site.Id, drive.Id); + } + + private static async Task EnsureFolderPathAsync(GraphServiceClient graph, string driveId, string folderPath) + { + var segments = folderPath.Split('/', StringSplitOptions.RemoveEmptyEntries); + var currentPath = string.Empty; + + foreach (var segment in segments) + { + currentPath = string.IsNullOrEmpty(currentPath) ? segment : $"{currentPath}/{segment}"; + + try + { + _ = await graph.Drives[driveId].Root.ItemWithPath(currentPath).GetAsync(); + } + catch + { + var parentPath = currentPath.Contains('/') + ? currentPath[..currentPath.LastIndexOf('/')] + : string.Empty; + + var parent = string.IsNullOrEmpty(parentPath) + ? await graph.Drives[driveId].Root.GetAsync() + : await graph.Drives[driveId].Root.ItemWithPath(parentPath).GetAsync(); + + if (parent?.Id is null) + { + throw new InvalidOperationException("SharePoint Parent-Ordner konnte nicht ermittelt werden."); + } + + await graph.Drives[driveId].Items[parent.Id].Children.PostAsync(new DriveItem + { + Name = segment, + Folder = new Folder(), + AdditionalData = new Dictionary + { + ["@microsoft.graph.conflictBehavior"] = "replace" + } + }); + } + } + } +} diff --git a/TrafagSalesExporter/Services/TimerBackgroundService.cs b/TrafagSalesExporter/Services/TimerBackgroundService.cs new file mode 100644 index 0000000..14537c5 --- /dev/null +++ b/TrafagSalesExporter/Services/TimerBackgroundService.cs @@ -0,0 +1,58 @@ +using Microsoft.EntityFrameworkCore; +using TrafagSalesExporter.Data; + +namespace TrafagSalesExporter.Services; + +public class TimerBackgroundService( + IServiceScopeFactory scopeFactory, + ILogger logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + try + { + using var scope = scopeFactory.CreateScope(); + var dbFactory = scope.ServiceProvider.GetRequiredService>(); + var exportService = scope.ServiceProvider.GetRequiredService(); + + await using var db = await dbFactory.CreateDbContextAsync(stoppingToken); + var settings = await db.ExportSettings.OrderBy(x => x.Id).FirstOrDefaultAsync(stoppingToken); + + if (settings is null || !settings.TimerEnabled) + { + await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken); + continue; + } + + var now = DateTime.Now; + var nextRun = new DateTime(now.Year, now.Month, now.Day, settings.TimerHour, settings.TimerMinute, 0); + if (nextRun <= now) + { + nextRun = nextRun.AddDays(1); + } + + var delay = nextRun - now; + logger.LogInformation("Nächster automatischer Export um {NextRun}", nextRun); + await Task.Delay(delay, stoppingToken); + + if (stoppingToken.IsCancellationRequested) + { + break; + } + + await exportService.ExportAllActiveSitesAsync(stoppingToken); + } + catch (TaskCanceledException) + { + break; + } + catch (Exception ex) + { + logger.LogError(ex, "Fehler im TimerBackgroundService"); + await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken); + } + } + } +} diff --git a/TrafagSalesExporter/TrafagSalesExporter.csproj b/TrafagSalesExporter/TrafagSalesExporter.csproj new file mode 100644 index 0000000..9295d92 --- /dev/null +++ b/TrafagSalesExporter/TrafagSalesExporter.csproj @@ -0,0 +1,20 @@ + + + net8.0 + enable + enable + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/TrafagSalesExporter/wwwroot/app.css b/TrafagSalesExporter/wwwroot/app.css new file mode 100644 index 0000000..9c5a924 --- /dev/null +++ b/TrafagSalesExporter/wwwroot/app.css @@ -0,0 +1,3 @@ +html, body { + font-family: Roboto, Arial, sans-serif; +}