manometer

This commit is contained in:
2026-04-17 12:00:03 +02:00
parent bec0410ef4
commit eb187cdc15
15 changed files with 1817 additions and 43 deletions
@@ -134,6 +134,46 @@
<MudItem xs="12" md="2"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">@T("Periode", "Period")</MudText><MudText Typo="Typo.h6">@BuildPeriodLabel(_centralResult)</MudText></MudPaper></MudItem> <MudItem xs="12" md="2"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">@T("Periode", "Period")</MudText><MudText Typo="Typo.h6">@BuildPeriodLabel(_centralResult)</MudText></MudPaper></MudItem>
</MudGrid> </MudGrid>
<MudPaper Class="pa-4 mb-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Cockpit Manometer", "Cockpit gauges")</MudText>
<MudText Typo="Typo.caption" Class="d-block mb-3">
@T("Verdichtete Kennzahlen aus der zentralen Rohsicht. Die Manometer zeigen Anteile, Dichte und Abdeckung, ohne Waehrungsumrechnung oder Budgetlogik.", "Condensed metrics from the central raw view. The gauges show shares, density and coverage without currency conversion or budget logic.")
</MudText>
<MudGrid>
@foreach (var gauge in BuildCentralGauges(_centralResult))
{
<MudItem xs="12" sm="6" lg="3">
<MudPaper Class="pa-3 cockpit-gauge-card" Elevation="0">
<MudText Typo="Typo.caption" Class="d-block mb-1">@gauge.Title</MudText>
<div class="cockpit-gauge-wrap">
<svg viewBox="0 0 220 140" class="cockpit-gauge" role="img" aria-label="@gauge.Title">
<path d="@GaugeArcPath"
fill="none"
stroke="#d7e2ea"
stroke-width="16"
stroke-linecap="round" />
<path d="@GaugeArcPath"
fill="none"
stroke="@gauge.Color"
stroke-width="16"
stroke-linecap="round"
pathLength="100"
stroke-dasharray="@BuildGaugeDashArray(gauge.Percent)" />
<line x1="110" y1="110" x2="@BuildGaugeNeedleX(gauge.Percent)" y2="@BuildGaugeNeedleY(gauge.Percent)"
stroke="#23313d"
stroke-width="5"
stroke-linecap="round" />
<circle cx="110" cy="110" r="8" fill="#23313d" />
<text x="110" y="76" text-anchor="middle" class="cockpit-gauge-value">@gauge.DisplayValue</text>
<text x="110" y="96" text-anchor="middle" class="cockpit-gauge-subtitle">@gauge.Subtitle</text>
</svg>
</div>
</MudPaper>
</MudItem>
}
</MudGrid>
</MudPaper>
<MudPaper Class="pa-4 mb-4" Elevation="1"> <MudPaper Class="pa-4 mb-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Hinweise", "Notes")</MudText> <MudText Typo="Typo.h6" Class="mb-2">@T("Hinweise", "Notes")</MudText>
@foreach (var notice in _centralResult.Notices) @foreach (var notice in _centralResult.Notices)
@@ -248,9 +288,42 @@
</MudPaper> </MudPaper>
} }
<style>
.cockpit-gauge-card {
background: linear-gradient(180deg, #fbfdff 0%, #f1f6fa 100%);
border: 1px solid #dce7ee;
border-radius: 18px;
min-height: 220px;
}
.cockpit-gauge-wrap {
display: flex;
justify-content: center;
align-items: center;
}
.cockpit-gauge {
width: 100%;
max-width: 240px;
height: auto;
}
.cockpit-gauge-value {
font-size: 22px;
font-weight: 700;
fill: #153047;
}
.cockpit-gauge-subtitle {
font-size: 11px;
fill: #607587;
}
</style>
@code { @code {
private List<ManagementCockpitFileOption> _files = []; private List<ManagementCockpitFileOption> _files = [];
private List<int> _centralYears = []; private List<int> _centralYears = [];
private const string GaugeArcPath = "M 30 110 A 80 80 0 0 1 190 110";
private string? _selectedFilePath; private string? _selectedFilePath;
private ManagementCockpitResult? _result; private ManagementCockpitResult? _result;
private ManagementCockpitCentralResult? _centralResult; private ManagementCockpitCentralResult? _centralResult;
@@ -341,6 +414,181 @@
return $"{result.Summary.PeriodStart.Value:dd.MM.yyyy} - {result.Summary.PeriodEnd.Value:dd.MM.yyyy}"; return $"{result.Summary.PeriodStart.Value:dd.MM.yyyy} - {result.Summary.PeriodEnd.Value:dd.MM.yyyy}";
} }
private List<CentralGaugeModel> BuildCentralGauges(ManagementCockpitCentralResult result)
{
var invoiceDensity = result.Summary.RowCount == 0 ? 0m : result.Summary.InvoiceCount * 100m / result.Summary.RowCount;
var sourceDominance = result.SourceSystemTotals.Count == 0
? 0m
: result.SourceSystemTotals.Max(x => x.RowCount) * 100m / Math.Max(1, result.Summary.RowCount);
var countryDominance = result.CountryTotals.Count == 0
? 0m
: result.CountryTotals.Max(x => x.RowCount) * 100m / Math.Max(1, result.Summary.RowCount);
var periodCoverage = BuildPeriodCoveragePercent(result);
var topCountrySalesShare = BuildTopSalesSharePercent(result.CountryTotals);
var topSourceSalesShare = BuildTopSalesSharePercent(result.SourceSystemTotals);
var currencyComplexity = result.Summary.CurrencyCount <= 1 ? 0m : Math.Min(100m, (result.Summary.CurrencyCount - 1) * 25m);
var peakVsAverageMonth = BuildPeakVsAverageMonthPercent(result);
return
[
new CentralGaugeModel
{
Title = T("Rechnungsdichte", "Invoice density"),
Percent = invoiceDensity,
DisplayValue = $"{invoiceDensity:F0}%",
Subtitle = T("Rechnungen pro 100 Zeilen", "Invoices per 100 rows"),
Color = "#1f8a70"
},
new CentralGaugeModel
{
Title = T("Quellen-Dominanz", "Source dominance"),
Percent = sourceDominance,
DisplayValue = $"{sourceDominance:F0}%",
Subtitle = T("Groesste Quelle nach Zeilen", "Largest source by rows"),
Color = "#d9822b"
},
new CentralGaugeModel
{
Title = T("Land-Dominanz", "Country dominance"),
Percent = countryDominance,
DisplayValue = $"{countryDominance:F0}%",
Subtitle = T("Groesstes Land nach Zeilen", "Largest country by rows"),
Color = "#c4496b"
},
new CentralGaugeModel
{
Title = T("Perioden-Abdeckung", "Period coverage"),
Percent = periodCoverage,
DisplayValue = $"{periodCoverage:F0}%",
Subtitle = BuildPeriodGaugeSubtitle(result),
Color = "#3d7ff0"
},
new CentralGaugeModel
{
Title = T("Top-Land Umsatz", "Top country sales"),
Percent = topCountrySalesShare,
DisplayValue = $"{topCountrySalesShare:F0}%",
Subtitle = T("Anteil des umsatzstaerksten Landes", "Share of top-selling country"),
Color = "#7f56d9"
},
new CentralGaugeModel
{
Title = T("Top-Quelle Umsatz", "Top source sales"),
Percent = topSourceSalesShare,
DisplayValue = $"{topSourceSalesShare:F0}%",
Subtitle = T("Anteil der staerksten Quelle", "Share of strongest source"),
Color = "#0f9fb5"
},
new CentralGaugeModel
{
Title = T("Waehrungs-Komplexitaet", "Currency complexity"),
Percent = currencyComplexity,
DisplayValue = result.Summary.CurrencyCount.ToString("N0"),
Subtitle = T("Anzahl Waehrungen im Zeitraum", "Number of currencies in period"),
Color = "#b54708"
},
new CentralGaugeModel
{
Title = T("Monat gegen Peak", "Month vs peak"),
Percent = peakVsAverageMonth,
DisplayValue = $"{peakVsAverageMonth:F0}%",
Subtitle = T("Durchschnittsmonat relativ zum Peak", "Average month relative to peak"),
Color = "#d92d20"
}
];
}
private static decimal BuildPeriodCoveragePercent(ManagementCockpitCentralResult result)
{
if (result.Summary.PeriodStart is null || result.Summary.PeriodEnd is null)
return 0m;
if (result.Filter.Month.HasValue)
{
var daysInMonth = DateTime.DaysInMonth(result.Filter.Year, result.Filter.Month.Value);
var coveredDays = result.DailyTotals
.Select(x => x.Day)
.Where(x => x.HasValue)
.Distinct()
.Count();
return daysInMonth == 0 ? 0m : coveredDays * 100m / daysInMonth;
}
var coveredMonths = result.MonthlyTotals
.Select(x => x.Month)
.Where(x => x.HasValue)
.Distinct()
.Count();
return coveredMonths * 100m / 12m;
}
private string BuildPeriodGaugeSubtitle(ManagementCockpitCentralResult result)
=> result.Filter.Month.HasValue
? T("Tage mit Daten im Monat", "Days with data in month")
: T("Monate mit Daten im Jahr", "Months with data in year");
private static decimal BuildTopSalesSharePercent(IEnumerable<ManagementCockpitDimensionValueRow> rows)
{
var materialized = rows.ToList();
if (materialized.Count == 0)
return 0m;
var total = materialized.Sum(x => x.SalesValue);
if (total == 0)
return 0m;
return materialized.Max(x => x.SalesValue) * 100m / total;
}
private static decimal BuildPeakVsAverageMonthPercent(ManagementCockpitCentralResult result)
{
var monthRows = result.MonthlyTotals.ToList();
if (monthRows.Count == 0)
return 0m;
var groupedMonths = monthRows
.GroupBy(x => x.Label, StringComparer.OrdinalIgnoreCase)
.Select(g => g.Sum(x => x.SalesValue))
.ToList();
if (groupedMonths.Count == 0)
return 0m;
var peak = groupedMonths.Max();
if (peak == 0)
return 0m;
var average = groupedMonths.Average();
return Math.Min(100m, average * 100m / peak);
}
private static string BuildGaugeDashArray(decimal percent)
=> $"{Math.Clamp(percent, 0m, 100m).ToString("F2", System.Globalization.CultureInfo.InvariantCulture)} 100";
private static string BuildGaugeNeedleX(decimal percent)
=> GetGaugePoint(percent, 68d).X.ToString("F2", System.Globalization.CultureInfo.InvariantCulture);
private static string BuildGaugeNeedleY(decimal percent)
=> GetGaugePoint(percent, 68d).Y.ToString("F2", System.Globalization.CultureInfo.InvariantCulture);
private static (double X, double Y) GetGaugePoint(decimal percent, double radius = 80d)
{
var clamped = Math.Clamp((double)percent, 0d, 100d);
var angle = Math.PI * (1d - clamped / 100d);
var x = 110d + radius * Math.Cos(angle);
var y = 110d - radius * Math.Sin(angle);
return (x, y);
}
private sealed class CentralGaugeModel
{
public string Title { get; set; } = string.Empty;
public decimal Percent { get; set; }
public string DisplayValue { get; set; } = string.Empty;
public string Subtitle { get; set; } = string.Empty;
public string Color { get; set; } = "#3d7ff0";
}
} }
@code { @code {
@@ -9,6 +9,7 @@
@inject IDbContextFactory<AppDbContext> DbFactory @inject IDbContextFactory<AppDbContext> DbFactory
@inject IHanaQueryService HanaService @inject IHanaQueryService HanaService
@inject ISapGatewayService SapGatewayService @inject ISapGatewayService SapGatewayService
@inject ISharePointUploadService SharePointService
@inject IAppEventLogService AppEventLogService @inject IAppEventLogService AppEventLogService
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@inject IDialogService DialogService @inject IDialogService DialogService
@@ -141,9 +142,44 @@
</TitleContent> </TitleContent>
<DialogContent> <DialogContent>
<MudTextField @bind-Value="_editingSite.Schema" Label="Schema" Required /> <MudTextField @bind-Value="_editingSite.Schema" Label="Schema" Required />
@if (UsesHanaConnection())
{
<MudStack Row Spacing="2" Class="mb-2">
<MudButton Variant="Variant.Outlined" Color="Color.Info"
StartIcon="@Icons.Material.Filled.Refresh"
OnClick="LoadAvailableSchemasAsync"
Disabled="_loadingSchemas">
@if (_loadingSchemas)
{
<MudProgressCircular Size="Size.Small" Indeterminate Class="mr-2" />
@("Lade Schemas...")
}
else
{
@("Schemas laden")
}
</MudButton>
@if (_availableSchemas.Count > 0)
{
<MudSelect T="string" Value="_editingSite.Schema"
ValueChanged="OnSchemaSelected"
Label="Gefundene Schemas"
Dense
Style="min-width: 260px;">
@foreach (var schema in _availableSchemas)
{
<MudSelectItem Value="@schema">@schema</MudSelectItem>
}
</MudSelect>
}
</MudStack>
<MudText Typo="Typo.caption" Class="mb-2">
Die Liste wird aus der zentralen HANA-Verbindung des Quellsystems gelesen und auf typische B1-Schemas eingeschraenkt.
</MudText>
}
<MudTextField @bind-Value="_editingSite.TSC" Label="TSC" Required /> <MudTextField @bind-Value="_editingSite.TSC" Label="TSC" Required />
<MudTextField @bind-Value="_editingSite.Land" Label="Land" Required /> <MudTextField @bind-Value="_editingSite.Land" Label="Land" Required />
<MudSelect @bind-Value="_editingSite.SourceSystem" Label="Quellsystem" Required> <MudSelect T="string" Value="_editingSite.SourceSystem" ValueChanged="OnSourceSystemChanged" Label="Quellsystem" Required>
@foreach (var system in GetAvailableSourceSystems()) @foreach (var system in GetAvailableSourceSystems())
{ {
<MudSelectItem Value="@system.Code">@GetSourceSystemLabel(system)</MudSelectItem> <MudSelectItem Value="@system.Code">@GetSourceSystemLabel(system)</MudSelectItem>
@@ -351,6 +387,13 @@
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Outlined" Class="mb-3"> <MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Outlined" Class="mb-3">
Für diesen Standort wird keine SAP- oder HANA-Verbindung verwendet. Es wird die hier hinterlegte Excel-Datei gelesen und in `CentralSalesRecords` übernommen. Für diesen Standort wird keine SAP- oder HANA-Verbindung verwendet. Es wird die hier hinterlegte Excel-Datei gelesen und in `CentralSalesRecords` übernommen.
</MudAlert> </MudAlert>
<MudTextField @bind-Value="_editingSite.ManualImportFilePath" Label="Excel-Dateipfad"
HelperText="Unterstuetzt lokale Pfade, UNC-Pfade und SharePoint-Referenzen wie https://... oder Shared Documents/Ordner/Datei.xlsx."
Class="mb-2" />
<MudButton Variant="Variant.Outlined" Color="Color.Info" OnClick="ValidateManualImportPathAsync"
Disabled="_uploadingManualImport" Class="mb-3">
Pfad pruefen
</MudButton>
<InputFile OnChange="UploadManualImportFileAsync" accept=".xlsx" /> <InputFile OnChange="UploadManualImportFileAsync" accept=".xlsx" />
@if (_uploadingManualImport) @if (_uploadingManualImport)
{ {
@@ -394,6 +437,7 @@
private List<Site> _sites = new(); private List<Site> _sites = new();
private List<SourceSystemDefinition> _sourceSystemDefinitions = new(); private List<SourceSystemDefinition> _sourceSystemDefinitions = new();
private List<string> _sapEntitySetsCache = []; private List<string> _sapEntitySetsCache = [];
private List<string> _availableSchemas = [];
private List<string> _sapAvailableSourceExpressions = []; private List<string> _sapAvailableSourceExpressions = [];
private Dictionary<string, List<string>> _sapSourceFieldMap = new(StringComparer.OrdinalIgnoreCase); private Dictionary<string, List<string>> _sapSourceFieldMap = new(StringComparer.OrdinalIgnoreCase);
private List<SapSourceDefinition> _sapSources = []; private List<SapSourceDefinition> _sapSources = [];
@@ -411,6 +455,7 @@
private bool _refreshingSapSourceFields; private bool _refreshingSapSourceFields;
private bool _savingServer; private bool _savingServer;
private bool _savingSite; private bool _savingSite;
private bool _loadingSchemas;
private bool _uploadingManualImport; private bool _uploadingManualImport;
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true }; private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
@@ -625,6 +670,7 @@
HanaServerId = null, HanaServerId = null,
ManualImportFilePath = string.Empty ManualImportFilePath = string.Empty
}; };
_availableSchemas = [];
_sapEntitySetsCache = []; _sapEntitySetsCache = [];
_sapAvailableSourceExpressions = []; _sapAvailableSourceExpressions = [];
_sapSourceFieldMap = new(StringComparer.OrdinalIgnoreCase); _sapSourceFieldMap = new(StringComparer.OrdinalIgnoreCase);
@@ -657,6 +703,7 @@
SapEntitySetsRefreshedAtUtc = site.SapEntitySetsRefreshedAtUtc, SapEntitySetsRefreshedAtUtc = site.SapEntitySetsRefreshedAtUtc,
IsActive = site.IsActive IsActive = site.IsActive
}; };
_availableSchemas = [];
_sapEntitySetsCache = ParseSapEntitySets(site.SapEntitySetsCache); _sapEntitySetsCache = ParseSapEntitySets(site.SapEntitySetsCache);
using var db = DbFactory.CreateDbContext(); using var db = DbFactory.CreateDbContext();
_sapSources = db.SapSourceDefinitions.Where(s => s.SiteId == site.Id).OrderBy(s => s.SortOrder).ThenBy(s => s.Id).ToList(); _sapSources = db.SapSourceDefinitions.Where(s => s.SiteId == site.Id).OrderBy(s => s.SortOrder).ThenBy(s => s.Id).ToList();
@@ -798,6 +845,19 @@
return centralServer.Id; return centralServer.Id;
} }
private Task OnSchemaSelected(string schema)
{
_editingSite.Schema = schema;
return Task.CompletedTask;
}
private Task OnSourceSystemChanged(string value)
{
_editingSite.SourceSystem = value;
_availableSchemas = [];
return Task.CompletedTask;
}
private IEnumerable<SourceSystemDefinition> GetAvailableSourceSystems() private IEnumerable<SourceSystemDefinition> GetAvailableSourceSystems()
=> _sourceSystemDefinitions => _sourceSystemDefinitions
.Where(x => x.IsActive || string.Equals(x.Code, _editingSite.SourceSystem, StringComparison.OrdinalIgnoreCase)) .Where(x => x.IsActive || string.Equals(x.Code, _editingSite.SourceSystem, StringComparison.OrdinalIgnoreCase))
@@ -871,6 +931,85 @@
return $"{centralServer.Name} | {GetServerNode(centralServer)}"; return $"{centralServer.Name} | {GetServerNode(centralServer)}";
} }
private async Task LoadAvailableSchemasAsync()
{
if (_loadingSchemas)
return;
_loadingSchemas = true;
try
{
using var db = await DbFactory.CreateDbContextAsync();
var sourceDefinition = await db.SourceSystemDefinitions
.OrderBy(x => x.Id)
.FirstOrDefaultAsync(x => x.Code == _editingSite.SourceSystem);
if (sourceDefinition is null)
throw new InvalidOperationException($"Quellsystem '{_editingSite.SourceSystem}' nicht gefunden.");
var centralServer = await db.HanaServers
.OrderBy(x => x.Id)
.FirstOrDefaultAsync(x => x.SourceSystem == _editingSite.SourceSystem);
if (centralServer is null || string.IsNullOrWhiteSpace(centralServer.Host))
throw new InvalidOperationException($"Fuer {_editingSite.SourceSystem} ist keine gueltige zentrale HANA-Konfiguration vorhanden.");
var username = string.IsNullOrWhiteSpace(_editingSite.UsernameOverride)
? sourceDefinition.CentralUsername ?? string.Empty
: _editingSite.UsernameOverride;
var password = string.IsNullOrWhiteSpace(_editingSite.PasswordOverride)
? sourceDefinition.CentralPassword ?? string.Empty
: _editingSite.PasswordOverride;
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
throw new InvalidOperationException($"Fuer {_editingSite.SourceSystem} sind weder zentrale Zugangsdaten noch Standort-Overrides gesetzt.");
var lookupServer = new HanaServer
{
Id = centralServer.Id,
SourceSystem = centralServer.SourceSystem,
Name = centralServer.Name,
Host = centralServer.Host,
Port = centralServer.Port,
Username = username.Trim(),
Password = password,
DatabaseName = centralServer.DatabaseName,
UseSsl = centralServer.UseSsl,
ValidateCertificate = centralServer.ValidateCertificate,
AdditionalParams = centralServer.AdditionalParams
};
var schemas = await Task.Run(() => HanaService.GetAvailableSchemas(lookupServer));
_availableSchemas = schemas
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.ToList();
if (_availableSchemas.Count == 0)
{
Snackbar.Add("Keine passenden Schemas gefunden.", Severity.Info);
return;
}
if (string.IsNullOrWhiteSpace(_editingSite.Schema) ||
!_availableSchemas.Contains(_editingSite.Schema, StringComparer.OrdinalIgnoreCase))
{
_editingSite.Schema = _availableSchemas[0];
}
Snackbar.Add($"{_availableSchemas.Count} Schemas geladen.", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"Schemas laden fehlgeschlagen: {ex.Message}", Severity.Error);
}
finally
{
_loadingSchemas = false;
}
}
private async Task RefreshSapEntitySets() private async Task RefreshSapEntitySets()
{ {
if (_refreshingSapEntitySets) if (_refreshingSapEntitySets)
@@ -993,6 +1132,62 @@
} }
} }
private async Task ValidateManualImportPathAsync()
{
try
{
_editingSite.ManualImportFilePath = _editingSite.ManualImportFilePath.Trim();
if (string.IsNullOrWhiteSpace(_editingSite.ManualImportFilePath))
throw new InvalidOperationException("Bitte zuerst einen Dateipfad eintragen.");
if (!string.Equals(Path.GetExtension(_editingSite.ManualImportFilePath), ".xlsx", StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("Bitte eine Excel-Datei mit Endung .xlsx angeben.");
if (File.Exists(_editingSite.ManualImportFilePath))
{
_editingSite.ManualImportLastUploadedAtUtc = File.GetLastWriteTimeUtc(_editingSite.ManualImportFilePath);
}
else if (LooksLikeSharePointReference(_editingSite.ManualImportFilePath))
{
using var db = await DbFactory.CreateDbContextAsync();
var spConfig = await db.SharePointConfigs.FirstOrDefaultAsync();
if (spConfig is null ||
string.IsNullOrWhiteSpace(spConfig.TenantId) ||
string.IsNullOrWhiteSpace(spConfig.ClientId) ||
string.IsNullOrWhiteSpace(spConfig.ClientSecret) ||
string.IsNullOrWhiteSpace(spConfig.SiteUrl))
{
throw new InvalidOperationException("Fuer SharePoint-Pruefung fehlt eine vollstaendige SharePoint-Konfiguration in Settings.");
}
var tempPath = await SharePointService.DownloadToTempFileAsync(
spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret, spConfig.SiteUrl, _editingSite.ManualImportFilePath);
try
{
_editingSite.ManualImportLastUploadedAtUtc = File.GetLastWriteTimeUtc(tempPath);
}
finally
{
if (File.Exists(tempPath))
File.Delete(tempPath);
}
}
else
{
throw new InvalidOperationException($"Datei nicht gefunden oder nicht erreichbar: {_editingSite.ManualImportFilePath}");
}
Snackbar.Add("Dateipfad ist gueltig und die Excel-Datei ist erreichbar.", Severity.Success);
await AppEventLogService.WriteAsync("ManualImport", "Dateipfad erfolgreich geprueft", siteId: _editingSite.Id, land: _editingSite.Land, details: _editingSite.ManualImportFilePath);
}
catch (Exception ex)
{
Snackbar.Add($"Pfadpruefung fehlgeschlagen: {ex.Message}", Severity.Error);
await AppEventLogService.WriteAsync("ManualImport", "Dateipfadpruefung fehlgeschlagen", "Error", siteId: _editingSite.Id, land: _editingSite.Land, details: ex.ToString());
}
}
private static List<string> ParseSapEntitySets(string json) private static List<string> ParseSapEntitySets(string json)
{ {
if (string.IsNullOrWhiteSpace(json)) if (string.IsNullOrWhiteSpace(json))
@@ -1011,6 +1206,12 @@
private static string SerializeSapEntitySets(List<string> entitySets) private static string SerializeSapEntitySets(List<string> entitySets)
=> JsonSerializer.Serialize(entitySets); => JsonSerializer.Serialize(entitySets);
private static bool LooksLikeSharePointReference(string path)
=> path.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("/Shared Documents/", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("Shared Documents/", StringComparison.OrdinalIgnoreCase);
private void AddSapSource() private void AddSapSource()
{ {
_sapSources.Add(new SapSourceDefinition _sapSources.Add(new SapSourceDefinition
+521
View File
@@ -0,0 +1,521 @@
# TrafagSalesExporter LLM System Guide
Stand: 2026-04-17
Diese Datei ist fuer andere LLMs gedacht, die das Projekt schnell verstehen und daraus Architekturtexte, Visualisierungen, Ablaufdiagramme oder UI-/Datenflussgrafiken erzeugen sollen.
## Zweck des Systems
`TrafagSalesExporter` ist eine Blazor Server App auf `.NET 8`, die Verkaufsdaten aus mehreren Quellsystemen in ein gemeinsames Zielschema ueberfuehrt.
Quellsysteme:
- `HANA`-basierte Systeme wie `BI1` und `SAGE`
- `SAP_GATEWAY` ueber OData
- `MANUAL_EXCEL` aus hochgeladenen oder referenzierten Excel-Dateien
Zielbild:
- jede Quelle wird in `SalesRecord` normalisiert
- Standortdaten koennen lokal als Excel exportiert werden
- alle Datensaetze werden in `CentralSalesRecords` gespeichert
- eine zentrale konsolidierte Datei wird aus dem zentralen Datenbestand erzeugt
- ein `Management Cockpit` analysiert sowohl exportierte Dateien als auch zentrale Rohdaten
## Technologie-Stack
- UI: Blazor Server + MudBlazor
- Datenbank: SQLite (`trafag_exporter.db`)
- Excel lesen/schreiben: ClosedXML
- SAP HANA Zugriff: `Sap.Data.Hana.Core.v2.1.dll`
- SAP Gateway / OData: eigener Service ueber HTTP
- SharePoint Upload/Download: Microsoft Graph + Azure Identity
- Tests: xUnit
## Einstiegspunkte
Wichtige Dateien:
- [Program.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Program.cs)
- [Data/AppDbContext.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Data/AppDbContext.cs)
- [Components/Layout/NavMenu.razor](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Components/Layout/NavMenu.razor)
`Program.cs` registriert fast die komplette Architektur ueber DI und fuehrt beim Start `DatabaseInitializationService.InitializeAsync()` aus.
## Hauptseiten
Navigation:
- `/` Dashboard
- `/standorte`
- `/transformations`
- `/management-cockpit`
- `/settings`
- `/logs`
Dateien:
- [Components/Pages/Dashboard.razor](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Components/Pages/Dashboard.razor)
- [Components/Pages/Standorte.razor](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Components/Pages/Standorte.razor)
- [Components/Pages/Transformations.razor](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Components/Pages/Transformations.razor)
- [Components/Pages/ManagementCockpit.razor](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Components/Pages/ManagementCockpit.razor)
- [Components/Pages/Settings.razor](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Components/Pages/Settings.razor)
- [Components/Pages/Logs.razor](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Components/Pages/Logs.razor)
Kurzrollen:
- `Dashboard`: Einzel-Export, Alle exportieren, zentrale Datei neu erzeugen, Live-Status
- `Standorte`: Standortpflege, zentrale HANA-Technik, SAP-Konfiguration pro Standort, manueller Excel-Import
- `Transformations`: feldweise und record-basierte Regeln
- `Management Cockpit`: Dateianalyse und Rohanalyse aus `CentralSalesRecords`
- `Settings`: SharePoint, Exportpfade, Quellsysteme, Wechselkurse, Config Import/Export
- `Logs`: technische Ereignisprotokolle
## Kernmodelle
Wichtige Entity-Klassen:
- [Models/Site.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/Site.cs)
- [Models/SourceSystemDefinition.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/SourceSystemDefinition.cs)
- [Models/HanaServer.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/HanaServer.cs)
- [Models/SalesRecord.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/SalesRecord.cs)
- [Models/CentralSalesRecord.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/CentralSalesRecord.cs)
- [Models/FieldTransformationRule.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/FieldTransformationRule.cs)
- [Models/SapSourceDefinition.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/SapSourceDefinition.cs)
- [Models/SapJoinDefinition.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/SapJoinDefinition.cs)
- [Models/SapFieldMapping.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/SapFieldMapping.cs)
- [Models/SharePointConfig.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/SharePointConfig.cs)
- [Models/ExportSettings.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/ExportSettings.cs)
- [Models/ExportLog.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/ExportLog.cs)
- [Models/AppEventLog.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/AppEventLog.cs)
- [Models/CurrencyExchangeRate.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/CurrencyExchangeRate.cs)
Wichtige Relationen:
- `Site -> HanaServer` optional
- `Site -> SapSourceDefinitions`
- `Site -> SapJoinDefinitions`
- `Site -> SapFieldMappings`
- `Site -> CentralSalesRecords`
- `SourceSystemDefinition` ist zentrale Stammdatenquelle fuer Quellsysteme
## Datenbanktabellen
`AppDbContext` enthaelt:
- `HanaServers`
- `SourceSystemDefinitions`
- `Sites`
- `SharePointConfigs`
- `ExportSettings`
- `ExportLogs`
- `AppEventLogs`
- `FieldTransformationRules`
- `CurrencyExchangeRates`
- `SapSourceDefinitions`
- `SapJoinDefinitions`
- `SapFieldMappings`
- `CentralSalesRecords`
## Architekturrollen der Services
### Export / Orchestrierung
- [Services/ExportOrchestrationService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/ExportOrchestrationService.cs)
- [Services/SiteExportService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/SiteExportService.cs)
- [Services/ConsolidatedExportService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/ConsolidatedExportService.cs)
- [Services/CentralSalesRecordService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/CentralSalesRecordService.cs)
- [Services/ExportLogService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/ExportLogService.cs)
Rollen:
- `ExportOrchestrationService` steuert UI-nahe Exportlaeufe und Live-Status
- `SiteExportService` entscheidet anhand des Quellsystems, wie ein Standort gelesen wird
- `CentralSalesRecordService` ersetzt zentrale Saetze pro Standort
- `ConsolidatedExportService` erzeugt die zentrale Datei
### Datenquellen
- [Services/HanaQueryService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/HanaQueryService.cs)
- [Services/SapGatewayService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/SapGatewayService.cs)
- [Services/SapCompositionService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/SapCompositionService.cs)
- [Services/ManualExcelImportService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/ManualExcelImportService.cs)
- [Services/SharePointUploadService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/SharePointUploadService.cs)
Rollen:
- `HanaQueryService`: SQL gegen SAP B1/HANA-nahe Schemata
- `SapGatewayService`: OData-Metadaten und Reads
- `SapCompositionService`: Mehrquellen-/Join-/Mapping-Aufbau fuer SAP
- `ManualExcelImportService`: Import im Exportformat aus `.xlsx`
- `SharePointUploadService`: Upload fuer Exportdateien und Download fuer manuelle Excel-Dateien
### Transformation / Mapping
- [Services/TransformationCatalog.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/TransformationCatalog.cs)
- [Services/TransformationStrategies.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/TransformationStrategies.cs)
- [Services/RecordTransformationService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/RecordTransformationService.cs)
- [Services/CurrencyExchangeRateService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/CurrencyExchangeRateService.cs)
- [Services/ExchangeRateImportService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/ExchangeRateImportService.cs)
Rollen:
- `Value`-Transformationen fuer einzelne Felder
- `Record`-Transformationen fuer zeilenweite Regeln
- Wechselkursimport und -umrechnung
### Reporting / Monitoring / Infrastruktur
- [Services/ManagementCockpitService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/ManagementCockpitService.cs)
- [Services/AppEventLogService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/AppEventLogService.cs)
- [Services/ConfigTransferService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/ConfigTransferService.cs)
- [Services/DatabaseInitializationService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/DatabaseInitializationService.cs)
- [Services/TimerBackgroundService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/TimerBackgroundService.cs)
## Der wichtigste technische Ablauf
### 1. Standort-Export
Pfad:
`Dashboard/Standorte -> ExportOrchestrationService -> SiteExportService`
`SiteExportService` unterscheidet drei Modi:
1. `SAP_GATEWAY`
- SAP-Quellen lesen
- SAP-Joins anwenden
- SAP-Feldmappings auf `SalesRecord`
- Transformationen anwenden
- Standort-Excel erzeugen
- `CentralSalesRecords` ersetzen
- optional SharePoint-Upload
2. `HANA`
- effektive zentrale HANA-Konfiguration laden
- optionale Standort-Credential-Overrides anwenden
- SQL in HANA ausfuehren
- `SalesRecord` erzeugen
- Transformationen anwenden
- Standort-Excel erzeugen
- `CentralSalesRecords` ersetzen
- optional SharePoint-Upload
3. `MANUAL_EXCEL`
- `ManualImportFilePath` auswerten
- wenn lokal/UNC vorhanden: lokal lesen
- wenn SharePoint-Referenz: via Graph temp herunterladen
- Excel in `SalesRecord` lesen
- Transformationen anwenden
- keine neue Standortdatei erzeugen, bestehende Excel dient als Eingabe
- `CentralSalesRecords` ersetzen
### 2. Konsolidierter Export
Pfad:
`Dashboard -> ExportOrchestrationService -> ConsolidatedExportService`
Semantik aktuell:
- die zentrale Datei basiert fachlich auf `CentralSalesRecords`
- `ExportAllAsync()` sammelt zwar auch `consolidatedRecords`, aber die zentrale Exportsemantik ist historisch noch nicht vollkommen bereinigt
### 3. Management Cockpit
Zwei Betriebsarten:
1. Dateibasiert
- vorhandene `.xlsx` waehlen
- Datei mit ClosedXML lesen
- Kennzahlen, Top-Listen, Datenqualitaet, Findings erzeugen
2. Zentraldatenbasiert
- direkt aus `CentralSalesRecords`
- Jahr/Monat Filter
- Rohsicht ohne Intercompany-, CHF-, Budget- oder Spartelogik
## Quellsystemlogik
### SourceSystemDefinition
`SourceSystemDefinition` ist die fuehrende Wahrheit fuer:
- `Code`
- `DisplayName`
- `ConnectionKind`
- `IsActive`
- `CentralUsername`
- `CentralPassword`
- `CentralServiceUrl` fuer SAP
Anschlussarten:
- `HANA`
- `SAP_GATEWAY`
- `MANUAL_EXCEL`
### HANA
Fachliche Logik:
- zentrale technische HANA-Konfiguration pro Quellsystem
- keine separaten Vollverbindungen pro Standort
- Standort speichert nur Fachdaten plus optionale Username-/Password-Overrides
Schema-Lookup:
- in `Standorte` gibt es jetzt `Schemas laden`
- Lookup fragt `sys.tables` in HANA ab
- eingeschraenkt auf typische B1-Schemas mit Tabellen wie `OINV`, `INV1`, `ORIN`, `RIN1`, `OCRD`, `OITM`
### SAP
Fachliche Logik:
- zentrale SAP Service URL in `SourceSystemDefinition.CentralServiceUrl`
- Standort kann `SapServiceUrl` als Override pflegen
- pro Standort gibt es SAP-Quellen, Joins und Feldmappings
### Manual Excel
Fachliche Logik:
- `Site.ManualImportFilePath` kann sein:
- lokaler Windows-Pfad
- UNC-Pfad
- SharePoint-URL
- SharePoint-Pfad unterhalb der konfigurierten Site
- Standortdaten werden aus der Excel eingelesen und in `CentralSalesRecords` uebernommen
- SharePoint dient hier als Eingangsquelle, nicht nur als Exportziel
## Transformationen
Das System unterscheidet:
- `Value`-Transformationen
- `Record`-Transformationen
Beispiele:
- `Copy`
- `Uppercase`
- `Lowercase`
- `Prefix`
- `Suffix`
- `Replace`
- `Constant`
- `NormalizeCurrencyCode`
- `FirstNonEmpty`
- `ConvertCurrency`
Technischer Ablauf:
- Regeln liegen in `FieldTransformationRules`
- `TransformationCatalog` meldet verfuegbare Strategien an die UI
- `RecordTransformationService` wendet record-basierte Strategien an
## Wechselkurse
Vorhanden:
- `CurrencyExchangeRates`
- `ExchangeRateImportService` fuer ECB-Tageskurse
- `NormalizeCurrencyCode`
- `ConvertCurrency`
Wichtig:
- die Rohsicht im `Management Cockpit` rechnet aktuell bewusst nicht in CHF um
- CHF ist derzeit Teil des allgemeinen Transformationssystems, nicht Default in der Cockpit-Rohsicht
## SharePoint-Rolle im Gesamtsystem
`SharePointConfig` enthaelt:
- `SiteUrl`
- `ExportFolder`
- `CentralExportFolder`
- `TenantId`
- `ClientId`
- `ClientSecret`
Verwendung:
- Upload von Standort-Exporten
- Upload der zentralen Datei
- Download von manuellen Excel-Dateien fuer `MANUAL_EXCEL`
Wichtig:
- die App arbeitet gegen dieselbe SharePoint-Site, die in `Settings` konfiguriert ist
- fuer `MANUAL_EXCEL` muessen Referenzen auf derselben Site aufloesbar sein
## Startinitialisierung / Migrationen
Kritische Datei:
- [Services/DatabaseInitializationService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/DatabaseInitializationService.cs)
Aktuelle Rolle:
- `EnsureCreated`
- Schema-Ergaenzungen per `ALTER TABLE`
- Tabellen-Rebuilds bei Legacy-Schemas
- FK-Reparaturen
- Stammdaten-Seeding
- empfohlene Transformationsregeln
Bekannte Architekturrealitaet:
- das ist funktional hilfreich, aber kein sauberes Migrationssystem
- die Startlogik traegt produktive Schema-Reparaturverantwortung
- das ist einer der wichtigsten technischen Risikobloecke
Bereits gehaertete Fehlerbilder:
- kaputte FK-Referenzen auf `Sites_old`
- kaputte FK-Referenzen auf `HanaServers_repair_old`
- Legacy-Credential-Spalten in `ExportSettings`
- Legacy-Credential-Spalten in `HanaServers`
- verschobene Spalten im `Sites_old -> Sites`-Kopierpfad
## Config Import / Export
Dateien:
- [Services/ConfigTransferService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/ConfigTransferService.cs)
- [Models/ConfigTransferPackage.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/ConfigTransferPackage.cs)
Aktueller Stand:
- JSON Export/Import fuer Konfiguration
- Secrets optional
- `SourceSystemDefinitions` im aktuellen Modell enthalten
- HANA-Technik ohne HANA-Credentials
- Standort-Overrides bleiben erhalten
Wichtige Punkte:
- Import laeuft jetzt transaktional
- alte `ConnectionKind`-lose Formate bekommen Fallbacks
- `CentralSalesRecords` werden nicht mehr blind geloescht
- bestehende zentrale Laufzeitdaten werden fuer weiterhin vorhandene Standorte remappt
## Logging
Es gibt zwei Log-Ebenen:
- `ExportLogs` fuer fachliche Exporthistorie
- `AppEventLogs` fuer technische und UI-nahe Ereignisse
Die `Logs`-Seite liest vor allem `AppEventLogs`.
## Tests
Testprojekt:
- [TrafagSalesExporter.Tests](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/TrafagSalesExporter.Tests)
Aktuell vorhandene Schwerpunkte:
- Transformationen
- Record-Transformationen
- TransformationCatalog
- CurrencyExchangeRateService
- ExchangeRateImportService
- ManualExcelImportService
- ManagementCockpitService
- ConfigTransferService
- DatabaseInitializationService
Wichtig:
- es gibt aktuell keine echten UI-Komponententests mit `bUnit`
- es gibt keine Browser-E2E-Tests mit `Playwright`
- viele Button-Aktionen sind nur indirekt ueber Services und Persistenz getestet
## Bekannte offene Architekturfragen
Fuer andere LLMs wichtig, damit Visualisierungen nicht zu glatt oder zu idealisiert werden:
1. `DatabaseInitializationService` ist ein produktiver Reparatur-/Migrationslayer, nicht nur Bootstrap.
2. `Settings.razor` und `Standorte.razor` enthalten weiterhin relativ viel Anwendungslogik.
3. Die Semantik der konsolidierten Datei ist historisch teilweise doppelt angelegt.
4. Das `Management Cockpit` ist noch kein voll generalisierter Reporting-Layer.
5. SharePoint ist sowohl Exportziel als auch bei `MANUAL_EXCEL` mittlerweile moegliche Eingangsquelle.
## Empfohlene Diagramme fuer andere LLMs
### 1. Kontextdiagramm
Zeige:
- Benutzer
- Blazor App
- SQLite
- SAP HANA
- SAP Gateway
- lokale Dateisystempfade
- SharePoint
### 2. Komponenten-/Service-Diagramm
Gruppiere:
- UI
- Orchestrierung
- Quelladapter
- Transformation
- Persistenz
- Reporting
### 3. Datenflussdiagramm pro Quelltyp
Je ein separater Flow fuer:
- HANA
- SAP Gateway
- Manual Excel lokal
- Manual Excel SharePoint
### 4. ER-Diagramm
Fokussiere auf:
- `SourceSystemDefinition`
- `HanaServer`
- `Site`
- `SapSourceDefinition`
- `SapJoinDefinition`
- `SapFieldMapping`
- `CentralSalesRecord`
- `FieldTransformationRule`
### 5. Sequenzdiagramm fuer Export
Wichtige Stationen:
- Dashboard
- ExportOrchestrationService
- SiteExportService
- spezifischer Quellservice
- Transformation
- CentralSalesRecordService
- Excel/SharePoint
- ExportLog/AppEventLog
## Prompt-Vorlage fuer ein anderes LLM
Wenn ein anderes LLM daraus Visualisierungen erzeugen soll, funktioniert diese Anweisung gut:
> Lies `LLM_SYSTEM_GUIDE.md` als primaeren Systemkontext. Erzeuge daraus ein Architekturdiagramm, ein Datenflussdiagramm fuer HANA/SAP/MANUAL_EXCEL, ein ER-Diagramm der wichtigsten Tabellen und ein Sequenzdiagramm fuer `ExportAsync`. Achte darauf, dass `DatabaseInitializationService` produktive Reparaturlogik enthaelt und dass `MANUAL_EXCEL` sowohl lokal als auch ueber SharePoint gelesen werden kann.
## Weitere Kontextdateien
Zusatzkontext fuer Verlauf und Risiken:
- [HANDOFF_2026-04-15.md](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/HANDOFF_2026-04-15.md)
- [NEXT_STEPS_2026-04-15.md](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md)
Diese beiden Dateien sind wichtig, wenn ein anderes LLM nicht nur Struktur, sondern auch historische Umbauten, Risiken und Prioritaeten verstehen soll.
@@ -158,19 +158,21 @@ public class ConfigTransferService : IConfigTransferService
{ {
var package = JsonSerializer.Deserialize<ConfigTransferPackage>(json, JsonOptions) var package = JsonSerializer.Deserialize<ConfigTransferPackage>(json, JsonOptions)
?? throw new InvalidOperationException("Konfigurationsdatei konnte nicht gelesen werden."); ?? throw new InvalidOperationException("Konfigurationsdatei konnte nicht gelesen werden.");
var importedSourceSystems = ResolveImportedSourceSystems(json, package);
using var db = await _dbFactory.CreateDbContextAsync(); using var db = await _dbFactory.CreateDbContextAsync();
await using var transaction = await db.Database.BeginTransactionAsync();
var existingSharePoint = await db.SharePointConfigs.FirstOrDefaultAsync(); var existingSharePoint = await db.SharePointConfigs.FirstOrDefaultAsync();
var existingSettings = await db.ExportSettings.FirstOrDefaultAsync(); var existingSettings = await db.ExportSettings.FirstOrDefaultAsync();
var existingSourceSystems = await db.SourceSystemDefinitions.ToListAsync(); var existingSourceSystems = await db.SourceSystemDefinitions.ToListAsync();
var existingServers = await db.HanaServers.ToListAsync(); var existingServers = await db.HanaServers.ToListAsync();
var existingExchangeRates = await db.CurrencyExchangeRates.ToListAsync(); var existingExchangeRates = await db.CurrencyExchangeRates.ToListAsync();
var existingSites = await db.Sites.ToListAsync(); var existingSites = await db.Sites.ToListAsync();
var existingCentralRecords = await db.CentralSalesRecords.AsNoTracking().ToListAsync();
var existingRules = await db.FieldTransformationRules.ToListAsync(); var existingRules = await db.FieldTransformationRules.ToListAsync();
var existingSapSources = await db.SapSourceDefinitions.ToListAsync(); var existingSapSources = await db.SapSourceDefinitions.ToListAsync();
var existingSapJoins = await db.SapJoinDefinitions.ToListAsync(); var existingSapJoins = await db.SapJoinDefinitions.ToListAsync();
var existingSapMappings = await db.SapFieldMappings.ToListAsync(); var existingSapMappings = await db.SapFieldMappings.ToListAsync();
var existingCentralRecords = await db.CentralSalesRecords.ToListAsync();
var preservedSharePointSecret = existingSharePoint?.ClientSecret ?? string.Empty; var preservedSharePointSecret = existingSharePoint?.ClientSecret ?? string.Empty;
var preservedSourceSystemSecrets = existingSourceSystems.ToDictionary( var preservedSourceSystemSecrets = existingSourceSystems.ToDictionary(
@@ -180,13 +182,15 @@ public class ConfigTransferService : IConfigTransferService
var preservedSiteSecrets = existingSites.ToDictionary( var preservedSiteSecrets = existingSites.ToDictionary(
x => BuildSiteSignature(x.Land, x.TSC, x.Schema, x.SourceSystem), x => BuildSiteSignature(x.Land, x.TSC, x.Schema, x.SourceSystem),
x => (x.UsernameOverride, x.PasswordOverride)); x => (x.UsernameOverride, x.PasswordOverride));
var existingSiteSignaturesById = existingSites.ToDictionary(
x => x.Id,
x => BuildSiteSignature(x.Land, x.TSC, x.Schema, x.SourceSystem));
if (existingSapMappings.Count > 0) db.SapFieldMappings.RemoveRange(existingSapMappings); if (existingSapMappings.Count > 0) db.SapFieldMappings.RemoveRange(existingSapMappings);
if (existingSapJoins.Count > 0) db.SapJoinDefinitions.RemoveRange(existingSapJoins); if (existingSapJoins.Count > 0) db.SapJoinDefinitions.RemoveRange(existingSapJoins);
if (existingSapSources.Count > 0) db.SapSourceDefinitions.RemoveRange(existingSapSources); if (existingSapSources.Count > 0) db.SapSourceDefinitions.RemoveRange(existingSapSources);
if (existingRules.Count > 0) db.FieldTransformationRules.RemoveRange(existingRules); if (existingRules.Count > 0) db.FieldTransformationRules.RemoveRange(existingRules);
if (existingExchangeRates.Count > 0) db.CurrencyExchangeRates.RemoveRange(existingExchangeRates); if (existingExchangeRates.Count > 0) db.CurrencyExchangeRates.RemoveRange(existingExchangeRates);
if (existingCentralRecords.Count > 0) db.CentralSalesRecords.RemoveRange(existingCentralRecords);
if (existingSites.Count > 0) db.Sites.RemoveRange(existingSites); if (existingSites.Count > 0) db.Sites.RemoveRange(existingSites);
if (existingServers.Count > 0) db.HanaServers.RemoveRange(existingServers); if (existingServers.Count > 0) db.HanaServers.RemoveRange(existingServers);
if (existingSourceSystems.Count > 0) db.SourceSystemDefinitions.RemoveRange(existingSourceSystems); if (existingSourceSystems.Count > 0) db.SourceSystemDefinitions.RemoveRange(existingSourceSystems);
@@ -217,10 +221,6 @@ public class ConfigTransferService : IConfigTransferService
LocalConsolidatedExportFolder = importedSettings.LocalConsolidatedExportFolder LocalConsolidatedExportFolder = importedSettings.LocalConsolidatedExportFolder
}); });
var importedSourceSystems = package.SourceSystemDefinitions.Count > 0
? package.SourceSystemDefinitions
: BuildDefaultSourceSystems();
foreach (var sourceSystem in importedSourceSystems) foreach (var sourceSystem in importedSourceSystems)
{ {
preservedSourceSystemSecrets.TryGetValue(sourceSystem.Code, out var preserved); preservedSourceSystemSecrets.TryGetValue(sourceSystem.Code, out var preserved);
@@ -272,6 +272,7 @@ public class ConfigTransferService : IConfigTransferService
} }
var siteIdMap = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase); var siteIdMap = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var importedSiteIdBySignature = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (var site in package.Sites) foreach (var site in package.Sites)
{ {
preservedSiteSecrets.TryGetValue(BuildSiteSignature(site.Land, site.TSC, site.Schema, site.SourceSystem), out var preserved); preservedSiteSecrets.TryGetValue(BuildSiteSignature(site.Land, site.TSC, site.Schema, site.SourceSystem), out var preserved);
@@ -298,8 +299,52 @@ public class ConfigTransferService : IConfigTransferService
db.Sites.Add(entity); db.Sites.Add(entity);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
siteIdMap[site.Key] = entity.Id; siteIdMap[site.Key] = entity.Id;
importedSiteIdBySignature[BuildSiteSignature(site.Land, site.TSC, site.Schema, site.SourceSystem)] = entity.Id;
} }
var centralRecordsToPreserve = existingCentralRecords
.Where(record => existingSiteSignaturesById.TryGetValue(record.SiteId, out var signature) && importedSiteIdBySignature.ContainsKey(signature))
.Select(record =>
{
var signature = existingSiteSignaturesById[record.SiteId];
return new CentralSalesRecord
{
StoredAtUtc = record.StoredAtUtc,
SiteId = importedSiteIdBySignature[signature],
SourceSystem = record.SourceSystem,
ExtractionDate = record.ExtractionDate,
Tsc = record.Tsc,
InvoiceNumber = record.InvoiceNumber,
PositionOnInvoice = record.PositionOnInvoice,
Material = record.Material,
Name = record.Name,
ProductGroup = record.ProductGroup,
Quantity = record.Quantity,
SupplierNumber = record.SupplierNumber,
SupplierName = record.SupplierName,
SupplierCountry = record.SupplierCountry,
CustomerNumber = record.CustomerNumber,
CustomerName = record.CustomerName,
CustomerCountry = record.CustomerCountry,
CustomerIndustry = record.CustomerIndustry,
StandardCost = record.StandardCost,
StandardCostCurrency = record.StandardCostCurrency,
PurchaseOrderNumber = record.PurchaseOrderNumber,
SalesPriceValue = record.SalesPriceValue,
SalesCurrency = record.SalesCurrency,
Incoterms2020 = record.Incoterms2020,
SalesResponsibleEmployee = record.SalesResponsibleEmployee,
InvoiceDate = record.InvoiceDate,
OrderDate = record.OrderDate,
Land = record.Land,
DocumentType = record.DocumentType
};
})
.ToList();
if (centralRecordsToPreserve.Count > 0)
db.CentralSalesRecords.AddRange(centralRecordsToPreserve);
if (package.FieldTransformationRules.Count > 0) if (package.FieldTransformationRules.Count > 0)
{ {
db.FieldTransformationRules.AddRange(package.FieldTransformationRules.Select(r => new FieldTransformationRule db.FieldTransformationRules.AddRange(package.FieldTransformationRules.Select(r => new FieldTransformationRule
@@ -363,10 +408,53 @@ public class ConfigTransferService : IConfigTransferService
} }
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await transaction.CommitAsync();
} }
private static string BuildSiteSignature(string land, string tsc, string schema, string sourceSystem) private static string BuildSiteSignature(string land, string tsc, string schema, string sourceSystem)
=> $"{land}|{tsc}|{schema}|{sourceSystem}".ToUpperInvariant(); => $"{land}|{tsc}|{schema}|{sourceSystem}".ToUpperInvariant();
private static List<ConfigTransferSourceSystemDefinition> ResolveImportedSourceSystems(string json, ConfigTransferPackage package)
{
if (package.SourceSystemDefinitions.Count == 0)
return BuildDefaultSourceSystems();
using var document = JsonDocument.Parse(json);
if (!document.RootElement.TryGetProperty(nameof(ConfigTransferPackage.SourceSystemDefinitions), out var sourceSystemsElement) ||
sourceSystemsElement.ValueKind != JsonValueKind.Array)
{
return package.SourceSystemDefinitions;
}
var imported = package.SourceSystemDefinitions
.Select((sourceSystem, index) =>
{
var hasExplicitConnectionKind =
index < sourceSystemsElement.GetArrayLength() &&
sourceSystemsElement[index].TryGetProperty(nameof(ConfigTransferSourceSystemDefinition.ConnectionKind), out _);
if (hasExplicitConnectionKind)
return sourceSystem;
sourceSystem.ConnectionKind = InferLegacyConnectionKind(sourceSystem.Code);
return sourceSystem;
})
.ToList();
return imported;
}
private static string InferLegacyConnectionKind(string code)
{
if (string.Equals(code, "SAP", StringComparison.OrdinalIgnoreCase))
return SourceSystemConnectionKinds.SapGateway;
if (string.Equals(code, "MANUAL_EXCEL", StringComparison.OrdinalIgnoreCase))
return SourceSystemConnectionKinds.ManualExcel;
return SourceSystemConnectionKinds.Hana;
}
private static List<ConfigTransferSourceSystemDefinition> BuildDefaultSourceSystems() private static List<ConfigTransferSourceSystemDefinition> BuildDefaultSourceSystems()
{ {
return return
@@ -48,7 +48,7 @@ public class DatabaseInitializationService : IDatabaseInitializationService
EnsureSitesTableSupportsOptionalHanaServer(db); EnsureSitesTableSupportsOptionalHanaServer(db);
EnsureExportSettingsTableSupportsCurrentSchema(db); EnsureExportSettingsTableSupportsCurrentSchema(db);
EnsureHanaServersTableSupportsCurrentSchema(db); EnsureHanaServersTableSupportsCurrentSchema(db);
RepairBrokenSiteForeignKeys(db); RepairBrokenForeignKeys(db);
AddColumnIfMissing(db, "HanaServers", "SourceSystem", "TEXT NOT NULL DEFAULT ''"); AddColumnIfMissing(db, "HanaServers", "SourceSystem", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "HanaServers", "DatabaseName", "TEXT NOT NULL DEFAULT ''"); AddColumnIfMissing(db, "HanaServers", "DatabaseName", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "HanaServers", "UseSsl", "INTEGER NOT NULL DEFAULT 0"); AddColumnIfMissing(db, "HanaServers", "UseSsl", "INTEGER NOT NULL DEFAULT 0");
@@ -166,26 +166,7 @@ public class DatabaseInitializationService : IDatabaseInitializationService
using (var create = conn.CreateCommand()) using (var create = conn.CreateCommand())
{ {
create.Transaction = transaction; create.Transaction = transaction;
create.CommandText = @" create.CommandText = GetSitesCreateSql();
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 '',
LocalExportFolderOverride TEXT NOT NULL DEFAULT '',
ManualImportFilePath TEXT NOT NULL DEFAULT '',
ManualImportLastUploadedAtUtc TEXT NULL,
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(); create.ExecuteNonQuery();
} }
@@ -195,8 +176,9 @@ CREATE TABLE Sites (
copy.CommandText = @" copy.CommandText = @"
INSERT INTO Sites ( INSERT INTO Sites (
Id, HanaServerId, Schema, TSC, Land, SourceSystem, Id, HanaServerId, Schema, TSC, Land, SourceSystem,
UsernameOverride, PasswordOverride, LocalExportFolderOverride, SapServiceUrl, SapEntitySet, UsernameOverride, PasswordOverride, LocalExportFolderOverride, ManualImportFilePath,
ManualImportFilePath, ManualImportLastUploadedAtUtc, SapEntitySetsCache, SapEntitySetsRefreshedAtUtc, IsActive ManualImportLastUploadedAtUtc, SapServiceUrl, SapEntitySet, SapEntitySetsCache,
SapEntitySetsRefreshedAtUtc, IsActive
) )
SELECT SELECT
Id, HanaServerId, Schema, TSC, Land, Id, HanaServerId, Schema, TSC, Land,
@@ -229,13 +211,13 @@ FROM Sites_old;";
enableFk.ExecuteNonQuery(); enableFk.ExecuteNonQuery();
} }
private static void RepairBrokenSiteForeignKeys(AppDbContext db) private static void RepairBrokenForeignKeys(AppDbContext db)
{ {
var conn = db.Database.GetDbConnection(); var conn = db.Database.GetDbConnection();
if (conn.State != ConnectionState.Open) if (conn.State != ConnectionState.Open)
conn.Open(); conn.Open();
var tablesToRepair = new[] var siteDependentTables = new[]
{ {
("ExportLogs", GetExportLogsCreateSql()), ("ExportLogs", GetExportLogsCreateSql()),
("AppEventLogs", GetAppEventLogsCreateSql()), ("AppEventLogs", GetAppEventLogsCreateSql()),
@@ -245,14 +227,17 @@ FROM Sites_old;";
("SapFieldMappings", GetSapFieldMappingsCreateSql()) ("SapFieldMappings", GetSapFieldMappingsCreateSql())
}; };
foreach (var (tableName, createSql) in tablesToRepair) foreach (var (tableName, createSql) in siteDependentTables)
{ {
if (TableReferencesSitesOld(conn, tableName)) if (TableReferences(conn, tableName, "Sites_old"))
RebuildTable(conn, tableName, createSql); RebuildTable(conn, tableName, createSql);
} }
if (TableReferences(conn, "Sites", "HanaServers_repair_old"))
RebuildTable(conn, "Sites", GetSitesCreateSql());
} }
private static bool TableReferencesSitesOld(System.Data.Common.DbConnection connection, string tableName) private static bool TableReferences(System.Data.Common.DbConnection connection, string tableName, string referencedTableName)
{ {
using var command = connection.CreateCommand(); using var command = connection.CreateCommand();
command.CommandText = "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = $tableName;"; command.CommandText = "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = $tableName;";
@@ -263,7 +248,7 @@ FROM Sites_old;";
command.Parameters.Add(parameter); command.Parameters.Add(parameter);
var sql = command.ExecuteScalar()?.ToString() ?? string.Empty; var sql = command.ExecuteScalar()?.ToString() ?? string.Empty;
return sql.Contains("Sites_old", StringComparison.OrdinalIgnoreCase); return sql.Contains(referencedTableName, StringComparison.OrdinalIgnoreCase);
} }
private static void RebuildTable(System.Data.Common.DbConnection connection, string tableName, string createSql) private static void RebuildTable(System.Data.Common.DbConnection connection, string tableName, string createSql)
@@ -383,6 +368,27 @@ CREATE TABLE HanaServers (
AdditionalParams TEXT NOT NULL DEFAULT '' AdditionalParams TEXT NOT NULL DEFAULT ''
);"; );";
private static string GetSitesCreateSql() => @"
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 '',
LocalExportFolderOverride TEXT NOT NULL DEFAULT '',
ManualImportFilePath TEXT NOT NULL DEFAULT '',
ManualImportLastUploadedAtUtc TEXT NULL,
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)
);";
private static string GetAppEventLogsCreateSql() => @" private static string GetAppEventLogsCreateSql() => @"
CREATE TABLE AppEventLogs ( CREATE TABLE AppEventLogs (
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
@@ -3,5 +3,6 @@ namespace TrafagSalesExporter.Services;
public interface ISharePointUploadService public interface ISharePointUploadService
{ {
Task UploadAsync(string tenantId, string clientId, string clientSecret, string siteUrl, string exportFolder, string land, string localFilePath); Task UploadAsync(string tenantId, string clientId, string clientSecret, string siteUrl, string exportFolder, string land, string localFilePath);
Task<string> DownloadToTempFileAsync(string tenantId, string clientId, string clientSecret, string siteUrl, string fileReference);
Task TestConnectionAsync(string tenantId, string clientId, string clientSecret, string siteUrl); Task TestConnectionAsync(string tenantId, string clientId, string clientSecret, string siteUrl);
} }
@@ -43,6 +43,45 @@ public class SharePointUploadService : ISharePointUploadService
await graphClient.Drives[drive.Id].Root.ItemWithPath(remotePath).Content.PutAsync(stream); await graphClient.Drives[drive.Id].Root.ItemWithPath(remotePath).Content.PutAsync(stream);
} }
public async Task<string> DownloadToTempFileAsync(string tenantId, string clientId, string clientSecret, string siteUrl, string fileReference)
{
var normalizedTenantId = Normalize(tenantId);
var normalizedClientId = Normalize(clientId);
var normalizedClientSecret = Normalize(clientSecret);
var normalizedSiteUrl = Normalize(siteUrl);
var normalizedReference = Normalize(fileReference);
if (string.IsNullOrWhiteSpace(normalizedReference))
throw new InvalidOperationException("SharePoint-Dateireferenz fehlt.");
var credential = new ClientSecretCredential(normalizedTenantId, normalizedClientId, normalizedClientSecret);
var graphClient = new GraphServiceClient(credential, ["https://graph.microsoft.com/.default"]);
var siteUri = new Uri(normalizedSiteUrl);
var sitePath = siteUri.AbsolutePath.TrimEnd('/');
var site = await graphClient.Sites[$"{siteUri.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 remotePath = ResolveRemotePath(normalizedReference, siteUri);
var fileName = Path.GetFileName(remotePath);
if (string.IsNullOrWhiteSpace(fileName))
throw new InvalidOperationException("Aus der SharePoint-Dateireferenz konnte kein Dateiname gelesen werden.");
await using var contentStream = await graphClient.Drives[drive.Id].Root.ItemWithPath(remotePath).Content.GetAsync()
?? throw new InvalidOperationException("SharePoint-Datei konnte nicht gelesen werden.");
var tempPath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}_{fileName}");
await using var targetStream = File.Create(tempPath);
await contentStream.CopyToAsync(targetStream);
return tempPath;
}
public async Task TestConnectionAsync(string tenantId, string clientId, string clientSecret, string siteUrl) public async Task TestConnectionAsync(string tenantId, string clientId, string clientSecret, string siteUrl)
{ {
var normalizedTenantId = Normalize(tenantId); var normalizedTenantId = Normalize(tenantId);
@@ -86,6 +125,24 @@ public class SharePointUploadService : ISharePointUploadService
private static string Normalize(string value) => value?.Trim() ?? string.Empty; private static string Normalize(string value) => value?.Trim() ?? string.Empty;
private static string ResolveRemotePath(string fileReference, Uri siteUri)
{
if (Uri.TryCreate(fileReference, UriKind.Absolute, out var fileUri))
{
if (!string.Equals(fileUri.Host, siteUri.Host, StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("Die SharePoint-Datei muss auf derselben SharePoint-Site liegen wie die zentrale Konfiguration.");
var sitePath = siteUri.AbsolutePath.TrimEnd('/');
var absolutePath = Uri.UnescapeDataString(fileUri.AbsolutePath);
if (absolutePath.StartsWith(sitePath, StringComparison.OrdinalIgnoreCase))
absolutePath = absolutePath[sitePath.Length..];
return absolutePath.Trim('/').Trim();
}
return fileReference.Trim('/').Trim();
}
private static string BuildInputPreview(string tenantId, string clientId, string clientSecret, string siteUrl) private static string BuildInputPreview(string tenantId, string clientId, string clientSecret, string siteUrl)
{ {
var maskedSecret = string.IsNullOrEmpty(clientSecret) var maskedSecret = string.IsNullOrEmpty(clientSecret)
@@ -110,13 +110,48 @@ public class SiteExportService : ISiteExportService
{ {
if (string.IsNullOrWhiteSpace(site.ManualImportFilePath)) if (string.IsNullOrWhiteSpace(site.ManualImportFilePath))
throw new InvalidOperationException($"Standort '{site.Land}' hat keine manuelle Excel-Datei."); throw new InvalidOperationException($"Standort '{site.Land}' hat keine manuelle Excel-Datei.");
if (!File.Exists(site.ManualImportFilePath)) string? tempManualImportPath = null;
throw new InvalidOperationException($"Die manuelle Excel-Datei wurde nicht gefunden: {site.ManualImportFilePath}"); try
{
var manualImportPath = site.ManualImportFilePath.Trim();
if (File.Exists(manualImportPath))
{
filePath = manualImportPath;
}
else if (LooksLikeSharePointReference(manualImportPath))
{
if (spConfig is null ||
string.IsNullOrWhiteSpace(spConfig.TenantId) ||
string.IsNullOrWhiteSpace(spConfig.ClientId) ||
string.IsNullOrWhiteSpace(spConfig.ClientSecret) ||
string.IsNullOrWhiteSpace(spConfig.SiteUrl))
{
throw new InvalidOperationException("Fuer SharePoint-Manuellimport fehlt eine vollstaendige SharePoint-Konfiguration in Settings.");
}
updateStatus?.Invoke("Manuelle Excel von SharePoint laden...");
await _appEventLogService.WriteAsync("Export", "Manuelle Excel von SharePoint laden", siteId: site.Id, land: site.Land,
details: manualImportPath);
tempManualImportPath = await _sharePointService.DownloadToTempFileAsync(
spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret, spConfig.SiteUrl, manualImportPath);
filePath = manualImportPath;
}
else
{
throw new InvalidOperationException($"Die manuelle Excel-Datei wurde nicht gefunden: {manualImportPath}");
}
var readPath = tempManualImportPath ?? filePath;
updateStatus?.Invoke("Manuelle Excel lesen..."); updateStatus?.Invoke("Manuelle Excel lesen...");
await _appEventLogService.WriteAsync("Export", "Manuelle Excel lesen", siteId: site.Id, land: site.Land, await _appEventLogService.WriteAsync("Export", "Manuelle Excel lesen", siteId: site.Id, land: site.Land,
details: site.ManualImportFilePath); details: filePath);
records = await _manualExcelImportService.ReadSalesRecordsAsync(site.ManualImportFilePath, site); records = await _manualExcelImportService.ReadSalesRecordsAsync(readPath, site);
}
finally
{
if (!string.IsNullOrWhiteSpace(tempManualImportPath) && File.Exists(tempManualImportPath))
File.Delete(tempManualImportPath);
}
updateStatus?.Invoke("Transformationen anwenden..."); updateStatus?.Invoke("Transformationen anwenden...");
await _appEventLogService.WriteAsync("Export", "Transformationen anwenden", siteId: site.Id, land: site.Land, await _appEventLogService.WriteAsync("Export", "Transformationen anwenden", siteId: site.Id, land: site.Land,
@@ -127,7 +162,6 @@ public class SiteExportService : ISiteExportService
.ToListAsync(); .ToListAsync();
_transformationService.Apply(records, rules); _transformationService.Apply(records, rules);
filePath = site.ManualImportFilePath;
log.RowCount = records.Count; log.RowCount = records.Count;
} }
else else
@@ -272,6 +306,12 @@ public class SiteExportService : ISiteExportService
: configured; : configured;
} }
private static bool LooksLikeSharePointReference(string path)
=> path.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("/Shared Documents/", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("Shared Documents/", StringComparison.OrdinalIgnoreCase);
private static Site CloneSiteWithSapServiceUrl(Site site, string sapServiceUrl) private static Site CloneSiteWithSapServiceUrl(Site site, string sapServiceUrl)
{ {
return new Site return new Site
@@ -224,6 +224,96 @@ public class ConfigTransferServiceTests : IDisposable
Assert.Equal("FirstNonEmpty", rule.TransformationType); Assert.Equal("FirstNonEmpty", rule.TransformationType);
} }
[Fact]
public async Task ImportJsonAsync_Preserves_CentralSalesRecords()
{
await SeedExistingSecretsAsync();
await using (var db = await _dbFactory.CreateDbContextAsync())
{
db.CentralSalesRecords.Add(new CentralSalesRecord
{
StoredAtUtc = new DateTime(2026, 4, 17, 8, 0, 0, DateTimeKind.Utc),
SiteId = 1,
SourceSystem = "MANUAL_EXCEL",
ExtractionDate = new DateTime(2026, 4, 17),
Tsc = "TRCH",
InvoiceNumber = "INV-1",
PositionOnInvoice = 1,
Material = "MAT-1",
Name = "Material 1",
ProductGroup = "PG",
Quantity = 1m,
SupplierNumber = "SUP-1",
SupplierName = "Supplier 1",
SupplierCountry = "CH",
CustomerNumber = "CUS-1",
CustomerName = "Customer 1",
CustomerCountry = "CH",
CustomerIndustry = "Industry",
StandardCost = 10m,
StandardCostCurrency = "CHF",
PurchaseOrderNumber = "PO-1",
SalesPriceValue = 20m,
SalesCurrency = "CHF",
Incoterms2020 = "EXW",
SalesResponsibleEmployee = "Owner",
InvoiceDate = new DateTime(2026, 4, 17),
OrderDate = new DateTime(2026, 4, 16),
Land = "Schweiz",
DocumentType = "Invoice"
});
await db.SaveChangesAsync();
}
var package = new ConfigTransferPackage
{
IncludesSecrets = false,
SourceSystemDefinitions = BuildStandardSourceSystems(),
Sites =
[
new ConfigTransferSite
{
Key = "site-1",
Schema = "schema_a",
TSC = "TRCH",
Land = "Schweiz",
SourceSystem = "MANUAL_EXCEL",
IsActive = true
}
]
};
await _service.ImportJsonAsync(JsonSerializer.Serialize(package));
await using var verifyDb = await _dbFactory.CreateDbContextAsync();
Assert.Single(verifyDb.CentralSalesRecords);
}
[Fact]
public async Task ImportJsonAsync_Uses_Legacy_ConnectionKind_Fallbacks()
{
var packageJson = """
{
"includesSecrets": false,
"sourceSystemDefinitions": [
{ "code": "SAP", "displayName": "SAP", "isActive": true },
{ "code": "BI1", "displayName": "BI1", "isActive": true },
{ "code": "MANUAL_EXCEL", "displayName": "Manual Excel", "isActive": true }
]
}
""";
await _service.ImportJsonAsync(packageJson);
await using var db = await _dbFactory.CreateDbContextAsync();
var systems = await db.SourceSystemDefinitions.OrderBy(x => x.Code).ToListAsync();
Assert.Equal(SourceSystemConnectionKinds.Hana, Assert.Single(systems, x => x.Code == "BI1").ConnectionKind);
Assert.Equal(SourceSystemConnectionKinds.ManualExcel, Assert.Single(systems, x => x.Code == "MANUAL_EXCEL").ConnectionKind);
Assert.Equal(SourceSystemConnectionKinds.SapGateway, Assert.Single(systems, x => x.Code == "SAP").ConnectionKind);
}
private async Task SeedExportConfigurationAsync() private async Task SeedExportConfigurationAsync()
{ {
await using var db = await _dbFactory.CreateDbContextAsync(); await using var db = await _dbFactory.CreateDbContextAsync();
@@ -381,6 +471,41 @@ public class ConfigTransferServiceTests : IDisposable
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
private static List<ConfigTransferSourceSystemDefinition> BuildStandardSourceSystems()
{
return
[
new ConfigTransferSourceSystemDefinition
{
Code = "SAP",
DisplayName = "SAP",
ConnectionKind = SourceSystemConnectionKinds.SapGateway,
IsActive = true
},
new ConfigTransferSourceSystemDefinition
{
Code = "BI1",
DisplayName = "BI1",
ConnectionKind = SourceSystemConnectionKinds.Hana,
IsActive = true
},
new ConfigTransferSourceSystemDefinition
{
Code = "SAGE",
DisplayName = "SAGE",
ConnectionKind = SourceSystemConnectionKinds.Hana,
IsActive = true
},
new ConfigTransferSourceSystemDefinition
{
Code = "MANUAL_EXCEL",
DisplayName = "Manual Excel",
ConnectionKind = SourceSystemConnectionKinds.ManualExcel,
IsActive = true
}
];
}
private sealed class TestDbContextFactory : IDbContextFactory<AppDbContext> private sealed class TestDbContextFactory : IDbContextFactory<AppDbContext>
{ {
private readonly DbContextOptions<AppDbContext> _options; private readonly DbContextOptions<AppDbContext> _options;
@@ -0,0 +1,196 @@
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Data;
using TrafagSalesExporter.Services;
namespace TrafagSalesExporter.Tests;
public class DatabaseInitializationServiceTests : IDisposable
{
private readonly SqliteConnection _connection;
private readonly TestDbContextFactory _dbFactory;
public DatabaseInitializationServiceTests()
{
_connection = new SqliteConnection("DataSource=:memory:");
_connection.Open();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(_connection)
.Options;
using (var db = new AppDbContext(options))
{
db.Database.EnsureCreated();
}
_dbFactory = new TestDbContextFactory(options);
}
public void Dispose()
{
_connection.Dispose();
}
[Fact]
public async Task InitializeAsync_Migrates_Sites_Without_Shifting_Columns()
{
await PrepareLegacySitesTableAsync();
var service = new DatabaseInitializationService(_dbFactory);
await service.InitializeAsync();
await using var db = await _dbFactory.CreateDbContextAsync();
var site = await db.Sites.SingleAsync();
Assert.Equal("override-user", site.UsernameOverride);
Assert.Equal("override-password", site.PasswordOverride);
Assert.Equal("C:\\exports\\ch", site.LocalExportFolderOverride);
Assert.Equal("C:\\imports\\manual.xlsx", site.ManualImportFilePath);
Assert.Equal("https://sap.example.local/service", site.SapServiceUrl);
Assert.Equal("A_Sales", site.SapEntitySet);
Assert.Equal("[\"A_Sales\",\"A_Orders\"]", site.SapEntitySetsCache);
Assert.Equal(new DateTime(2026, 4, 17, 7, 30, 0, DateTimeKind.Utc), site.ManualImportLastUploadedAtUtc?.ToUniversalTime());
Assert.Equal(new DateTime(2026, 4, 17, 8, 0, 0, DateTimeKind.Utc), site.SapEntitySetsRefreshedAtUtc?.ToUniversalTime());
}
[Fact]
public async Task InitializeAsync_Repairs_Sites_ForeignKey_To_HanaServersRepairOld()
{
await PrepareBrokenHanaServerForeignKeyAsync();
var service = new DatabaseInitializationService(_dbFactory);
await service.InitializeAsync();
await using var db = await _dbFactory.CreateDbContextAsync();
var site = await db.Sites.SingleAsync();
Assert.Null(await Record.ExceptionAsync(() => db.SaveChangesAsync()));
Assert.Equal("schema_a", site.Schema);
var tableSql = await ReadTableSqlAsync("Sites");
Assert.Contains("REFERENCES HanaServers (Id)", tableSql, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("HanaServers_repair_old", tableSql, StringComparison.OrdinalIgnoreCase);
}
private async Task PrepareLegacySitesTableAsync()
{
await using var db = await _dbFactory.CreateDbContextAsync();
await db.Database.ExecuteSqlRawAsync("DELETE FROM Sites;");
await db.Database.ExecuteSqlRawAsync("DELETE FROM HanaServers;");
await db.Database.ExecuteSqlRawAsync("""
INSERT INTO HanaServers (Id, SourceSystem, Name, Host, Port, DatabaseName, UseSsl, ValidateCertificate, AdditionalParams)
VALUES (1, 'SAP', 'SAP', 'hana-host', 30015, 'DB1', 0, 0, '');
""");
await db.Database.ExecuteSqlRawAsync("PRAGMA foreign_keys = OFF;");
await db.Database.ExecuteSqlRawAsync("ALTER TABLE Sites RENAME TO Sites_current;");
await db.Database.ExecuteSqlRawAsync("""
CREATE TABLE Sites (
Id INTEGER NOT NULL CONSTRAINT PK_Sites PRIMARY KEY AUTOINCREMENT,
HanaServerId INTEGER NOT NULL,
Schema TEXT NOT NULL,
TSC TEXT NOT NULL,
Land TEXT NOT NULL,
SourceSystem TEXT NULL,
UsernameOverride TEXT NULL,
PasswordOverride TEXT NULL,
LocalExportFolderOverride TEXT NULL,
ManualImportFilePath TEXT NULL,
ManualImportLastUploadedAtUtc TEXT NULL,
SapServiceUrl TEXT NULL,
SapEntitySet TEXT NULL,
SapEntitySetsCache TEXT NULL,
SapEntitySetsRefreshedAtUtc TEXT NULL,
IsActive INTEGER NOT NULL,
CONSTRAINT FK_Sites_HanaServers_HanaServerId FOREIGN KEY (HanaServerId) REFERENCES HanaServers (Id)
);
""");
await db.Database.ExecuteSqlRawAsync("""
INSERT INTO Sites (
Id, HanaServerId, Schema, TSC, Land, SourceSystem,
UsernameOverride, PasswordOverride, LocalExportFolderOverride, ManualImportFilePath,
ManualImportLastUploadedAtUtc, SapServiceUrl, SapEntitySet, SapEntitySetsCache,
SapEntitySetsRefreshedAtUtc, IsActive
)
VALUES (
1, 1, 'schema_a', 'TRCH', 'Schweiz', 'SAP',
'override-user', 'override-password', 'C:\exports\ch', 'C:\imports\manual.xlsx',
'2026-04-17 07:30:00Z', 'https://sap.example.local/service', 'A_Sales', '["A_Sales","A_Orders"]',
'2026-04-17 08:00:00Z', 1
);
""");
await db.Database.ExecuteSqlRawAsync("DROP TABLE Sites_current;");
await db.Database.ExecuteSqlRawAsync("PRAGMA foreign_keys = ON;");
}
private async Task PrepareBrokenHanaServerForeignKeyAsync()
{
await using var db = await _dbFactory.CreateDbContextAsync();
await db.Database.ExecuteSqlRawAsync("DELETE FROM Sites;");
await db.Database.ExecuteSqlRawAsync("DELETE FROM HanaServers;");
await db.Database.ExecuteSqlRawAsync("PRAGMA foreign_keys = OFF;");
await db.Database.ExecuteSqlRawAsync("ALTER TABLE Sites RENAME TO Sites_current;");
await db.Database.ExecuteSqlRawAsync("""
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 '',
LocalExportFolderOverride TEXT NOT NULL DEFAULT '',
ManualImportFilePath TEXT NOT NULL DEFAULT '',
ManualImportLastUploadedAtUtc TEXT NULL,
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_repair_old (Id)
);
""");
await db.Database.ExecuteSqlRawAsync("""
INSERT INTO Sites (
Id, HanaServerId, Schema, TSC, Land, SourceSystem,
UsernameOverride, PasswordOverride, LocalExportFolderOverride, ManualImportFilePath,
ManualImportLastUploadedAtUtc, SapServiceUrl, SapEntitySet, SapEntitySetsCache,
SapEntitySetsRefreshedAtUtc, IsActive
)
VALUES (
1, NULL, 'schema_a', 'TRUK', 'England', 'MANUAL_EXCEL',
'', '', '', '',
NULL, '', '', '',
NULL, 1
);
""");
await db.Database.ExecuteSqlRawAsync("DROP TABLE Sites_current;");
await db.Database.ExecuteSqlRawAsync("PRAGMA foreign_keys = ON;");
}
private async Task<string> ReadTableSqlAsync(string tableName)
{
await using var command = _connection.CreateCommand();
command.CommandText = "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = $tableName;";
command.Parameters.AddWithValue("$tableName", tableName);
return (await command.ExecuteScalarAsync())?.ToString() ?? string.Empty;
}
private sealed class TestDbContextFactory : IDbContextFactory<AppDbContext>
{
private readonly DbContextOptions<AppDbContext> _options;
public TestDbContextFactory(DbContextOptions<AppDbContext> options)
{
_options = options;
}
public AppDbContext CreateDbContext() => new(_options);
public Task<AppDbContext> CreateDbContextAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(new AppDbContext(_options));
}
}
+66
View File
@@ -0,0 +1,66 @@
flowchart TD
User[Benutzer]
UI[Blazor Server UI\nDashboard / Standorte / Settings / Cockpit / Logs]
Orch[ExportOrchestrationService]
SiteExport[SiteExportService]
Consolidated[ConsolidatedExportService]
Hana[HanaQueryService]
SapGateway[SapGatewayService]
SapComposition[SapCompositionService]
ManualExcel[ManualExcelImportService]
Transform[TransformationCatalog + RecordTransformationService]
Central[CentralSalesRecordService]
Cockpit[ManagementCockpitService]
Config[ConfigTransferService]
Init[DatabaseInitializationService]
Timer[TimerBackgroundService]
Logs[AppEventLogService + ExportLogService]
SQLite[(SQLite\ntrafag_exporter.db)]
HANA[(SAP HANA)]
SAP[(SAP Gateway / OData)]
LocalFS[(Lokales Dateisystem)]
SharePoint[(SharePoint)]
User --> UI
UI --> Orch
UI --> Cockpit
UI --> Config
UI --> Init
Timer --> Orch
Orch --> SiteExport
Orch --> Consolidated
SiteExport --> Hana
SiteExport --> SapComposition
SiteExport --> ManualExcel
SiteExport --> Transform
SiteExport --> Central
SiteExport --> Logs
SapComposition --> SapGateway
Consolidated --> LocalFS
Consolidated --> SharePoint
Cockpit --> SQLite
Cockpit --> LocalFS
Config --> SQLite
Init --> SQLite
Logs --> SQLite
Central --> SQLite
Hana --> HANA
SapGateway --> SAP
ManualExcel --> LocalFS
ManualExcel --> SharePoint
SiteExport --> LocalFS
SiteExport --> SharePoint
SiteExport --> SQLite
UI --> SQLite
classDef infra fill:#eef6ff,stroke:#336699,color:#102030;
classDef app fill:#f7f2ff,stroke:#6f42c1,color:#201030;
classDef ext fill:#eefaf0,stroke:#2d7a46,color:#102010;
class UI,Orch,SiteExport,Consolidated,Hana,SapGateway,SapComposition,ManualExcel,Transform,Central,Cockpit,Config,Init,Timer,Logs app;
class SQLite,LocalFS infra;
class HANA,SAP,SharePoint,User ext;
@@ -0,0 +1,41 @@
flowchart TD
Start([Export gestartet])
Decide{ConnectionKind}
Start --> Decide
Decide -->|HANA| H1[Zentrale HANA-Konfiguration laden]
H1 --> H2[Optionale Standort-Credentials anwenden]
H2 --> H3[Schema in HANA lesen]
H3 --> H4[SalesRecord-Liste erzeugen]
Decide -->|SAP_GATEWAY| S1[Zentrale oder Override Service URL aufloesen]
S1 --> S2[SAP Quellen laden]
S2 --> S3[Joins anwenden]
S3 --> S4[Feldmappings auf SalesRecord]
Decide -->|MANUAL_EXCEL| M1{ManualImportFilePath Typ}
M1 -->|lokal / UNC| M2[Excel lokal lesen]
M1 -->|SharePoint| M3[Excel via Graph temp herunterladen]
M3 --> M4[Excel aus Temp-Datei lesen]
M2 --> M5[SalesRecord-Liste erzeugen]
M4 --> M5
H4 --> T[Transformationen anwenden]
S4 --> T
M5 --> T
T --> C1[CentralSalesRecords fuer Standort ersetzen]
C1 --> E1{Standortdatei erzeugen?}
E1 -->|ja: HANA / SAP| E2[Excel-Datei lokal erzeugen]
E1 -->|nein: MANUAL_EXCEL| E3[Eingangsdatei bleibt Referenz]
E2 --> SP{SharePoint konfiguriert?}
E3 --> SP
SP -->|ja| SP1[Datei nach SharePoint hochladen]
SP -->|nein| L1[Kein Upload]
SP1 --> Log[ExportLog + AppEventLog schreiben]
L1 --> Log
Log --> Done([Export fertig])
+184
View File
@@ -0,0 +1,184 @@
erDiagram
HANA_SERVERS ||--o{ SITES : "default for HANA source system"
SITES ||--o{ CENTRAL_SALES_RECORDS : stores
SITES ||--o{ EXPORT_LOGS : writes
SITES ||--o{ APP_EVENT_LOGS : logs
SITES ||--o{ SAP_SOURCE_DEFINITIONS : configures
SITES ||--o{ SAP_JOIN_DEFINITIONS : configures
SITES ||--o{ SAP_FIELD_MAPPINGS : configures
SOURCE_SYSTEM_DEFINITIONS {
int Id PK
string Code
string DisplayName
string ConnectionKind
bool IsActive
string CentralServiceUrl
string CentralUsername
string CentralPassword
}
HANA_SERVERS {
int Id PK
string SourceSystem
string Name
string Host
int Port
string DatabaseName
bool UseSsl
bool ValidateCertificate
string AdditionalParams
}
SITES {
int Id PK
int HanaServerId FK
string Schema
string TSC
string Land
string SourceSystem
string UsernameOverride
string PasswordOverride
string LocalExportFolderOverride
string ManualImportFilePath
datetime ManualImportLastUploadedAtUtc
string SapServiceUrl
string SapEntitySet
string SapEntitySetsCache
datetime SapEntitySetsRefreshedAtUtc
bool IsActive
}
SHARE_POINT_CONFIGS {
int Id PK
string SiteUrl
string ExportFolder
string CentralExportFolder
string TenantId
string ClientId
string ClientSecret
}
EXPORT_SETTINGS {
int Id PK
string DateFilter
int TimerHour
int TimerMinute
bool TimerEnabled
bool DebugLoggingEnabled
string LocalSiteExportFolder
string LocalConsolidatedExportFolder
}
FIELD_TRANSFORMATION_RULES {
int Id PK
string SourceSystem
string SourceField
string TargetField
string TransformationType
string RuleScope
string Argument
int SortOrder
bool IsActive
}
SAP_SOURCE_DEFINITIONS {
int Id PK
int SiteId FK
string Alias
string EntitySet
bool IsPrimary
bool IsActive
int SortOrder
}
SAP_JOIN_DEFINITIONS {
int Id PK
int SiteId FK
string LeftAlias
string RightAlias
string LeftKeys
string RightKeys
string JoinType
bool IsActive
int SortOrder
}
SAP_FIELD_MAPPINGS {
int Id PK
int SiteId FK
string TargetField
string SourceExpression
bool IsRequired
bool IsActive
int SortOrder
}
CENTRAL_SALES_RECORDS {
int Id PK
datetime StoredAtUtc
int SiteId FK
string SourceSystem
datetime ExtractionDate
string Tsc
string InvoiceNumber
int PositionOnInvoice
string Material
string Name
string ProductGroup
decimal Quantity
string SupplierNumber
string SupplierName
string SupplierCountry
string CustomerNumber
string CustomerName
string CustomerCountry
string CustomerIndustry
decimal StandardCost
string StandardCostCurrency
string PurchaseOrderNumber
decimal SalesPriceValue
string SalesCurrency
string Incoterms2020
string SalesResponsibleEmployee
datetime InvoiceDate
datetime OrderDate
string Land
string DocumentType
}
EXPORT_LOGS {
int Id PK
datetime Timestamp
int SiteId FK
string Land
string TSC
string Status
int RowCount
string ErrorMessage
string FileName
string FilePath
double DurationSeconds
}
APP_EVENT_LOGS {
int Id PK
datetime Timestamp
string Level
string Category
int SiteId FK
string Land
string Message
string Details
}
CURRENCY_EXCHANGE_RATES {
int Id PK
string FromCurrency
string ToCurrency
decimal Rate
datetime ValidFrom
datetime ValidTo
string Notes
bool IsActive
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB