Compare commits

..

18 Commits

Author SHA1 Message Date
admin 1102252e71 Merge branch 'main' into codex/fix-git-permission-denied-error-ipz1d6 2026-04-13 13:11:12 +02:00
admin 70f1802721 Fix MudBlazor analyzer issues and target x64 for HANA client 2026-04-13 13:10:01 +02:00
admin 2b9b40af93 Merge pull request #59 from metacube2/codex/fix-git-permission-denied-error-92qoc3
Add transformation rules UI and engine; add connection testing/status and Site SourceSystem
2026-04-13 12:20:43 +02:00
admin eb427ac608 Merge branch 'main' into codex/fix-git-permission-denied-error-92qoc3 2026-04-13 12:20:33 +02:00
admin 97e598fe3b Fix MudBlazor generic/value callback compile errors 2026-04-13 12:19:42 +02:00
admin 9406843988 Merge pull request #58 from metacube2/codex/fix-git-permission-denied-error
Add field transformation rules, UI, DB schema and integrate into export; improve HANA connection testing
2026-04-13 11:52:17 +02:00
admin ec827a4ce8 Add connection diagnostics and visual field transformation mapping 2026-04-13 11:52:05 +02:00
admin c4a93a7f15 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	TrafagSalesExporter/TrafagSalesExporter.csproj
2026-04-13 11:31:18 +02:00
admin 0d11315848 Merge pull request #57 from metacube2/codex/fix-git-permission-denied-error
Ignore Visual Studio workspace files in TrafagSalesExporter
2026-04-13 11:24:18 +02:00
admin c336c1c7f8 Ignore Visual Studio workspace files in TrafagSalesExporter 2026-04-13 11:24:04 +02:00
admin 3b6f66d0fb asdf 2026-04-13 11:22:40 +02:00
Claude af40d87213 Merge HANA SSL/MDC support and DLL reference fix from claude/blazor-sap-sales-exporter-9VrM0 2026-04-13 09:07:41 +00:00
Claude efcf7b180c Add SSL, MDC database, and custom HANA connection parameters
Fixes 'error while parsing protocol' HanaException by supporting
SSL/TLS encryption, Multi-Tenant Database Container (MDC) database
name, and arbitrary additional connection parameters.

- HanaServer model: added DatabaseName, UseSsl, ValidateCertificate,
  AdditionalParams fields + BuildConnectionString() helper
- HanaQueryService: accepts HanaServer directly, uses
  BuildConnectionString() for full parameter support
- AppDbContext: added EnsureSchema() method that uses PRAGMA table_info
  + ALTER TABLE ADD COLUMN to add the new fields to existing SQLite
  databases without losing data (EnsureCreated does not update schema)
- Program.cs: calls EnsureSchema on startup before seeding
- Standorte.razor: server dialog now exposes DatabaseName, UseSsl,
  ValidateCertificate, AdditionalParams with helper texts; test
  connection uses new signature

https://claude.ai/code/session_012heAXNMbbyxqYf2S2HrKLj
2026-04-13 08:52:10 +00:00
Claude f916c26fb4 Fix SAP HANA client reference - use direct DLL reference instead of missing NuGet package
Das Paket 'Sap.Data.Hana.v2' existiert nicht auf nuget.org. SAP liefert den
HANA .NET Client ausschliesslich ueber das SAP HANA Client Installationspaket
aus. Stattdessen wird nun Sap.Data.Hana.Core.v2.1.dll direkt aus dem
Standard-Installationspfad referenziert (via HanaClientDll MSBuild-Property
ueberschreibbar). Warnung beim Build wenn DLL nicht gefunden wird.

https://claude.ai/code/session_012heAXNMbbyxqYf2S2HrKLj
2026-04-10 06:23:59 +00:00
admin c198d362b1 Merge pull request #55 from metacube2/claude/blazor-sap-sales-exporter-9VrM0
Migrate from console app to Blazor web UI with database
2026-04-09 16:07:10 +02:00
Claude 8524631508 Convert TrafagSalesExporter from console app to Blazor Server app with MudBlazor UI
- Replaced console app with .NET 8 Blazor Server architecture
- Added EF Core SQLite database (trafag_exporter.db) with auto-seed data
- Models: HanaServer, Site, SharePointConfig, ExportSettings, ExportLog, SalesRecord
- Services: HanaQueryService (with configurable dateFilter), ExcelExportService,
  SharePointUploadService, ExportOrchestrationService (with live status events),
  TimerBackgroundService (scheduled daily export)
- MudBlazor UI pages: Dashboard (export status + manual trigger), Standorte
  (HANA server + site CRUD), Settings (SharePoint + timer config), Logs (filtered view)
- SAP HANA queries unchanged (INV + CRN with exact SAP B1 table joins)
- SharePoint upload via Microsoft Graph with app registration auth

https://claude.ai/code/session_012heAXNMbbyxqYf2S2HrKLj
2026-04-09 14:00:44 +00:00
admin 2f56082adc Merge pull request #53 from metacube2/codex/create-c#-console-app-for-sap-hana-export
Add Trafag SAP HANA → Excel → SharePoint exporter (.NET 8 console)
2026-04-09 15:48:14 +02:00
admin 673bba7298 Add Trafag SAP HANA to Excel SharePoint exporter console app 2026-04-09 15:47:55 +02:00
34 changed files with 1533 additions and 742 deletions
+10
View File
@@ -0,0 +1,10 @@
# Ignore Visual Studio + build artifacts
.vs/
TrafagSalesExporter/.vs/
TrafagSalesExporter/bin/
TrafagSalesExporter/obj/
TrafagSalesExporter/*.user
TrafagSalesExporter/*.suo
TrafagSalesExporter/*.db
TrafagSalesExporter/*.db-shm
TrafagSalesExporter/*.db-wal
+8
View File
@@ -0,0 +1,8 @@
# Build artifacts
bin/
obj/
# Visual Studio user/IDE files
.vs/
*.user
*.suo
+4 -3
View File
@@ -3,14 +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" />
<link href="app.css" rel="stylesheet" /> <HeadOutlet @rendermode="@Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer" />
<HeadOutlet />
</head> </head>
<body> <body>
<Routes /> <Routes @rendermode="@Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer" />
<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>
@@ -1,19 +1,38 @@
@inherits LayoutComponentBase @inherits LayoutComponentBase
<MudThemeProvider /> <MudThemeProvider Theme="_theme" />
<MudPopoverProvider />
<MudDialogProvider /> <MudDialogProvider />
<MudSnackbarProvider /> <MudSnackbarProvider />
<MudLayout> <MudLayout>
<MudAppBar Elevation="1"> <MudAppBar Elevation="1" Color="Color.Primary">
<MudText Typo="Typo.h6">Trafag Sales Exporter</MudText> <MudIconButton Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" Edge="Edge.Start"
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>
<MudContainer MaxWidth="MaxWidth.False" Class="pa-4"> <MudMainContent 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,6 +1,17 @@
<MudNavMenu> <MudNavMenu>
<MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">Dashboard</MudNavLink> <MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">
<MudNavLink Href="/standorte" Icon="@Icons.Material.Filled.LocationOn">Standorte</MudNavLink> Dashboard
<MudNavLink Href="/settings" Icon="@Icons.Material.Filled.Settings">Settings</MudNavLink> </MudNavLink>
<MudNavLink Href="/logs" Icon="@Icons.Material.Filled.ReceiptLong">Logs</MudNavLink> <MudNavLink Href="/standorte" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.LocationOn">
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,25 +1,46 @@
@page "/" @page "/"
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using TrafagSalesExporter.Data
@using TrafagSalesExporter.Services
@inject IDbContextFactory<AppDbContext> DbFactory @inject IDbContextFactory<AppDbContext> DbFactory
@inject ExportOrchestrationService ExportService @inject ExportOrchestrationService Orchestrator
@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>
<MudStack Row="true" Spacing="2" Class="mb-4"> <MudPaper Class="pa-4 mb-4" Elevation="1">
<MudButton Variant="Variant.Filled" Color="Color.Primary" Disabled="@isRunningAll" OnClick="ExportAllAsync">Alle exportieren</MudButton> <MudStack Row AlignItems="AlignItems.Center" Spacing="4">
<MudText Typo="Typo.body1">Nächster automatischer Lauf: @nextRunText</MudText> <MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.PlayArrow"
OnClick="ExportAll" Disabled="_anyRunning">
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> </MudStack>
</MudPaper>
<MudTable Items="sites" Hover="true" Dense="true"> <MudTable Items="_dashboardRows" Dense Hover Striped Loading="_loading">
<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>Letzter Status</MudTh> <MudTh>Status</MudTh>
<MudTh>Row Count</MudTh> <MudTh>Zeilen</MudTh>
<MudTh>Letzter Lauf</MudTh> <MudTh>Letzter Lauf</MudTh>
<MudTh>Dauer</MudTh> <MudTh>Dauer</MudTh>
<MudTh>Aktion</MudTh> <MudTh>Aktion</MudTh>
@@ -28,90 +49,145 @@
<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.HanaServer?.Name</MudTd> <MudTd>@context.ServerName</MudTd>
<MudTd>@GetStatusIcon(context.Id)</MudTd>
<MudTd>@GetRows(context.Id)</MudTd>
<MudTd>@GetLastRun(context.Id)</MudTd>
<MudTd>@GetDuration(context.Id)</MudTd>
<MudTd> <MudTd>
@if (runningSiteIds.Contains(context.Id)) @if (Orchestrator.IsExporting(context.SiteId))
{ {
<MudProgressCircular Indeterminate="true" Size="Size.Small" /> <MudProgressCircular Size="Size.Small" Indeterminate Color="Color.Primary" Class="mr-1" />
<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
{ {
<MudButton Size="Size.Small" Variant="Variant.Outlined" OnClick="() => ExportSingleAsync(context.Id)">Einzeln exportieren</MudButton> <MudText Typo="Typo.caption" Color="Color.Default">-</MudText>
} }
</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<Site> sites = []; private List<DashboardRow> _dashboardRows = new();
private Dictionary<int, ExportLog?> latestLogs = new(); private bool _loading = true;
private HashSet<int> runningSiteIds = []; private bool _anyRunning;
private bool isRunningAll;
private string nextRunText = "-";
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
await LoadAsync(); Orchestrator.OnExportStatusChanged += HandleStatusChanged;
await LoadDataAsync();
} }
private async Task LoadAsync() private async Task LoadDataAsync()
{ {
await using var db = await DbFactory.CreateDbContextAsync(); _loading = true;
sites = await db.Sites using var db = await DbFactory.CreateDbContextAsync();
.Include(x => x.HanaServer)
.Where(x => x.IsActive) var sites = await db.Sites.Include(s => s.HanaServer).Where(s => s.IsActive).ToListAsync();
.OrderBy(x => x.Land) var logs = await db.ExportLogs
.GroupBy(l => l.SiteId)
.Select(g => g.OrderByDescending(l => l.Timestamp).First())
.ToListAsync(); .ToListAsync();
latestLogs = await ExportService.GetLatestLogsPerSiteAsync(); _dashboardRows = sites.Select(s =>
var nextRun = await ExportService.GetNextRunAsync(); {
nextRunText = nextRun.HasValue ? nextRun.Value.ToString("dd.MM.yyyy HH:mm") : "Deaktiviert"; var log = logs.FirstOrDefault(l => l.SiteId == s.Id);
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 ExportAllAsync() private async Task ExportAll()
{ {
isRunningAll = true; _anyRunning = true;
foreach (var site in sites) _ = Task.Run(async () =>
{ {
runningSiteIds.Add(site.Id); await Orchestrator.ExportAllAsync();
} await InvokeAsync(async () =>
{
await LoadDataAsync();
StateHasChanged(); StateHasChanged();
await ExportService.ExportAllActiveSitesAsync(); });
runningSiteIds.Clear(); });
isRunningAll = false; Snackbar.Add("Export für alle Standorte gestartet", Severity.Info);
await LoadAsync();
} }
private async Task ExportSingleAsync(int siteId) private void ExportSingle(int siteId)
{ {
runningSiteIds.Add(siteId); _ = Task.Run(async () =>
{
await Orchestrator.ExportSiteByIdAsync(siteId);
await InvokeAsync(async () =>
{
await LoadDataAsync();
StateHasChanged(); StateHasChanged();
await ExportService.ExportSiteAsync(siteId); });
runningSiteIds.Remove(siteId); });
await LoadAsync(); Snackbar.Add("Export gestartet", Severity.Info);
} }
private string GetStatusIcon(int siteId) private async void HandleStatusChanged()
{ {
if (!latestLogs.TryGetValue(siteId, out var log) || log is null) await InvokeAsync(async () =>
{ {
return "-"; _anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId));
StateHasChanged();
if (!_anyRunning)
{
await LoadDataAsync();
StateHasChanged();
}
});
} }
return log.Status == "OK" ? "✅" : "❌"; public void Dispose()
{
Orchestrator.OnExportStatusChanged -= HandleStatusChanged;
} }
private string GetRows(int siteId) => private class DashboardRow
latestLogs.TryGetValue(siteId, out var log) && log is not null ? log.RowCount.ToString() : "-"; {
public int SiteId { get; set; }
private string GetLastRun(int siteId) => public string Land { get; set; } = "";
latestLogs.TryGetValue(siteId, out var log) && log is not null ? log.Timestamp.ToLocalTime().ToString("dd.MM.yyyy HH:mm:ss") : "-"; public string TSC { get; set; } = "";
public string Schema { get; set; } = "";
private string GetDuration(int siteId) => public string ServerName { get; set; } = "";
latestLogs.TryGetValue(siteId, out var log) && log is not null ? $"{log.DurationSeconds:F1}s" : "-"; public string LastStatus { get; set; } = "";
public int RowCount { get; set; }
public DateTime? LastRun { get; set; }
public double DurationSeconds { get; set; }
public string ErrorMessage { get; set; } = "";
}
} }
+94 -54
View File
@@ -1,94 +1,134 @@
@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">Logs</MudText> <MudText Typo="Typo.h4" Class="mb-4">Export Logs</MudText>
<MudGrid Class="mb-4"> <MudPaper Class="pa-4 mb-4" Elevation="1">
<MudItem xs="12" md="3"><MudTextField Label="Land" @bind-Value="filterLand" /></MudItem> <MudStack Row AlignItems="AlignItems.Center" Spacing="3">
<MudItem xs="12" md="3"> <MudSelect @bind-Value="_filterLand" Label="Land" Clearable Style="max-width:200px;">
<MudSelect T="string" Label="Status" @bind-Value="filterStatus"> @foreach (var land in _availableLands)
<MudSelectItem Value="">Alle</MudSelectItem> {
<MudSelectItem Value="OK">OK</MudSelectItem> <MudSelectItem Value="@land">@land</MudSelectItem>
<MudSelectItem Value="Error">Error</MudSelectItem> }
</MudSelect> </MudSelect>
</MudItem> <MudSelect @bind-Value="_filterStatus" Label="Status" Clearable Style="max-width:150px;">
<MudItem xs="12" md="3"><MudDatePicker Label="Ab Datum" @bind-Date="filterFromDate" /></MudItem> <MudSelectItem Value="@("OK")">OK</MudSelectItem>
<MudItem xs="12" md="3"><MudButton Variant="Variant.Filled" OnClick="LoadAsync">Filtern</MudButton></MudItem> <MudSelectItem Value="@("Error")">Error</MudSelectItem>
</MudGrid> </MudSelect>
<MudDatePicker @bind-Date="_filterDate" Label="Datum" Clearable Style="max-width:200px;" />
<MudStack Row="true" Spacing="2" Class="mb-2"> <MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="ApplyFilter"
<MudNumericField T="int" Label="Logs älter als Tage löschen" @bind-Value="deleteOlderThanDays" Min="1" /> StartIcon="@Icons.Material.Filled.FilterAlt">
<MudButton Variant="Variant.Outlined" Color="Color.Error" OnClick="DeleteOlderAsync">Logs löschen</MudButton> Filtern
</MudButton>
<MudSpacer />
<MudButton Variant="Variant.Outlined" Color="Color.Error" OnClick="DeleteOldLogs"
StartIcon="@Icons.Material.Filled.DeleteSweep">
Alte Logs löschen
</MudButton>
</MudStack> </MudStack>
</MudPaper>
<MudTable Items="logs" Dense="true" Hover="true" RowClassFunc="GetRowClass"> <MudTable Items="_logs" Dense Hover Striped Loading="_loading">
<HeaderContent> <HeaderContent>
<MudTh>Timestamp</MudTh> <MudTh>Zeitpunkt</MudTh>
<MudTh>Land</MudTh> <MudTh>Land</MudTh>
<MudTh>TSC</MudTh> <MudTh>TSC</MudTh>
<MudTh>Status</MudTh> <MudTh>Status</MudTh>
<MudTh>Rows</MudTh> <MudTh>Zeilen</MudTh>
<MudTh>Dauer</MudTh> <MudTh>Dauer</MudTh>
<MudTh>Fehler</MudTh>
<MudTh>Dateiname</MudTh> <MudTh>Dateiname</MudTh>
<MudTh>Fehler</MudTh>
</HeaderContent> </HeaderContent>
<RowTemplate> <RowTemplate>
<MudTd>@context.Timestamp.ToLocalTime().ToString("dd.MM.yyyy HH:mm:ss")</MudTd> <MudTd>@context.Timestamp.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>@context.Status</MudTd> <MudTd>
<MudTd>@context.RowCount</MudTd> @if (context.Status == "OK")
{
<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 = []; private List<ExportLog> _logs = new();
private string filterLand = string.Empty; private List<string> _availableLands = new();
private string filterStatus = string.Empty; private string? _filterLand;
private DateTime? filterFromDate; private string? _filterStatus;
private int deleteOlderThanDays = 30; private DateTime? _filterDate;
private bool _loading = true;
protected override async Task OnInitializedAsync() => await LoadAsync(); protected override async Task OnInitializedAsync()
private async Task LoadAsync()
{ {
await using var db = await DbFactory.CreateDbContextAsync(); using var db = await DbFactory.CreateDbContextAsync();
var query = db.ExportLogs.AsQueryable(); _availableLands = await db.ExportLogs.Select(l => l.Land).Distinct().OrderBy(l => l).ToListAsync();
await LoadLogsAsync();
if (!string.IsNullOrWhiteSpace(filterLand))
{
query = query.Where(x => x.Land.Contains(filterLand));
} }
if (!string.IsNullOrWhiteSpace(filterStatus)) private async Task LoadLogsAsync()
{ {
query = query.Where(x => x.Status == filterStatus); _loading = true;
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;
} }
if (filterFromDate.HasValue) private async Task ApplyFilter()
{ {
var fromUtc = filterFromDate.Value.Date.ToUniversalTime(); await LoadLogsAsync();
query = query.Where(x => x.Timestamp >= fromUtc);
} }
logs = await query.OrderByDescending(x => x.Timestamp).ToListAsync(); private async Task DeleteOldLogs()
}
private string GetRowClass(ExportLog log, int _) => log.Status == "Error" ? "mud-theme-error" : string.Empty;
private async Task DeleteOlderAsync()
{ {
var threshold = DateTime.UtcNow.AddDays(-deleteOlderThanDays); var result = await DialogService.ShowMessageBox(
await using var db = await DbFactory.CreateDbContextAsync(); "Alte Logs löschen",
var oldLogs = await db.ExportLogs.Where(x => x.Timestamp < threshold).ToListAsync(); "Logs älter als 90 Tage löschen?",
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);
await db.SaveChangesAsync(); var count = await db.SaveChangesAsync();
await LoadAsync(); await LoadLogsAsync();
Snackbar.Add($"{oldLogs.Count} alte Logs gelöscht", Severity.Info);
} }
} }
@@ -1,93 +1,164 @@
@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 CryptoService CryptoService @inject SharePointUploadService SpService
@inject SharePointUploadService SharePointUploadService @inject TimerBackgroundService TimerService
@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>
<MudPaper Class="pa-4 mb-4"> @* SharePoint Config *@
<MudText Typo="Typo.h6">SharePoint</MudText> <MudText Typo="Typo.h5" Class="mb-2">SharePoint Konfiguration</MudText>
<MudPaper Class="pa-4 mb-6" Elevation="1">
<MudGrid> <MudGrid>
<MudItem xs="12" md="6"><MudTextField Label="SiteUrl" @bind-Value="sharePointConfig.SiteUrl" /></MudItem> <MudItem xs="12" md="6">
<MudItem xs="12" md="6"><MudTextField Label="ExportFolder" @bind-Value="sharePointConfig.ExportFolder" /></MudItem> <MudTextField @bind-Value="_spConfig.SiteUrl" Label="Site URL" />
<MudItem xs="12" md="4"><MudTextField Label="TenantId" @bind-Value="sharePointConfig.TenantId" /></MudItem> </MudItem>
<MudItem xs="12" md="4"><MudTextField Label="ClientId" @bind-Value="sharePointConfig.ClientId" /></MudItem> <MudItem xs="12" md="6">
<MudItem xs="12" md="4"><MudTextField Label="ClientSecret" InputType="InputType.Password" @bind-Value="sharePointClientSecret" /></MudItem> <MudTextField @bind-Value="_spConfig.ExportFolder" Label="Export Folder" />
</MudGrid> </MudItem>
<MudStack Row="true" Spacing="2" Class="mt-3"> <MudItem xs="12" md="4">
<MudButton Variant="Variant.Filled" OnClick="SaveAsync">Speichern</MudButton> <MudTextField @bind-Value="_spConfig.TenantId" Label="Tenant ID" />
<MudButton Variant="Variant.Outlined" OnClick="TestSharePointAsync">SharePoint Verbindung testen</MudButton> </MudItem>
<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>
</MudPaper> </MudItem>
<MudPaper Class="pa-4 mb-4">
<MudText Typo="Typo.h6">Export & Timer</MudText>
<MudGrid>
<MudItem xs="12" md="3"><MudTextField Label="DateFilter" @bind-Value="settings.DateFilter" /></MudItem>
<MudItem xs="12" md="2"><MudNumericField T="int" Label="TimerHour" Min="0" Max="23" @bind-Value="settings.TimerHour" /></MudItem>
<MudItem xs="12" md="2"><MudNumericField T="int" Label="TimerMinute" Min="0" Max="59" @bind-Value="settings.TimerMinute" /></MudItem>
<MudItem xs="12" md="2"><MudCheckBox Label="TimerEnabled" @bind-Value="settings.TimerEnabled" /></MudItem>
</MudGrid> </MudGrid>
<MudText Typo="Typo.body2" Class="mt-3">Dateiname-Vorschau: @PreviewFileName</MudText>
</MudPaper> </MudPaper>
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined">@message</MudAlert> @* Export Settings *@
<MudText Typo="Typo.h5" Class="mb-2">Export Einstellungen</MudText>
<MudPaper Class="pa-4 mb-6" Elevation="1">
<MudGrid>
<MudItem xs="12" md="4">
<MudTextField @bind-Value="_exportSettings.DateFilter" Label="Datum-Filter (ab)"
HelperText="Format: yyyy-MM-dd" />
</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>
</MudPaper>
@* Filename Preview *@
<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 sharePointConfig = new(); private SharePointConfig _spConfig = new();
private ExportSettings settings = new(); private ExportSettings _exportSettings = new();
private string sharePointClientSecret = string.Empty; private bool _testingSp;
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()
{ {
await using var db = await DbFactory.CreateDbContextAsync(); using var db = await DbFactory.CreateDbContextAsync();
sharePointConfig = await db.SharePointConfigs.OrderBy(x => x.Id).FirstAsync(); _spConfig = await db.SharePointConfigs.FirstOrDefaultAsync() ?? new SharePointConfig();
settings = await db.ExportSettings.OrderBy(x => x.Id).FirstAsync(); _exportSettings = await db.ExportSettings.FirstOrDefaultAsync() ?? new ExportSettings();
sharePointClientSecret = CryptoService.Decrypt(sharePointConfig.EncryptedClientSecret);
} }
private async Task SaveAsync() private async Task SaveSharePoint()
{ {
await using var db = await DbFactory.CreateDbContextAsync(); using var db = await DbFactory.CreateDbContextAsync();
var sp = await db.SharePointConfigs.SingleAsync(x => x.Id == sharePointConfig.Id); var existing = await db.SharePointConfigs.FirstOrDefaultAsync();
var es = await db.ExportSettings.SingleAsync(x => x.Id == settings.Id); if (existing is null)
{
sp.SiteUrl = sharePointConfig.SiteUrl; db.SharePointConfigs.Add(_spConfig);
sp.ExportFolder = sharePointConfig.ExportFolder; }
sp.TenantId = sharePointConfig.TenantId; else
sp.ClientId = sharePointConfig.ClientId; {
sp.EncryptedClientSecret = CryptoService.Encrypt(sharePointClientSecret); existing.SiteUrl = _spConfig.SiteUrl;
existing.ExportFolder = _spConfig.ExportFolder;
es.DateFilter = settings.DateFilter; existing.TenantId = _spConfig.TenantId;
es.TimerHour = settings.TimerHour; existing.ClientId = _spConfig.ClientId;
es.TimerMinute = settings.TimerMinute; existing.ClientSecret = _spConfig.ClientSecret;
es.TimerEnabled = settings.TimerEnabled; }
await db.SaveChangesAsync(); await db.SaveChangesAsync();
message = "Settings gespeichert."; Snackbar.Add("SharePoint Konfiguration gespeichert", Severity.Success);
} }
private async Task TestSharePointAsync() private async Task TestSharePoint()
{ {
_testingSp = true;
try try
{ {
var ok = await SharePointUploadService.TestConnectionAsync( await SpService.TestConnectionAsync(
sharePointConfig.SiteUrl, _spConfig.TenantId, _spConfig.ClientId, _spConfig.ClientSecret, _spConfig.SiteUrl);
sharePointConfig.TenantId, Snackbar.Add("SharePoint Verbindung erfolgreich!", Severity.Success);
sharePointConfig.ClientId,
sharePointClientSecret);
message = ok ? "SharePoint Verbindung OK." : "SharePoint Verbindung fehlgeschlagen.";
} }
catch (Exception ex) catch (Exception ex)
{ {
message = $"SharePoint Test fehlgeschlagen: {ex.Message}"; Snackbar.Add($"Verbindung fehlgeschlagen: {ex.Message}", Severity.Error);
}
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,74 +1,31 @@
@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 HanaQueryService @inject HanaQueryService HanaService
@inject CryptoService CryptoService @inject ISnackbar Snackbar
@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>
<MudPaper Class="pa-4 mb-4"> <MudText Typo="Typo.h5" Class="mb-2">HANA Server</MudText>
<MudText Typo="Typo.h6">Neuen Standort hinzufügen</MudText> <MudPaper Class="pa-4 mb-6" Elevation="1">
<MudGrid> <MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add"
<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> OnClick="AddServer" Class="mb-3">
<MudItem xs="12" md="2"><MudTextField Label="Schema" @bind-Value="newSite.Schema" /></MudItem> Server hinzufügen
<MudItem xs="12" md="2"><MudTextField Label="TSC" @bind-Value="newSite.TSC" /></MudItem> </MudButton>
<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="sites" Dense="true" Hover="true" Class="mb-6"> <MudTable Items="_servers" Dense Hover Striped>
<HeaderContent> <HeaderContent>
<MudTh>Land</MudTh><MudTh>TSC</MudTh><MudTh>Schema</MudTh><MudTh>Server</MudTh><MudTh>Aktiv</MudTh><MudTh>Aktion</MudTh> <MudTh>Name</MudTh>
</HeaderContent> <MudTh>Host</MudTh>
<RowTemplate> <MudTh>Port</MudTh>
<MudTd>@context.Land</MudTd> <MudTh>Username</MudTh>
<MudTd>@context.TSC</MudTd> <MudTh>Verbindungsstatus</MudTh>
<MudTd>@context.Schema</MudTd> <MudTh>Aktionen</MudTh>
<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>
@@ -76,140 +33,321 @@
<MudTd>@context.Port</MudTd> <MudTd>@context.Port</MudTd>
<MudTd>@context.Username</MudTd> <MudTd>@context.Username</MudTd>
<MudTd> <MudTd>
<MudButton Size="Size.Small" Variant="Variant.Outlined" OnClick="() => TestServerAsync(context)">Verbindung testen</MudButton> @if (_connectionStatus.TryGetValue(context.Id, out var status))
<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>
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Class="mt-4">@message</MudAlert> <MudText Typo="Typo.h5" Class="mb-2">Standorte (Sites)</MudText>
<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 List<Site> sites = []; private readonly string[] _sourceSystems = ["SAP", "BI1", "SAGE"];
private List<HanaServer> servers = []; private readonly Dictionary<int, ConnectionTestResult> _connectionStatus = new();
private Site newSite = new() { IsActive = true }; private List<HanaServer> _servers = new();
private Site? editingSite; private List<Site> _sites = new();
private HanaServer newServer = new() { Port = 30015 }; private HanaServer _editingServer = new();
private string newServerPassword = string.Empty; private Site _editingSite = new();
private string message = "Bereit."; private bool _serverDialogVisible;
private bool _siteDialogVisible;
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
protected override async Task OnInitializedAsync() => await LoadAsync(); protected override async Task OnInitializedAsync()
private async Task LoadAsync()
{ {
await using var db = await DbFactory.CreateDbContextAsync(); await LoadDataAsync();
servers = await db.HanaServers.OrderBy(x => x.Name).ToListAsync(); }
sites = await db.Sites.Include(x => x.HanaServer).OrderBy(x => x.Land).ToListAsync();
if (servers.Count > 0 && newSite.HanaServerId == 0) private async Task LoadDataAsync()
{ {
newSite.HanaServerId = servers[0].Id; using var db = await DbFactory.CreateDbContextAsync();
_servers = await db.HanaServers.OrderBy(s => s.Name).ToListAsync();
_sites = await db.Sites.Include(s => s.HanaServer).OrderBy(s => s.Land).ToListAsync();
}
private void AddServer()
{
_editingServer = new HanaServer { Port = 30015 };
_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();
newSite = new Site { IsActive = true, HanaServerId = servers.FirstOrDefault()?.Id ?? 0 }; _serverDialogVisible = false;
await LoadAsync(); await LoadDataAsync();
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 SaveSiteAsync() private async Task SaveSite()
{ {
if (editingSite is null) using var db = await DbFactory.CreateDbContextAsync();
if (_editingSite.Id == 0)
{ {
return; db.Sites.Add(_editingSite);
}
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();
editingSite = null; _siteDialogVisible = false;
await LoadAsync(); await LoadDataAsync();
Snackbar.Add("Standort gespeichert", Severity.Success);
} }
private async Task DeleteSiteAsync(int id) private async Task DeleteSite(Site site)
{ {
await using var db = await DbFactory.CreateDbContextAsync(); var result = await DialogService.ShowMessageBox(
var site = await db.Sites.SingleAsync(x => x.Id == id); "Standort löschen",
db.Sites.Remove(site); $"Standort '{site.Land}' wirklich löschen?",
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();
} }
private async Task AddServerAsync() await LoadDataAsync();
{ 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);
} }
} }
@@ -0,0 +1,137 @@
@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();
}
}
+1 -3
View File
@@ -1,8 +1,6 @@
@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(MainLayout)" /> <RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
<FocusOnNavigate RouteData="routeData" Selector="h1" /> <FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found> </Found>
</Router> </Router>
@@ -1,13 +1,9 @@
@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
+75 -59
View File
@@ -1,91 +1,107 @@
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(DbContextOptions<AppDbContext> options) : DbContext(options) public class AppDbContext : DbContext
{ {
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>();
protected override void OnModelCreating(ModelBuilder modelBuilder) /// <summary>
/// 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)
{ {
modelBuilder.Entity<HanaServer>().HasIndex(x => x.Name).IsUnique(); AddColumnIfMissing(db, "HanaServers", "DatabaseName", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "HanaServers", "UseSsl", "INTEGER NOT NULL DEFAULT 0");
AddColumnIfMissing(db, "HanaServers", "ValidateCertificate", "INTEGER NOT NULL DEFAULT 0");
AddColumnIfMissing(db, "HanaServers", "AdditionalParams", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "Sites", "SourceSystem", "TEXT NOT NULL DEFAULT 'SAP'");
EnsureTransformationTable(db);
}
modelBuilder.Entity<Site>() private static void AddColumnIfMissing(AppDbContext db, string table, string column, string type)
.HasOne(x => x.HanaServer) {
.WithMany(x => x.Sites) var conn = db.Database.GetDbConnection();
.HasForeignKey(x => x.HanaServerId) if (conn.State != ConnectionState.Open) conn.Open();
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<ExportLog>() bool exists = false;
.HasOne(x => x.Site) using (var cmd = conn.CreateCommand())
.WithMany() {
.HasForeignKey(x => x.SiteId) cmd.CommandText = $"PRAGMA table_info({table})";
.OnDelete(DeleteBehavior.SetNull); using var reader = cmd.ExecuteReader();
while (reader.Read())
{
if (string.Equals(reader["name"]?.ToString(), column, StringComparison.OrdinalIgnoreCase))
{
exists = true;
break;
}
} }
} }
public static class DbInitializer if (!exists)
{ {
public static async Task SeedDefaultsAsync(AppDbContext db, CryptoService cryptoService) using var alter = conn.CreateCommand();
{ alter.CommandText = $"ALTER TABLE {table} ADD COLUMN {column} {type}";
if (!await db.HanaServers.AnyAsync()) alter.ExecuteNonQuery();
{ }
db.HanaServers.AddRange(
new HanaServer
{
Name = "Internal",
Host = "travtrp0",
Port = 30015,
Username = string.Empty,
EncryptedPassword = cryptoService.Encrypt(string.Empty)
},
new HanaServer
{
Name = "India",
Host = "20.197.20.60",
Port = 30015,
Username = string.Empty,
EncryptedPassword = cryptoService.Encrypt(string.Empty)
});
await db.SaveChangesAsync();
} }
if (!await db.Sites.AnyAsync()) private static void EnsureTransformationTable(AppDbContext db)
{ {
var internalServer = await db.HanaServers.SingleAsync(x => x.Name == "Internal"); var conn = db.Database.GetDbConnection();
var indiaServer = await db.HanaServers.SingleAsync(x => x.Name == "India"); 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 = internalServer.Id, Schema = "fr01_p", TSC = "TRFR", Land = "Frankreich", IsActive = true }, new Site { HanaServerId = serverInternal.Id, Schema = "fr01_p", TSC = "TRFR", Land = "Frankreich", IsActive = true },
new Site { HanaServerId = internalServer.Id, Schema = "it01_p", TSC = "TRIT", Land = "Italien", IsActive = true }, new Site { HanaServerId = serverInternal.Id, Schema = "it01_p", TSC = "TRIT", Land = "Italien", IsActive = true },
new Site { HanaServerId = internalServer.Id, Schema = "us01_p", TSC = "TRUS", Land = "USA", IsActive = true }, new Site { HanaServerId = serverInternal.Id, Schema = "us01_p", TSC = "TRUS", Land = "USA", IsActive = true },
new Site { HanaServerId = indiaServer.Id, Schema = "TRAFAG_LIVE", TSC = "TRIN", Land = "Indien", IsActive = true }); new Site { HanaServerId = serverIndia.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 = string.Empty, TenantId = "",
ClientId = string.Empty, ClientId = "",
EncryptedClientSecret = cryptoService.Encrypt(string.Empty) ClientSecret = ""
}); });
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",
@@ -93,7 +109,7 @@ public static class DbInitializer
TimerMinute = 0, TimerMinute = 0,
TimerEnabled = true TimerEnabled = true
}); });
await db.SaveChangesAsync();
} db.SaveChanges();
} }
} }
+6 -1
View File
@@ -1,11 +1,16 @@
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; } = 0; public int TimerMinute { get; set; }
public bool TimerEnabled { get; set; } = true; public bool TimerEnabled { get; set; } = true;
} }
@@ -0,0 +1,26 @@
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;
}
+65 -3
View File
@@ -12,11 +12,73 @@ public class HanaServer
[Required] [Required]
public string Host { get; set; } = string.Empty; public string Host { get; set; } = string.Empty;
public int Port { get; set; } public int Port { get; set; } = 30015;
public string Username { get; set; } = string.Empty; public string Username { get; set; } = string.Empty;
public string EncryptedPassword { get; set; } = string.Empty; public string Password { get; set; } = string.Empty;
public List<Site> Sites { get; set; } = []; /// <summary>
/// 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;
+2 -13
View File
@@ -1,22 +1,11 @@
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,12 +1,15 @@
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]
@@ -18,5 +21,8 @@ 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;
} }
+10 -11
View File
@@ -13,31 +13,30 @@ 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.AddScoped<CryptoService>(); builder.Services.AddSingleton<HanaQueryService>();
builder.Services.AddScoped<HanaQueryService>(); builder.Services.AddSingleton<ExcelExportService>();
builder.Services.AddScoped<ExcelExportService>(); builder.Services.AddSingleton<SharePointUploadService>();
builder.Services.AddScoped<SharePointUploadService>(); builder.Services.AddSingleton<RecordTransformationService>();
builder.Services.AddScoped<ExportOrchestrationService>(); builder.Services.AddSingleton<ExportOrchestrationService>();
builder.Services.AddHostedService<TimerBackgroundService>(); builder.Services.AddSingleton<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>>();
var cryptoService = scope.ServiceProvider.GetRequiredService<CryptoService>(); using var db = await dbFactory.CreateDbContextAsync();
await using var db = await dbFactory.CreateDbContextAsync();
await db.Database.EnsureCreatedAsync(); await db.Database.EnsureCreatedAsync();
await DbInitializer.SeedDefaultsAsync(db, cryptoService); AppDbContext.EnsureSchema(db);
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,12 @@
{
"profiles": {
"TrafagSalesExporter": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:55415;http://localhost:55416"
}
}
}
@@ -1,26 +0,0 @@
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,16 +5,14 @@ namespace TrafagSalesExporter.Services;
public class ExcelExportService public class ExcelExportService
{ {
public string CreateFile(string baseDirectory, string land, string tsc, List<SalesRecord> records) public string CreateExcelFile(string outputDirectory, string tsc, DateTime fileDate, 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 fileName = $"Sales_{tsc}_{DateTime.UtcNow:yyyy-MM-dd}.xlsx"; var fullPath = Path.Combine(outputDirectory, fileName);
var filePath = Path.Combine(outputDirectory, fileName);
using var workbook = new XLWorkbook(); using var workbook = new XLWorkbook();
var ws = workbook.AddWorksheet("Sales"); var ws = workbook.Worksheets.Add("Sales");
var headers = new[] var headers = new[]
{ {
@@ -53,39 +51,39 @@ public class ExcelExportService
} }
var row = 2; var row = 2;
foreach (var r in records) foreach (var record in records)
{ {
ws.Cell(row, 1).Value = r.ExtractionDate.ToString("dd.MM.yyyy HH:mm:ss"); ws.Cell(row, 1).Value = record.ExtractionDate.ToString("dd.MM.yyyy HH:mm:ss");
ws.Cell(row, 2).Value = r.TSC; ws.Cell(row, 2).Value = record.Tsc;
ws.Cell(row, 3).Value = r.InvoiceNumber; ws.Cell(row, 3).Value = record.InvoiceNumber;
ws.Cell(row, 4).Value = r.PositionOnInvoice; ws.Cell(row, 4).Value = record.PositionOnInvoice;
ws.Cell(row, 5).Value = r.Material; ws.Cell(row, 5).Value = record.Material;
ws.Cell(row, 6).Value = r.Name; ws.Cell(row, 6).Value = record.Name;
ws.Cell(row, 7).Value = r.ProductGroup; ws.Cell(row, 7).Value = record.ProductGroup;
ws.Cell(row, 8).Value = r.Quantity; ws.Cell(row, 8).Value = record.Quantity;
ws.Cell(row, 9).Value = r.SupplierNumber; ws.Cell(row, 9).Value = record.SupplierNumber;
ws.Cell(row, 10).Value = r.SupplierName; ws.Cell(row, 10).Value = record.SupplierName;
ws.Cell(row, 11).Value = r.SupplierCountry; ws.Cell(row, 11).Value = record.SupplierCountry;
ws.Cell(row, 12).Value = r.CustomerNumber; ws.Cell(row, 12).Value = record.CustomerNumber;
ws.Cell(row, 13).Value = r.CustomerName; ws.Cell(row, 13).Value = record.CustomerName;
ws.Cell(row, 14).Value = r.CustomerCountry; ws.Cell(row, 14).Value = record.CustomerCountry;
ws.Cell(row, 15).Value = r.CustomerIndustry; ws.Cell(row, 15).Value = record.CustomerIndustry;
ws.Cell(row, 16).Value = r.StandardCost; ws.Cell(row, 16).Value = record.StandardCost;
ws.Cell(row, 17).Value = r.StandardCostCurrency; ws.Cell(row, 17).Value = record.StandardCostCurrency;
ws.Cell(row, 18).Value = r.PurchaseOrderNumber; ws.Cell(row, 18).Value = record.PurchaseOrderNumber;
ws.Cell(row, 19).Value = r.SalesPriceValue; ws.Cell(row, 19).Value = record.SalesPriceValue;
ws.Cell(row, 20).Value = r.SalesCurrency; ws.Cell(row, 20).Value = record.SalesCurrency;
ws.Cell(row, 21).Value = r.Incoterms2020; ws.Cell(row, 21).Value = record.Incoterms2020;
ws.Cell(row, 22).Value = r.SalesResponsibleEmployee; ws.Cell(row, 22).Value = record.SalesResponsibleEmployee;
ws.Cell(row, 23).Value = r.InvoiceDate?.ToString("dd.MM.yyyy") ?? string.Empty; ws.Cell(row, 23).Value = record.InvoiceDate?.ToString("dd.MM.yyyy") ?? string.Empty;
ws.Cell(row, 24).Value = r.OrderDate?.ToString("dd.MM.yyyy") ?? string.Empty; ws.Cell(row, 24).Value = record.OrderDate?.ToString("dd.MM.yyyy") ?? string.Empty;
ws.Cell(row, 25).Value = r.Land; ws.Cell(row, 25).Value = record.Land;
ws.Cell(row, 26).Value = r.DocumentType; ws.Cell(row, 26).Value = record.DocumentType;
row++; row++;
} }
ws.Columns().AdjustToContents(); ws.Columns().AdjustToContents();
workbook.SaveAs(filePath); workbook.SaveAs(fullPath);
return filePath; return fullPath;
} }
} }
@@ -1,120 +1,171 @@
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,
CryptoService cryptoService, HanaQueryService hanaService,
HanaQueryService hanaQueryService, ExcelExportService excelService,
ExcelExportService excelExportService, SharePointUploadService sharePointService,
SharePointUploadService sharePointUploadService) RecordTransformationService transformationService,
ILogger<ExportOrchestrationService> logger)
{ {
public async Task ExportAllActiveSitesAsync(CancellationToken ct = default) _dbFactory = dbFactory;
{ _hanaService = hanaService;
await using var db = await dbFactory.CreateDbContextAsync(ct); _excelService = excelService;
var siteIds = await db.Sites.Where(x => x.IsActive).Select(x => x.Id).ToListAsync(ct); _sharePointService = sharePointService;
_transformationService = transformationService;
_logger = logger;
}
foreach (var siteId in siteIds) public bool IsExporting(int siteId)
{ {
await ExportSiteAsync(siteId, ct); lock (_lock)
{
return _runningExports.ContainsKey(siteId);
} }
} }
public async Task ExportSiteAsync(int siteId, CancellationToken ct = default) public string GetExportStatus(int siteId)
{ {
var started = DateTime.UtcNow; lock (_lock)
{
return _runningExports.TryGetValue(siteId, out var status) ? status : string.Empty;
}
}
await using var db = await dbFactory.CreateDbContextAsync(ct); public async Task ExportAllAsync()
var site = await db.Sites.Include(x => x.HanaServer).SingleAsync(x => x.Id == siteId, ct); {
var settings = await db.ExportSettings.OrderBy(x => x.Id).FirstAsync(ct); using var db = await _dbFactory.CreateDbContextAsync();
var sp = await db.SharePointConfigs.OrderBy(x => x.Id).FirstAsync(ct); var sites = await db.Sites.Include(s => s.HanaServer).Where(s => s.IsActive).ToListAsync();
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.UtcNow, Timestamp = DateTime.Now,
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
{ {
var hanaServer = site.HanaServer ?? throw new InvalidOperationException("HANA Server fehlt."); using var db = await _dbFactory.CreateDbContextAsync();
var hanaPassword = cryptoService.Decrypt(hanaServer.EncryptedPassword); var settings = await db.ExportSettings.FirstOrDefaultAsync() ?? new ExportSettings();
var clientSecret = cryptoService.Decrypt(sp.EncryptedClientSecret); var spConfig = await db.SharePointConfigs.FirstOrDefaultAsync();
var records = hanaQueryService.QuerySales( UpdateStatus(site.Id, "HANA Abfrage...");
hanaServer.Host, var records = await Task.Run(() => _hanaService.GetSalesRecords(
hanaServer.Port, site.HanaServer, site.Schema, site.TSC, site.Land, settings.DateFilter));
hanaServer.Username,
hanaPassword,
site.Schema,
site.TSC,
site.Land,
settings.DateFilter);
var filePath = excelExportService.CreateFile(AppContext.BaseDirectory, site.Land, site.TSC, records); UpdateStatus(site.Id, "Transformationen anwenden...");
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);
await sharePointUploadService.UploadAsync( UpdateStatus(site.Id, "Excel erstellen...");
sp.SiteUrl, var outputDir = Path.Combine(AppContext.BaseDirectory, "output");
sp.ExportFolder, var filePath = _excelService.CreateExcelFile(outputDir, site.TSC, DateTime.UtcNow.Date, records);
sp.TenantId, var fileName = Path.GetFileName(filePath);
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 = Path.GetFileName(filePath); log.FileName = fileName;
log.ErrorMessage = null; log.DurationSeconds = sw.Elapsed.TotalSeconds;
_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
{ {
log.DurationSeconds = (DateTime.UtcNow - started).TotalSeconds; using var db = await _dbFactory.CreateDbContextAsync();
db.ExportLogs.Add(log); db.ExportLogs.Add(log);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync();
}
}
public async Task<DateTime?> GetNextRunAsync(CancellationToken ct = default) lock (_lock)
{ {
await using var db = await dbFactory.CreateDbContextAsync(ct); _runningExports.Remove(site.Id);
var settings = await db.ExportSettings.OrderBy(x => x.Id).FirstOrDefaultAsync(ct); }
if (settings is null || !settings.TimerEnabled) NotifyChanged();
}
}
private void UpdateStatus(int siteId, string status)
{ {
return null; lock (_lock)
}
var now = DateTime.Now;
var next = new DateTime(now.Year, now.Month, now.Day, settings.TimerHour, settings.TimerMinute, 0);
if (next <= now)
{ {
next = next.AddDays(1); _runningExports[siteId] = status;
}
NotifyChanged();
} }
return next; private void NotifyChanged()
}
public async Task<Dictionary<int, ExportLog?>> GetLatestLogsPerSiteAsync(CancellationToken ct = default)
{ {
await using var db = await dbFactory.CreateDbContextAsync(ct); OnExportStatusChanged?.Invoke();
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,19 +5,20 @@ namespace TrafagSalesExporter.Services;
public class HanaQueryService public class HanaQueryService
{ {
public List<SalesRecord> QuerySales(string host, int port, string username, string password, string schema, string tsc, string land, string dateFilter) public List<SalesRecord> GetSalesRecords(HanaServer server,
string schema, string tsc, string land, string dateFilter)
{ {
var connectionString = $"ServerNode={host}:{port};UserName={username};Password={password}"; var connectionString = server.BuildConnectionString();
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 creditQuery = GetCreditNoteQuery(schema, tsc, dateFilter); var creditNoteQuery = GetCreditNoteQuery(schema, tsc, dateFilter);
result.AddRange(Read(connection, invoiceQuery, land)); result.AddRange(ReadRecords(connection, invoiceQuery, land));
result.AddRange(Read(connection, creditQuery, land)); result.AddRange(ReadRecords(connection, creditNoteQuery, land));
foreach (var record in result) foreach (var record in result)
{ {
@@ -31,26 +32,58 @@ public class HanaQueryService
return result; return result;
} }
public bool TestConnection(string host, int port, string username, string password) public ConnectionTestResult TestConnectionDetailed(HanaServer server)
{ {
var connectionString = $"ServerNode={host}:{port};UserName={username};Password={password}"; var testResult = new ConnectionTestResult
{
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;
}
} }
private static List<SalesRecord> Read(HanaConnection connection, string query, string land) public void TestConnection(HanaServer server)
{
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 reader = cmd.ExecuteReader(); using var command = new HanaCommand(query, connection);
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["tsc"]?.ToString() ?? string.Empty, Tsc = reader.GetString(reader.GetOrdinal("tsc")),
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")),
@@ -172,3 +205,13 @@ 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;
}
@@ -0,0 +1,92 @@
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,95 +1,45 @@
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 siteUrl, string exportFolder, string tenantId, string clientId, string clientSecret, string land, string localFilePath) public async Task UploadAsync(string tenantId, string clientId, string clientSecret,
{ string siteUrl, string exportFolder, string land, string localFilePath)
var graph = CreateGraphClient(tenantId, clientId, clientSecret);
var (siteId, driveId) = await ResolveSiteAndDriveAsync(graph, siteUrl);
var folderPath = $"{exportFolder.Trim('/')}/{land}";
await EnsureFolderPathAsync(graph, driveId, folderPath);
var fileName = Path.GetFileName(localFilePath);
var remotePath = $"{folderPath}/{fileName}";
await using var stream = File.OpenRead(localFilePath);
await graph.Drives[driveId].Root.ItemWithPath(remotePath).Content.PutAsync(stream);
}
public async Task<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);
return new GraphServiceClient(credential, ["https://graph.microsoft.com/.default"]); var graphClient = 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 site = await graph.Sites[$"{uri.Host}:{uri.AbsolutePath}"].GetAsync(); var sitePath = uri.AbsolutePath;
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(); var drive = await graphClient.Sites[site.Id].Drive.GetAsync();
if (drive?.Id is null) if (drive?.Id is null)
{ throw new InvalidOperationException("SharePoint Dokumentenbibliothek konnte nicht gefunden werden.");
throw new InvalidOperationException("SharePoint Dokumentenbibliothek nicht gefunden.");
var fileName = Path.GetFileName(localFilePath);
var folderPath = exportFolder.Trim('/').Trim();
var remotePath = $"{folderPath}/{land}/{fileName}";
await using var stream = File.OpenRead(localFilePath);
await graphClient.Drives[drive.Id].Root.ItemWithPath(remotePath).Content.PutAsync(stream);
} }
return (site.Id, drive.Id); public async Task TestConnectionAsync(string tenantId, string clientId, string clientSecret, string siteUrl)
} {
var credential = new ClientSecretCredential(tenantId, clientId, clientSecret);
var graphClient = new GraphServiceClient(credential, ["https://graph.microsoft.com/.default"]);
private static async Task EnsureFolderPathAsync(GraphServiceClient graph, string driveId, string folderPath) var uri = new Uri(siteUrl);
{ var sitePath = uri.AbsolutePath;
var segments = folderPath.Split('/', StringSplitOptions.RemoveEmptyEntries); var site = await graphClient.Sites[$"{uri.Host}:{sitePath}"].GetAsync();
var currentPath = string.Empty;
foreach (var segment in segments) if (site?.Id is null)
{ throw new InvalidOperationException("SharePoint Site konnte nicht gefunden werden.");
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,56 +3,65 @@ using TrafagSalesExporter.Data;
namespace TrafagSalesExporter.Services; namespace TrafagSalesExporter.Services;
public class TimerBackgroundService( public class TimerBackgroundService : BackgroundService
IServiceScopeFactory scopeFactory,
ILogger<TimerBackgroundService> logger) : BackgroundService
{ {
protected override async Task ExecuteAsync(CancellationToken stoppingToken) private readonly IServiceProvider _serviceProvider;
{ private readonly ILogger<TimerBackgroundService> _logger;
while (!stoppingToken.IsCancellationRequested) private DateTime _nextRun = DateTime.MaxValue;
{
try
{
using var scope = scopeFactory.CreateScope();
var dbFactory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<AppDbContext>>();
var exportService = scope.ServiceProvider.GetRequiredService<ExportOrchestrationService>();
await using var db = await dbFactory.CreateDbContextAsync(stoppingToken); public DateTime NextRun => _nextRun;
var settings = await db.ExportSettings.OrderBy(x => x.Id).FirstOrDefaultAsync(stoppingToken);
public TimerBackgroundService(IServiceProvider serviceProvider, ILogger<TimerBackgroundService> logger)
{
_serviceProvider = serviceProvider;
_logger = logger;
}
public void Recalculate()
{
_ = RecalculateNextRunAsync();
}
private async Task RecalculateNextRunAsync()
{
var dbFactory = _serviceProvider.GetRequiredService<IDbContextFactory<AppDbContext>>();
using var db = await dbFactory.CreateDbContextAsync();
var settings = await db.ExportSettings.FirstOrDefaultAsync();
if (settings is null || !settings.TimerEnabled) if (settings is null || !settings.TimerEnabled)
{ {
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken); _nextRun = DateTime.MaxValue;
continue; return;
} }
var now = DateTime.Now; var now = DateTime.Now;
var nextRun = new DateTime(now.Year, now.Month, now.Day, settings.TimerHour, settings.TimerMinute, 0); var todayRun = new DateTime(now.Year, now.Month, now.Day, settings.TimerHour, settings.TimerMinute, 0);
if (nextRun <= now) _nextRun = todayRun <= now ? todayRun.AddDays(1) : todayRun;
{
nextRun = nextRun.AddDays(1);
} }
var delay = nextRun - now; protected override async Task ExecuteAsync(CancellationToken stoppingToken)
logger.LogInformation("Nächster automatischer Export um {NextRun}", nextRun);
await Task.Delay(delay, stoppingToken);
if (stoppingToken.IsCancellationRequested)
{ {
break; await RecalculateNextRunAsync();
}
await exportService.ExportAllActiveSitesAsync(stoppingToken); while (!stoppingToken.IsCancellationRequested)
}
catch (TaskCanceledException)
{ {
break; await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
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 im TimerBackgroundService"); _logger.LogError(ex, "Fehler beim Timer-Export");
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken); }
}
await RecalculateNextRunAsync();
} }
} }
} }
+26 -5
View File
@@ -1,20 +1,41 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PlatformTarget>x64</PlatformTarget>
<Prefer32Bit>false</Prefer32Bit>
<!--
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.Design" Version="9.0.0"> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" />
<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>
@@ -0,0 +1,25 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.14.37012.4 d17.14
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TrafagSalesExporter", "TrafagSalesExporter.csproj", "{49B56D6D-731C-6482-4A5C-82EAEEBCE593}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{49B56D6D-731C-6482-4A5C-82EAEEBCE593}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{49B56D6D-731C-6482-4A5C-82EAEEBCE593}.Debug|Any CPU.Build.0 = Debug|Any CPU
{49B56D6D-731C-6482-4A5C-82EAEEBCE593}.Release|Any CPU.ActiveCfg = Release|Any CPU
{49B56D6D-731C-6482-4A5C-82EAEEBCE593}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {DC174EA0-ECCB-4957-9D97-E7ABED992867}
EndGlobalSection
EndGlobal
+8
View File
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
-3
View File
@@ -1,3 +0,0 @@
html, body {
font-family: Roboto, Arial, sans-serif;
}
+3
View File
@@ -0,0 +1,3 @@
html, body {
font-family: 'Roboto', sans-serif;
}