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;
+}