Compare commits

..

1 Commits

Author SHA1 Message Date
admin ec14b838e5 Convert Trafag exporter to Blazor Server app with UI and scheduler 2026-04-09 15:52:23 +02:00
31 changed files with 747 additions and 1489 deletions
-8
View File
@@ -1,8 +0,0 @@
# Build artifacts
bin/
obj/
# Visual Studio user/IDE files
.vs/
*.user
*.suo
+5 -6
View File
@@ -3,16 +3,15 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Trafag Sales Exporter</title>
<base href="/" /> <base href="/" />
<link href="css/app.css" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" rel="stylesheet" />
<link href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet" /> <link href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet" />
<HeadOutlet @rendermode="@Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer" /> <link href="app.css" rel="stylesheet" />
<HeadOutlet />
</head> </head>
<body> <body>
<Routes @rendermode="@Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer" /> <Routes />
<script src="_framework/blazor.web.js"></script> <script src="_framework/blazor.web.js"></script>
<script src="_content/MudBlazor/MudBlazor.min.js"></script> <script src="_content/MudBlazor/MudBlazor.min.js"></script>
</body> </body>
</html> </html>
@@ -1,38 +1,19 @@
@inherits LayoutComponentBase @inherits LayoutComponentBase
<MudThemeProvider Theme="_theme" /> <MudThemeProvider />
<MudPopoverProvider />
<MudDialogProvider /> <MudDialogProvider />
<MudSnackbarProvider /> <MudSnackbarProvider />
<MudLayout> <MudLayout>
<MudAppBar Elevation="1" Color="Color.Primary"> <MudAppBar Elevation="1">
<MudIconButton Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" Edge="Edge.Start" <MudText Typo="Typo.h6">Trafag Sales Exporter</MudText>
OnClick="ToggleDrawer" />
<MudText Typo="Typo.h6" Class="ml-3">Trafag Sales Exporter</MudText>
</MudAppBar> </MudAppBar>
<MudDrawer Open="true" Variant="DrawerVariant.Mini" Elevation="1">
<MudDrawer @bind-Open="_drawerOpen" Elevation="2" ClipMode="DrawerClipMode.Always">
<NavMenu /> <NavMenu />
</MudDrawer> </MudDrawer>
<MudMainContent>
<MudMainContent Class="pa-4"> <MudContainer MaxWidth="MaxWidth.False" Class="pa-4">
@Body @Body
</MudContainer>
</MudMainContent> </MudMainContent>
</MudLayout> </MudLayout>
@code {
private bool _drawerOpen = true;
private readonly MudTheme _theme = new()
{
PaletteLight = new PaletteLight
{
Primary = "#1565C0",
Secondary = "#00897B",
AppbarBackground = "#1565C0"
}
};
private void ToggleDrawer() => _drawerOpen = !_drawerOpen;
}
@@ -1,17 +1,6 @@
<MudNavMenu> <MudNavMenu>
<MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard"> <MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">Dashboard</MudNavLink>
Dashboard <MudNavLink Href="/standorte" Icon="@Icons.Material.Filled.LocationOn">Standorte</MudNavLink>
</MudNavLink> <MudNavLink Href="/settings" Icon="@Icons.Material.Filled.Settings">Settings</MudNavLink>
<MudNavLink Href="/standorte" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.LocationOn"> <MudNavLink Href="/logs" Icon="@Icons.Material.Filled.ReceiptLong">Logs</MudNavLink>
Standorte
</MudNavLink>
<MudNavLink Href="/transformations" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Transform">
Transformationen
</MudNavLink>
<MudNavLink Href="/settings" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Settings">
Settings
</MudNavLink>
<MudNavLink Href="/logs" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.List">
Logs
</MudNavLink>
</MudNavMenu> </MudNavMenu>
@@ -1,46 +1,25 @@
@page "/" @page "/"
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using TrafagSalesExporter.Data
@using TrafagSalesExporter.Services
@inject IDbContextFactory<AppDbContext> DbFactory @inject IDbContextFactory<AppDbContext> DbFactory
@inject ExportOrchestrationService Orchestrator @inject ExportOrchestrationService ExportService
@inject TimerBackgroundService TimerService
@inject ISnackbar Snackbar
@implements IDisposable
<PageTitle>Dashboard</PageTitle> <PageTitle>Dashboard</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Dashboard</MudText> <MudText Typo="Typo.h4" Class="mb-4">Dashboard</MudText>
<MudPaper Class="pa-4 mb-4" Elevation="1"> <MudStack Row="true" Spacing="2" Class="mb-4">
<MudStack Row AlignItems="AlignItems.Center" Spacing="4"> <MudButton Variant="Variant.Filled" Color="Color.Primary" Disabled="@isRunningAll" OnClick="ExportAllAsync">Alle exportieren</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.PlayArrow" <MudText Typo="Typo.body1">Nächster automatischer Lauf: @nextRunText</MudText>
OnClick="ExportAll" Disabled="_anyRunning"> </MudStack>
Alle exportieren
</MudButton>
<MudText Typo="Typo.body1">
@if (TimerService.NextRun < DateTime.MaxValue)
{
<MudIcon Icon="@Icons.Material.Filled.Schedule" Size="Size.Small" Class="mr-1" />
@($"Nächster automatischer Lauf: {TimerService.NextRun:dd.MM.yyyy HH:mm}")
}
else
{
<MudIcon Icon="@Icons.Material.Filled.TimerOff" Size="Size.Small" Class="mr-1" />
@("Timer deaktiviert")
}
</MudText>
</MudStack>
</MudPaper>
<MudTable Items="_dashboardRows" Dense Hover Striped Loading="_loading"> <MudTable Items="sites" Hover="true" Dense="true">
<HeaderContent> <HeaderContent>
<MudTh>Land</MudTh> <MudTh>Land</MudTh>
<MudTh>TSC</MudTh> <MudTh>TSC</MudTh>
<MudTh>Schema</MudTh> <MudTh>Schema</MudTh>
<MudTh>Server</MudTh> <MudTh>Server</MudTh>
<MudTh>Status</MudTh> <MudTh>Letzter Status</MudTh>
<MudTh>Zeilen</MudTh> <MudTh>Row Count</MudTh>
<MudTh>Letzter Lauf</MudTh> <MudTh>Letzter Lauf</MudTh>
<MudTh>Dauer</MudTh> <MudTh>Dauer</MudTh>
<MudTh>Aktion</MudTh> <MudTh>Aktion</MudTh>
@@ -49,145 +28,90 @@
<MudTd>@context.Land</MudTd> <MudTd>@context.Land</MudTd>
<MudTd>@context.TSC</MudTd> <MudTd>@context.TSC</MudTd>
<MudTd>@context.Schema</MudTd> <MudTd>@context.Schema</MudTd>
<MudTd>@context.ServerName</MudTd> <MudTd>@context.HanaServer?.Name</MudTd>
<MudTd>@GetStatusIcon(context.Id)</MudTd>
<MudTd>@GetRows(context.Id)</MudTd>
<MudTd>@GetLastRun(context.Id)</MudTd>
<MudTd>@GetDuration(context.Id)</MudTd>
<MudTd> <MudTd>
@if (Orchestrator.IsExporting(context.SiteId)) @if (runningSiteIds.Contains(context.Id))
{ {
<MudProgressCircular Size="Size.Small" Indeterminate Color="Color.Primary" Class="mr-1" /> <MudProgressCircular Indeterminate="true" Size="Size.Small" />
<MudText Typo="Typo.caption">@Orchestrator.GetExportStatus(context.SiteId)</MudText>
}
else if (context.LastStatus == "OK")
{
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
}
else if (context.LastStatus == "Error")
{
<MudTooltip Text="@context.ErrorMessage">
<MudIcon Icon="@Icons.Material.Filled.Error" Color="Color.Error" Size="Size.Small" />
</MudTooltip>
} }
else else
{ {
<MudText Typo="Typo.caption" Color="Color.Default">-</MudText> <MudButton Size="Size.Small" Variant="Variant.Outlined" OnClick="() => ExportSingleAsync(context.Id)">Einzeln exportieren</MudButton>
} }
</MudTd> </MudTd>
<MudTd>@(context.RowCount > 0 ? context.RowCount.ToString("N0") : "-")</MudTd>
<MudTd>@(context.LastRun.HasValue ? context.LastRun.Value.ToString("dd.MM.yyyy HH:mm:ss") : "-")</MudTd>
<MudTd>@(context.DurationSeconds > 0 ? $"{context.DurationSeconds:F1}s" : "-")</MudTd>
<MudTd>
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.FileDownload"
OnClick="() => ExportSingle(context.SiteId)"
Disabled="Orchestrator.IsExporting(context.SiteId)">
Export
</MudButton>
</MudTd>
</RowTemplate> </RowTemplate>
</MudTable> </MudTable>
@code { @code {
private List<DashboardRow> _dashboardRows = new(); private List<Site> sites = [];
private bool _loading = true; private Dictionary<int, ExportLog?> latestLogs = new();
private bool _anyRunning; private HashSet<int> runningSiteIds = [];
private bool isRunningAll;
private string nextRunText = "-";
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
Orchestrator.OnExportStatusChanged += HandleStatusChanged; await LoadAsync();
await LoadDataAsync();
} }
private async Task LoadDataAsync() private async Task LoadAsync()
{ {
_loading = true; await using var db = await DbFactory.CreateDbContextAsync();
using var db = await DbFactory.CreateDbContextAsync(); sites = await db.Sites
.Include(x => x.HanaServer)
var sites = await db.Sites.Include(s => s.HanaServer).Where(s => s.IsActive).ToListAsync(); .Where(x => x.IsActive)
var logs = await db.ExportLogs .OrderBy(x => x.Land)
.GroupBy(l => l.SiteId)
.Select(g => g.OrderByDescending(l => l.Timestamp).First())
.ToListAsync(); .ToListAsync();
_dashboardRows = sites.Select(s => latestLogs = await ExportService.GetLatestLogsPerSiteAsync();
{ var nextRun = await ExportService.GetNextRunAsync();
var log = logs.FirstOrDefault(l => l.SiteId == s.Id); nextRunText = nextRun.HasValue ? nextRun.Value.ToString("dd.MM.yyyy HH:mm") : "Deaktiviert";
return new DashboardRow
{
SiteId = s.Id,
Land = s.Land,
TSC = s.TSC,
Schema = s.Schema,
ServerName = s.HanaServer?.Name ?? "",
LastStatus = log?.Status ?? "",
RowCount = log?.RowCount ?? 0,
LastRun = log?.Timestamp,
DurationSeconds = log?.DurationSeconds ?? 0,
ErrorMessage = log?.ErrorMessage ?? ""
};
}).ToList();
_anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId));
_loading = false;
} }
private async Task ExportAll() private async Task ExportAllAsync()
{ {
_anyRunning = true; isRunningAll = true;
_ = Task.Run(async () => foreach (var site in sites)
{ {
await Orchestrator.ExportAllAsync(); runningSiteIds.Add(site.Id);
await InvokeAsync(async () => }
{
await LoadDataAsync();
StateHasChanged(); StateHasChanged();
}); await ExportService.ExportAllActiveSitesAsync();
}); runningSiteIds.Clear();
Snackbar.Add("Export für alle Standorte gestartet", Severity.Info); isRunningAll = false;
await LoadAsync();
} }
private void ExportSingle(int siteId) private async Task ExportSingleAsync(int siteId)
{ {
_ = Task.Run(async () => runningSiteIds.Add(siteId);
{
await Orchestrator.ExportSiteByIdAsync(siteId);
await InvokeAsync(async () =>
{
await LoadDataAsync();
StateHasChanged(); StateHasChanged();
}); await ExportService.ExportSiteAsync(siteId);
}); runningSiteIds.Remove(siteId);
Snackbar.Add("Export gestartet", Severity.Info); await LoadAsync();
} }
private async void HandleStatusChanged() private string GetStatusIcon(int siteId)
{ {
await InvokeAsync(async () => if (!latestLogs.TryGetValue(siteId, out var log) || log is null)
{ {
_anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId)); return "-";
StateHasChanged();
if (!_anyRunning)
{
await LoadDataAsync();
StateHasChanged();
}
});
} }
public void Dispose() return log.Status == "OK" ? "✅" : "❌";
{
Orchestrator.OnExportStatusChanged -= HandleStatusChanged;
} }
private class DashboardRow private string GetRows(int siteId) =>
{ latestLogs.TryGetValue(siteId, out var log) && log is not null ? log.RowCount.ToString() : "-";
public int SiteId { get; set; }
public string Land { get; set; } = ""; private string GetLastRun(int siteId) =>
public string TSC { get; set; } = ""; latestLogs.TryGetValue(siteId, out var log) && log is not null ? log.Timestamp.ToLocalTime().ToString("dd.MM.yyyy HH:mm:ss") : "-";
public string Schema { get; set; } = "";
public string ServerName { get; set; } = ""; private string GetDuration(int siteId) =>
public string LastStatus { get; set; } = ""; latestLogs.TryGetValue(siteId, out var log) && log is not null ? $"{log.DurationSeconds:F1}s" : "-";
public int RowCount { get; set; }
public DateTime? LastRun { get; set; }
public double DurationSeconds { get; set; }
public string ErrorMessage { get; set; } = "";
}
} }
+55 -95
View File
@@ -1,134 +1,94 @@
@page "/logs" @page "/logs"
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using TrafagSalesExporter.Data
@inject IDbContextFactory<AppDbContext> DbFactory @inject IDbContextFactory<AppDbContext> DbFactory
@inject ISnackbar Snackbar
@inject IDialogService DialogService
<PageTitle>Logs</PageTitle> <PageTitle>Logs</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Export Logs</MudText> <MudText Typo="Typo.h4" Class="mb-4">Logs</MudText>
<MudPaper Class="pa-4 mb-4" Elevation="1"> <MudGrid Class="mb-4">
<MudStack Row AlignItems="AlignItems.Center" Spacing="3"> <MudItem xs="12" md="3"><MudTextField Label="Land" @bind-Value="filterLand" /></MudItem>
<MudSelect @bind-Value="_filterLand" Label="Land" Clearable Dense Style="max-width:200px;"> <MudItem xs="12" md="3">
@foreach (var land in _availableLands) <MudSelect T="string" Label="Status" @bind-Value="filterStatus">
{ <MudSelectItem Value="">Alle</MudSelectItem>
<MudSelectItem Value="@land">@land</MudSelectItem> <MudSelectItem Value="OK">OK</MudSelectItem>
} <MudSelectItem Value="Error">Error</MudSelectItem>
</MudSelect> </MudSelect>
<MudSelect @bind-Value="_filterStatus" Label="Status" Clearable Dense Style="max-width:150px;"> </MudItem>
<MudSelectItem Value="@("OK")">OK</MudSelectItem> <MudItem xs="12" md="3"><MudDatePicker Label="Ab Datum" @bind-Date="filterFromDate" /></MudItem>
<MudSelectItem Value="@("Error")">Error</MudSelectItem> <MudItem xs="12" md="3"><MudButton Variant="Variant.Filled" OnClick="LoadAsync">Filtern</MudButton></MudItem>
</MudSelect> </MudGrid>
<MudDatePicker @bind-Date="_filterDate" Label="Datum" Clearable Dense Style="max-width:200px;" />
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="ApplyFilter"
StartIcon="@Icons.Material.Filled.FilterAlt">
Filtern
</MudButton>
<MudSpacer />
<MudButton Variant="Variant.Outlined" Color="Color.Error" OnClick="DeleteOldLogs"
StartIcon="@Icons.Material.Filled.DeleteSweep">
Alte Logs löschen
</MudButton>
</MudStack>
</MudPaper>
<MudTable Items="_logs" Dense Hover Striped Loading="_loading"> <MudStack Row="true" Spacing="2" Class="mb-2">
<MudNumericField T="int" Label="Logs älter als Tage löschen" @bind-Value="deleteOlderThanDays" Min="1" />
<MudButton Variant="Variant.Outlined" Color="Color.Error" OnClick="DeleteOlderAsync">Logs löschen</MudButton>
</MudStack>
<MudTable Items="logs" Dense="true" Hover="true" RowClassFunc="GetRowClass">
<HeaderContent> <HeaderContent>
<MudTh>Zeitpunkt</MudTh> <MudTh>Timestamp</MudTh>
<MudTh>Land</MudTh> <MudTh>Land</MudTh>
<MudTh>TSC</MudTh> <MudTh>TSC</MudTh>
<MudTh>Status</MudTh> <MudTh>Status</MudTh>
<MudTh>Zeilen</MudTh> <MudTh>Rows</MudTh>
<MudTh>Dauer</MudTh> <MudTh>Dauer</MudTh>
<MudTh>Dateiname</MudTh>
<MudTh>Fehler</MudTh> <MudTh>Fehler</MudTh>
<MudTh>Dateiname</MudTh>
</HeaderContent> </HeaderContent>
<RowTemplate> <RowTemplate>
<MudTd>@context.Timestamp.ToString("dd.MM.yyyy HH:mm:ss")</MudTd> <MudTd>@context.Timestamp.ToLocalTime().ToString("dd.MM.yyyy HH:mm:ss")</MudTd>
<MudTd>@context.Land</MudTd> <MudTd>@context.Land</MudTd>
<MudTd>@context.TSC</MudTd> <MudTd>@context.TSC</MudTd>
<MudTd> <MudTd>@context.Status</MudTd>
@if (context.Status == "OK") <MudTd>@context.RowCount</MudTd>
{
<MudChip T="string" Size="Size.Small" Color="Color.Success">OK</MudChip>
}
else
{
<MudChip T="string" Size="Size.Small" Color="Color.Error">Error</MudChip>
}
</MudTd>
<MudTd>@context.RowCount.ToString("N0")</MudTd>
<MudTd>@($"{context.DurationSeconds:F1}s")</MudTd> <MudTd>@($"{context.DurationSeconds:F1}s")</MudTd>
<MudTd>@context.ErrorMessage</MudTd>
<MudTd>@context.FileName</MudTd> <MudTd>@context.FileName</MudTd>
<MudTd>
@if (!string.IsNullOrEmpty(context.ErrorMessage))
{
<MudTooltip Text="@context.ErrorMessage">
<MudText Typo="Typo.caption" Color="Color.Error" Style="max-width:300px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block;">
@context.ErrorMessage
</MudText>
</MudTooltip>
}
</MudTd>
</RowTemplate> </RowTemplate>
</MudTable> </MudTable>
@code { @code {
private List<ExportLog> _logs = new(); private List<ExportLog> logs = [];
private List<string> _availableLands = new(); private string filterLand = string.Empty;
private string? _filterLand; private string filterStatus = string.Empty;
private string? _filterStatus; private DateTime? filterFromDate;
private DateTime? _filterDate; private int deleteOlderThanDays = 30;
private bool _loading = true;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync() => await LoadAsync();
private async Task LoadAsync()
{ {
using var db = await DbFactory.CreateDbContextAsync(); await using var db = await DbFactory.CreateDbContextAsync();
_availableLands = await db.ExportLogs.Select(l => l.Land).Distinct().OrderBy(l => l).ToListAsync(); var query = db.ExportLogs.AsQueryable();
await LoadLogsAsync();
if (!string.IsNullOrWhiteSpace(filterLand))
{
query = query.Where(x => x.Land.Contains(filterLand));
} }
private async Task LoadLogsAsync() if (!string.IsNullOrWhiteSpace(filterStatus))
{ {
_loading = true; query = query.Where(x => x.Status == filterStatus);
using var db = await DbFactory.CreateDbContextAsync();
IQueryable<ExportLog> query = db.ExportLogs.OrderByDescending(l => l.Timestamp);
if (!string.IsNullOrEmpty(_filterLand))
query = query.Where(l => l.Land == _filterLand);
if (!string.IsNullOrEmpty(_filterStatus))
query = query.Where(l => l.Status == _filterStatus);
if (_filterDate.HasValue)
query = query.Where(l => l.Timestamp.Date == _filterDate.Value.Date);
_logs = await query.Take(500).ToListAsync();
_loading = false;
} }
private async Task ApplyFilter() if (filterFromDate.HasValue)
{ {
await LoadLogsAsync(); var fromUtc = filterFromDate.Value.Date.ToUniversalTime();
query = query.Where(x => x.Timestamp >= fromUtc);
} }
private async Task DeleteOldLogs() 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 result = await DialogService.ShowMessageBox( var threshold = DateTime.UtcNow.AddDays(-deleteOlderThanDays);
"Alte Logs löschen", await using var db = await DbFactory.CreateDbContextAsync();
"Logs älter als 90 Tage löschen?", var oldLogs = await db.ExportLogs.Where(x => x.Timestamp < threshold).ToListAsync();
yesText: "Löschen", cancelText: "Abbrechen");
if (result != true) return;
using var db = await DbFactory.CreateDbContextAsync();
var cutoff = DateTime.Now.AddDays(-90);
var oldLogs = await db.ExportLogs.Where(l => l.Timestamp < cutoff).ToListAsync();
db.ExportLogs.RemoveRange(oldLogs); db.ExportLogs.RemoveRange(oldLogs);
var count = await db.SaveChangesAsync(); await db.SaveChangesAsync();
await LoadLogsAsync(); await LoadAsync();
Snackbar.Add($"{oldLogs.Count} alte Logs gelöscht", Severity.Info);
} }
} }
@@ -1,164 +1,93 @@
@page "/settings" @page "/settings"
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using TrafagSalesExporter.Data
@using TrafagSalesExporter.Services
@inject IDbContextFactory<AppDbContext> DbFactory @inject IDbContextFactory<AppDbContext> DbFactory
@inject SharePointUploadService SpService @inject CryptoService CryptoService
@inject TimerBackgroundService TimerService @inject SharePointUploadService SharePointUploadService
@inject ISnackbar Snackbar
<PageTitle>Settings</PageTitle> <PageTitle>Settings</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Settings</MudText> <MudText Typo="Typo.h4" Class="mb-4">Settings</MudText>
@* SharePoint Config *@ <MudPaper Class="pa-4 mb-4">
<MudText Typo="Typo.h5" Class="mb-2">SharePoint Konfiguration</MudText> <MudText Typo="Typo.h6">SharePoint</MudText>
<MudPaper Class="pa-4 mb-6" Elevation="1">
<MudGrid> <MudGrid>
<MudItem xs="12" md="6"> <MudItem xs="12" md="6"><MudTextField Label="SiteUrl" @bind-Value="sharePointConfig.SiteUrl" /></MudItem>
<MudTextField @bind-Value="_spConfig.SiteUrl" Label="Site URL" /> <MudItem xs="12" md="6"><MudTextField Label="ExportFolder" @bind-Value="sharePointConfig.ExportFolder" /></MudItem>
</MudItem> <MudItem xs="12" md="4"><MudTextField Label="TenantId" @bind-Value="sharePointConfig.TenantId" /></MudItem>
<MudItem xs="12" md="6"> <MudItem xs="12" md="4"><MudTextField Label="ClientId" @bind-Value="sharePointConfig.ClientId" /></MudItem>
<MudTextField @bind-Value="_spConfig.ExportFolder" Label="Export Folder" /> <MudItem xs="12" md="4"><MudTextField Label="ClientSecret" InputType="InputType.Password" @bind-Value="sharePointClientSecret" /></MudItem>
</MudItem> </MudGrid>
<MudItem xs="12" md="4"> <MudStack Row="true" Spacing="2" Class="mt-3">
<MudTextField @bind-Value="_spConfig.TenantId" Label="Tenant ID" /> <MudButton Variant="Variant.Filled" OnClick="SaveAsync">Speichern</MudButton>
</MudItem> <MudButton Variant="Variant.Outlined" OnClick="TestSharePointAsync">SharePoint Verbindung testen</MudButton>
<MudItem xs="12" md="4">
<MudTextField @bind-Value="_spConfig.ClientId" Label="Client ID" />
</MudItem>
<MudItem xs="12" md="4">
<MudTextField @bind-Value="_spConfig.ClientSecret" Label="Client Secret" InputType="InputType.Password" />
</MudItem>
<MudItem xs="12">
<MudStack Row Spacing="2">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveSharePoint"
StartIcon="@Icons.Material.Filled.Save">
Speichern
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Info" OnClick="TestSharePoint"
StartIcon="@Icons.Material.Filled.NetworkCheck" Disabled="_testingSp">
@if (_testingSp)
{
<MudProgressCircular Size="Size.Small" Indeterminate Class="mr-2" />
@("Teste...")
}
else
{
@("SharePoint Verbindung testen")
}
</MudButton>
</MudStack> </MudStack>
</MudItem>
</MudGrid>
</MudPaper> </MudPaper>
@* Export Settings *@ <MudPaper Class="pa-4 mb-4">
<MudText Typo="Typo.h5" Class="mb-2">Export Einstellungen</MudText> <MudText Typo="Typo.h6">Export & Timer</MudText>
<MudPaper Class="pa-4 mb-6" Elevation="1">
<MudGrid> <MudGrid>
<MudItem xs="12" md="4"> <MudItem xs="12" md="3"><MudTextField Label="DateFilter" @bind-Value="settings.DateFilter" /></MudItem>
<MudTextField @bind-Value="_exportSettings.DateFilter" Label="Datum-Filter (ab)" <MudItem xs="12" md="2"><MudNumericField T="int" Label="TimerHour" Min="0" Max="23" @bind-Value="settings.TimerHour" /></MudItem>
HelperText="Format: yyyy-MM-dd" /> <MudItem xs="12" md="2"><MudNumericField T="int" Label="TimerMinute" Min="0" Max="59" @bind-Value="settings.TimerMinute" /></MudItem>
</MudItem> <MudItem xs="12" md="2"><MudCheckBox Label="TimerEnabled" @bind-Value="settings.TimerEnabled" /></MudItem>
<MudItem xs="12" md="2">
<MudNumericField @bind-Value="_exportSettings.TimerHour" Label="Timer Stunde" Min="0" Max="23" />
</MudItem>
<MudItem xs="12" md="2">
<MudNumericField @bind-Value="_exportSettings.TimerMinute" Label="Timer Minute" Min="0" Max="59" />
</MudItem>
<MudItem xs="12" md="4">
<MudSwitch @bind-Value="_exportSettings.TimerEnabled" Label="Timer aktiviert" Color="Color.Primary" />
</MudItem>
<MudItem xs="12">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveExportSettings"
StartIcon="@Icons.Material.Filled.Save">
Speichern
</MudButton>
</MudItem>
</MudGrid> </MudGrid>
<MudText Typo="Typo.body2" Class="mt-3">Dateiname-Vorschau: @PreviewFileName</MudText>
</MudPaper> </MudPaper>
@* Filename Preview *@ <MudAlert Severity="Severity.Info" Variant="Variant.Outlined">@message</MudAlert>
<MudText Typo="Typo.h5" Class="mb-2">Dateiname Vorschau</MudText>
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.body1">
<MudIcon Icon="@Icons.Material.Filled.InsertDriveFile" Size="Size.Small" Class="mr-1" />
Sales_{"{TSC}"}_{DateTime.Now:yyyy-MM-dd}.xlsx
</MudText>
<MudText Typo="Typo.caption" Class="mt-1">
Beispiel: Sales_TRFR_@(DateTime.Now.ToString("yyyy-MM-dd")).xlsx
</MudText>
</MudPaper>
@code { @code {
private SharePointConfig _spConfig = new(); private SharePointConfig sharePointConfig = new();
private ExportSettings _exportSettings = new(); private ExportSettings settings = new();
private bool _testingSp; 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() protected override async Task OnInitializedAsync()
{ {
using var db = await DbFactory.CreateDbContextAsync(); await using var db = await DbFactory.CreateDbContextAsync();
_spConfig = await db.SharePointConfigs.FirstOrDefaultAsync() ?? new SharePointConfig(); sharePointConfig = await db.SharePointConfigs.OrderBy(x => x.Id).FirstAsync();
_exportSettings = await db.ExportSettings.FirstOrDefaultAsync() ?? new ExportSettings(); settings = await db.ExportSettings.OrderBy(x => x.Id).FirstAsync();
sharePointClientSecret = CryptoService.Decrypt(sharePointConfig.EncryptedClientSecret);
} }
private async Task SaveSharePoint() private async Task SaveAsync()
{ {
using var db = await DbFactory.CreateDbContextAsync(); await using var db = await DbFactory.CreateDbContextAsync();
var existing = await db.SharePointConfigs.FirstOrDefaultAsync(); var sp = await db.SharePointConfigs.SingleAsync(x => x.Id == sharePointConfig.Id);
if (existing is null) var es = await db.ExportSettings.SingleAsync(x => x.Id == settings.Id);
{
db.SharePointConfigs.Add(_spConfig); sp.SiteUrl = sharePointConfig.SiteUrl;
} sp.ExportFolder = sharePointConfig.ExportFolder;
else sp.TenantId = sharePointConfig.TenantId;
{ sp.ClientId = sharePointConfig.ClientId;
existing.SiteUrl = _spConfig.SiteUrl; sp.EncryptedClientSecret = CryptoService.Encrypt(sharePointClientSecret);
existing.ExportFolder = _spConfig.ExportFolder;
existing.TenantId = _spConfig.TenantId; es.DateFilter = settings.DateFilter;
existing.ClientId = _spConfig.ClientId; es.TimerHour = settings.TimerHour;
existing.ClientSecret = _spConfig.ClientSecret; es.TimerMinute = settings.TimerMinute;
} es.TimerEnabled = settings.TimerEnabled;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
Snackbar.Add("SharePoint Konfiguration gespeichert", Severity.Success); message = "Settings gespeichert.";
} }
private async Task TestSharePoint() private async Task TestSharePointAsync()
{ {
_testingSp = true;
try try
{ {
await SpService.TestConnectionAsync( var ok = await SharePointUploadService.TestConnectionAsync(
_spConfig.TenantId, _spConfig.ClientId, _spConfig.ClientSecret, _spConfig.SiteUrl); sharePointConfig.SiteUrl,
Snackbar.Add("SharePoint Verbindung erfolgreich!", Severity.Success); sharePointConfig.TenantId,
sharePointConfig.ClientId,
sharePointClientSecret);
message = ok ? "SharePoint Verbindung OK." : "SharePoint Verbindung fehlgeschlagen.";
} }
catch (Exception ex) catch (Exception ex)
{ {
Snackbar.Add($"Verbindung fehlgeschlagen: {ex.Message}", Severity.Error); message = $"SharePoint Test fehlgeschlagen: {ex.Message}";
} }
finally
{
_testingSp = false;
}
}
private async Task SaveExportSettings()
{
using var db = await DbFactory.CreateDbContextAsync();
var existing = await db.ExportSettings.FirstOrDefaultAsync();
if (existing is null)
{
db.ExportSettings.Add(_exportSettings);
}
else
{
existing.DateFilter = _exportSettings.DateFilter;
existing.TimerHour = _exportSettings.TimerHour;
existing.TimerMinute = _exportSettings.TimerMinute;
existing.TimerEnabled = _exportSettings.TimerEnabled;
}
await db.SaveChangesAsync();
TimerService.Recalculate();
Snackbar.Add("Export Einstellungen gespeichert", Severity.Success);
} }
} }
@@ -1,31 +1,74 @@
@page "/standorte" @page "/standorte"
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using TrafagSalesExporter.Data
@using TrafagSalesExporter.Services
@inject IDbContextFactory<AppDbContext> DbFactory @inject IDbContextFactory<AppDbContext> DbFactory
@inject HanaQueryService HanaService @inject HanaQueryService HanaQueryService
@inject ISnackbar Snackbar @inject CryptoService CryptoService
@inject IDialogService DialogService
<PageTitle>Standorte</PageTitle> <PageTitle>Standorte</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Standorte</MudText> <MudText Typo="Typo.h4" Class="mb-4">Standorte</MudText>
<MudText Typo="Typo.h5" Class="mb-2">HANA Server</MudText> <MudPaper Class="pa-4 mb-4">
<MudPaper Class="pa-4 mb-6" Elevation="1"> <MudText Typo="Typo.h6">Neuen Standort hinzufügen</MudText>
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" <MudGrid>
OnClick="AddServer" Class="mb-3"> <MudItem xs="12" md="3"><MudSelect T="int" Label="Server" @bind-Value="newSite.HanaServerId">@foreach (var srv in servers) { <MudSelectItem Value="@srv.Id">@srv.Name</MudSelectItem> }</MudSelect></MudItem>
Server hinzufügen <MudItem xs="12" md="2"><MudTextField Label="Schema" @bind-Value="newSite.Schema" /></MudItem>
</MudButton> <MudItem xs="12" md="2"><MudTextField Label="TSC" @bind-Value="newSite.TSC" /></MudItem>
<MudItem xs="12" md="3"><MudTextField Label="Land" @bind-Value="newSite.Land" /></MudItem>
<MudItem xs="12" md="1"><MudCheckBox Label="Aktiv" @bind-Value="newSite.IsActive" /></MudItem>
<MudItem xs="12" md="1"><MudButton Variant="Variant.Filled" OnClick="AddSiteAsync">Speichern</MudButton></MudItem>
</MudGrid>
</MudPaper>
<MudTable Items="_servers" Dense Hover Striped> <MudTable Items="sites" Dense="true" Hover="true" Class="mb-6">
<HeaderContent> <HeaderContent>
<MudTh>Name</MudTh> <MudTh>Land</MudTh><MudTh>TSC</MudTh><MudTh>Schema</MudTh><MudTh>Server</MudTh><MudTh>Aktiv</MudTh><MudTh>Aktion</MudTh>
<MudTh>Host</MudTh> </HeaderContent>
<MudTh>Port</MudTh> <RowTemplate>
<MudTh>Username</MudTh> <MudTd>@context.Land</MudTd>
<MudTh>Verbindungsstatus</MudTh> <MudTd>@context.TSC</MudTd>
<MudTh>Aktionen</MudTh> <MudTd>@context.Schema</MudTd>
<MudTd>@context.HanaServer?.Name</MudTd>
<MudTd>@(context.IsActive ? "Ja" : "Nein")</MudTd>
<MudTd>
<MudButton Size="Size.Small" Variant="Variant.Outlined" OnClick="() => EditSite(context)">Edit</MudButton>
<MudButton Size="Size.Small" Color="Color.Error" Variant="Variant.Text" OnClick="() => DeleteSiteAsync(context.Id)">Delete</MudButton>
</MudTd>
</RowTemplate>
</MudTable>
@if (editingSite is not null)
{
<MudPaper Class="pa-4 mb-4">
<MudText Typo="Typo.h6">Standort bearbeiten</MudText>
<MudGrid>
<MudItem xs="12" md="3"><MudSelect T="int" Label="Server" @bind-Value="editingSite.HanaServerId">@foreach (var srv in servers) { <MudSelectItem Value="@srv.Id">@srv.Name</MudSelectItem> }</MudSelect></MudItem>
<MudItem xs="12" md="2"><MudTextField Label="Schema" @bind-Value="editingSite.Schema" /></MudItem>
<MudItem xs="12" md="2"><MudTextField Label="TSC" @bind-Value="editingSite.TSC" /></MudItem>
<MudItem xs="12" md="3"><MudTextField Label="Land" @bind-Value="editingSite.Land" /></MudItem>
<MudItem xs="12" md="1"><MudCheckBox Label="Aktiv" @bind-Value="editingSite.IsActive" /></MudItem>
<MudItem xs="12" md="1"><MudButton Variant="Variant.Filled" OnClick="SaveSiteAsync">Update</MudButton></MudItem>
</MudGrid>
</MudPaper>
}
<MudDivider Class="my-4" />
<MudText Typo="Typo.h5" Class="mb-3">HANA Server</MudText>
<MudPaper Class="pa-4 mb-4">
<MudGrid>
<MudItem xs="12" md="2"><MudTextField Label="Name" @bind-Value="newServer.Name" /></MudItem>
<MudItem xs="12" md="3"><MudTextField Label="Host" @bind-Value="newServer.Host" /></MudItem>
<MudItem xs="12" md="1"><MudNumericField T="int" Label="Port" @bind-Value="newServer.Port" /></MudItem>
<MudItem xs="12" md="2"><MudTextField Label="Username" @bind-Value="newServer.Username" /></MudItem>
<MudItem xs="12" md="2"><MudTextField Label="Password" InputType="InputType.Password" @bind-Value="newServerPassword" /></MudItem>
<MudItem xs="12" md="2"><MudButton Variant="Variant.Filled" OnClick="AddServerAsync">Server speichern</MudButton></MudItem>
</MudGrid>
</MudPaper>
<MudTable Items="servers" Dense="true" Hover="true">
<HeaderContent>
<MudTh>Name</MudTh><MudTh>Host</MudTh><MudTh>Port</MudTh><MudTh>Username</MudTh><MudTh>Aktion</MudTh>
</HeaderContent> </HeaderContent>
<RowTemplate> <RowTemplate>
<MudTd>@context.Name</MudTd> <MudTd>@context.Name</MudTd>
@@ -33,321 +76,140 @@
<MudTd>@context.Port</MudTd> <MudTd>@context.Port</MudTd>
<MudTd>@context.Username</MudTd> <MudTd>@context.Username</MudTd>
<MudTd> <MudTd>
@if (_connectionStatus.TryGetValue(context.Id, out var status)) <MudButton Size="Size.Small" Variant="Variant.Outlined" OnClick="() => TestServerAsync(context)">Verbindung testen</MudButton>
{ <MudButton Size="Size.Small" Color="Color.Error" Variant="Variant.Text" OnClick="() => DeleteServerAsync(context.Id)">Delete</MudButton>
<MudTooltip Text="@BuildStatusTooltip(status)">
<MudChip Color="@(status.Success ? Color.Success : Color.Error)" Variant="Variant.Filled" Size="Size.Small">
@(status.Success ? "OK" : "Fehler") - @status.Stage
</MudChip>
</MudTooltip>
}
else
{
<MudChip Color="Color.Default" Variant="Variant.Outlined" Size="Size.Small">Nicht getestet</MudChip>
}
</MudTd>
<MudTd>
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small"
OnClick="() => EditServer(context)" />
<MudIconButton Icon="@Icons.Material.Filled.NetworkCheck" Size="Size.Small" Color="Color.Info"
OnClick="() => TestServerConnection(context)" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error"
OnClick="() => DeleteServer(context)" />
</MudTd> </MudTd>
</RowTemplate> </RowTemplate>
</MudTable> </MudTable>
</MudPaper>
<MudText Typo="Typo.h5" Class="mb-2">Standorte (Sites)</MudText> <MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Class="mt-4">@message</MudAlert>
<MudPaper Class="pa-4" Elevation="1">
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add"
OnClick="AddSite" Class="mb-3">
Neuen Standort hinzufügen
</MudButton>
<MudTable Items="_sites" Dense Hover Striped>
<HeaderContent>
<MudTh>Land</MudTh>
<MudTh>TSC</MudTh>
<MudTh>Schema</MudTh>
<MudTh>Quellsystem</MudTh>
<MudTh>Server</MudTh>
<MudTh>Aktiv</MudTh>
<MudTh>Aktionen</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Land</MudTd>
<MudTd>@context.TSC</MudTd>
<MudTd>@context.Schema</MudTd>
<MudTd>@context.SourceSystem</MudTd>
<MudTd>@(context.HanaServer?.Name ?? "-")</MudTd>
<MudTd>
@if (context.IsActive)
{
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
}
else
{
<MudIcon Icon="@Icons.Material.Filled.Cancel" Color="Color.Default" Size="Size.Small" />
}
</MudTd>
<MudTd>
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small"
OnClick="() => EditSite(context)" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error"
OnClick="() => DeleteSite(context)" />
</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>
<MudDialog @bind-Visible="_serverDialogVisible" Options="_dialogOptions">
<TitleContent>
<MudText Typo="Typo.h6">@(_editingServer.Id == 0 ? "Server hinzufügen" : "Server bearbeiten")</MudText>
</TitleContent>
<DialogContent>
<MudTextField @bind-Value="_editingServer.Name" Label="Name" Required />
<MudTextField @bind-Value="_editingServer.Host" Label="Host" Required
HelperText="IP oder Hostname (ohne Protokoll)" />
<MudNumericField @bind-Value="_editingServer.Port" Label="Port"
HelperText="Typisch 30015 (Tenant), 30013 (SystemDB), 3xx15 für Instanz xx" />
<MudTextField @bind-Value="_editingServer.Username" Label="Username" />
<MudTextField @bind-Value="_editingServer.Password" Label="Password" InputType="InputType.Password" />
<MudTextField @bind-Value="_editingServer.DatabaseName" Label="Database Name (MDC)"
HelperText="Nur bei Multi-Tenant Setup angeben, sonst leer lassen" />
<MudSwitch @bind-Value="_editingServer.UseSsl" Label="SSL/TLS verwenden (encrypt=true)" Color="Color.Primary" />
<MudSwitch @bind-Value="_editingServer.ValidateCertificate" Label="SSL-Zertifikat validieren" Color="Color.Primary"
Disabled="!_editingServer.UseSsl" />
<MudTextField @bind-Value="_editingServer.AdditionalParams" Label="Zusätzliche Parameter"
HelperText="Optional, z.B. sslCryptoProvider=openssl;communicationTimeout=0" />
</DialogContent>
<DialogActions>
<MudButton OnClick="() => _serverDialogVisible = false">Abbrechen</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveServer">Speichern</MudButton>
</DialogActions>
</MudDialog>
<MudDialog @bind-Visible="_siteDialogVisible" Options="_dialogOptions">
<TitleContent>
<MudText Typo="Typo.h6">@(_editingSite.Id == 0 ? "Standort hinzufügen" : "Standort bearbeiten")</MudText>
</TitleContent>
<DialogContent>
<MudSelect @bind-Value="_editingSite.HanaServerId" Label="Server" Required>
@foreach (var s in _servers)
{
<MudSelectItem Value="s.Id">@s.Name</MudSelectItem>
}
</MudSelect>
<MudTextField @bind-Value="_editingSite.Schema" Label="Schema" Required />
<MudTextField @bind-Value="_editingSite.TSC" Label="TSC" Required />
<MudTextField @bind-Value="_editingSite.Land" Label="Land" Required />
<MudSelect @bind-Value="_editingSite.SourceSystem" Label="Quellsystem" Required>
@foreach (var system in _sourceSystems)
{
<MudSelectItem Value="system">@system</MudSelectItem>
}
</MudSelect>
<MudCheckBox @bind-Value="_editingSite.IsActive" Label="Aktiv" />
</DialogContent>
<DialogActions>
<MudButton OnClick="() => _siteDialogVisible = false">Abbrechen</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveSite">Speichern</MudButton>
</DialogActions>
</MudDialog>
@code { @code {
private readonly string[] _sourceSystems = ["SAP", "BI1", "SAGE"]; private List<Site> sites = [];
private readonly Dictionary<int, ConnectionTestResult> _connectionStatus = new(); private List<HanaServer> servers = [];
private List<HanaServer> _servers = new(); private Site newSite = new() { IsActive = true };
private List<Site> _sites = new(); private Site? editingSite;
private HanaServer _editingServer = new(); private HanaServer newServer = new() { Port = 30015 };
private Site _editingSite = new(); private string newServerPassword = string.Empty;
private bool _serverDialogVisible; private string message = "Bereit.";
private bool _siteDialogVisible;
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync() => await LoadAsync();
{
await LoadDataAsync();
}
private async Task LoadDataAsync() private async Task LoadAsync()
{ {
using var db = await DbFactory.CreateDbContextAsync(); await using var db = await DbFactory.CreateDbContextAsync();
_servers = await db.HanaServers.OrderBy(s => s.Name).ToListAsync(); servers = await db.HanaServers.OrderBy(x => x.Name).ToListAsync();
_sites = await db.Sites.Include(s => s.HanaServer).OrderBy(s => s.Land).ToListAsync(); sites = await db.Sites.Include(x => x.HanaServer).OrderBy(x => x.Land).ToListAsync();
}
private void AddServer() if (servers.Count > 0 && newSite.HanaServerId == 0)
{ {
_editingServer = new HanaServer { Port = 30015 }; newSite.HanaServerId = servers[0].Id;
_serverDialogVisible = true;
}
private void EditServer(HanaServer server)
{
_editingServer = new HanaServer
{
Id = server.Id,
Name = server.Name,
Host = server.Host,
Port = server.Port,
Username = server.Username,
Password = server.Password,
DatabaseName = server.DatabaseName,
UseSsl = server.UseSsl,
ValidateCertificate = server.ValidateCertificate,
AdditionalParams = server.AdditionalParams
};
_serverDialogVisible = true;
}
private async Task SaveServer()
{
using var db = await DbFactory.CreateDbContextAsync();
if (_editingServer.Id == 0)
{
db.HanaServers.Add(_editingServer);
}
else
{
var existing = await db.HanaServers.FindAsync(_editingServer.Id);
if (existing is not null)
{
existing.Name = _editingServer.Name;
existing.Host = _editingServer.Host;
existing.Port = _editingServer.Port;
existing.Username = _editingServer.Username;
existing.Password = _editingServer.Password;
existing.DatabaseName = _editingServer.DatabaseName;
existing.UseSsl = _editingServer.UseSsl;
existing.ValidateCertificate = _editingServer.ValidateCertificate;
existing.AdditionalParams = _editingServer.AdditionalParams;
} }
} }
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(); await db.SaveChangesAsync();
_serverDialogVisible = false; newSite = new Site { IsActive = true, HanaServerId = servers.FirstOrDefault()?.Id ?? 0 };
await LoadDataAsync(); await LoadAsync();
Snackbar.Add("Server gespeichert", Severity.Success);
}
private async Task DeleteServer(HanaServer server)
{
var result = await DialogService.ShowMessageBox(
"Server löschen",
$"Server '{server.Name}' wirklich löschen?",
yesText: "Löschen", cancelText: "Abbrechen");
if (result != true) return;
using var db = await DbFactory.CreateDbContextAsync();
var entity = await db.HanaServers.FindAsync(server.Id);
if (entity is not null)
{
db.HanaServers.Remove(entity);
await db.SaveChangesAsync();
}
await LoadDataAsync();
Snackbar.Add("Server gelöscht", Severity.Info);
}
private async Task TestServerConnection(HanaServer server)
{
var result = await Task.Run(() => HanaService.TestConnectionDetailed(server));
_connectionStatus[server.Id] = result;
if (result.Success)
{
Snackbar.Add($"Verbindung zu '{server.Name}' erfolgreich.", Severity.Success);
}
else
{
Snackbar.Add($"{server.Name}: {result.ExceptionType} - {result.ErrorMessage}", Severity.Error);
}
}
private static string BuildStatusTooltip(ConnectionTestResult status)
{
var stamp = status.TestedAtUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss");
if (status.Success)
return $"Letzter Test: {stamp}\nStage: {status.Stage}\n{status.ConnectionStringPreview}";
return $"Letzter Test: {stamp}\nStage: {status.Stage}\nFehler: {status.ErrorMessage}\n{status.ConnectionStringPreview}";
}
private void AddSite()
{
_editingSite = new Site
{
IsActive = true,
SourceSystem = "SAP",
HanaServerId = _servers.FirstOrDefault()?.Id ?? 0
};
_siteDialogVisible = true;
} }
private void EditSite(Site site) private void EditSite(Site site)
{ {
_editingSite = new Site editingSite = new Site
{ {
Id = site.Id, Id = site.Id,
HanaServerId = site.HanaServerId, HanaServerId = site.HanaServerId,
Schema = site.Schema, Schema = site.Schema,
TSC = site.TSC, TSC = site.TSC,
Land = site.Land, Land = site.Land,
SourceSystem = string.IsNullOrWhiteSpace(site.SourceSystem) ? "SAP" : site.SourceSystem,
IsActive = site.IsActive IsActive = site.IsActive
}; };
_siteDialogVisible = true;
} }
private async Task SaveSite() private async Task SaveSiteAsync()
{ {
using var db = await DbFactory.CreateDbContextAsync(); if (editingSite is null)
if (_editingSite.Id == 0)
{ {
db.Sites.Add(_editingSite); return;
}
else
{
var existing = await db.Sites.FindAsync(_editingSite.Id);
if (existing is not null)
{
existing.HanaServerId = _editingSite.HanaServerId;
existing.Schema = _editingSite.Schema;
existing.TSC = _editingSite.TSC;
existing.Land = _editingSite.Land;
existing.SourceSystem = _editingSite.SourceSystem;
existing.IsActive = _editingSite.IsActive;
}
} }
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(); await db.SaveChangesAsync();
_siteDialogVisible = false; editingSite = null;
await LoadDataAsync(); await LoadAsync();
Snackbar.Add("Standort gespeichert", Severity.Success);
} }
private async Task DeleteSite(Site site) private async Task DeleteSiteAsync(int id)
{ {
var result = await DialogService.ShowMessageBox( await using var db = await DbFactory.CreateDbContextAsync();
"Standort löschen", var site = await db.Sites.SingleAsync(x => x.Id == id);
$"Standort '{site.Land}' wirklich löschen?", db.Sites.Remove(site);
yesText: "Löschen", cancelText: "Abbrechen");
if (result != true) return;
using var db = await DbFactory.CreateDbContextAsync();
var entity = await db.Sites.FindAsync(site.Id);
if (entity is not null)
{
db.Sites.Remove(entity);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await LoadAsync();
} }
await LoadDataAsync(); private async Task AddServerAsync()
Snackbar.Add("Standort gelöscht", Severity.Info); {
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);
} }
} }
@@ -1,137 +0,0 @@
@page "/transformations"
@using Microsoft.EntityFrameworkCore
@using System.Reflection
@using TrafagSalesExporter.Data
@using TrafagSalesExporter.Models
@inject IDbContextFactory<AppDbContext> DbFactory
@inject ISnackbar Snackbar
<PageTitle>Transformationen</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Transformer Ansicht</MudText>
<MudText Typo="Typo.body1" Class="mb-4">Definiere pro Quellsystem (SAP, BI1, SAGE) Feld-Remapping und Transformationen.</MudText>
<MudPaper Class="pa-4" Elevation="1">
<MudStack Row="true" Spacing="2" Class="mb-3">
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="AddRule">
Regel hinzufügen
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" StartIcon="@Icons.Material.Filled.Save" OnClick="SaveAllAsync">
Alle speichern
</MudButton>
</MudStack>
<MudTable Items="_rules" Dense Hover Striped>
<HeaderContent>
<MudTh>Aktiv</MudTh>
<MudTh>System</MudTh>
<MudTh>Source</MudTh>
<MudTh>Target</MudTh>
<MudTh>Typ</MudTh>
<MudTh>Argument</MudTh>
<MudTh>Sort</MudTh>
<MudTh>Aktionen</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd><MudCheckBox @bind-Value="context.IsActive" /></MudTd>
<MudTd>
<MudSelect T="string" Value="@context.SourceSystem" ValueChanged="@(v => context.SourceSystem = v)" Dense>
@foreach (var system in _systems)
{
<MudSelectItem Value="system">@system</MudSelectItem>
}
</MudSelect>
</MudTd>
<MudTd>
<MudSelect T="string" Value="@context.SourceField" ValueChanged="@(v => context.SourceField = v)" Dense>
@foreach (var field in _recordFields)
{
<MudSelectItem Value="field">@field</MudSelectItem>
}
</MudSelect>
</MudTd>
<MudTd>
<MudSelect T="string" Value="@context.TargetField" ValueChanged="@(v => context.TargetField = v)" Dense>
@foreach (var field in _recordFields)
{
<MudSelectItem Value="field">@field</MudSelectItem>
}
</MudSelect>
</MudTd>
<MudTd>
<MudSelect T="string" Value="@context.TransformationType" ValueChanged="@(v => context.TransformationType = v)" Dense>
@foreach (var type in _types)
{
<MudSelectItem Value="type">@type</MudSelectItem>
}
</MudSelect>
</MudTd>
<MudTd>
<MudTextField Value="@context.Argument" ValueChanged="@(v => context.Argument = v)" Dense
HelperText="Replace: alt=>neu" />
</MudTd>
<MudTd>
<MudNumericField T="int" Value="@context.SortOrder" ValueChanged="@(v => context.SortOrder = v)" Dense />
</MudTd>
<MudTd>
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Size="Size.Small"
OnClick="() => RemoveRule(context)" />
</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>
@code {
private readonly string[] _systems = ["SAP", "BI1", "SAGE"];
private readonly string[] _types = ["Copy", "Uppercase", "Lowercase", "Prefix", "Suffix", "Replace", "Constant"];
private readonly string[] _recordFields = typeof(SalesRecord)
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Select(p => p.Name)
.OrderBy(n => n)
.ToArray();
private List<FieldTransformationRule> _rules = new();
protected override async Task OnInitializedAsync()
{
await LoadAsync();
}
private async Task LoadAsync()
{
using var db = await DbFactory.CreateDbContextAsync();
_rules = await db.FieldTransformationRules.OrderBy(r => r.SortOrder).ThenBy(r => r.Id).ToListAsync();
}
private void AddRule()
{
var nextSort = _rules.Count == 0 ? 10 : _rules.Max(r => r.SortOrder) + 10;
_rules.Add(new FieldTransformationRule
{
SourceSystem = "SAP",
SourceField = nameof(SalesRecord.Material),
TargetField = nameof(SalesRecord.Material),
TransformationType = "Copy",
SortOrder = nextSort,
IsActive = true
});
}
private void RemoveRule(FieldTransformationRule rule)
{
_rules.Remove(rule);
}
private async Task SaveAllAsync()
{
using var db = await DbFactory.CreateDbContextAsync();
db.FieldTransformationRules.RemoveRange(db.FieldTransformationRules);
await db.SaveChangesAsync();
db.FieldTransformationRules.AddRange(_rules);
await db.SaveChangesAsync();
Snackbar.Add("Transformationsregeln gespeichert.", Severity.Success);
await LoadAsync();
}
}
+3 -1
View File
@@ -1,6 +1,8 @@
@using TrafagSalesExporter.Components.Layout
<Router AppAssembly="typeof(Program).Assembly"> <Router AppAssembly="typeof(Program).Assembly">
<Found Context="routeData"> <Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" /> <RouteView RouteData="routeData" DefaultLayout="typeof(MainLayout)" />
<FocusOnNavigate RouteData="routeData" Selector="h1" /> <FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found> </Found>
</Router> </Router>
@@ -1,9 +1,13 @@
@using System.Net.Http @using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing @using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web @using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop @using Microsoft.JSInterop
@using MudBlazor @using MudBlazor
@using TrafagSalesExporter
@using TrafagSalesExporter.Components @using TrafagSalesExporter.Components
@using TrafagSalesExporter.Components.Layout
@using TrafagSalesExporter.Models @using TrafagSalesExporter.Models
@using TrafagSalesExporter.Services
@using TrafagSalesExporter.Data
+65 -81
View File
@@ -1,107 +1,91 @@
using System.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Models; using TrafagSalesExporter.Models;
using TrafagSalesExporter.Services;
namespace TrafagSalesExporter.Data; namespace TrafagSalesExporter.Data;
public class AppDbContext : DbContext public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{ {
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<HanaServer> HanaServers => Set<HanaServer>(); public DbSet<HanaServer> HanaServers => Set<HanaServer>();
public DbSet<Site> Sites => Set<Site>(); public DbSet<Site> Sites => Set<Site>();
public DbSet<SharePointConfig> SharePointConfigs => Set<SharePointConfig>(); public DbSet<SharePointConfig> SharePointConfigs => Set<SharePointConfig>();
public DbSet<ExportSettings> ExportSettings => Set<ExportSettings>(); public DbSet<ExportSettings> ExportSettings => Set<ExportSettings>();
public DbSet<ExportLog> ExportLogs => Set<ExportLog>(); public DbSet<ExportLog> ExportLogs => Set<ExportLog>();
public DbSet<FieldTransformationRule> FieldTransformationRules => Set<FieldTransformationRule>();
/// <summary> protected override void OnModelCreating(ModelBuilder modelBuilder)
/// Fügt Spalten zu existierenden Tabellen hinzu, die bei neueren Versionen
/// hinzugekommen sind. EnsureCreated aktualisiert das Schema nicht automatisch.
/// </summary>
public static void EnsureSchema(AppDbContext db)
{ {
AddColumnIfMissing(db, "HanaServers", "DatabaseName", "TEXT NOT NULL DEFAULT ''"); modelBuilder.Entity<HanaServer>().HasIndex(x => x.Name).IsUnique();
AddColumnIfMissing(db, "HanaServers", "UseSsl", "INTEGER NOT NULL DEFAULT 0");
AddColumnIfMissing(db, "HanaServers", "ValidateCertificate", "INTEGER NOT NULL DEFAULT 0"); modelBuilder.Entity<Site>()
AddColumnIfMissing(db, "HanaServers", "AdditionalParams", "TEXT NOT NULL DEFAULT ''"); .HasOne(x => x.HanaServer)
AddColumnIfMissing(db, "Sites", "SourceSystem", "TEXT NOT NULL DEFAULT 'SAP'"); .WithMany(x => x.Sites)
EnsureTransformationTable(db); .HasForeignKey(x => x.HanaServerId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<ExportLog>()
.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();
} }
private static void AddColumnIfMissing(AppDbContext db, string table, string column, string type) if (!await db.Sites.AnyAsync())
{ {
var conn = db.Database.GetDbConnection(); var internalServer = await db.HanaServers.SingleAsync(x => x.Name == "Internal");
if (conn.State != ConnectionState.Open) conn.Open(); var indiaServer = await db.HanaServers.SingleAsync(x => x.Name == "India");
bool exists = false;
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = $"PRAGMA table_info({table})";
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
if (string.Equals(reader["name"]?.ToString(), column, StringComparison.OrdinalIgnoreCase))
{
exists = true;
break;
}
}
}
if (!exists)
{
using var alter = conn.CreateCommand();
alter.CommandText = $"ALTER TABLE {table} ADD COLUMN {column} {type}";
alter.ExecuteNonQuery();
}
}
private static void EnsureTransformationTable(AppDbContext db)
{
var conn = db.Database.GetDbConnection();
if (conn.State != ConnectionState.Open) conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = @"
CREATE TABLE IF NOT EXISTS FieldTransformationRules (
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
SourceSystem TEXT NOT NULL DEFAULT 'SAP',
SourceField TEXT NOT NULL,
TargetField TEXT NOT NULL,
TransformationType TEXT NOT NULL,
Argument TEXT NOT NULL DEFAULT '',
SortOrder INTEGER NOT NULL DEFAULT 0,
IsActive INTEGER NOT NULL DEFAULT 1
);";
cmd.ExecuteNonQuery();
}
public static void SeedIfEmpty(AppDbContext db)
{
if (db.HanaServers.Any()) return;
var serverInternal = new HanaServer { Name = "Internal", Host = "travtrp0", Port = 30015, Username = "", Password = "" };
var serverIndia = new HanaServer { Name = "India", Host = "20.197.20.60", Port = 30015, Username = "", Password = "" };
db.HanaServers.AddRange(serverInternal, serverIndia);
db.SaveChanges();
db.Sites.AddRange( db.Sites.AddRange(
new Site { HanaServerId = serverInternal.Id, Schema = "fr01_p", TSC = "TRFR", Land = "Frankreich", IsActive = true }, new Site { HanaServerId = internalServer.Id, Schema = "fr01_p", TSC = "TRFR", Land = "Frankreich", IsActive = true },
new Site { HanaServerId = serverInternal.Id, Schema = "it01_p", TSC = "TRIT", Land = "Italien", IsActive = true }, new Site { HanaServerId = internalServer.Id, Schema = "it01_p", TSC = "TRIT", Land = "Italien", IsActive = true },
new Site { HanaServerId = serverInternal.Id, Schema = "us01_p", TSC = "TRUS", Land = "USA", IsActive = true }, new Site { HanaServerId = internalServer.Id, Schema = "us01_p", TSC = "TRUS", Land = "USA", IsActive = true },
new Site { HanaServerId = serverIndia.Id, Schema = "TRAFAG_LIVE", TSC = "TRIN", Land = "Indien", 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 db.SharePointConfigs.Add(new SharePointConfig
{ {
SiteUrl = "https://trafagag.sharepoint.com/sites/WorldwideBIPlatform", SiteUrl = "https://trafagag.sharepoint.com/sites/WorldwideBIPlatform",
ExportFolder = "/Shared Documents/Exports/", ExportFolder = "/Shared Documents/Exports/",
TenantId = "", TenantId = string.Empty,
ClientId = "", ClientId = string.Empty,
ClientSecret = "" EncryptedClientSecret = cryptoService.Encrypt(string.Empty)
}); });
await db.SaveChangesAsync();
}
if (!await db.ExportSettings.AnyAsync())
{
db.ExportSettings.Add(new ExportSettings db.ExportSettings.Add(new ExportSettings
{ {
DateFilter = "2025-01-01", DateFilter = "2025-01-01",
@@ -109,7 +93,7 @@ CREATE TABLE IF NOT EXISTS FieldTransformationRules (
TimerMinute = 0, TimerMinute = 0,
TimerEnabled = true TimerEnabled = true
}); });
await db.SaveChangesAsync();
db.SaveChanges(); }
} }
} }
+1 -6
View File
@@ -1,16 +1,11 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace TrafagSalesExporter.Models; namespace TrafagSalesExporter.Models;
public class ExportLog public class ExportLog
{ {
public int Id { get; set; } public int Id { get; set; }
public DateTime Timestamp { get; set; } public DateTime Timestamp { get; set; }
public int SiteId { get; set; } public int? SiteId { get; set; }
[ForeignKey(nameof(SiteId))]
public Site? Site { get; set; } public Site? Site { get; set; }
public string Land { get; set; } = string.Empty; public string Land { get; set; } = string.Empty;
public string TSC { get; set; } = string.Empty; public string TSC { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty; public string Status { get; set; } = string.Empty;
+1 -1
View File
@@ -5,6 +5,6 @@ public class ExportSettings
public int Id { get; set; } public int Id { get; set; }
public string DateFilter { get; set; } = "2025-01-01"; public string DateFilter { get; set; } = "2025-01-01";
public int TimerHour { get; set; } = 3; public int TimerHour { get; set; } = 3;
public int TimerMinute { get; set; } public int TimerMinute { get; set; } = 0;
public bool TimerEnabled { get; set; } = true; public bool TimerEnabled { get; set; } = true;
} }
@@ -1,26 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace TrafagSalesExporter.Models;
public class FieldTransformationRule
{
public int Id { get; set; }
[Required]
public string SourceSystem { get; set; } = "SAP";
[Required]
public string SourceField { get; set; } = nameof(SalesRecord.Material);
[Required]
public string TargetField { get; set; } = nameof(SalesRecord.Material);
[Required]
public string TransformationType { get; set; } = "Copy";
public string Argument { get; set; } = string.Empty;
public int SortOrder { get; set; }
public bool IsActive { get; set; } = true;
}
+3 -65
View File
@@ -12,73 +12,11 @@ public class HanaServer
[Required] [Required]
public string Host { get; set; } = string.Empty; public string Host { get; set; } = string.Empty;
public int Port { get; set; } = 30015; public int Port { get; set; }
public string Username { get; set; } = string.Empty; public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty; public string EncryptedPassword { get; set; } = string.Empty;
/// <summary> public List<Site> Sites { get; set; } = [];
/// Name der Tenant-Datenbank bei Multi-Tenant Database Container (MDC) Setups.
/// Leer lassen, wenn direkt auf einen Tenant-Port verbunden wird.
/// </summary>
public string DatabaseName { get; set; } = string.Empty;
/// <summary>
/// SSL/TLS Verschlüsselung aktivieren (encrypt=true).
/// </summary>
public bool UseSsl { get; set; }
/// <summary>
/// SSL-Zertifikat validieren. Bei self-signed Zertifikaten auf false setzen.
/// </summary>
public bool ValidateCertificate { get; set; }
/// <summary>
/// Zusätzliche Verbindungsparameter (Semikolon-getrennt), z.B. "sslCryptoProvider=openssl".
/// </summary>
public string AdditionalParams { get; set; } = string.Empty;
public string BuildConnectionString()
{
var parts = new List<string>
{
$"ServerNode={Host}:{Port}",
$"UserName={Username}",
$"Password={Password}"
};
if (!string.IsNullOrWhiteSpace(DatabaseName))
parts.Add($"DatabaseName={DatabaseName}");
if (UseSsl)
{
parts.Add("encrypt=true");
parts.Add($"sslValidateCertificate={(ValidateCertificate ? "true" : "false")}");
}
if (!string.IsNullOrWhiteSpace(AdditionalParams))
parts.Add(AdditionalParams.Trim().Trim(';'));
return string.Join(";", parts);
}
public string GetConnectionStringPreview()
{
var pwdMasked = string.IsNullOrEmpty(Password) ? "" : "***";
var copy = new HanaServer
{
Host = Host,
Port = Port,
Username = Username,
Password = pwdMasked,
DatabaseName = DatabaseName,
UseSsl = UseSsl,
ValidateCertificate = ValidateCertificate,
AdditionalParams = AdditionalParams
};
return copy.BuildConnectionString();
}
} }
+1 -1
View File
@@ -3,7 +3,7 @@ namespace TrafagSalesExporter.Models;
public class SalesRecord public class SalesRecord
{ {
public DateTime ExtractionDate { get; set; } public DateTime ExtractionDate { get; set; }
public string Tsc { get; set; } = string.Empty; public string TSC { get; set; } = string.Empty;
public string InvoiceNumber { get; set; } = string.Empty; public string InvoiceNumber { get; set; } = string.Empty;
public int PositionOnInvoice { get; set; } public int PositionOnInvoice { get; set; }
public string Material { get; set; } = string.Empty; public string Material { get; set; } = string.Empty;
+13 -2
View File
@@ -1,11 +1,22 @@
using System.ComponentModel.DataAnnotations;
namespace TrafagSalesExporter.Models; namespace TrafagSalesExporter.Models;
public class SharePointConfig public class SharePointConfig
{ {
public int Id { get; set; } public int Id { get; set; }
[Required]
public string SiteUrl { get; set; } = string.Empty; public string SiteUrl { get; set; } = string.Empty;
public string ExportFolder { get; set; } = string.Empty;
[Required]
public string ExportFolder { get; set; } = "/Shared Documents/Exports/";
[Required]
public string TenantId { get; set; } = string.Empty; public string TenantId { get; set; } = string.Empty;
[Required]
public string ClientId { get; set; } = string.Empty; public string ClientId { get; set; } = string.Empty;
public string ClientSecret { get; set; } = string.Empty;
public string EncryptedClientSecret { get; set; } = string.Empty;
} }
-6
View File
@@ -1,15 +1,12 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace TrafagSalesExporter.Models; namespace TrafagSalesExporter.Models;
public class Site public class Site
{ {
public int Id { get; set; } public int Id { get; set; }
public int HanaServerId { get; set; } public int HanaServerId { get; set; }
[ForeignKey(nameof(HanaServerId))]
public HanaServer? HanaServer { get; set; } public HanaServer? HanaServer { get; set; }
[Required] [Required]
@@ -21,8 +18,5 @@ public class Site
[Required] [Required]
public string Land { get; set; } = string.Empty; public string Land { get; set; } = string.Empty;
[Required]
public string SourceSystem { get; set; } = "SAP";
public bool IsActive { get; set; } = true; public bool IsActive { get; set; } = true;
} }
+11 -10
View File
@@ -13,30 +13,31 @@ builder.Services.AddMudServices();
builder.Services.AddDbContextFactory<AppDbContext>(options => builder.Services.AddDbContextFactory<AppDbContext>(options =>
options.UseSqlite("Data Source=trafag_exporter.db")); options.UseSqlite("Data Source=trafag_exporter.db"));
builder.Services.AddSingleton<HanaQueryService>(); builder.Services.AddScoped<CryptoService>();
builder.Services.AddSingleton<ExcelExportService>(); builder.Services.AddScoped<HanaQueryService>();
builder.Services.AddSingleton<SharePointUploadService>(); builder.Services.AddScoped<ExcelExportService>();
builder.Services.AddSingleton<RecordTransformationService>(); builder.Services.AddScoped<SharePointUploadService>();
builder.Services.AddSingleton<ExportOrchestrationService>(); builder.Services.AddScoped<ExportOrchestrationService>();
builder.Services.AddSingleton<TimerBackgroundService>(); builder.Services.AddHostedService<TimerBackgroundService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<TimerBackgroundService>());
var app = builder.Build(); var app = builder.Build();
using (var scope = app.Services.CreateScope()) using (var scope = app.Services.CreateScope())
{ {
var dbFactory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<AppDbContext>>(); var dbFactory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<AppDbContext>>();
using var db = await dbFactory.CreateDbContextAsync(); var cryptoService = scope.ServiceProvider.GetRequiredService<CryptoService>();
await using var db = await dbFactory.CreateDbContextAsync();
await db.Database.EnsureCreatedAsync(); await db.Database.EnsureCreatedAsync();
AppDbContext.EnsureSchema(db); await DbInitializer.SeedDefaultsAsync(db, cryptoService);
AppDbContext.SeedIfEmpty(db);
} }
if (!app.Environment.IsDevelopment()) if (!app.Environment.IsDevelopment())
{ {
app.UseExceptionHandler("/Error", createScopeForErrors: true);
app.UseHsts(); app.UseHsts();
} }
app.UseHttpsRedirection();
app.UseStaticFiles(); app.UseStaticFiles();
app.UseAntiforgery(); app.UseAntiforgery();
@@ -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);
}
}
@@ -5,14 +5,16 @@ namespace TrafagSalesExporter.Services;
public class ExcelExportService public class ExcelExportService
{ {
public string CreateExcelFile(string outputDirectory, string tsc, DateTime fileDate, List<SalesRecord> records) public string CreateFile(string baseDirectory, string land, string tsc, List<SalesRecord> records)
{ {
var outputDirectory = Path.Combine(baseDirectory, "exports", land);
Directory.CreateDirectory(outputDirectory); Directory.CreateDirectory(outputDirectory);
var fileName = $"Sales_{tsc}_{fileDate:yyyy-MM-dd}.xlsx";
var fullPath = Path.Combine(outputDirectory, fileName); var fileName = $"Sales_{tsc}_{DateTime.UtcNow:yyyy-MM-dd}.xlsx";
var filePath = Path.Combine(outputDirectory, fileName);
using var workbook = new XLWorkbook(); using var workbook = new XLWorkbook();
var ws = workbook.Worksheets.Add("Sales"); var ws = workbook.AddWorksheet("Sales");
var headers = new[] var headers = new[]
{ {
@@ -51,39 +53,39 @@ public class ExcelExportService
} }
var row = 2; var row = 2;
foreach (var record in records) foreach (var r in records)
{ {
ws.Cell(row, 1).Value = record.ExtractionDate.ToString("dd.MM.yyyy HH:mm:ss"); ws.Cell(row, 1).Value = r.ExtractionDate.ToString("dd.MM.yyyy HH:mm:ss");
ws.Cell(row, 2).Value = record.Tsc; ws.Cell(row, 2).Value = r.TSC;
ws.Cell(row, 3).Value = record.InvoiceNumber; ws.Cell(row, 3).Value = r.InvoiceNumber;
ws.Cell(row, 4).Value = record.PositionOnInvoice; ws.Cell(row, 4).Value = r.PositionOnInvoice;
ws.Cell(row, 5).Value = record.Material; ws.Cell(row, 5).Value = r.Material;
ws.Cell(row, 6).Value = record.Name; ws.Cell(row, 6).Value = r.Name;
ws.Cell(row, 7).Value = record.ProductGroup; ws.Cell(row, 7).Value = r.ProductGroup;
ws.Cell(row, 8).Value = record.Quantity; ws.Cell(row, 8).Value = r.Quantity;
ws.Cell(row, 9).Value = record.SupplierNumber; ws.Cell(row, 9).Value = r.SupplierNumber;
ws.Cell(row, 10).Value = record.SupplierName; ws.Cell(row, 10).Value = r.SupplierName;
ws.Cell(row, 11).Value = record.SupplierCountry; ws.Cell(row, 11).Value = r.SupplierCountry;
ws.Cell(row, 12).Value = record.CustomerNumber; ws.Cell(row, 12).Value = r.CustomerNumber;
ws.Cell(row, 13).Value = record.CustomerName; ws.Cell(row, 13).Value = r.CustomerName;
ws.Cell(row, 14).Value = record.CustomerCountry; ws.Cell(row, 14).Value = r.CustomerCountry;
ws.Cell(row, 15).Value = record.CustomerIndustry; ws.Cell(row, 15).Value = r.CustomerIndustry;
ws.Cell(row, 16).Value = record.StandardCost; ws.Cell(row, 16).Value = r.StandardCost;
ws.Cell(row, 17).Value = record.StandardCostCurrency; ws.Cell(row, 17).Value = r.StandardCostCurrency;
ws.Cell(row, 18).Value = record.PurchaseOrderNumber; ws.Cell(row, 18).Value = r.PurchaseOrderNumber;
ws.Cell(row, 19).Value = record.SalesPriceValue; ws.Cell(row, 19).Value = r.SalesPriceValue;
ws.Cell(row, 20).Value = record.SalesCurrency; ws.Cell(row, 20).Value = r.SalesCurrency;
ws.Cell(row, 21).Value = record.Incoterms2020; ws.Cell(row, 21).Value = r.Incoterms2020;
ws.Cell(row, 22).Value = record.SalesResponsibleEmployee; ws.Cell(row, 22).Value = r.SalesResponsibleEmployee;
ws.Cell(row, 23).Value = record.InvoiceDate?.ToString("dd.MM.yyyy") ?? string.Empty; ws.Cell(row, 23).Value = r.InvoiceDate?.ToString("dd.MM.yyyy") ?? string.Empty;
ws.Cell(row, 24).Value = record.OrderDate?.ToString("dd.MM.yyyy") ?? string.Empty; ws.Cell(row, 24).Value = r.OrderDate?.ToString("dd.MM.yyyy") ?? string.Empty;
ws.Cell(row, 25).Value = record.Land; ws.Cell(row, 25).Value = r.Land;
ws.Cell(row, 26).Value = record.DocumentType; ws.Cell(row, 26).Value = r.DocumentType;
row++; row++;
} }
ws.Columns().AdjustToContents(); ws.Columns().AdjustToContents();
workbook.SaveAs(fullPath); workbook.SaveAs(filePath);
return fullPath; return filePath;
} }
} }
@@ -1,171 +1,120 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using System.Diagnostics;
using TrafagSalesExporter.Data; using TrafagSalesExporter.Data;
using TrafagSalesExporter.Models; using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services; namespace TrafagSalesExporter.Services;
public class ExportOrchestrationService public class ExportOrchestrationService(
{
private readonly IDbContextFactory<AppDbContext> _dbFactory;
private readonly HanaQueryService _hanaService;
private readonly ExcelExportService _excelService;
private readonly SharePointUploadService _sharePointService;
private readonly RecordTransformationService _transformationService;
private readonly ILogger<ExportOrchestrationService> _logger;
public event Action? OnExportStatusChanged;
private readonly Dictionary<int, string> _runningExports = new();
private readonly object _lock = new();
public ExportOrchestrationService(
IDbContextFactory<AppDbContext> dbFactory, IDbContextFactory<AppDbContext> dbFactory,
HanaQueryService hanaService, CryptoService cryptoService,
ExcelExportService excelService, HanaQueryService hanaQueryService,
SharePointUploadService sharePointService, ExcelExportService excelExportService,
RecordTransformationService transformationService, SharePointUploadService sharePointUploadService)
ILogger<ExportOrchestrationService> logger) {
public async Task ExportAllActiveSitesAsync(CancellationToken ct = default)
{ {
_dbFactory = dbFactory; await using var db = await dbFactory.CreateDbContextAsync(ct);
_hanaService = hanaService; var siteIds = await db.Sites.Where(x => x.IsActive).Select(x => x.Id).ToListAsync(ct);
_excelService = excelService;
_sharePointService = sharePointService;
_transformationService = transformationService;
_logger = logger;
}
public bool IsExporting(int siteId) foreach (var siteId in siteIds)
{ {
lock (_lock) await ExportSiteAsync(siteId, ct);
{
return _runningExports.ContainsKey(siteId);
} }
} }
public string GetExportStatus(int siteId) public async Task ExportSiteAsync(int siteId, CancellationToken ct = default)
{ {
lock (_lock) var started = DateTime.UtcNow;
{
return _runningExports.TryGetValue(siteId, out var status) ? status : string.Empty;
}
}
public async Task ExportAllAsync() await using var db = await dbFactory.CreateDbContextAsync(ct);
{ var site = await db.Sites.Include(x => x.HanaServer).SingleAsync(x => x.Id == siteId, ct);
using var db = await _dbFactory.CreateDbContextAsync(); var settings = await db.ExportSettings.OrderBy(x => x.Id).FirstAsync(ct);
var sites = await db.Sites.Include(s => s.HanaServer).Where(s => s.IsActive).ToListAsync(); var sp = await db.SharePointConfigs.OrderBy(x => x.Id).FirstAsync(ct);
foreach (var site in sites)
{
await ExportSiteAsync(site);
}
}
public async Task ExportSiteByIdAsync(int siteId)
{
using var db = await _dbFactory.CreateDbContextAsync();
var site = await db.Sites.Include(s => s.HanaServer).FirstOrDefaultAsync(s => s.Id == siteId);
if (site is null) return;
await ExportSiteAsync(site);
}
private async Task ExportSiteAsync(Site site)
{
if (site.HanaServer is null) return;
lock (_lock)
{
if (_runningExports.ContainsKey(site.Id)) return;
_runningExports[site.Id] = "HANA Abfrage...";
}
NotifyChanged();
var sw = Stopwatch.StartNew();
var log = new ExportLog var log = new ExportLog
{ {
Timestamp = DateTime.Now, Timestamp = DateTime.UtcNow,
SiteId = site.Id, SiteId = site.Id,
Land = site.Land, Land = site.Land,
TSC = site.TSC TSC = site.TSC,
Status = "Error",
RowCount = 0,
FileName = string.Empty,
DurationSeconds = 0
}; };
try try
{ {
using var db = await _dbFactory.CreateDbContextAsync(); var hanaServer = site.HanaServer ?? throw new InvalidOperationException("HANA Server fehlt.");
var settings = await db.ExportSettings.FirstOrDefaultAsync() ?? new ExportSettings(); var hanaPassword = cryptoService.Decrypt(hanaServer.EncryptedPassword);
var spConfig = await db.SharePointConfigs.FirstOrDefaultAsync(); var clientSecret = cryptoService.Decrypt(sp.EncryptedClientSecret);
UpdateStatus(site.Id, "HANA Abfrage..."); var records = hanaQueryService.QuerySales(
var records = await Task.Run(() => _hanaService.GetSalesRecords( hanaServer.Host,
site.HanaServer, site.Schema, site.TSC, site.Land, settings.DateFilter)); hanaServer.Port,
hanaServer.Username,
hanaPassword,
site.Schema,
site.TSC,
site.Land,
settings.DateFilter);
UpdateStatus(site.Id, "Transformationen anwenden..."); var filePath = excelExportService.CreateFile(AppContext.BaseDirectory, site.Land, site.TSC, records);
var rules = await db.FieldTransformationRules
.Where(r => r.IsActive && r.SourceSystem == (string.IsNullOrWhiteSpace(site.SourceSystem) ? "SAP" : site.SourceSystem))
.OrderBy(r => r.SortOrder)
.ToListAsync();
_transformationService.Apply(records, rules);
UpdateStatus(site.Id, "Excel erstellen..."); await sharePointUploadService.UploadAsync(
var outputDir = Path.Combine(AppContext.BaseDirectory, "output"); sp.SiteUrl,
var filePath = _excelService.CreateExcelFile(outputDir, site.TSC, DateTime.UtcNow.Date, records); sp.ExportFolder,
var fileName = Path.GetFileName(filePath); sp.TenantId,
sp.ClientId,
clientSecret,
site.Land,
filePath);
if (spConfig is not null &&
!string.IsNullOrWhiteSpace(spConfig.TenantId) &&
!string.IsNullOrWhiteSpace(spConfig.ClientId) &&
!string.IsNullOrWhiteSpace(spConfig.ClientSecret))
{
UpdateStatus(site.Id, "SharePoint Upload...");
await _sharePointService.UploadAsync(
spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret,
spConfig.SiteUrl, spConfig.ExportFolder, site.Land, filePath);
}
sw.Stop();
log.Status = "OK"; log.Status = "OK";
log.RowCount = records.Count; log.RowCount = records.Count;
log.FileName = fileName; log.FileName = Path.GetFileName(filePath);
log.DurationSeconds = sw.Elapsed.TotalSeconds; log.ErrorMessage = null;
_logger.LogInformation("Export OK: {Land} ({TSC}) - {Rows} Zeilen in {Duration:F1}s",
site.Land, site.TSC, records.Count, sw.Elapsed.TotalSeconds);
} }
catch (Exception ex) catch (Exception ex)
{ {
sw.Stop();
log.Status = "Error";
log.ErrorMessage = ex.Message; log.ErrorMessage = ex.Message;
log.FileName = string.Empty;
log.DurationSeconds = sw.Elapsed.TotalSeconds;
_logger.LogError(ex, "Export Fehler: {Land} ({TSC})", site.Land, site.TSC);
} }
finally finally
{ {
using var db = await _dbFactory.CreateDbContextAsync(); log.DurationSeconds = (DateTime.UtcNow - started).TotalSeconds;
db.ExportLogs.Add(log); db.ExportLogs.Add(log);
await db.SaveChangesAsync(); await db.SaveChangesAsync(ct);
lock (_lock)
{
_runningExports.Remove(site.Id);
}
NotifyChanged();
} }
} }
private void UpdateStatus(int siteId, string status) public async Task<DateTime?> GetNextRunAsync(CancellationToken ct = default)
{ {
lock (_lock) 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)
{ {
_runningExports[siteId] = status; return null;
}
NotifyChanged();
} }
private void NotifyChanged() var now = DateTime.Now;
var next = new DateTime(now.Year, now.Month, now.Day, settings.TimerHour, settings.TimerMinute, 0);
if (next <= now)
{ {
OnExportStatusChanged?.Invoke(); next = next.AddDays(1);
}
return next;
}
public async Task<Dictionary<int, ExportLog?>> 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());
} }
} }
@@ -5,20 +5,19 @@ namespace TrafagSalesExporter.Services;
public class HanaQueryService public class HanaQueryService
{ {
public List<SalesRecord> GetSalesRecords(HanaServer server, public List<SalesRecord> QuerySales(string host, int port, string username, string password, string schema, string tsc, string land, string dateFilter)
string schema, string tsc, string land, string dateFilter)
{ {
var connectionString = server.BuildConnectionString(); var connectionString = $"ServerNode={host}:{port};UserName={username};Password={password}";
var result = new List<SalesRecord>(); var result = new List<SalesRecord>();
using var connection = new HanaConnection(connectionString); using var connection = new HanaConnection(connectionString);
connection.Open(); connection.Open();
var invoiceQuery = GetInvoiceQuery(schema, tsc, dateFilter); var invoiceQuery = GetInvoiceQuery(schema, tsc, dateFilter);
var creditNoteQuery = GetCreditNoteQuery(schema, tsc, dateFilter); var creditQuery = GetCreditNoteQuery(schema, tsc, dateFilter);
result.AddRange(ReadRecords(connection, invoiceQuery, land)); result.AddRange(Read(connection, invoiceQuery, land));
result.AddRange(ReadRecords(connection, creditNoteQuery, land)); result.AddRange(Read(connection, creditQuery, land));
foreach (var record in result) foreach (var record in result)
{ {
@@ -32,58 +31,26 @@ public class HanaQueryService
return result; return result;
} }
public ConnectionTestResult TestConnectionDetailed(HanaServer server) public bool TestConnection(string host, int port, string username, string password)
{ {
var testResult = new ConnectionTestResult var connectionString = $"ServerNode={host}:{port};UserName={username};Password={password}";
{
TestedAtUtc = DateTime.UtcNow,
ConnectionStringPreview = server.GetConnectionStringPreview(),
Stage = "Verbindungsaufbau"
};
try
{
var connectionString = server.BuildConnectionString();
using var connection = new HanaConnection(connectionString); using var connection = new HanaConnection(connectionString);
connection.Open(); connection.Open();
return connection.State == System.Data.ConnectionState.Open;
testResult.Stage = "Ping-Query";
using var command = new HanaCommand("SELECT 1 FROM DUMMY", connection);
command.ExecuteScalar();
testResult.Success = true;
testResult.Stage = "OK";
return testResult;
}
catch (Exception ex)
{
testResult.Success = false;
testResult.ErrorMessage = ex.Message;
testResult.ExceptionType = ex.GetType().Name;
return testResult;
}
} }
public void TestConnection(HanaServer server) private static List<SalesRecord> Read(HanaConnection connection, string query, string land)
{
var connectionString = server.BuildConnectionString();
using var connection = new HanaConnection(connectionString);
connection.Open();
}
private static List<SalesRecord> ReadRecords(HanaConnection connection, string query, string land)
{ {
var records = new List<SalesRecord>(); var records = new List<SalesRecord>();
using var cmd = new HanaCommand(query, connection);
using var command = new HanaCommand(query, connection); using var reader = cmd.ExecuteReader();
using var reader = command.ExecuteReader();
while (reader.Read()) while (reader.Read())
{ {
records.Add(new SalesRecord records.Add(new SalesRecord
{ {
ExtractionDate = reader.GetDateTime(reader.GetOrdinal("extraction_date")), ExtractionDate = reader.GetDateTime(reader.GetOrdinal("extraction_date")),
Tsc = reader.GetString(reader.GetOrdinal("tsc")), TSC = reader["tsc"]?.ToString() ?? string.Empty,
InvoiceNumber = reader["invoice_number"]?.ToString() ?? string.Empty, InvoiceNumber = reader["invoice_number"]?.ToString() ?? string.Empty,
PositionOnInvoice = Convert.ToInt32(reader["invoice_position"]), PositionOnInvoice = Convert.ToInt32(reader["invoice_position"]),
InvoiceDate = reader.IsDBNull(reader.GetOrdinal("invoice_date")) ? null : reader.GetDateTime(reader.GetOrdinal("invoice_date")), InvoiceDate = reader.IsDBNull(reader.GetOrdinal("invoice_date")) ? null : reader.GetDateTime(reader.GetOrdinal("invoice_date")),
@@ -205,13 +172,3 @@ LEFT JOIN {schema}.""OSLP"" emp ON h.""SlpCode"" = emp.""SlpCode""
WHERE h.""CANCELED"" = 'N' AND h.""DocDate"" >= '{dateFilter}' WHERE h.""CANCELED"" = 'N' AND h.""DocDate"" >= '{dateFilter}'
ORDER BY h.""DocDate"" DESC, h.""DocNum"", p.""LineNum"""; ORDER BY h.""DocDate"" DESC, h.""DocNum"", p.""LineNum""";
} }
public class ConnectionTestResult
{
public bool Success { get; set; }
public DateTime TestedAtUtc { get; set; }
public string Stage { get; set; } = string.Empty;
public string ErrorMessage { get; set; } = string.Empty;
public string ExceptionType { get; set; } = string.Empty;
public string ConnectionStringPreview { get; set; } = string.Empty;
}
@@ -1,92 +0,0 @@
using System.Reflection;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public class RecordTransformationService
{
private static readonly Dictionary<string, PropertyInfo> PropertyMap = typeof(SalesRecord)
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.ToDictionary(p => p.Name, p => p, StringComparer.OrdinalIgnoreCase);
public void Apply(List<SalesRecord> records, IEnumerable<FieldTransformationRule> rules)
{
var orderedRules = rules.Where(r => r.IsActive).OrderBy(r => r.SortOrder).ToList();
if (orderedRules.Count == 0 || records.Count == 0) return;
foreach (var record in records)
{
foreach (var rule in orderedRules)
{
ApplyRule(record, rule);
}
}
}
private static void ApplyRule(SalesRecord record, FieldTransformationRule rule)
{
if (!PropertyMap.TryGetValue(rule.SourceField, out var sourceProp)) return;
if (!PropertyMap.TryGetValue(rule.TargetField, out var targetProp)) return;
var sourceValue = sourceProp.GetValue(record);
object? result = rule.TransformationType switch
{
"Copy" => sourceValue,
"Uppercase" => sourceValue?.ToString()?.ToUpperInvariant(),
"Lowercase" => sourceValue?.ToString()?.ToLowerInvariant(),
"Prefix" => $"{rule.Argument}{sourceValue}",
"Suffix" => $"{sourceValue}{rule.Argument}",
"Replace" => ApplyReplace(sourceValue?.ToString(), rule.Argument),
"Constant" => rule.Argument,
_ => sourceValue
};
SetPropertyValue(record, targetProp, result);
}
private static string ApplyReplace(string? input, string? argument)
{
if (string.IsNullOrEmpty(input)) return string.Empty;
if (string.IsNullOrWhiteSpace(argument)) return input;
var parts = argument.Split("=>", 2, StringSplitOptions.TrimEntries);
if (parts.Length != 2) return input;
return input.Replace(parts[0], parts[1], StringComparison.OrdinalIgnoreCase);
}
private static void SetPropertyValue(SalesRecord record, PropertyInfo property, object? value)
{
try
{
if (property.PropertyType == typeof(string))
{
property.SetValue(record, value?.ToString() ?? string.Empty);
return;
}
if (property.PropertyType == typeof(int))
{
if (int.TryParse(value?.ToString(), out var parsedInt)) property.SetValue(record, parsedInt);
return;
}
if (property.PropertyType == typeof(decimal))
{
if (decimal.TryParse(value?.ToString(), out var parsedDecimal)) property.SetValue(record, parsedDecimal);
return;
}
if (property.PropertyType == typeof(DateTime?) || property.PropertyType == typeof(DateTime))
{
if (DateTime.TryParse(value?.ToString(), out var parsedDate)) property.SetValue(record, parsedDate);
return;
}
property.SetValue(record, value);
}
catch
{
// skip invalid conversion to keep export running
}
}
}
@@ -1,45 +1,95 @@
using Azure.Identity; using Azure.Identity;
using Microsoft.Graph; using Microsoft.Graph;
using Microsoft.Graph.Models;
namespace TrafagSalesExporter.Services; namespace TrafagSalesExporter.Services;
public class SharePointUploadService public class SharePointUploadService
{ {
public async Task UploadAsync(string tenantId, string clientId, string clientSecret, public async Task UploadAsync(string siteUrl, string exportFolder, string tenantId, string clientId, string clientSecret, string land, string localFilePath)
string siteUrl, string exportFolder, string land, string localFilePath)
{ {
var credential = new ClientSecretCredential(tenantId, clientId, clientSecret); var graph = CreateGraphClient(tenantId, clientId, clientSecret);
var graphClient = new GraphServiceClient(credential, ["https://graph.microsoft.com/.default"]); var (siteId, driveId) = await ResolveSiteAndDriveAsync(graph, siteUrl);
var uri = new Uri(siteUrl); var folderPath = $"{exportFolder.Trim('/')}/{land}";
var sitePath = uri.AbsolutePath; await EnsureFolderPathAsync(graph, driveId, folderPath);
var site = await graphClient.Sites[$"{uri.Host}:{sitePath}"].GetAsync();
if (site?.Id is null)
throw new InvalidOperationException("SharePoint Site konnte nicht gefunden werden.");
var drive = await graphClient.Sites[site.Id].Drive.GetAsync();
if (drive?.Id is null)
throw new InvalidOperationException("SharePoint Dokumentenbibliothek konnte nicht gefunden werden.");
var fileName = Path.GetFileName(localFilePath); var fileName = Path.GetFileName(localFilePath);
var folderPath = exportFolder.Trim('/').Trim(); var remotePath = $"{folderPath}/{fileName}";
var remotePath = $"{folderPath}/{land}/{fileName}";
await using var stream = File.OpenRead(localFilePath); await using var stream = File.OpenRead(localFilePath);
await graphClient.Drives[drive.Id].Root.ItemWithPath(remotePath).Content.PutAsync(stream); await graph.Drives[driveId].Root.ItemWithPath(remotePath).Content.PutAsync(stream);
} }
public async Task TestConnectionAsync(string tenantId, string clientId, string clientSecret, string siteUrl) public async Task<bool> 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); var credential = new ClientSecretCredential(tenantId, clientId, clientSecret);
var graphClient = new GraphServiceClient(credential, ["https://graph.microsoft.com/.default"]); 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 uri = new Uri(siteUrl);
var sitePath = uri.AbsolutePath; var site = await graph.Sites[$"{uri.Host}:{uri.AbsolutePath}"].GetAsync();
var site = await graphClient.Sites[$"{uri.Host}:{sitePath}"].GetAsync();
if (site?.Id is null) if (site?.Id is null)
throw new InvalidOperationException("SharePoint Site konnte nicht gefunden werden."); {
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<string, object>
{
["@microsoft.graph.conflictBehavior"] = "replace"
}
});
}
}
} }
} }
@@ -3,65 +3,56 @@ using TrafagSalesExporter.Data;
namespace TrafagSalesExporter.Services; namespace TrafagSalesExporter.Services;
public class TimerBackgroundService : BackgroundService public class TimerBackgroundService(
IServiceScopeFactory scopeFactory,
ILogger<TimerBackgroundService> logger) : BackgroundService
{ {
private readonly IServiceProvider _serviceProvider; protected override async Task ExecuteAsync(CancellationToken stoppingToken)
private readonly ILogger<TimerBackgroundService> _logger;
private DateTime _nextRun = DateTime.MaxValue;
public DateTime NextRun => _nextRun;
public TimerBackgroundService(IServiceProvider serviceProvider, ILogger<TimerBackgroundService> logger)
{ {
_serviceProvider = serviceProvider; while (!stoppingToken.IsCancellationRequested)
_logger = logger;
}
public void Recalculate()
{ {
_ = RecalculateNextRunAsync(); try
}
private async Task RecalculateNextRunAsync()
{ {
var dbFactory = _serviceProvider.GetRequiredService<IDbContextFactory<AppDbContext>>(); using var scope = scopeFactory.CreateScope();
using var db = await dbFactory.CreateDbContextAsync(); var dbFactory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<AppDbContext>>();
var settings = await db.ExportSettings.FirstOrDefaultAsync(); var exportService = scope.ServiceProvider.GetRequiredService<ExportOrchestrationService>();
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) if (settings is null || !settings.TimerEnabled)
{ {
_nextRun = DateTime.MaxValue; await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
return; continue;
} }
var now = DateTime.Now; var now = DateTime.Now;
var todayRun = new DateTime(now.Year, now.Month, now.Day, settings.TimerHour, settings.TimerMinute, 0); var nextRun = new DateTime(now.Year, now.Month, now.Day, settings.TimerHour, settings.TimerMinute, 0);
_nextRun = todayRun <= now ? todayRun.AddDays(1) : todayRun; if (nextRun <= now)
{
nextRun = nextRun.AddDays(1);
} }
protected override async Task ExecuteAsync(CancellationToken stoppingToken) var delay = nextRun - now;
logger.LogInformation("Nächster automatischer Export um {NextRun}", nextRun);
await Task.Delay(delay, stoppingToken);
if (stoppingToken.IsCancellationRequested)
{ {
await RecalculateNextRunAsync(); break;
}
while (!stoppingToken.IsCancellationRequested) await exportService.ExportAllActiveSitesAsync(stoppingToken);
}
catch (TaskCanceledException)
{ {
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken); break;
if (DateTime.Now < _nextRun) continue;
_logger.LogInformation("Timer-Export gestartet um {Time}", DateTime.Now);
try
{
var orchestrator = _serviceProvider.GetRequiredService<ExportOrchestrationService>();
await orchestrator.ExportAllAsync();
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Fehler beim Timer-Export"); logger.LogError(ex, "Fehler im TimerBackgroundService");
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
} }
await RecalculateNextRunAsync();
} }
} }
} }
+5 -24
View File
@@ -1,39 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<!--
Pfad zur SAP HANA Client DLL (wird mit dem SAP HANA Client installiert).
Standard-Pfad nach Installation: C:\Program Files\sap\hdbclient\dotnetcore\v2.1\
Kann bei Bedarf via MSBuild-Property überschrieben werden:
dotnet build /p:HanaClientDll="D:\pfad\zu\Sap.Data.Hana.Core.v2.1.dll"
-->
<HanaClientDll Condition="'$(HanaClientDll)' == ''">C:\Program Files\sap\hdbclient\dotnetcore\v2.1\Sap.Data.Hana.Core.v2.1.dll</HanaClientDll>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Azure.Identity" Version="1.13.1" />
<PackageReference Include="ClosedXML" Version="0.104.2" /> <PackageReference Include="ClosedXML" Version="0.104.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.0">
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.0" />
<PackageReference Include="Microsoft.Graph" Version="5.80.0" /> <PackageReference Include="Microsoft.Graph" Version="5.80.0" />
<PackageReference Include="Azure.Identity" Version="1.13.1" />
<PackageReference Include="MudBlazor" Version="7.15.0" /> <PackageReference Include="MudBlazor" Version="7.15.0" />
<PackageReference Include="Sap.Data.Hana.v2" Version="2.22.26" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<Reference Include="Sap.Data.Hana.Core.v2.1">
<HintPath>$(HanaClientDll)</HintPath>
<Private>true</Private>
</Reference>
</ItemGroup>
<Target Name="CheckHanaClient" BeforeTargets="ResolveAssemblyReferences">
<Warning Condition="!Exists('$(HanaClientDll)')"
Text="SAP HANA Client DLL nicht gefunden: $(HanaClientDll). Bitte SAP HANA Client installieren (https://tools.hana.ondemand.com) oder MSBuild-Property 'HanaClientDll' setzen." />
</Target>
</Project> </Project>
-8
View File
@@ -1,8 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
+3
View File
@@ -0,0 +1,3 @@
html, body {
font-family: Roboto, Arial, sans-serif;
}
-3
View File
@@ -1,3 +0,0 @@
html, body {
font-family: 'Roboto', sans-serif;
}