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
32 changed files with 2229 additions and 0 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
+18
View File
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Trafag Sales Exporter</title>
<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="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet" />
<HeadOutlet @rendermode="@Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer" />
</head>
<body>
<Routes @rendermode="@Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer" />
<script src="_framework/blazor.web.js"></script>
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
</body>
</html>
@@ -0,0 +1,38 @@
@inherits LayoutComponentBase
<MudThemeProvider Theme="_theme" />
<MudPopoverProvider />
<MudDialogProvider />
<MudSnackbarProvider />
<MudLayout>
<MudAppBar Elevation="1" Color="Color.Primary">
<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>
<MudDrawer @bind-Open="_drawerOpen" Elevation="2" ClipMode="DrawerClipMode.Always">
<NavMenu />
</MudDrawer>
<MudMainContent Class="pa-4">
@Body
</MudMainContent>
</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;
}
@@ -0,0 +1,17 @@
<MudNavMenu>
<MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">
Dashboard
</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>
@@ -0,0 +1,193 @@
@page "/"
@using Microsoft.EntityFrameworkCore
@using TrafagSalesExporter.Data
@using TrafagSalesExporter.Services
@inject IDbContextFactory<AppDbContext> DbFactory
@inject ExportOrchestrationService Orchestrator
@inject TimerBackgroundService TimerService
@inject ISnackbar Snackbar
@implements IDisposable
<PageTitle>Dashboard</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Dashboard</MudText>
<MudPaper Class="pa-4 mb-4" Elevation="1">
<MudStack Row AlignItems="AlignItems.Center" Spacing="4">
<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>
</MudPaper>
<MudTable Items="_dashboardRows" Dense Hover Striped Loading="_loading">
<HeaderContent>
<MudTh>Land</MudTh>
<MudTh>TSC</MudTh>
<MudTh>Schema</MudTh>
<MudTh>Server</MudTh>
<MudTh>Status</MudTh>
<MudTh>Zeilen</MudTh>
<MudTh>Letzter Lauf</MudTh>
<MudTh>Dauer</MudTh>
<MudTh>Aktion</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Land</MudTd>
<MudTd>@context.TSC</MudTd>
<MudTd>@context.Schema</MudTd>
<MudTd>@context.ServerName</MudTd>
<MudTd>
@if (Orchestrator.IsExporting(context.SiteId))
{
<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
{
<MudText Typo="Typo.caption" Color="Color.Default">-</MudText>
}
</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>
</MudTable>
@code {
private List<DashboardRow> _dashboardRows = new();
private bool _loading = true;
private bool _anyRunning;
protected override async Task OnInitializedAsync()
{
Orchestrator.OnExportStatusChanged += HandleStatusChanged;
await LoadDataAsync();
}
private async Task LoadDataAsync()
{
_loading = true;
using var db = await DbFactory.CreateDbContextAsync();
var sites = await db.Sites.Include(s => s.HanaServer).Where(s => s.IsActive).ToListAsync();
var logs = await db.ExportLogs
.GroupBy(l => l.SiteId)
.Select(g => g.OrderByDescending(l => l.Timestamp).First())
.ToListAsync();
_dashboardRows = sites.Select(s =>
{
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 ExportAll()
{
_anyRunning = true;
_ = Task.Run(async () =>
{
await Orchestrator.ExportAllAsync();
await InvokeAsync(async () =>
{
await LoadDataAsync();
StateHasChanged();
});
});
Snackbar.Add("Export für alle Standorte gestartet", Severity.Info);
}
private void ExportSingle(int siteId)
{
_ = Task.Run(async () =>
{
await Orchestrator.ExportSiteByIdAsync(siteId);
await InvokeAsync(async () =>
{
await LoadDataAsync();
StateHasChanged();
});
});
Snackbar.Add("Export gestartet", Severity.Info);
}
private async void HandleStatusChanged()
{
await InvokeAsync(async () =>
{
_anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId));
StateHasChanged();
if (!_anyRunning)
{
await LoadDataAsync();
StateHasChanged();
}
});
}
public void Dispose()
{
Orchestrator.OnExportStatusChanged -= HandleStatusChanged;
}
private class DashboardRow
{
public int SiteId { get; set; }
public string Land { get; set; } = "";
public string TSC { get; set; } = "";
public string Schema { get; set; } = "";
public string ServerName { get; set; } = "";
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; } = "";
}
}
@@ -0,0 +1,134 @@
@page "/logs"
@using Microsoft.EntityFrameworkCore
@using TrafagSalesExporter.Data
@inject IDbContextFactory<AppDbContext> DbFactory
@inject ISnackbar Snackbar
@inject IDialogService DialogService
<PageTitle>Logs</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Export Logs</MudText>
<MudPaper Class="pa-4 mb-4" Elevation="1">
<MudStack Row AlignItems="AlignItems.Center" Spacing="3">
<MudSelect @bind-Value="_filterLand" Label="Land" Clearable Style="max-width:200px;">
@foreach (var land in _availableLands)
{
<MudSelectItem Value="@land">@land</MudSelectItem>
}
</MudSelect>
<MudSelect @bind-Value="_filterStatus" Label="Status" Clearable Style="max-width:150px;">
<MudSelectItem Value="@("OK")">OK</MudSelectItem>
<MudSelectItem Value="@("Error")">Error</MudSelectItem>
</MudSelect>
<MudDatePicker @bind-Date="_filterDate" Label="Datum" Clearable Style="max-width:200px;" />
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="ApplyFilter"
StartIcon="@Icons.Material.Filled.FilterAlt">
Filtern
</MudButton>
<MudSpacer />
<MudButton Variant="Variant.Outlined" Color="Color.Error" OnClick="DeleteOldLogs"
StartIcon="@Icons.Material.Filled.DeleteSweep">
Alte Logs löschen
</MudButton>
</MudStack>
</MudPaper>
<MudTable Items="_logs" Dense Hover Striped Loading="_loading">
<HeaderContent>
<MudTh>Zeitpunkt</MudTh>
<MudTh>Land</MudTh>
<MudTh>TSC</MudTh>
<MudTh>Status</MudTh>
<MudTh>Zeilen</MudTh>
<MudTh>Dauer</MudTh>
<MudTh>Dateiname</MudTh>
<MudTh>Fehler</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Timestamp.ToString("dd.MM.yyyy HH:mm:ss")</MudTd>
<MudTd>@context.Land</MudTd>
<MudTd>@context.TSC</MudTd>
<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.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>
</MudTable>
@code {
private List<ExportLog> _logs = new();
private List<string> _availableLands = new();
private string? _filterLand;
private string? _filterStatus;
private DateTime? _filterDate;
private bool _loading = true;
protected override async Task OnInitializedAsync()
{
using var db = await DbFactory.CreateDbContextAsync();
_availableLands = await db.ExportLogs.Select(l => l.Land).Distinct().OrderBy(l => l).ToListAsync();
await LoadLogsAsync();
}
private async Task LoadLogsAsync()
{
_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;
}
private async Task ApplyFilter()
{
await LoadLogsAsync();
}
private async Task DeleteOldLogs()
{
var result = await DialogService.ShowMessageBox(
"Alte Logs löschen",
"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);
var count = await db.SaveChangesAsync();
await LoadLogsAsync();
Snackbar.Add($"{oldLogs.Count} alte Logs gelöscht", Severity.Info);
}
}
@@ -0,0 +1,164 @@
@page "/settings"
@using Microsoft.EntityFrameworkCore
@using TrafagSalesExporter.Data
@using TrafagSalesExporter.Services
@inject IDbContextFactory<AppDbContext> DbFactory
@inject SharePointUploadService SpService
@inject TimerBackgroundService TimerService
@inject ISnackbar Snackbar
<PageTitle>Settings</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Settings</MudText>
@* SharePoint Config *@
<MudText Typo="Typo.h5" Class="mb-2">SharePoint Konfiguration</MudText>
<MudPaper Class="pa-4 mb-6" Elevation="1">
<MudGrid>
<MudItem xs="12" md="6">
<MudTextField @bind-Value="_spConfig.SiteUrl" Label="Site URL" />
</MudItem>
<MudItem xs="12" md="6">
<MudTextField @bind-Value="_spConfig.ExportFolder" Label="Export Folder" />
</MudItem>
<MudItem xs="12" md="4">
<MudTextField @bind-Value="_spConfig.TenantId" Label="Tenant ID" />
</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>
</MudItem>
</MudGrid>
</MudPaper>
@* 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 {
private SharePointConfig _spConfig = new();
private ExportSettings _exportSettings = new();
private bool _testingSp;
protected override async Task OnInitializedAsync()
{
using var db = await DbFactory.CreateDbContextAsync();
_spConfig = await db.SharePointConfigs.FirstOrDefaultAsync() ?? new SharePointConfig();
_exportSettings = await db.ExportSettings.FirstOrDefaultAsync() ?? new ExportSettings();
}
private async Task SaveSharePoint()
{
using var db = await DbFactory.CreateDbContextAsync();
var existing = await db.SharePointConfigs.FirstOrDefaultAsync();
if (existing is null)
{
db.SharePointConfigs.Add(_spConfig);
}
else
{
existing.SiteUrl = _spConfig.SiteUrl;
existing.ExportFolder = _spConfig.ExportFolder;
existing.TenantId = _spConfig.TenantId;
existing.ClientId = _spConfig.ClientId;
existing.ClientSecret = _spConfig.ClientSecret;
}
await db.SaveChangesAsync();
Snackbar.Add("SharePoint Konfiguration gespeichert", Severity.Success);
}
private async Task TestSharePoint()
{
_testingSp = true;
try
{
await SpService.TestConnectionAsync(
_spConfig.TenantId, _spConfig.ClientId, _spConfig.ClientSecret, _spConfig.SiteUrl);
Snackbar.Add("SharePoint Verbindung erfolgreich!", Severity.Success);
}
catch (Exception ex)
{
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);
}
}
@@ -0,0 +1,353 @@
@page "/standorte"
@using Microsoft.EntityFrameworkCore
@using TrafagSalesExporter.Data
@using TrafagSalesExporter.Services
@inject IDbContextFactory<AppDbContext> DbFactory
@inject HanaQueryService HanaService
@inject ISnackbar Snackbar
@inject IDialogService DialogService
<PageTitle>Standorte</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Standorte</MudText>
<MudText Typo="Typo.h5" Class="mb-2">HANA Server</MudText>
<MudPaper Class="pa-4 mb-6" Elevation="1">
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add"
OnClick="AddServer" Class="mb-3">
Server hinzufügen
</MudButton>
<MudTable Items="_servers" Dense Hover Striped>
<HeaderContent>
<MudTh>Name</MudTh>
<MudTh>Host</MudTh>
<MudTh>Port</MudTh>
<MudTh>Username</MudTh>
<MudTh>Verbindungsstatus</MudTh>
<MudTh>Aktionen</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Name</MudTd>
<MudTd>@context.Host</MudTd>
<MudTd>@context.Port</MudTd>
<MudTd>@context.Username</MudTd>
<MudTd>
@if (_connectionStatus.TryGetValue(context.Id, out var status))
{
<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>
</RowTemplate>
</MudTable>
</MudPaper>
<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 {
private readonly string[] _sourceSystems = ["SAP", "BI1", "SAGE"];
private readonly Dictionary<int, ConnectionTestResult> _connectionStatus = new();
private List<HanaServer> _servers = new();
private List<Site> _sites = new();
private HanaServer _editingServer = new();
private Site _editingSite = new();
private bool _serverDialogVisible;
private bool _siteDialogVisible;
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
protected override async Task OnInitializedAsync()
{
await LoadDataAsync();
}
private async Task LoadDataAsync()
{
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;
}
}
await db.SaveChangesAsync();
_serverDialogVisible = false;
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)
{
_editingSite = new Site
{
Id = site.Id,
HanaServerId = site.HanaServerId,
Schema = site.Schema,
TSC = site.TSC,
Land = site.Land,
SourceSystem = string.IsNullOrWhiteSpace(site.SourceSystem) ? "SAP" : site.SourceSystem,
IsActive = site.IsActive
};
_siteDialogVisible = true;
}
private async Task SaveSite()
{
using var db = await DbFactory.CreateDbContextAsync();
if (_editingSite.Id == 0)
{
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 db.SaveChangesAsync();
_siteDialogVisible = false;
await LoadDataAsync();
Snackbar.Add("Standort gespeichert", Severity.Success);
}
private async Task DeleteSite(Site site)
{
var result = await DialogService.ShowMessageBox(
"Standort löschen",
$"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 LoadDataAsync();
Snackbar.Add("Standort gelöscht", Severity.Info);
}
}
@@ -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();
}
}
@@ -0,0 +1,6 @@
<Router AppAssembly="typeof(Program).Assembly">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
<FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found>
</Router>
@@ -0,0 +1,9 @@
@using System.Net.Http
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.JSInterop
@using MudBlazor
@using TrafagSalesExporter.Components
@using TrafagSalesExporter.Components.Layout
@using TrafagSalesExporter.Models
+115
View File
@@ -0,0 +1,115 @@
using System.Data;
using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Data;
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<HanaServer> HanaServers => Set<HanaServer>();
public DbSet<Site> Sites => Set<Site>();
public DbSet<SharePointConfig> SharePointConfigs => Set<SharePointConfig>();
public DbSet<ExportSettings> ExportSettings => Set<ExportSettings>();
public DbSet<ExportLog> ExportLogs => Set<ExportLog>();
public DbSet<FieldTransformationRule> FieldTransformationRules => Set<FieldTransformationRule>();
/// <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)
{
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);
}
private static void AddColumnIfMissing(AppDbContext db, string table, string column, string type)
{
var conn = db.Database.GetDbConnection();
if (conn.State != ConnectionState.Open) conn.Open();
bool exists = false;
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = $"PRAGMA table_info({table})";
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
if (string.Equals(reader["name"]?.ToString(), column, StringComparison.OrdinalIgnoreCase))
{
exists = true;
break;
}
}
}
if (!exists)
{
using var alter = conn.CreateCommand();
alter.CommandText = $"ALTER TABLE {table} ADD COLUMN {column} {type}";
alter.ExecuteNonQuery();
}
}
private static void EnsureTransformationTable(AppDbContext db)
{
var conn = db.Database.GetDbConnection();
if (conn.State != ConnectionState.Open) conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = @"
CREATE TABLE IF NOT EXISTS FieldTransformationRules (
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
SourceSystem TEXT NOT NULL DEFAULT 'SAP',
SourceField TEXT NOT NULL,
TargetField TEXT NOT NULL,
TransformationType TEXT NOT NULL,
Argument TEXT NOT NULL DEFAULT '',
SortOrder INTEGER NOT NULL DEFAULT 0,
IsActive INTEGER NOT NULL DEFAULT 1
);";
cmd.ExecuteNonQuery();
}
public static void SeedIfEmpty(AppDbContext db)
{
if (db.HanaServers.Any()) return;
var serverInternal = new HanaServer { Name = "Internal", Host = "travtrp0", Port = 30015, Username = "", Password = "" };
var serverIndia = new HanaServer { Name = "India", Host = "20.197.20.60", Port = 30015, Username = "", Password = "" };
db.HanaServers.AddRange(serverInternal, serverIndia);
db.SaveChanges();
db.Sites.AddRange(
new Site { HanaServerId = serverInternal.Id, Schema = "fr01_p", TSC = "TRFR", Land = "Frankreich", IsActive = true },
new Site { HanaServerId = serverInternal.Id, Schema = "it01_p", TSC = "TRIT", Land = "Italien", IsActive = true },
new Site { HanaServerId = serverInternal.Id, Schema = "us01_p", TSC = "TRUS", Land = "USA", IsActive = true },
new Site { HanaServerId = serverIndia.Id, Schema = "TRAFAG_LIVE", TSC = "TRIN", Land = "Indien", IsActive = true }
);
db.SharePointConfigs.Add(new SharePointConfig
{
SiteUrl = "https://trafagag.sharepoint.com/sites/WorldwideBIPlatform",
ExportFolder = "/Shared Documents/Exports/",
TenantId = "",
ClientId = "",
ClientSecret = ""
});
db.ExportSettings.Add(new ExportSettings
{
DateFilter = "2025-01-01",
TimerHour = 3,
TimerMinute = 0,
TimerEnabled = true
});
db.SaveChanges();
}
}
+21
View File
@@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace TrafagSalesExporter.Models;
public class ExportLog
{
public int Id { get; set; }
public DateTime Timestamp { get; set; }
public int SiteId { get; set; }
[ForeignKey(nameof(SiteId))]
public Site? Site { get; set; }
public string Land { get; set; } = string.Empty;
public string TSC { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public int RowCount { get; set; }
public string? ErrorMessage { get; set; }
public string FileName { get; set; } = string.Empty;
public double DurationSeconds { get; set; }
}
@@ -0,0 +1,10 @@
namespace TrafagSalesExporter.Models;
public class ExportSettings
{
public int Id { get; set; }
public string DateFilter { get; set; } = "2025-01-01";
public int TimerHour { get; set; } = 3;
public int TimerMinute { get; set; }
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;
}
+84
View File
@@ -0,0 +1,84 @@
using System.ComponentModel.DataAnnotations;
namespace TrafagSalesExporter.Models;
public class HanaServer
{
public int Id { get; set; }
[Required]
public string Name { get; set; } = string.Empty;
[Required]
public string Host { get; set; } = string.Empty;
public int Port { get; set; } = 30015;
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
/// <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();
}
}
+31
View File
@@ -0,0 +1,31 @@
namespace TrafagSalesExporter.Models;
public class SalesRecord
{
public DateTime ExtractionDate { get; set; }
public string Tsc { get; set; } = string.Empty;
public string InvoiceNumber { get; set; } = string.Empty;
public int PositionOnInvoice { get; set; }
public string Material { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string ProductGroup { get; set; } = string.Empty;
public decimal Quantity { get; set; }
public string SupplierNumber { get; set; } = string.Empty;
public string SupplierName { get; set; } = string.Empty;
public string SupplierCountry { get; set; } = string.Empty;
public string CustomerNumber { get; set; } = string.Empty;
public string CustomerName { get; set; } = string.Empty;
public string CustomerCountry { get; set; } = string.Empty;
public string CustomerIndustry { get; set; } = string.Empty;
public decimal StandardCost { get; set; }
public string StandardCostCurrency { get; set; } = string.Empty;
public string PurchaseOrderNumber { get; set; } = string.Empty;
public decimal SalesPriceValue { get; set; }
public string SalesCurrency { get; set; } = string.Empty;
public string Incoterms2020 { get; set; } = string.Empty;
public string SalesResponsibleEmployee { get; set; } = string.Empty;
public DateTime? InvoiceDate { get; set; }
public DateTime? OrderDate { get; set; }
public string Land { get; set; } = string.Empty;
public string DocumentType { get; set; } = string.Empty;
}
@@ -0,0 +1,11 @@
namespace TrafagSalesExporter.Models;
public class SharePointConfig
{
public int Id { get; set; }
public string SiteUrl { get; set; } = string.Empty;
public string ExportFolder { get; set; } = string.Empty;
public string TenantId { get; set; } = string.Empty;
public string ClientId { get; set; } = string.Empty;
public string ClientSecret { get; set; } = string.Empty;
}
+28
View File
@@ -0,0 +1,28 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace TrafagSalesExporter.Models;
public class Site
{
public int Id { get; set; }
public int HanaServerId { get; set; }
[ForeignKey(nameof(HanaServerId))]
public HanaServer? HanaServer { get; set; }
[Required]
public string Schema { get; set; } = string.Empty;
[Required]
public string TSC { get; set; } = string.Empty;
[Required]
public string Land { get; set; } = string.Empty;
[Required]
public string SourceSystem { get; set; } = "SAP";
public bool IsActive { get; set; } = true;
}
+46
View File
@@ -0,0 +1,46 @@
using Microsoft.EntityFrameworkCore;
using MudBlazor.Services;
using TrafagSalesExporter.Data;
using TrafagSalesExporter.Services;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
builder.Services.AddMudServices();
builder.Services.AddDbContextFactory<AppDbContext>(options =>
options.UseSqlite("Data Source=trafag_exporter.db"));
builder.Services.AddSingleton<HanaQueryService>();
builder.Services.AddSingleton<ExcelExportService>();
builder.Services.AddSingleton<SharePointUploadService>();
builder.Services.AddSingleton<RecordTransformationService>();
builder.Services.AddSingleton<ExportOrchestrationService>();
builder.Services.AddSingleton<TimerBackgroundService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<TimerBackgroundService>());
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var dbFactory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<AppDbContext>>();
using var db = await dbFactory.CreateDbContextAsync();
await db.Database.EnsureCreatedAsync();
AppDbContext.EnsureSchema(db);
AppDbContext.SeedIfEmpty(db);
}
if (!app.Environment.IsDevelopment())
{
app.UseHsts();
}
app.UseStaticFiles();
app.UseAntiforgery();
app.MapRazorComponents<TrafagSalesExporter.Components.App>()
.AddInteractiveServerRenderMode();
app.Run();
@@ -0,0 +1,12 @@
{
"profiles": {
"TrafagSalesExporter": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:55415;http://localhost:55416"
}
}
}
@@ -0,0 +1,89 @@
using ClosedXML.Excel;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public class ExcelExportService
{
public string CreateExcelFile(string outputDirectory, string tsc, DateTime fileDate, List<SalesRecord> records)
{
Directory.CreateDirectory(outputDirectory);
var fileName = $"Sales_{tsc}_{fileDate:yyyy-MM-dd}.xlsx";
var fullPath = Path.Combine(outputDirectory, fileName);
using var workbook = new XLWorkbook();
var ws = workbook.Worksheets.Add("Sales");
var headers = new[]
{
"extraction date",
"TSC",
"Invoice Number",
"Position on invoice",
"Material",
"Name",
"Product Group",
"Quantity",
"Supplier number",
"Supplier name",
"Supplier country",
"Customer number",
"Customer name",
"Customer country",
"Customer Industry",
"Standard cost",
"Standard Cost Currency",
"Purchase Order number",
"Sales Price/Value",
"Sales Currency",
"Incoterms 2020",
"Sales responsible employee",
"invoice date",
"order date",
"Land",
"Document Type"
};
for (var i = 0; i < headers.Length; i++)
{
ws.Cell(1, i + 1).Value = headers[i];
ws.Cell(1, i + 1).Style.Font.Bold = true;
}
var row = 2;
foreach (var record in records)
{
ws.Cell(row, 1).Value = record.ExtractionDate.ToString("dd.MM.yyyy HH:mm:ss");
ws.Cell(row, 2).Value = record.Tsc;
ws.Cell(row, 3).Value = record.InvoiceNumber;
ws.Cell(row, 4).Value = record.PositionOnInvoice;
ws.Cell(row, 5).Value = record.Material;
ws.Cell(row, 6).Value = record.Name;
ws.Cell(row, 7).Value = record.ProductGroup;
ws.Cell(row, 8).Value = record.Quantity;
ws.Cell(row, 9).Value = record.SupplierNumber;
ws.Cell(row, 10).Value = record.SupplierName;
ws.Cell(row, 11).Value = record.SupplierCountry;
ws.Cell(row, 12).Value = record.CustomerNumber;
ws.Cell(row, 13).Value = record.CustomerName;
ws.Cell(row, 14).Value = record.CustomerCountry;
ws.Cell(row, 15).Value = record.CustomerIndustry;
ws.Cell(row, 16).Value = record.StandardCost;
ws.Cell(row, 17).Value = record.StandardCostCurrency;
ws.Cell(row, 18).Value = record.PurchaseOrderNumber;
ws.Cell(row, 19).Value = record.SalesPriceValue;
ws.Cell(row, 20).Value = record.SalesCurrency;
ws.Cell(row, 21).Value = record.Incoterms2020;
ws.Cell(row, 22).Value = record.SalesResponsibleEmployee;
ws.Cell(row, 23).Value = record.InvoiceDate?.ToString("dd.MM.yyyy") ?? string.Empty;
ws.Cell(row, 24).Value = record.OrderDate?.ToString("dd.MM.yyyy") ?? string.Empty;
ws.Cell(row, 25).Value = record.Land;
ws.Cell(row, 26).Value = record.DocumentType;
row++;
}
ws.Columns().AdjustToContents();
workbook.SaveAs(fullPath);
return fullPath;
}
}
@@ -0,0 +1,171 @@
using Microsoft.EntityFrameworkCore;
using System.Diagnostics;
using TrafagSalesExporter.Data;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
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,
HanaQueryService hanaService,
ExcelExportService excelService,
SharePointUploadService sharePointService,
RecordTransformationService transformationService,
ILogger<ExportOrchestrationService> logger)
{
_dbFactory = dbFactory;
_hanaService = hanaService;
_excelService = excelService;
_sharePointService = sharePointService;
_transformationService = transformationService;
_logger = logger;
}
public bool IsExporting(int siteId)
{
lock (_lock)
{
return _runningExports.ContainsKey(siteId);
}
}
public string GetExportStatus(int siteId)
{
lock (_lock)
{
return _runningExports.TryGetValue(siteId, out var status) ? status : string.Empty;
}
}
public async Task ExportAllAsync()
{
using var db = await _dbFactory.CreateDbContextAsync();
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
{
Timestamp = DateTime.Now,
SiteId = site.Id,
Land = site.Land,
TSC = site.TSC
};
try
{
using var db = await _dbFactory.CreateDbContextAsync();
var settings = await db.ExportSettings.FirstOrDefaultAsync() ?? new ExportSettings();
var spConfig = await db.SharePointConfigs.FirstOrDefaultAsync();
UpdateStatus(site.Id, "HANA Abfrage...");
var records = await Task.Run(() => _hanaService.GetSalesRecords(
site.HanaServer, site.Schema, site.TSC, site.Land, settings.DateFilter));
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);
UpdateStatus(site.Id, "Excel erstellen...");
var outputDir = Path.Combine(AppContext.BaseDirectory, "output");
var filePath = _excelService.CreateExcelFile(outputDir, site.TSC, DateTime.UtcNow.Date, records);
var fileName = Path.GetFileName(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.RowCount = records.Count;
log.FileName = fileName;
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)
{
sw.Stop();
log.Status = "Error";
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
{
using var db = await _dbFactory.CreateDbContextAsync();
db.ExportLogs.Add(log);
await db.SaveChangesAsync();
lock (_lock)
{
_runningExports.Remove(site.Id);
}
NotifyChanged();
}
}
private void UpdateStatus(int siteId, string status)
{
lock (_lock)
{
_runningExports[siteId] = status;
}
NotifyChanged();
}
private void NotifyChanged()
{
OnExportStatusChanged?.Invoke();
}
}
@@ -0,0 +1,217 @@
using Sap.Data.Hana;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public class HanaQueryService
{
public List<SalesRecord> GetSalesRecords(HanaServer server,
string schema, string tsc, string land, string dateFilter)
{
var connectionString = server.BuildConnectionString();
var result = new List<SalesRecord>();
using var connection = new HanaConnection(connectionString);
connection.Open();
var invoiceQuery = GetInvoiceQuery(schema, tsc, dateFilter);
var creditNoteQuery = GetCreditNoteQuery(schema, tsc, dateFilter);
result.AddRange(ReadRecords(connection, invoiceQuery, land));
result.AddRange(ReadRecords(connection, creditNoteQuery, land));
foreach (var record in result)
{
if (record.Material.Contains('/'))
{
var parts = record.Material.Split('/');
record.Material = parts[^1];
}
}
return result;
}
public ConnectionTestResult TestConnectionDetailed(HanaServer server)
{
var testResult = new ConnectionTestResult
{
TestedAtUtc = DateTime.UtcNow,
ConnectionStringPreview = server.GetConnectionStringPreview(),
Stage = "Verbindungsaufbau"
};
try
{
var connectionString = server.BuildConnectionString();
using var connection = new HanaConnection(connectionString);
connection.Open();
testResult.Stage = "Ping-Query";
using var command = new HanaCommand("SELECT 1 FROM DUMMY", connection);
command.ExecuteScalar();
testResult.Success = true;
testResult.Stage = "OK";
return testResult;
}
catch (Exception ex)
{
testResult.Success = false;
testResult.ErrorMessage = ex.Message;
testResult.ExceptionType = ex.GetType().Name;
return testResult;
}
}
public void TestConnection(HanaServer server)
{
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>();
using var command = new HanaCommand(query, connection);
using var reader = command.ExecuteReader();
while (reader.Read())
{
records.Add(new SalesRecord
{
ExtractionDate = reader.GetDateTime(reader.GetOrdinal("extraction_date")),
Tsc = reader.GetString(reader.GetOrdinal("tsc")),
InvoiceNumber = reader["invoice_number"]?.ToString() ?? string.Empty,
PositionOnInvoice = Convert.ToInt32(reader["invoice_position"]),
InvoiceDate = reader.IsDBNull(reader.GetOrdinal("invoice_date")) ? null : reader.GetDateTime(reader.GetOrdinal("invoice_date")),
Material = reader["material"]?.ToString() ?? string.Empty,
Name = reader["material_name"]?.ToString() ?? string.Empty,
ProductGroup = reader["product_group"]?.ToString() ?? string.Empty,
Quantity = Convert.ToDecimal(reader["quantity"]),
SupplierNumber = reader["supplier_number"]?.ToString() ?? string.Empty,
SupplierName = reader["supplier_name"]?.ToString() ?? string.Empty,
SupplierCountry = reader["supplier_country"]?.ToString() ?? string.Empty,
CustomerNumber = reader["customer_number"]?.ToString() ?? string.Empty,
CustomerName = reader["customer_name"]?.ToString() ?? string.Empty,
CustomerCountry = reader["customer_country"]?.ToString() ?? string.Empty,
CustomerIndustry = reader["customer_industry"]?.ToString() ?? string.Empty,
StandardCost = Convert.ToDecimal(reader["standard_cost"]),
StandardCostCurrency = reader["standard_cost_currency"]?.ToString() ?? string.Empty,
PurchaseOrderNumber = reader["purchase_order_number"]?.ToString() ?? string.Empty,
SalesPriceValue = Convert.ToDecimal(reader["sales_value"]),
SalesCurrency = reader["sales_currency"]?.ToString() ?? string.Empty,
Incoterms2020 = reader["incoterms_2020"]?.ToString() ?? string.Empty,
SalesResponsibleEmployee = reader["sales_responsible"]?.ToString() ?? string.Empty,
OrderDate = reader.IsDBNull(reader.GetOrdinal("order_date")) ? null : reader.GetDateTime(reader.GetOrdinal("order_date")),
Land = land,
DocumentType = reader["doc_type"]?.ToString() ?? string.Empty
});
}
return records;
}
private static string GetInvoiceQuery(string schema, string tsc, string dateFilter) => $@"
SELECT
CURRENT_TIMESTAMP AS extraction_date,
'{tsc}' AS tsc,
h.""DocNum"" AS invoice_number,
p.""LineNum"" AS invoice_position,
h.""DocDate"" AS invoice_date,
p.""ItemCode"" AS material,
p.""Dscription"" AS material_name,
COALESCE(grp.""ItmsGrpNam"", '') AS product_group,
p.""Quantity"" AS quantity,
COALESCE(itm.""CardCode"", '') AS supplier_number,
COALESCE(sup.""CardName"", '') AS supplier_name,
COALESCE(sup_adr.""Country"", '') AS supplier_country,
h.""CardCode"" AS customer_number,
h.""CardName"" AS customer_name,
COALESCE(cust_adr.""Country"", '') AS customer_country,
COALESCE(ind.""IndName"", '') AS customer_industry,
p.""StockPrice"" AS standard_cost,
COALESCE(p.""Currency"", h.""DocCur"") AS standard_cost_currency,
CASE WHEN p.""BaseType"" = 22
THEN CAST(p.""BaseRef"" AS NVARCHAR(20))
ELSE '' END AS purchase_order_number,
p.""LineTotal"" AS sales_value,
COALESCE(p.""Currency"", h.""DocCur"") AS sales_currency,
'' AS incoterms_2020,
COALESCE(emp.""SlpName"", '') AS sales_responsible,
CASE WHEN p.""BaseType"" = 17
THEN (SELECT o.""DocDate"" FROM {schema}.""ORDR"" o
WHERE o.""DocEntry"" = p.""BaseEntry"")
ELSE NULL END AS order_date,
'INV' AS doc_type
FROM {schema}.""OINV"" h
INNER JOIN {schema}.""INV1"" p ON h.""DocEntry"" = p.""DocEntry""
LEFT JOIN {schema}.""OITM"" itm ON p.""ItemCode"" = itm.""ItemCode""
LEFT JOIN {schema}.""OITB"" grp ON itm.""ItmsGrpCod"" = grp.""ItmsGrpCod""
LEFT JOIN {schema}.""OCRD"" cust ON h.""CardCode"" = cust.""CardCode""
LEFT JOIN {schema}.""CRD1"" cust_adr ON h.""CardCode"" = cust_adr.""CardCode""
AND cust_adr.""AdresType"" = 'B' AND cust_adr.""Address"" = h.""PayToCode""
LEFT JOIN {schema}.""OOND"" ind ON cust.""IndustryC"" = ind.""IndCode""
LEFT JOIN {schema}.""OCRD"" sup ON itm.""CardCode"" = sup.""CardCode""
AND sup.""CardType"" = 'S'
LEFT JOIN {schema}.""CRD1"" sup_adr ON itm.""CardCode"" = sup_adr.""CardCode""
AND sup_adr.""AdresType"" = 'B'
LEFT JOIN {schema}.""OSLP"" emp ON h.""SlpCode"" = emp.""SlpCode""
WHERE h.""CANCELED"" = 'N' AND h.""DocDate"" >= '{dateFilter}'
ORDER BY h.""DocDate"" DESC, h.""DocNum"", p.""LineNum""";
private static string GetCreditNoteQuery(string schema, string tsc, string dateFilter) => $@"
SELECT
CURRENT_TIMESTAMP AS extraction_date,
'{tsc}' AS tsc,
h.""DocNum"" AS invoice_number,
p.""LineNum"" AS invoice_position,
h.""DocDate"" AS invoice_date,
p.""ItemCode"" AS material,
p.""Dscription"" AS material_name,
COALESCE(grp.""ItmsGrpNam"", '') AS product_group,
p.""Quantity"" * -1 AS quantity,
COALESCE(itm.""CardCode"", '') AS supplier_number,
COALESCE(sup.""CardName"", '') AS supplier_name,
COALESCE(sup_adr.""Country"", '') AS supplier_country,
h.""CardCode"" AS customer_number,
h.""CardName"" AS customer_name,
COALESCE(cust_adr.""Country"", '') AS customer_country,
COALESCE(ind.""IndName"", '') AS customer_industry,
p.""StockPrice"" AS standard_cost,
COALESCE(p.""Currency"", h.""DocCur"") AS standard_cost_currency,
'' AS purchase_order_number,
p.""LineTotal"" * -1 AS sales_value,
COALESCE(p.""Currency"", h.""DocCur"") AS sales_currency,
'' AS incoterms_2020,
COALESCE(emp.""SlpName"", '') AS sales_responsible,
NULL AS order_date,
'CRN' AS doc_type
FROM {schema}.""ORIN"" h
INNER JOIN {schema}.""RIN1"" p ON h.""DocEntry"" = p.""DocEntry""
LEFT JOIN {schema}.""OITM"" itm ON p.""ItemCode"" = itm.""ItemCode""
LEFT JOIN {schema}.""OITB"" grp ON itm.""ItmsGrpCod"" = grp.""ItmsGrpCod""
LEFT JOIN {schema}.""OCRD"" cust ON h.""CardCode"" = cust.""CardCode""
LEFT JOIN {schema}.""CRD1"" cust_adr ON h.""CardCode"" = cust_adr.""CardCode""
AND cust_adr.""AdresType"" = 'B' AND cust_adr.""Address"" = h.""PayToCode""
LEFT JOIN {schema}.""OOND"" ind ON cust.""IndustryC"" = ind.""IndCode""
LEFT JOIN {schema}.""OCRD"" sup ON itm.""CardCode"" = sup.""CardCode""
AND sup.""CardType"" = 'S'
LEFT JOIN {schema}.""CRD1"" sup_adr ON itm.""CardCode"" = sup_adr.""CardCode""
AND sup_adr.""AdresType"" = 'B'
LEFT JOIN {schema}.""OSLP"" emp ON h.""SlpCode"" = emp.""SlpCode""
WHERE h.""CANCELED"" = 'N' AND h.""DocDate"" >= '{dateFilter}'
ORDER BY h.""DocDate"" DESC, h.""DocNum"", p.""LineNum""";
}
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
}
}
}
@@ -0,0 +1,45 @@
using Azure.Identity;
using Microsoft.Graph;
namespace TrafagSalesExporter.Services;
public class SharePointUploadService
{
public async Task UploadAsync(string tenantId, string clientId, string clientSecret,
string siteUrl, string exportFolder, string land, string localFilePath)
{
var credential = new ClientSecretCredential(tenantId, clientId, clientSecret);
var graphClient = new GraphServiceClient(credential, ["https://graph.microsoft.com/.default"]);
var uri = new Uri(siteUrl);
var sitePath = uri.AbsolutePath;
var site = await graphClient.Sites[$"{uri.Host}:{sitePath}"].GetAsync();
if (site?.Id is null)
throw new InvalidOperationException("SharePoint Site konnte nicht gefunden werden.");
var drive = await graphClient.Sites[site.Id].Drive.GetAsync();
if (drive?.Id is null)
throw new InvalidOperationException("SharePoint Dokumentenbibliothek konnte nicht gefunden werden.");
var fileName = Path.GetFileName(localFilePath);
var 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);
}
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"]);
var uri = new Uri(siteUrl);
var sitePath = uri.AbsolutePath;
var site = await graphClient.Sites[$"{uri.Host}:{sitePath}"].GetAsync();
if (site?.Id is null)
throw new InvalidOperationException("SharePoint Site konnte nicht gefunden werden.");
}
}
@@ -0,0 +1,67 @@
using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Data;
namespace TrafagSalesExporter.Services;
public class TimerBackgroundService : BackgroundService
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<TimerBackgroundService> _logger;
private DateTime _nextRun = DateTime.MaxValue;
public DateTime NextRun => _nextRun;
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)
{
_nextRun = DateTime.MaxValue;
return;
}
var now = DateTime.Now;
var todayRun = new DateTime(now.Year, now.Month, now.Day, settings.TimerHour, settings.TimerMinute, 0);
_nextRun = todayRun <= now ? todayRun.AddDays(1) : todayRun;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await RecalculateNextRunAsync();
while (!stoppingToken.IsCancellationRequested)
{
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)
{
_logger.LogError(ex, "Fehler beim Timer-Export");
}
await RecalculateNextRunAsync();
}
}
}
@@ -0,0 +1,41 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<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>
<ItemGroup>
<PackageReference Include="ClosedXML" Version="0.104.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Graph" Version="5.80.0" />
<PackageReference Include="Azure.Identity" Version="1.13.1" />
<PackageReference Include="MudBlazor" Version="7.15.0" />
</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>
@@ -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
@@ -0,0 +1,3 @@
html, body {
font-family: 'Roboto', sans-serif;
}