This commit is contained in:
2026-04-14 10:54:52 +02:00
parent df90a4a172
commit 36a22202bf
14 changed files with 678 additions and 78 deletions
@@ -0,0 +1,36 @@
using System.Net.Http.Headers;
using System.Text;
using Microsoft.Data.Sqlite;
var conn = new SqliteConnection(@"Data Source=C:\Users\koi\source\repos\Ai\TrafagSalesExporter\trafag_exporter.db");
await conn.OpenAsync();
string sapUsername = "", sapPassword = "";
var cmd = conn.CreateCommand();
cmd.CommandText = "select SapUsername, SapPassword from ExportSettings limit 1";
using (var r = await cmd.ExecuteReaderAsync())
{
if (await r.ReadAsync())
{
sapUsername = r.IsDBNull(0) ? "" : r.GetString(0);
sapPassword = r.IsDBNull(1) ? "" : r.GetString(1);
}
}
if (string.IsNullOrWhiteSpace(sapUsername) || string.IsNullOrWhiteSpace(sapPassword)) throw new Exception("Central SAP credentials missing");
var serviceUrl = @"http://travt762.sap.trafag.com:8000/sap/opu/odata/sap/ZPOWERBI_EINKAUF_SRV/";
using var client = new HttpClient();
client.Timeout = TimeSpan.FromSeconds(20);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes($"{sapUsername}:{sapPassword}")));
foreach (var url in new[]{ serviceUrl, serviceUrl + "" })
{
Console.WriteLine($"URL|{url}");
using var response = await client.GetAsync(url);
Console.WriteLine($"STATUS|{(int)response.StatusCode}|{response.ReasonPhrase}");
foreach (var header in response.Headers)
Console.WriteLine($"HEADER|{header.Key}|{string.Join(",", header.Value)}");
foreach (var header in response.Content.Headers)
Console.WriteLine($"HEADER|{header.Key}|{string.Join(",", header.Value)}");
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine("BODY_START");
Console.WriteLine(body.Length > 5000 ? body[..5000] : body);
Console.WriteLine("BODY_END");
}
@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.11" />
</ItemGroup>
</Project>
@@ -7,6 +7,7 @@
@inject ISharePointUploadService SpService
@inject TimerBackgroundService TimerService
@inject IHanaQueryService HanaService
@inject ISapGatewayService SapGatewayService
@inject ISnackbar Snackbar
<PageTitle>Settings</PageTitle>
@@ -240,6 +241,17 @@
}
private async Task TestCentralCredentials(string sourceSystem)
{
if (sourceSystem == "SAP")
{
await TestCentralSapCredentials();
return;
}
await TestCentralHanaCredentials(sourceSystem);
}
private async Task TestCentralHanaCredentials(string sourceSystem)
{
if (!_testingSystems.Add(sourceSystem))
return;
@@ -297,6 +309,49 @@
}
}
private async Task TestCentralSapCredentials()
{
const string sourceSystem = "SAP";
if (!_testingSystems.Add(sourceSystem))
return;
try
{
var username = GetCentralUsername(sourceSystem);
var password = GetCentralPassword(sourceSystem);
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
Snackbar.Add("Für SAP sind keine zentralen Gateway-Zugangsdaten gepflegt.", Severity.Warning);
return;
}
using var db = await DbFactory.CreateDbContextAsync();
var site = await db.Sites
.Where(s => (string.IsNullOrWhiteSpace(s.SourceSystem) ? "SAP" : s.SourceSystem) == sourceSystem
&& !string.IsNullOrWhiteSpace(s.SapServiceUrl))
.OrderBy(s => s.Land)
.FirstOrDefaultAsync();
if (site is null)
{
Snackbar.Add("Kein SAP-Standort mit Service URL gefunden.", Severity.Warning);
return;
}
await SapGatewayService.TestConnectionAsync(site.SapServiceUrl, username.Trim(), password.Trim());
Snackbar.Add($"SAP: Gateway-Verbindung erfolgreich über Standort '{site.Land}'.", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"SAP: {ex.Message}", Severity.Error);
}
finally
{
_testingSystems.Remove(sourceSystem);
}
}
private string GetCentralUsername(string sourceSystem) => sourceSystem switch
{
"BI1" => _exportSettings.Bi1Username,
@@ -1,9 +1,12 @@
@page "/standorte"
@using Microsoft.EntityFrameworkCore
@using System.Text.Json
@using TrafagSalesExporter.Data
@using TrafagSalesExporter.Models
@using TrafagSalesExporter.Services
@inject IDbContextFactory<AppDbContext> DbFactory
@inject IHanaQueryService HanaService
@inject ISapGatewayService SapGatewayService
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@@ -71,7 +74,7 @@
<MudTh>TSC</MudTh>
<MudTh>Schema</MudTh>
<MudTh>Quellsystem</MudTh>
<MudTh>Host</MudTh>
<MudTh>Quelle</MudTh>
<MudTh>Aktiv</MudTh>
<MudTh>Aktionen</MudTh>
</HeaderContent>
@@ -80,7 +83,7 @@
<MudTd>@context.TSC</MudTd>
<MudTd>@context.Schema</MudTd>
<MudTd>@context.SourceSystem</MudTd>
<MudTd>@GetServerNode(context.HanaServer)</MudTd>
<MudTd>@GetConnectionTarget(context)</MudTd>
<MudTd>
@if (context.IsActive)
{
@@ -122,8 +125,8 @@
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>
<MudButton OnClick="CloseServerDialog">Abbrechen</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveServer" Disabled="_savingServer">Speichern</MudButton>
</DialogActions>
</MudDialog>
@@ -138,7 +141,7 @@
<MudSelect @bind-Value="_editingSite.SourceSystem" Label="Quellsystem" Required>
@foreach (var system in _sourceSystems)
{
<MudSelectItem Value="system">@system</MudSelectItem>
<MudSelectItem Value="@system">@system</MudSelectItem>
}
</MudSelect>
<MudTextField @bind-Value="_editingSite.UsernameOverride" Label="Username Override"
@@ -149,29 +152,67 @@
<MudDivider Class="my-4" />
<MudText Typo="Typo.h6" Class="mb-2">HANA-Verbindung</MudText>
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Outlined" Class="mb-3">
Host, Port und technische HANA-Parameter kommen von dieser Verbindung. Username und Password hier dienen nur noch als Fallback für bestehende Einträge.
</MudAlert>
<MudTextField @bind-Value="_editingSiteServer.Name" Label="Verbindungsname" Required
HelperText="Interner Anzeigename für diesen Standort" />
<MudTextField @bind-Value="_editingSiteServer.Host" Label="Host oder ServerNode" Required
HelperText="z.B. hana01 oder hana01:30015 oder derselbe HanaServer-Wert wie in Power BI" />
<MudNumericField @bind-Value="_editingSiteServer.Port" Label="Port"
HelperText="Wird ignoriert, wenn im Host bereits ein Port enthalten ist" />
<MudTextField @bind-Value="_editingSiteServer.Username" Label="Username" />
<MudTextField @bind-Value="_editingSiteServer.Password" Label="Password" InputType="InputType.Password" />
<MudTextField @bind-Value="_editingSiteServer.DatabaseName" Label="Database Name (MDC)"
HelperText="Nur bei Multi-Tenant Setup angeben, sonst leer lassen" />
<MudSwitch @bind-Value="_editingSiteServer.UseSsl" Label="SSL/TLS verwenden (encrypt=true)" Color="Color.Primary" />
<MudSwitch @bind-Value="_editingSiteServer.ValidateCertificate" Label="SSL-Zertifikat validieren" Color="Color.Primary"
Disabled="!_editingSiteServer.UseSsl" />
<MudTextField @bind-Value="_editingSiteServer.AdditionalParams" Label="Zusätzliche Parameter"
HelperText="Optional, z.B. sslCryptoProvider=openssl;communicationTimeout=0" />
@if (IsSapSite())
{
<MudText Typo="Typo.h6" Class="mb-2">SAP Gateway</MudText>
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Outlined" Class="mb-3">
Die Service-URL zeigt auf den OData-Service. Die verfügbaren Entity Sets werden nur per Knopfdruck aktualisiert und lokal zwischengespeichert.
</MudAlert>
<MudTextField @bind-Value="_editingSite.SapServiceUrl" Label="SAP Service URL" Required
HelperText="z.B. http://server:8000/sap/opu/odata/sap/ZPOWERBI_EINKAUF_SRV/" />
<MudStack Row Spacing="2" Class="mb-3">
<MudButton Variant="Variant.Outlined" Color="Color.Info" OnClick="RefreshSapEntitySets"
StartIcon="@Icons.Material.Filled.Refresh" Disabled="_refreshingSapEntitySets">
@if (_refreshingSapEntitySets)
{
<MudProgressCircular Size="Size.Small" Indeterminate Class="mr-2" />
@("Lade...")
}
else
{
@("Quellen refreshen")
}
</MudButton>
@if (_editingSite.SapEntitySetsRefreshedAtUtc.HasValue)
{
<MudText Typo="Typo.caption" Class="mt-2">
Letzter Refresh: @_editingSite.SapEntitySetsRefreshedAtUtc.Value.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss")
</MudText>
}
</MudStack>
<MudSelect @bind-Value="_editingSite.SapEntitySet" Label="SAP Entity Set" Required>
@foreach (var entitySet in _sapEntitySetsCache)
{
<MudSelectItem Value="@entitySet">@entitySet</MudSelectItem>
}
</MudSelect>
}
else
{
<MudText Typo="Typo.h6" Class="mb-2">HANA-Verbindung</MudText>
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Outlined" Class="mb-3">
Host, Port und technische HANA-Parameter kommen von dieser Verbindung. Username und Password hier dienen nur noch als Fallback für bestehende Einträge.
</MudAlert>
<MudTextField @bind-Value="_editingSiteServer.Name" Label="Verbindungsname" Required
HelperText="Interner Anzeigename für diesen Standort" />
<MudTextField @bind-Value="_editingSiteServer.Host" Label="Host oder ServerNode" Required
HelperText="z.B. hana01 oder hana01:30015 oder derselbe HanaServer-Wert wie in Power BI" />
<MudNumericField @bind-Value="_editingSiteServer.Port" Label="Port"
HelperText="Wird ignoriert, wenn im Host bereits ein Port enthalten ist" />
<MudTextField @bind-Value="_editingSiteServer.Username" Label="Username" />
<MudTextField @bind-Value="_editingSiteServer.Password" Label="Password" InputType="InputType.Password" />
<MudTextField @bind-Value="_editingSiteServer.DatabaseName" Label="Database Name (MDC)"
HelperText="Nur bei Multi-Tenant Setup angeben, sonst leer lassen" />
<MudSwitch @bind-Value="_editingSiteServer.UseSsl" Label="SSL/TLS verwenden (encrypt=true)" Color="Color.Primary" />
<MudSwitch @bind-Value="_editingSiteServer.ValidateCertificate" Label="SSL-Zertifikat validieren" Color="Color.Primary"
Disabled="!_editingSiteServer.UseSsl" />
<MudTextField @bind-Value="_editingSiteServer.AdditionalParams" Label="Zusätzliche Parameter"
HelperText="Optional, z.B. sslCryptoProvider=openssl;communicationTimeout=0" />
}
</DialogContent>
<DialogActions>
<MudButton OnClick="() => _siteDialogVisible = false">Abbrechen</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveSite">Speichern</MudButton>
<MudButton OnClick="CloseSiteDialog" Disabled="_savingSite">Abbrechen</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveSite" Disabled="_savingSite || _refreshingSapEntitySets">Speichern</MudButton>
</DialogActions>
</MudDialog>
@@ -180,11 +221,15 @@
private readonly Dictionary<int, ConnectionTestResult> _connectionStatus = new();
private List<HanaServer> _servers = new();
private List<Site> _sites = new();
private List<string> _sapEntitySetsCache = [];
private HanaServer _editingServer = new();
private Site _editingSite = new();
private HanaServer _editingSiteServer = new();
private bool _serverDialogVisible;
private bool _siteDialogVisible;
private bool _refreshingSapEntitySets;
private bool _savingServer;
private bool _savingSite;
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
protected override async Task OnInitializedAsync()
@@ -213,6 +258,12 @@
private async Task SaveServer()
{
if (_savingServer)
return;
_savingServer = true;
try
{
using var db = await DbFactory.CreateDbContextAsync();
if (_editingServer.Id == 0)
{
@@ -239,6 +290,11 @@
_serverDialogVisible = false;
await LoadDataAsync();
Snackbar.Add("Server gespeichert", Severity.Success);
}
finally
{
_savingServer = false;
}
}
private async Task DeleteServer(HanaServer server)
@@ -294,6 +350,7 @@
SourceSystem = "SAP",
HanaServerId = 0
};
_sapEntitySetsCache = [];
_editingSiteServer = CreateDefaultSiteServer();
_siteDialogVisible = true;
}
@@ -310,8 +367,13 @@
SourceSystem = string.IsNullOrWhiteSpace(site.SourceSystem) ? "SAP" : site.SourceSystem,
UsernameOverride = site.UsernameOverride,
PasswordOverride = site.PasswordOverride,
SapServiceUrl = site.SapServiceUrl,
SapEntitySet = site.SapEntitySet,
SapEntitySetsCache = site.SapEntitySetsCache,
SapEntitySetsRefreshedAtUtc = site.SapEntitySetsRefreshedAtUtc,
IsActive = site.IsActive
};
_sapEntitySetsCache = ParseSapEntitySets(site.SapEntitySetsCache);
_editingSiteServer = site.HanaServer is null
? CreateDefaultSiteServer(site)
: CloneServer(site.HanaServer);
@@ -320,34 +382,54 @@
private async Task SaveSite()
{
using var db = await DbFactory.CreateDbContextAsync();
var serverId = await SaveOrCreateSiteServerAsync(db);
if (_savingSite)
return;
if (_editingSite.Id == 0)
_savingSite = true;
try
{
using var db = await DbFactory.CreateDbContextAsync();
var serverId = IsSapSite() ? (int?)null : await SaveOrCreateSiteServerAsync(db);
_editingSite.HanaServerId = serverId;
db.Sites.Add(_editingSite);
}
else
{
var existing = await db.Sites.FindAsync(_editingSite.Id);
if (existing is not null)
{
existing.HanaServerId = serverId;
existing.Schema = _editingSite.Schema;
existing.TSC = _editingSite.TSC;
existing.Land = _editingSite.Land;
existing.SourceSystem = _editingSite.SourceSystem;
existing.UsernameOverride = _editingSite.UsernameOverride;
existing.PasswordOverride = _editingSite.PasswordOverride;
existing.IsActive = _editingSite.IsActive;
}
}
_editingSite.SapEntitySetsCache = SerializeSapEntitySets(_sapEntitySetsCache);
await db.SaveChangesAsync();
_siteDialogVisible = false;
await LoadDataAsync();
Snackbar.Add("Standort gespeichert", Severity.Success);
if (_editingSite.Id == 0)
{
db.Sites.Add(_editingSite);
}
else
{
var existing = await db.Sites.FindAsync(_editingSite.Id);
if (existing is not null)
{
existing.HanaServerId = serverId;
existing.Schema = _editingSite.Schema;
existing.TSC = _editingSite.TSC;
existing.Land = _editingSite.Land;
existing.SourceSystem = _editingSite.SourceSystem;
existing.UsernameOverride = _editingSite.UsernameOverride;
existing.PasswordOverride = _editingSite.PasswordOverride;
existing.SapServiceUrl = _editingSite.SapServiceUrl;
existing.SapEntitySet = _editingSite.SapEntitySet;
existing.SapEntitySetsCache = _editingSite.SapEntitySetsCache;
existing.SapEntitySetsRefreshedAtUtc = _editingSite.SapEntitySetsRefreshedAtUtc;
existing.IsActive = _editingSite.IsActive;
}
}
await db.SaveChangesAsync();
_siteDialogVisible = false;
await LoadDataAsync();
Snackbar.Add("Standort gespeichert", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"Speichern fehlgeschlagen: {ex.Message}", Severity.Error);
}
finally
{
_savingSite = false;
}
}
private async Task DeleteSite(Site site)
@@ -379,6 +461,15 @@
return server.Host.Contains(':', StringComparison.Ordinal) ? server.Host : $"{server.Host}:{server.Port}";
}
private static string GetConnectionTarget(Site site)
{
var sourceSystem = string.IsNullOrWhiteSpace(site.SourceSystem) ? "SAP" : site.SourceSystem;
if (string.Equals(sourceSystem, "SAP", StringComparison.OrdinalIgnoreCase))
return string.IsNullOrWhiteSpace(site.SapServiceUrl) ? "-" : site.SapServiceUrl;
return GetServerNode(site.HanaServer);
}
private HanaServer CreateDefaultSiteServer(Site? site = null)
{
var label = !string.IsNullOrWhiteSpace(site?.Land) ? site!.Land : site?.TSC;
@@ -416,6 +507,8 @@
: _editingSiteServer.Name.Trim();
_editingSite.UsernameOverride = _editingSite.UsernameOverride.Trim();
_editingSite.PasswordOverride = _editingSite.PasswordOverride.Trim();
_editingSite.SapServiceUrl = _editingSite.SapServiceUrl.Trim();
_editingSite.SapEntitySet = _editingSite.SapEntitySet.Trim();
_editingSiteServer.Host = _editingSiteServer.Host.Trim();
_editingSiteServer.Username = _editingSiteServer.Username.Trim();
_editingSiteServer.DatabaseName = _editingSiteServer.DatabaseName.Trim();
@@ -461,4 +554,82 @@
await db.SaveChangesAsync();
return existingServer.Id;
}
private bool IsSapSite() => string.Equals(_editingSite.SourceSystem, "SAP", StringComparison.OrdinalIgnoreCase);
private async Task RefreshSapEntitySets()
{
if (_refreshingSapEntitySets)
return;
_refreshingSapEntitySets = true;
try
{
if (string.IsNullOrWhiteSpace(_editingSite.SapServiceUrl))
throw new InvalidOperationException("SAP Service URL muss gesetzt sein.");
using var db = await DbFactory.CreateDbContextAsync();
var settings = await db.ExportSettings.FirstOrDefaultAsync() ?? new();
var username = string.IsNullOrWhiteSpace(_editingSite.UsernameOverride) ? settings.SapUsername : _editingSite.UsernameOverride;
var password = string.IsNullOrWhiteSpace(_editingSite.PasswordOverride) ? settings.SapPassword : _editingSite.PasswordOverride;
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
throw new InvalidOperationException("Für SAP sind weder zentrale Zugangsdaten noch Standort-Overrides gesetzt.");
var entitySets = await SapGatewayService.GetEntitySetsAsync(_editingSite.SapServiceUrl, username.Trim(), password.Trim());
_sapEntitySetsCache = entitySets;
_editingSite.SapEntitySetsCache = SerializeSapEntitySets(entitySets);
_editingSite.SapEntitySetsRefreshedAtUtc = DateTime.UtcNow;
if (!string.IsNullOrWhiteSpace(_editingSite.SapEntitySet) &&
!_sapEntitySetsCache.Contains(_editingSite.SapEntitySet, StringComparer.OrdinalIgnoreCase))
{
_editingSite.SapEntitySet = string.Empty;
}
Snackbar.Add($"{entitySets.Count} SAP Entity Sets geladen.", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add(ex.Message, Severity.Error);
}
finally
{
_refreshingSapEntitySets = false;
}
}
private void CloseServerDialog()
{
if (_savingServer)
return;
_serverDialogVisible = false;
}
private void CloseSiteDialog()
{
if (_savingSite || _refreshingSapEntitySets)
return;
_siteDialogVisible = false;
}
private static List<string> ParseSapEntitySets(string json)
{
if (string.IsNullOrWhiteSpace(json))
return [];
try
{
return JsonSerializer.Deserialize<List<string>>(json) ?? [];
}
catch
{
return [];
}
}
private static string SerializeSapEntitySets(List<string> entitySets)
=> JsonSerializer.Serialize(entitySets);
}
+9 -1
View File
@@ -7,7 +7,7 @@ public class Site
{
public int Id { get; set; }
public int HanaServerId { get; set; }
public int? HanaServerId { get; set; }
[ForeignKey(nameof(HanaServerId))]
public HanaServer? HanaServer { get; set; }
@@ -28,5 +28,13 @@ public class Site
public string PasswordOverride { get; set; } = string.Empty;
public string SapServiceUrl { get; set; } = string.Empty;
public string SapEntitySet { get; set; } = string.Empty;
public string SapEntitySetsCache { get; set; } = string.Empty;
public DateTime? SapEntitySetsRefreshedAtUtc { get; set; }
public bool IsActive { get; set; } = true;
}
+1
View File
@@ -16,6 +16,7 @@ builder.Services.AddDbContextFactory<AppDbContext>(options =>
builder.Services.AddSingleton<IHanaQueryService, HanaQueryService>();
builder.Services.AddSingleton<IExcelExportService, ExcelExportService>();
builder.Services.AddSingleton<ISharePointUploadService, SharePointUploadService>();
builder.Services.AddSingleton<ISapGatewayService, SapGatewayService>();
builder.Services.AddSingleton<ITransformationStrategy, CopyTransformationStrategy>();
builder.Services.AddSingleton<ITransformationStrategy, UppercaseTransformationStrategy>();
builder.Services.AddSingleton<ITransformationStrategy, LowercaseTransformationStrategy>();
@@ -24,6 +24,7 @@ public class DatabaseInitializationService : IDatabaseInitializationService
private static void EnsureSchema(AppDbContext db)
{
EnsureSitesTableSupportsOptionalHanaServer(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");
@@ -31,6 +32,10 @@ public class DatabaseInitializationService : IDatabaseInitializationService
AddColumnIfMissing(db, "Sites", "SourceSystem", "TEXT NOT NULL DEFAULT 'SAP'");
AddColumnIfMissing(db, "Sites", "UsernameOverride", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "Sites", "PasswordOverride", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "Sites", "SapServiceUrl", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "Sites", "SapEntitySet", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "Sites", "SapEntitySetsCache", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "Sites", "SapEntitySetsRefreshedAtUtc", "TEXT NULL");
AddColumnIfMissing(db, "ExportSettings", "SapUsername", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "ExportSettings", "SapPassword", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "ExportSettings", "Bi1Username", "TEXT NOT NULL DEFAULT ''");
@@ -40,6 +45,104 @@ public class DatabaseInitializationService : IDatabaseInitializationService
EnsureTransformationTable(db);
}
private static void EnsureSitesTableSupportsOptionalHanaServer(AppDbContext db)
{
var conn = db.Database.GetDbConnection();
if (conn.State != ConnectionState.Open)
conn.Open();
var hanaServerIdIsRequired = false;
{
using var pragma = conn.CreateCommand();
pragma.CommandText = "PRAGMA table_info(Sites)";
using var reader = pragma.ExecuteReader();
while (reader.Read())
{
if (string.Equals(reader["name"]?.ToString(), "HanaServerId", StringComparison.OrdinalIgnoreCase))
{
hanaServerIdIsRequired = Convert.ToInt32(reader["notnull"]) == 1;
break;
}
}
}
if (!hanaServerIdIsRequired)
return;
using var disableFk = conn.CreateCommand();
disableFk.CommandText = "PRAGMA foreign_keys = OFF;";
disableFk.ExecuteNonQuery();
using var transaction = conn.BeginTransaction();
using (var rename = conn.CreateCommand())
{
rename.Transaction = transaction;
rename.CommandText = "ALTER TABLE Sites RENAME TO Sites_old;";
rename.ExecuteNonQuery();
}
using (var create = conn.CreateCommand())
{
create.Transaction = transaction;
create.CommandText = @"
CREATE TABLE Sites (
Id INTEGER NOT NULL CONSTRAINT PK_Sites PRIMARY KEY AUTOINCREMENT,
HanaServerId INTEGER NULL,
Schema TEXT NOT NULL,
TSC TEXT NOT NULL,
Land TEXT NOT NULL,
SourceSystem TEXT NOT NULL DEFAULT 'SAP',
UsernameOverride TEXT NOT NULL DEFAULT '',
PasswordOverride TEXT NOT NULL DEFAULT '',
SapServiceUrl TEXT NOT NULL DEFAULT '',
SapEntitySet TEXT NOT NULL DEFAULT '',
SapEntitySetsCache TEXT NOT NULL DEFAULT '',
SapEntitySetsRefreshedAtUtc TEXT NULL,
IsActive INTEGER NOT NULL,
CONSTRAINT FK_Sites_HanaServers_HanaServerId FOREIGN KEY (HanaServerId) REFERENCES HanaServers (Id)
);";
create.ExecuteNonQuery();
}
using (var copy = conn.CreateCommand())
{
copy.Transaction = transaction;
copy.CommandText = @"
INSERT INTO Sites (
Id, HanaServerId, Schema, TSC, Land, SourceSystem,
UsernameOverride, PasswordOverride, SapServiceUrl, SapEntitySet,
SapEntitySetsCache, SapEntitySetsRefreshedAtUtc, IsActive
)
SELECT
Id, HanaServerId, Schema, TSC, Land,
COALESCE(SourceSystem, 'SAP'),
COALESCE(UsernameOverride, ''),
COALESCE(PasswordOverride, ''),
COALESCE(SapServiceUrl, ''),
COALESCE(SapEntitySet, ''),
COALESCE(SapEntitySetsCache, ''),
SapEntitySetsRefreshedAtUtc,
IsActive
FROM Sites_old;";
copy.ExecuteNonQuery();
}
using (var drop = conn.CreateCommand())
{
drop.Transaction = transaction;
drop.CommandText = "DROP TABLE Sites_old;";
drop.ExecuteNonQuery();
}
transaction.Commit();
using var enableFk = conn.CreateCommand();
enableFk.CommandText = "PRAGMA foreign_keys = ON;";
enableFk.ExecuteNonQuery();
}
private static void AddColumnIfMissing(AppDbContext db, string table, string column, string type)
{
var conn = db.Database.GetDbConnection();
@@ -23,6 +23,16 @@ public class ExcelExportService : IExcelExportService
return fullPath;
}
public string CreateGenericExcelFile(string outputDirectory, string filePrefix, DateTime fileDate, string worksheetName, IReadOnlyList<IReadOnlyDictionary<string, object?>> rows)
{
Directory.CreateDirectory(outputDirectory);
var safePrefix = string.IsNullOrWhiteSpace(filePrefix) ? "Export" : filePrefix.Trim();
var fileName = $"{safePrefix}_{fileDate:yyyy-MM-dd}.xlsx";
var fullPath = Path.Combine(outputDirectory, fileName);
WriteGenericWorkbook(fullPath, worksheetName, rows);
return fullPath;
}
private static void WriteWorkbook(string fullPath, List<SalesRecord> records)
{
using var workbook = new XLWorkbook();
@@ -99,4 +109,35 @@ public class ExcelExportService : IExcelExportService
ws.Columns().AdjustToContents();
workbook.SaveAs(fullPath);
}
private static void WriteGenericWorkbook(string fullPath, string worksheetName, IReadOnlyList<IReadOnlyDictionary<string, object?>> rows)
{
using var workbook = new XLWorkbook();
var sheetName = string.IsNullOrWhiteSpace(worksheetName) ? "Export" : worksheetName.Trim();
var ws = workbook.Worksheets.Add(sheetName.Length > 31 ? sheetName[..31] : sheetName);
var headers = rows
.SelectMany(r => r.Keys)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
for (var i = 0; i < headers.Count; i++)
{
ws.Cell(1, i + 1).Value = headers[i];
ws.Cell(1, i + 1).Style.Font.Bold = true;
}
for (var rowIndex = 0; rowIndex < rows.Count; rowIndex++)
{
var row = rows[rowIndex];
for (var colIndex = 0; colIndex < headers.Count; colIndex++)
{
row.TryGetValue(headers[colIndex], out var value);
ws.Cell(rowIndex + 2, colIndex + 1).Value = value?.ToString() ?? string.Empty;
}
}
ws.Columns().AdjustToContents();
workbook.SaveAs(fullPath);
}
}
@@ -70,7 +70,6 @@ public class ExportOrchestrationService
private async Task<SiteExportResult?> ExportSiteAsync(Site site)
{
if (site.HanaServer is null) return null;
SiteExportResult? result = null;
lock (_lock)
@@ -6,4 +6,5 @@ public interface IExcelExportService
{
string CreateExcelFile(string outputDirectory, string tsc, DateTime fileDate, List<SalesRecord> records);
string CreateConsolidatedExcelFile(string outputDirectory, DateTime fileDate, List<SalesRecord> records);
string CreateGenericExcelFile(string outputDirectory, string filePrefix, DateTime fileDate, string worksheetName, IReadOnlyList<IReadOnlyDictionary<string, object?>> rows);
}
@@ -0,0 +1,8 @@
namespace TrafagSalesExporter.Services;
public interface ISapGatewayService
{
Task TestConnectionAsync(string serviceUrl, string username, string password, CancellationToken cancellationToken = default);
Task<List<string>> GetEntitySetsAsync(string serviceUrl, string username, string password, CancellationToken cancellationToken = default);
Task<List<Dictionary<string, object?>>> GetEntityRowsAsync(string serviceUrl, string entitySet, string username, string password, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,140 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Xml.Linq;
namespace TrafagSalesExporter.Services;
public class SapGatewayService : ISapGatewayService
{
private static readonly XNamespace AppNs = "http://www.w3.org/2007/app";
private static readonly XNamespace EdmNs = "http://docs.oasis-open.org/odata/ns/edm";
public async Task TestConnectionAsync(string serviceUrl, string username, string password, CancellationToken cancellationToken = default)
{
using var client = CreateClient(username, password);
using var response = await client.GetAsync(BuildServiceUri(serviceUrl), cancellationToken);
response.EnsureSuccessStatusCode();
}
public async Task<List<string>> GetEntitySetsAsync(string serviceUrl, string username, string password, CancellationToken cancellationToken = default)
{
using var client = CreateClient(username, password);
var baseUrl = BuildServiceUri(serviceUrl);
var entitySets = await TryReadEntitySetsFromServiceRootAsync(client, baseUrl, cancellationToken);
if (entitySets.Count > 0)
return entitySets;
return await ReadEntitySetsFromMetadataAsync(client, baseUrl, cancellationToken);
}
public async Task<List<Dictionary<string, object?>>> GetEntityRowsAsync(string serviceUrl, string entitySet, string username, string password, CancellationToken cancellationToken = default)
{
using var client = CreateClient(username, password);
var requestUrl = $"{BuildServiceUri(serviceUrl)}{entitySet}?$format=json";
using var response = await client.GetAsync(requestUrl, cancellationToken);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync(cancellationToken);
using var document = JsonDocument.Parse(json);
if (!document.RootElement.TryGetProperty("d", out var dNode))
return [];
if (!dNode.TryGetProperty("results", out var resultsNode) || resultsNode.ValueKind != JsonValueKind.Array)
return [];
var rows = new List<Dictionary<string, object?>>();
foreach (var item in resultsNode.EnumerateArray())
{
var row = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
foreach (var property in item.EnumerateObject())
{
row[property.Name] = ConvertJsonValue(property.Value);
}
rows.Add(row);
}
return rows;
}
private static HttpClient CreateClient(string username, string password)
{
var client = new HttpClient();
client.Timeout = TimeSpan.FromSeconds(15);
client.DefaultRequestHeaders.Accept.Clear();
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/atomsvc+xml"));
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/xml"));
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Basic",
Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}")));
return client;
}
private static string BuildServiceUri(string serviceUrl)
{
var trimmed = serviceUrl.Trim();
if (string.IsNullOrWhiteSpace(trimmed))
throw new InvalidOperationException("SAP Service URL darf nicht leer sein.");
var entityPathMarker = "/sap/opu/odata/sap/";
var markerIndex = trimmed.IndexOf(entityPathMarker, StringComparison.OrdinalIgnoreCase);
if (markerIndex >= 0)
{
var servicePath = trimmed[(markerIndex + entityPathMarker.Length)..].Trim('/');
var parts = servicePath.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length > 1)
{
trimmed = $"{trimmed[..(markerIndex + entityPathMarker.Length)]}{parts[0]}/";
}
}
return trimmed.EndsWith('/') ? trimmed : $"{trimmed}/";
}
private static async Task<List<string>> TryReadEntitySetsFromServiceRootAsync(HttpClient client, string baseUrl, CancellationToken cancellationToken)
{
using var response = await client.GetAsync(baseUrl, cancellationToken);
response.EnsureSuccessStatusCode();
var xml = await response.Content.ReadAsStringAsync(cancellationToken);
var document = XDocument.Parse(xml);
return document
.Descendants(AppNs + "collection")
.Select(x => x.Attribute("href")?.Value ?? string.Empty)
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private static async Task<List<string>> ReadEntitySetsFromMetadataAsync(HttpClient client, string baseUrl, CancellationToken cancellationToken)
{
using var response = await client.GetAsync($"{baseUrl}$metadata", cancellationToken);
response.EnsureSuccessStatusCode();
var xml = await response.Content.ReadAsStringAsync(cancellationToken);
var document = XDocument.Parse(xml);
return document
.Descendants(EdmNs + "EntitySet")
.Select(x => x.Attribute("Name")?.Value ?? string.Empty)
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private static object? ConvertJsonValue(JsonElement element) => element.ValueKind switch
{
JsonValueKind.String => element.GetString(),
JsonValueKind.Number => element.ToString(),
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Null => null,
_ => element.ToString()
};
}
@@ -9,6 +9,7 @@ public class SiteExportService : ISiteExportService
{
private readonly IDbContextFactory<AppDbContext> _dbFactory;
private readonly IHanaQueryService _hanaService;
private readonly ISapGatewayService _sapGatewayService;
private readonly IExcelExportService _excelService;
private readonly ISharePointUploadService _sharePointService;
private readonly IRecordTransformationService _transformationService;
@@ -17,6 +18,7 @@ public class SiteExportService : ISiteExportService
public SiteExportService(
IDbContextFactory<AppDbContext> dbFactory,
IHanaQueryService hanaService,
ISapGatewayService sapGatewayService,
IExcelExportService excelService,
ISharePointUploadService sharePointService,
IRecordTransformationService transformationService,
@@ -24,6 +26,7 @@ public class SiteExportService : ISiteExportService
{
_dbFactory = dbFactory;
_hanaService = hanaService;
_sapGatewayService = sapGatewayService;
_excelService = excelService;
_sharePointService = sharePointService;
_transformationService = transformationService;
@@ -32,9 +35,6 @@ public class SiteExportService : ISiteExportService
public async Task<SiteExportResult> ExportAsync(Site site, Action<string>? updateStatus = null)
{
if (site.HanaServer is null)
throw new InvalidOperationException($"Standort '{site.Land}' hat keinen HANA-Server.");
var sw = Stopwatch.StartNew();
var log = new ExportLog
{
@@ -49,22 +49,44 @@ public class SiteExportService : ISiteExportService
using var db = await _dbFactory.CreateDbContextAsync();
var settings = await db.ExportSettings.FirstOrDefaultAsync() ?? new ExportSettings();
var spConfig = await db.SharePointConfigs.FirstOrDefaultAsync();
var exportServer = BuildEffectiveServer(site, settings);
updateStatus?.Invoke("HANA Abfrage...");
var records = await Task.Run(() => _hanaService.GetSalesRecords(
exportServer, site.Schema, site.TSC, site.Land, settings.DateFilter));
updateStatus?.Invoke("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?.Invoke("Excel erstellen...");
var outputDir = Path.Combine(AppContext.BaseDirectory, "output");
var filePath = _excelService.CreateExcelFile(outputDir, site.TSC, DateTime.UtcNow.Date, records);
var sourceSystem = NormalizeSourceSystem(site.SourceSystem);
var records = new List<SalesRecord>();
string filePath;
if (sourceSystem == "SAP")
{
var credentials = ResolveCredentials(site, settings, sourceSystem);
if (string.IsNullOrWhiteSpace(site.SapServiceUrl))
throw new InvalidOperationException($"Standort '{site.Land}' hat keine SAP Service URL.");
if (string.IsNullOrWhiteSpace(site.SapEntitySet))
throw new InvalidOperationException($"Standort '{site.Land}' hat kein SAP Entity Set ausgewählt.");
updateStatus?.Invoke("SAP Gateway Abfrage...");
var rows = await _sapGatewayService.GetEntityRowsAsync(site.SapServiceUrl, site.SapEntitySet, credentials.Username, credentials.Password);
updateStatus?.Invoke("Excel erstellen...");
filePath = _excelService.CreateGenericExcelFile(outputDir, $"SAP_{site.TSC}_{site.SapEntitySet}", DateTime.UtcNow.Date, site.SapEntitySet, rows);
log.RowCount = rows.Count;
}
else
{
var exportServer = BuildEffectiveServer(site, settings, sourceSystem);
updateStatus?.Invoke("HANA Abfrage...");
records = await Task.Run(() => _hanaService.GetSalesRecords(
exportServer, site.Schema, site.TSC, site.Land, settings.DateFilter));
updateStatus?.Invoke("Transformationen anwenden...");
var rules = await db.FieldTransformationRules
.Where(r => r.IsActive && r.SourceSystem == sourceSystem)
.OrderBy(r => r.SortOrder)
.ToListAsync();
_transformationService.Apply(records, rules);
updateStatus?.Invoke("Excel erstellen...");
filePath = _excelService.CreateExcelFile(outputDir, site.TSC, DateTime.UtcNow.Date, records);
log.RowCount = records.Count;
}
var fileName = Path.GetFileName(filePath);
if (spConfig is not null &&
@@ -80,12 +102,11 @@ public class SiteExportService : ISiteExportService
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);
site.Land, site.TSC, log.RowCount, sw.Elapsed.TotalSeconds);
return new SiteExportResult
{
@@ -113,14 +134,12 @@ public class SiteExportService : ISiteExportService
}
}
private static HanaServer BuildEffectiveServer(Site site, ExportSettings settings)
private static HanaServer BuildEffectiveServer(Site site, ExportSettings settings, string sourceSystem)
{
if (site.HanaServer is null)
throw new InvalidOperationException($"Standort '{site.Land}' hat keinen HANA-Server.");
var sourceSystem = string.IsNullOrWhiteSpace(site.SourceSystem) ? "SAP" : site.SourceSystem.Trim().ToUpperInvariant();
var inheritedUsername = GetCentralUsername(sourceSystem, settings);
var inheritedPassword = GetCentralPassword(sourceSystem, settings);
var credentials = ResolveCredentials(site, settings, sourceSystem);
return new HanaServer
{
@@ -128,8 +147,8 @@ public class SiteExportService : ISiteExportService
Name = site.HanaServer.Name,
Host = site.HanaServer.Host,
Port = site.HanaServer.Port,
Username = FirstNonEmpty(site.UsernameOverride, inheritedUsername, site.HanaServer.Username),
Password = FirstNonEmpty(site.PasswordOverride, inheritedPassword, site.HanaServer.Password),
Username = FirstNonEmpty(credentials.Username, site.HanaServer.Username),
Password = FirstNonEmpty(credentials.Password, site.HanaServer.Password),
DatabaseName = site.HanaServer.DatabaseName,
UseSsl = site.HanaServer.UseSsl,
ValidateCertificate = site.HanaServer.ValidateCertificate,
@@ -137,6 +156,10 @@ public class SiteExportService : ISiteExportService
};
}
private static (string Username, string Password) ResolveCredentials(Site site, ExportSettings settings, string sourceSystem)
=> (FirstNonEmpty(site.UsernameOverride, GetCentralUsername(sourceSystem, settings)),
FirstNonEmpty(site.PasswordOverride, GetCentralPassword(sourceSystem, settings)));
private static string GetCentralUsername(string sourceSystem, ExportSettings settings) => sourceSystem switch
{
"BI1" => settings.Bi1Username,
@@ -151,6 +174,9 @@ public class SiteExportService : ISiteExportService
_ => settings.SapPassword
};
private static string NormalizeSourceSystem(string? sourceSystem)
=> string.IsNullOrWhiteSpace(sourceSystem) ? "SAP" : sourceSystem.Trim().ToUpperInvariant();
private static string FirstNonEmpty(params string[] values)
{
foreach (var value in values)
View File