Convert Trafag exporter to Blazor Server app with UI and scheduler
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<base href="/" />
|
||||
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" rel="stylesheet" />
|
||||
<link href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet" />
|
||||
<link href="app.css" rel="stylesheet" />
|
||||
<HeadOutlet />
|
||||
</head>
|
||||
<body>
|
||||
<Routes />
|
||||
<script src="_framework/blazor.web.js"></script>
|
||||
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,19 @@
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
<MudThemeProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
<MudLayout>
|
||||
<MudAppBar Elevation="1">
|
||||
<MudText Typo="Typo.h6">Trafag Sales Exporter</MudText>
|
||||
</MudAppBar>
|
||||
<MudDrawer Open="true" Variant="DrawerVariant.Mini" Elevation="1">
|
||||
<NavMenu />
|
||||
</MudDrawer>
|
||||
<MudMainContent>
|
||||
<MudContainer MaxWidth="MaxWidth.False" Class="pa-4">
|
||||
@Body
|
||||
</MudContainer>
|
||||
</MudMainContent>
|
||||
</MudLayout>
|
||||
@@ -0,0 +1,6 @@
|
||||
<MudNavMenu>
|
||||
<MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">Dashboard</MudNavLink>
|
||||
<MudNavLink Href="/standorte" Icon="@Icons.Material.Filled.LocationOn">Standorte</MudNavLink>
|
||||
<MudNavLink Href="/settings" Icon="@Icons.Material.Filled.Settings">Settings</MudNavLink>
|
||||
<MudNavLink Href="/logs" Icon="@Icons.Material.Filled.ReceiptLong">Logs</MudNavLink>
|
||||
</MudNavMenu>
|
||||
@@ -0,0 +1,117 @@
|
||||
@page "/"
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@inject IDbContextFactory<AppDbContext> DbFactory
|
||||
@inject ExportOrchestrationService ExportService
|
||||
|
||||
<PageTitle>Dashboard</PageTitle>
|
||||
|
||||
<MudText Typo="Typo.h4" Class="mb-4">Dashboard</MudText>
|
||||
|
||||
<MudStack Row="true" Spacing="2" Class="mb-4">
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" Disabled="@isRunningAll" OnClick="ExportAllAsync">Alle exportieren</MudButton>
|
||||
<MudText Typo="Typo.body1">Nächster automatischer Lauf: @nextRunText</MudText>
|
||||
</MudStack>
|
||||
|
||||
<MudTable Items="sites" Hover="true" Dense="true">
|
||||
<HeaderContent>
|
||||
<MudTh>Land</MudTh>
|
||||
<MudTh>TSC</MudTh>
|
||||
<MudTh>Schema</MudTh>
|
||||
<MudTh>Server</MudTh>
|
||||
<MudTh>Letzter Status</MudTh>
|
||||
<MudTh>Row Count</MudTh>
|
||||
<MudTh>Letzter Lauf</MudTh>
|
||||
<MudTh>Dauer</MudTh>
|
||||
<MudTh>Aktion</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd>@context.Land</MudTd>
|
||||
<MudTd>@context.TSC</MudTd>
|
||||
<MudTd>@context.Schema</MudTd>
|
||||
<MudTd>@context.HanaServer?.Name</MudTd>
|
||||
<MudTd>@GetStatusIcon(context.Id)</MudTd>
|
||||
<MudTd>@GetRows(context.Id)</MudTd>
|
||||
<MudTd>@GetLastRun(context.Id)</MudTd>
|
||||
<MudTd>@GetDuration(context.Id)</MudTd>
|
||||
<MudTd>
|
||||
@if (runningSiteIds.Contains(context.Id))
|
||||
{
|
||||
<MudProgressCircular Indeterminate="true" Size="Size.Small" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudButton Size="Size.Small" Variant="Variant.Outlined" OnClick="() => ExportSingleAsync(context.Id)">Einzeln exportieren</MudButton>
|
||||
}
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
|
||||
@code {
|
||||
private List<Site> sites = [];
|
||||
private Dictionary<int, ExportLog?> latestLogs = new();
|
||||
private HashSet<int> runningSiteIds = [];
|
||||
private bool isRunningAll;
|
||||
private string nextRunText = "-";
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
sites = await db.Sites
|
||||
.Include(x => x.HanaServer)
|
||||
.Where(x => x.IsActive)
|
||||
.OrderBy(x => x.Land)
|
||||
.ToListAsync();
|
||||
|
||||
latestLogs = await ExportService.GetLatestLogsPerSiteAsync();
|
||||
var nextRun = await ExportService.GetNextRunAsync();
|
||||
nextRunText = nextRun.HasValue ? nextRun.Value.ToString("dd.MM.yyyy HH:mm") : "Deaktiviert";
|
||||
}
|
||||
|
||||
private async Task ExportAllAsync()
|
||||
{
|
||||
isRunningAll = true;
|
||||
foreach (var site in sites)
|
||||
{
|
||||
runningSiteIds.Add(site.Id);
|
||||
}
|
||||
|
||||
StateHasChanged();
|
||||
await ExportService.ExportAllActiveSitesAsync();
|
||||
runningSiteIds.Clear();
|
||||
isRunningAll = false;
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task ExportSingleAsync(int siteId)
|
||||
{
|
||||
runningSiteIds.Add(siteId);
|
||||
StateHasChanged();
|
||||
await ExportService.ExportSiteAsync(siteId);
|
||||
runningSiteIds.Remove(siteId);
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private string GetStatusIcon(int siteId)
|
||||
{
|
||||
if (!latestLogs.TryGetValue(siteId, out var log) || log is null)
|
||||
{
|
||||
return "-";
|
||||
}
|
||||
|
||||
return log.Status == "OK" ? "✅" : "❌";
|
||||
}
|
||||
|
||||
private string GetRows(int siteId) =>
|
||||
latestLogs.TryGetValue(siteId, out var log) && log is not null ? log.RowCount.ToString() : "-";
|
||||
|
||||
private string GetLastRun(int siteId) =>
|
||||
latestLogs.TryGetValue(siteId, out var log) && log is not null ? log.Timestamp.ToLocalTime().ToString("dd.MM.yyyy HH:mm:ss") : "-";
|
||||
|
||||
private string GetDuration(int siteId) =>
|
||||
latestLogs.TryGetValue(siteId, out var log) && log is not null ? $"{log.DurationSeconds:F1}s" : "-";
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
@page "/logs"
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@inject IDbContextFactory<AppDbContext> DbFactory
|
||||
|
||||
<PageTitle>Logs</PageTitle>
|
||||
|
||||
<MudText Typo="Typo.h4" Class="mb-4">Logs</MudText>
|
||||
|
||||
<MudGrid Class="mb-4">
|
||||
<MudItem xs="12" md="3"><MudTextField Label="Land" @bind-Value="filterLand" /></MudItem>
|
||||
<MudItem xs="12" md="3">
|
||||
<MudSelect T="string" Label="Status" @bind-Value="filterStatus">
|
||||
<MudSelectItem Value="">Alle</MudSelectItem>
|
||||
<MudSelectItem Value="OK">OK</MudSelectItem>
|
||||
<MudSelectItem Value="Error">Error</MudSelectItem>
|
||||
</MudSelect>
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="3"><MudDatePicker Label="Ab Datum" @bind-Date="filterFromDate" /></MudItem>
|
||||
<MudItem xs="12" md="3"><MudButton Variant="Variant.Filled" OnClick="LoadAsync">Filtern</MudButton></MudItem>
|
||||
</MudGrid>
|
||||
|
||||
<MudStack Row="true" Spacing="2" Class="mb-2">
|
||||
<MudNumericField T="int" Label="Logs älter als Tage löschen" @bind-Value="deleteOlderThanDays" Min="1" />
|
||||
<MudButton Variant="Variant.Outlined" Color="Color.Error" OnClick="DeleteOlderAsync">Logs löschen</MudButton>
|
||||
</MudStack>
|
||||
|
||||
<MudTable Items="logs" Dense="true" Hover="true" RowClassFunc="GetRowClass">
|
||||
<HeaderContent>
|
||||
<MudTh>Timestamp</MudTh>
|
||||
<MudTh>Land</MudTh>
|
||||
<MudTh>TSC</MudTh>
|
||||
<MudTh>Status</MudTh>
|
||||
<MudTh>Rows</MudTh>
|
||||
<MudTh>Dauer</MudTh>
|
||||
<MudTh>Fehler</MudTh>
|
||||
<MudTh>Dateiname</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd>@context.Timestamp.ToLocalTime().ToString("dd.MM.yyyy HH:mm:ss")</MudTd>
|
||||
<MudTd>@context.Land</MudTd>
|
||||
<MudTd>@context.TSC</MudTd>
|
||||
<MudTd>@context.Status</MudTd>
|
||||
<MudTd>@context.RowCount</MudTd>
|
||||
<MudTd>@($"{context.DurationSeconds:F1}s")</MudTd>
|
||||
<MudTd>@context.ErrorMessage</MudTd>
|
||||
<MudTd>@context.FileName</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
|
||||
@code {
|
||||
private List<ExportLog> logs = [];
|
||||
private string filterLand = string.Empty;
|
||||
private string filterStatus = string.Empty;
|
||||
private DateTime? filterFromDate;
|
||||
private int deleteOlderThanDays = 30;
|
||||
|
||||
protected override async Task OnInitializedAsync() => await LoadAsync();
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
var query = db.ExportLogs.AsQueryable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filterLand))
|
||||
{
|
||||
query = query.Where(x => x.Land.Contains(filterLand));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filterStatus))
|
||||
{
|
||||
query = query.Where(x => x.Status == filterStatus);
|
||||
}
|
||||
|
||||
if (filterFromDate.HasValue)
|
||||
{
|
||||
var fromUtc = filterFromDate.Value.Date.ToUniversalTime();
|
||||
query = query.Where(x => x.Timestamp >= fromUtc);
|
||||
}
|
||||
|
||||
logs = await query.OrderByDescending(x => x.Timestamp).ToListAsync();
|
||||
}
|
||||
|
||||
private string GetRowClass(ExportLog log, int _) => log.Status == "Error" ? "mud-theme-error" : string.Empty;
|
||||
|
||||
private async Task DeleteOlderAsync()
|
||||
{
|
||||
var threshold = DateTime.UtcNow.AddDays(-deleteOlderThanDays);
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
var oldLogs = await db.ExportLogs.Where(x => x.Timestamp < threshold).ToListAsync();
|
||||
db.ExportLogs.RemoveRange(oldLogs);
|
||||
await db.SaveChangesAsync();
|
||||
await LoadAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
@page "/settings"
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@inject IDbContextFactory<AppDbContext> DbFactory
|
||||
@inject CryptoService CryptoService
|
||||
@inject SharePointUploadService SharePointUploadService
|
||||
|
||||
<PageTitle>Settings</PageTitle>
|
||||
|
||||
<MudText Typo="Typo.h4" Class="mb-4">Settings</MudText>
|
||||
|
||||
<MudPaper Class="pa-4 mb-4">
|
||||
<MudText Typo="Typo.h6">SharePoint</MudText>
|
||||
<MudGrid>
|
||||
<MudItem xs="12" md="6"><MudTextField Label="SiteUrl" @bind-Value="sharePointConfig.SiteUrl" /></MudItem>
|
||||
<MudItem xs="12" md="6"><MudTextField Label="ExportFolder" @bind-Value="sharePointConfig.ExportFolder" /></MudItem>
|
||||
<MudItem xs="12" md="4"><MudTextField Label="TenantId" @bind-Value="sharePointConfig.TenantId" /></MudItem>
|
||||
<MudItem xs="12" md="4"><MudTextField Label="ClientId" @bind-Value="sharePointConfig.ClientId" /></MudItem>
|
||||
<MudItem xs="12" md="4"><MudTextField Label="ClientSecret" InputType="InputType.Password" @bind-Value="sharePointClientSecret" /></MudItem>
|
||||
</MudGrid>
|
||||
<MudStack Row="true" Spacing="2" Class="mt-3">
|
||||
<MudButton Variant="Variant.Filled" OnClick="SaveAsync">Speichern</MudButton>
|
||||
<MudButton Variant="Variant.Outlined" OnClick="TestSharePointAsync">SharePoint Verbindung testen</MudButton>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
|
||||
<MudPaper Class="pa-4 mb-4">
|
||||
<MudText Typo="Typo.h6">Export & Timer</MudText>
|
||||
<MudGrid>
|
||||
<MudItem xs="12" md="3"><MudTextField Label="DateFilter" @bind-Value="settings.DateFilter" /></MudItem>
|
||||
<MudItem xs="12" md="2"><MudNumericField T="int" Label="TimerHour" Min="0" Max="23" @bind-Value="settings.TimerHour" /></MudItem>
|
||||
<MudItem xs="12" md="2"><MudNumericField T="int" Label="TimerMinute" Min="0" Max="59" @bind-Value="settings.TimerMinute" /></MudItem>
|
||||
<MudItem xs="12" md="2"><MudCheckBox Label="TimerEnabled" @bind-Value="settings.TimerEnabled" /></MudItem>
|
||||
</MudGrid>
|
||||
<MudText Typo="Typo.body2" Class="mt-3">Dateiname-Vorschau: @PreviewFileName</MudText>
|
||||
</MudPaper>
|
||||
|
||||
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined">@message</MudAlert>
|
||||
|
||||
@code {
|
||||
private SharePointConfig sharePointConfig = new();
|
||||
private ExportSettings settings = new();
|
||||
private string sharePointClientSecret = string.Empty;
|
||||
private string message = "Bereit.";
|
||||
|
||||
private string PreviewFileName => $"Sales_{{TSC}}_{DateTime.UtcNow:yyyy-MM-dd}.xlsx";
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
sharePointConfig = await db.SharePointConfigs.OrderBy(x => x.Id).FirstAsync();
|
||||
settings = await db.ExportSettings.OrderBy(x => x.Id).FirstAsync();
|
||||
sharePointClientSecret = CryptoService.Decrypt(sharePointConfig.EncryptedClientSecret);
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
var sp = await db.SharePointConfigs.SingleAsync(x => x.Id == sharePointConfig.Id);
|
||||
var es = await db.ExportSettings.SingleAsync(x => x.Id == settings.Id);
|
||||
|
||||
sp.SiteUrl = sharePointConfig.SiteUrl;
|
||||
sp.ExportFolder = sharePointConfig.ExportFolder;
|
||||
sp.TenantId = sharePointConfig.TenantId;
|
||||
sp.ClientId = sharePointConfig.ClientId;
|
||||
sp.EncryptedClientSecret = CryptoService.Encrypt(sharePointClientSecret);
|
||||
|
||||
es.DateFilter = settings.DateFilter;
|
||||
es.TimerHour = settings.TimerHour;
|
||||
es.TimerMinute = settings.TimerMinute;
|
||||
es.TimerEnabled = settings.TimerEnabled;
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
message = "Settings gespeichert.";
|
||||
}
|
||||
|
||||
private async Task TestSharePointAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var ok = await SharePointUploadService.TestConnectionAsync(
|
||||
sharePointConfig.SiteUrl,
|
||||
sharePointConfig.TenantId,
|
||||
sharePointConfig.ClientId,
|
||||
sharePointClientSecret);
|
||||
|
||||
message = ok ? "SharePoint Verbindung OK." : "SharePoint Verbindung fehlgeschlagen.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
message = $"SharePoint Test fehlgeschlagen: {ex.Message}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
@page "/standorte"
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@inject IDbContextFactory<AppDbContext> DbFactory
|
||||
@inject HanaQueryService HanaQueryService
|
||||
@inject CryptoService CryptoService
|
||||
|
||||
<PageTitle>Standorte</PageTitle>
|
||||
|
||||
<MudText Typo="Typo.h4" Class="mb-4">Standorte</MudText>
|
||||
|
||||
<MudPaper Class="pa-4 mb-4">
|
||||
<MudText Typo="Typo.h6">Neuen Standort hinzufügen</MudText>
|
||||
<MudGrid>
|
||||
<MudItem xs="12" md="3"><MudSelect T="int" Label="Server" @bind-Value="newSite.HanaServerId">@foreach (var srv in servers) { <MudSelectItem Value="@srv.Id">@srv.Name</MudSelectItem> }</MudSelect></MudItem>
|
||||
<MudItem xs="12" md="2"><MudTextField Label="Schema" @bind-Value="newSite.Schema" /></MudItem>
|
||||
<MudItem xs="12" md="2"><MudTextField Label="TSC" @bind-Value="newSite.TSC" /></MudItem>
|
||||
<MudItem xs="12" md="3"><MudTextField Label="Land" @bind-Value="newSite.Land" /></MudItem>
|
||||
<MudItem xs="12" md="1"><MudCheckBox Label="Aktiv" @bind-Value="newSite.IsActive" /></MudItem>
|
||||
<MudItem xs="12" md="1"><MudButton Variant="Variant.Filled" OnClick="AddSiteAsync">Speichern</MudButton></MudItem>
|
||||
</MudGrid>
|
||||
</MudPaper>
|
||||
|
||||
<MudTable Items="sites" Dense="true" Hover="true" Class="mb-6">
|
||||
<HeaderContent>
|
||||
<MudTh>Land</MudTh><MudTh>TSC</MudTh><MudTh>Schema</MudTh><MudTh>Server</MudTh><MudTh>Aktiv</MudTh><MudTh>Aktion</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd>@context.Land</MudTd>
|
||||
<MudTd>@context.TSC</MudTd>
|
||||
<MudTd>@context.Schema</MudTd>
|
||||
<MudTd>@context.HanaServer?.Name</MudTd>
|
||||
<MudTd>@(context.IsActive ? "Ja" : "Nein")</MudTd>
|
||||
<MudTd>
|
||||
<MudButton Size="Size.Small" Variant="Variant.Outlined" OnClick="() => EditSite(context)">Edit</MudButton>
|
||||
<MudButton Size="Size.Small" Color="Color.Error" Variant="Variant.Text" OnClick="() => DeleteSiteAsync(context.Id)">Delete</MudButton>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
|
||||
@if (editingSite is not null)
|
||||
{
|
||||
<MudPaper Class="pa-4 mb-4">
|
||||
<MudText Typo="Typo.h6">Standort bearbeiten</MudText>
|
||||
<MudGrid>
|
||||
<MudItem xs="12" md="3"><MudSelect T="int" Label="Server" @bind-Value="editingSite.HanaServerId">@foreach (var srv in servers) { <MudSelectItem Value="@srv.Id">@srv.Name</MudSelectItem> }</MudSelect></MudItem>
|
||||
<MudItem xs="12" md="2"><MudTextField Label="Schema" @bind-Value="editingSite.Schema" /></MudItem>
|
||||
<MudItem xs="12" md="2"><MudTextField Label="TSC" @bind-Value="editingSite.TSC" /></MudItem>
|
||||
<MudItem xs="12" md="3"><MudTextField Label="Land" @bind-Value="editingSite.Land" /></MudItem>
|
||||
<MudItem xs="12" md="1"><MudCheckBox Label="Aktiv" @bind-Value="editingSite.IsActive" /></MudItem>
|
||||
<MudItem xs="12" md="1"><MudButton Variant="Variant.Filled" OnClick="SaveSiteAsync">Update</MudButton></MudItem>
|
||||
</MudGrid>
|
||||
</MudPaper>
|
||||
}
|
||||
|
||||
<MudDivider Class="my-4" />
|
||||
<MudText Typo="Typo.h5" Class="mb-3">HANA Server</MudText>
|
||||
|
||||
<MudPaper Class="pa-4 mb-4">
|
||||
<MudGrid>
|
||||
<MudItem xs="12" md="2"><MudTextField Label="Name" @bind-Value="newServer.Name" /></MudItem>
|
||||
<MudItem xs="12" md="3"><MudTextField Label="Host" @bind-Value="newServer.Host" /></MudItem>
|
||||
<MudItem xs="12" md="1"><MudNumericField T="int" Label="Port" @bind-Value="newServer.Port" /></MudItem>
|
||||
<MudItem xs="12" md="2"><MudTextField Label="Username" @bind-Value="newServer.Username" /></MudItem>
|
||||
<MudItem xs="12" md="2"><MudTextField Label="Password" InputType="InputType.Password" @bind-Value="newServerPassword" /></MudItem>
|
||||
<MudItem xs="12" md="2"><MudButton Variant="Variant.Filled" OnClick="AddServerAsync">Server speichern</MudButton></MudItem>
|
||||
</MudGrid>
|
||||
</MudPaper>
|
||||
|
||||
<MudTable Items="servers" Dense="true" Hover="true">
|
||||
<HeaderContent>
|
||||
<MudTh>Name</MudTh><MudTh>Host</MudTh><MudTh>Port</MudTh><MudTh>Username</MudTh><MudTh>Aktion</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd>@context.Name</MudTd>
|
||||
<MudTd>@context.Host</MudTd>
|
||||
<MudTd>@context.Port</MudTd>
|
||||
<MudTd>@context.Username</MudTd>
|
||||
<MudTd>
|
||||
<MudButton Size="Size.Small" Variant="Variant.Outlined" OnClick="() => TestServerAsync(context)">Verbindung testen</MudButton>
|
||||
<MudButton Size="Size.Small" Color="Color.Error" Variant="Variant.Text" OnClick="() => DeleteServerAsync(context.Id)">Delete</MudButton>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
|
||||
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Class="mt-4">@message</MudAlert>
|
||||
|
||||
@code {
|
||||
private List<Site> sites = [];
|
||||
private List<HanaServer> servers = [];
|
||||
private Site newSite = new() { IsActive = true };
|
||||
private Site? editingSite;
|
||||
private HanaServer newServer = new() { Port = 30015 };
|
||||
private string newServerPassword = string.Empty;
|
||||
private string message = "Bereit.";
|
||||
|
||||
protected override async Task OnInitializedAsync() => await LoadAsync();
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
servers = await db.HanaServers.OrderBy(x => x.Name).ToListAsync();
|
||||
sites = await db.Sites.Include(x => x.HanaServer).OrderBy(x => x.Land).ToListAsync();
|
||||
|
||||
if (servers.Count > 0 && newSite.HanaServerId == 0)
|
||||
{
|
||||
newSite.HanaServerId = servers[0].Id;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddSiteAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
db.Sites.Add(new Site
|
||||
{
|
||||
HanaServerId = newSite.HanaServerId,
|
||||
Schema = newSite.Schema,
|
||||
TSC = newSite.TSC,
|
||||
Land = newSite.Land,
|
||||
IsActive = newSite.IsActive
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
newSite = new Site { IsActive = true, HanaServerId = servers.FirstOrDefault()?.Id ?? 0 };
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private void EditSite(Site site)
|
||||
{
|
||||
editingSite = new Site
|
||||
{
|
||||
Id = site.Id,
|
||||
HanaServerId = site.HanaServerId,
|
||||
Schema = site.Schema,
|
||||
TSC = site.TSC,
|
||||
Land = site.Land,
|
||||
IsActive = site.IsActive
|
||||
};
|
||||
}
|
||||
|
||||
private async Task SaveSiteAsync()
|
||||
{
|
||||
if (editingSite is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
var entity = await db.Sites.SingleAsync(x => x.Id == editingSite.Id);
|
||||
entity.HanaServerId = editingSite.HanaServerId;
|
||||
entity.Schema = editingSite.Schema;
|
||||
entity.TSC = editingSite.TSC;
|
||||
entity.Land = editingSite.Land;
|
||||
entity.IsActive = editingSite.IsActive;
|
||||
await db.SaveChangesAsync();
|
||||
editingSite = null;
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task DeleteSiteAsync(int id)
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
var site = await db.Sites.SingleAsync(x => x.Id == id);
|
||||
db.Sites.Remove(site);
|
||||
await db.SaveChangesAsync();
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task AddServerAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
db.HanaServers.Add(new HanaServer
|
||||
{
|
||||
Name = newServer.Name,
|
||||
Host = newServer.Host,
|
||||
Port = newServer.Port,
|
||||
Username = newServer.Username,
|
||||
EncryptedPassword = CryptoService.Encrypt(newServerPassword)
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
newServer = new HanaServer { Port = 30015 };
|
||||
newServerPassword = string.Empty;
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task DeleteServerAsync(int id)
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
var isUsed = await db.Sites.AnyAsync(x => x.HanaServerId == id);
|
||||
if (isUsed)
|
||||
{
|
||||
message = "Server kann nicht gelöscht werden, solange Sites darauf zeigen.";
|
||||
return;
|
||||
}
|
||||
|
||||
var server = await db.HanaServers.SingleAsync(x => x.Id == id);
|
||||
db.HanaServers.Remove(server);
|
||||
await db.SaveChangesAsync();
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task TestServerAsync(HanaServer server)
|
||||
{
|
||||
try
|
||||
{
|
||||
var ok = HanaQueryService.TestConnection(server.Host, server.Port, server.Username, CryptoService.Decrypt(server.EncryptedPassword));
|
||||
message = ok ? $"Verbindung OK: {server.Name}" : $"Verbindung fehlgeschlagen: {server.Name}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
message = $"Verbindung fehlgeschlagen: {ex.Message}";
|
||||
}
|
||||
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
@using TrafagSalesExporter.Components.Layout
|
||||
|
||||
<Router AppAssembly="typeof(Program).Assembly">
|
||||
<Found Context="routeData">
|
||||
<RouteView RouteData="routeData" DefaultLayout="typeof(MainLayout)" />
|
||||
<FocusOnNavigate RouteData="routeData" Selector="h1" />
|
||||
</Found>
|
||||
</Router>
|
||||
@@ -0,0 +1,13 @@
|
||||
@using System.Net.Http
|
||||
@using System.Net.Http.Json
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
@using Microsoft.JSInterop
|
||||
@using MudBlazor
|
||||
@using TrafagSalesExporter
|
||||
@using TrafagSalesExporter.Components
|
||||
@using TrafagSalesExporter.Models
|
||||
@using TrafagSalesExporter.Services
|
||||
@using TrafagSalesExporter.Data
|
||||
@@ -0,0 +1,99 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrafagSalesExporter.Models;
|
||||
using TrafagSalesExporter.Services;
|
||||
|
||||
namespace TrafagSalesExporter.Data;
|
||||
|
||||
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
|
||||
{
|
||||
public DbSet<HanaServer> HanaServers => Set<HanaServer>();
|
||||
public DbSet<Site> Sites => Set<Site>();
|
||||
public DbSet<SharePointConfig> SharePointConfigs => Set<SharePointConfig>();
|
||||
public DbSet<ExportSettings> ExportSettings => Set<ExportSettings>();
|
||||
public DbSet<ExportLog> ExportLogs => Set<ExportLog>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<HanaServer>().HasIndex(x => x.Name).IsUnique();
|
||||
|
||||
modelBuilder.Entity<Site>()
|
||||
.HasOne(x => x.HanaServer)
|
||||
.WithMany(x => x.Sites)
|
||||
.HasForeignKey(x => x.HanaServerId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<ExportLog>()
|
||||
.HasOne(x => x.Site)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.SiteId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
}
|
||||
}
|
||||
|
||||
public static class DbInitializer
|
||||
{
|
||||
public static async Task SeedDefaultsAsync(AppDbContext db, CryptoService cryptoService)
|
||||
{
|
||||
if (!await db.HanaServers.AnyAsync())
|
||||
{
|
||||
db.HanaServers.AddRange(
|
||||
new HanaServer
|
||||
{
|
||||
Name = "Internal",
|
||||
Host = "travtrp0",
|
||||
Port = 30015,
|
||||
Username = string.Empty,
|
||||
EncryptedPassword = cryptoService.Encrypt(string.Empty)
|
||||
},
|
||||
new HanaServer
|
||||
{
|
||||
Name = "India",
|
||||
Host = "20.197.20.60",
|
||||
Port = 30015,
|
||||
Username = string.Empty,
|
||||
EncryptedPassword = cryptoService.Encrypt(string.Empty)
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
if (!await db.Sites.AnyAsync())
|
||||
{
|
||||
var internalServer = await db.HanaServers.SingleAsync(x => x.Name == "Internal");
|
||||
var indiaServer = await db.HanaServers.SingleAsync(x => x.Name == "India");
|
||||
|
||||
db.Sites.AddRange(
|
||||
new Site { HanaServerId = internalServer.Id, Schema = "fr01_p", TSC = "TRFR", Land = "Frankreich", IsActive = true },
|
||||
new Site { HanaServerId = internalServer.Id, Schema = "it01_p", TSC = "TRIT", Land = "Italien", IsActive = true },
|
||||
new Site { HanaServerId = internalServer.Id, Schema = "us01_p", TSC = "TRUS", Land = "USA", IsActive = true },
|
||||
new Site { HanaServerId = indiaServer.Id, Schema = "TRAFAG_LIVE", TSC = "TRIN", Land = "Indien", IsActive = true });
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
if (!await db.SharePointConfigs.AnyAsync())
|
||||
{
|
||||
db.SharePointConfigs.Add(new SharePointConfig
|
||||
{
|
||||
SiteUrl = "https://trafagag.sharepoint.com/sites/WorldwideBIPlatform",
|
||||
ExportFolder = "/Shared Documents/Exports/",
|
||||
TenantId = string.Empty,
|
||||
ClientId = string.Empty,
|
||||
EncryptedClientSecret = cryptoService.Encrypt(string.Empty)
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
if (!await db.ExportSettings.AnyAsync())
|
||||
{
|
||||
db.ExportSettings.Add(new ExportSettings
|
||||
{
|
||||
DateFilter = "2025-01-01",
|
||||
TimerHour = 3,
|
||||
TimerMinute = 0,
|
||||
TimerEnabled = true
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace TrafagSalesExporter.Models;
|
||||
|
||||
public class ExportLog
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public DateTime Timestamp { get; set; }
|
||||
public int? SiteId { get; set; }
|
||||
public Site? Site { get; set; }
|
||||
public string Land { get; set; } = string.Empty;
|
||||
public string TSC { get; set; } = string.Empty;
|
||||
public string Status { get; set; } = string.Empty;
|
||||
public int RowCount { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
public string FileName { get; set; } = string.Empty;
|
||||
public double DurationSeconds { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace TrafagSalesExporter.Models;
|
||||
|
||||
public class ExportSettings
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string DateFilter { get; set; } = "2025-01-01";
|
||||
public int TimerHour { get; set; } = 3;
|
||||
public int TimerMinute { get; set; } = 0;
|
||||
public bool TimerEnabled { get; set; } = true;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TrafagSalesExporter.Models;
|
||||
|
||||
public class HanaServer
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
[Required]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string Host { get; set; } = string.Empty;
|
||||
|
||||
public int Port { get; set; }
|
||||
|
||||
public string Username { get; set; } = string.Empty;
|
||||
|
||||
public string EncryptedPassword { get; set; } = string.Empty;
|
||||
|
||||
public List<Site> Sites { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace TrafagSalesExporter.Models;
|
||||
|
||||
public class SalesRecord
|
||||
{
|
||||
public DateTime ExtractionDate { get; set; }
|
||||
public string TSC { get; set; } = string.Empty;
|
||||
public string InvoiceNumber { get; set; } = string.Empty;
|
||||
public int PositionOnInvoice { get; set; }
|
||||
public string Material { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string ProductGroup { get; set; } = string.Empty;
|
||||
public decimal Quantity { get; set; }
|
||||
public string SupplierNumber { get; set; } = string.Empty;
|
||||
public string SupplierName { get; set; } = string.Empty;
|
||||
public string SupplierCountry { get; set; } = string.Empty;
|
||||
public string CustomerNumber { get; set; } = string.Empty;
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
public string CustomerCountry { get; set; } = string.Empty;
|
||||
public string CustomerIndustry { get; set; } = string.Empty;
|
||||
public decimal StandardCost { get; set; }
|
||||
public string StandardCostCurrency { get; set; } = string.Empty;
|
||||
public string PurchaseOrderNumber { get; set; } = string.Empty;
|
||||
public decimal SalesPriceValue { get; set; }
|
||||
public string SalesCurrency { get; set; } = string.Empty;
|
||||
public string Incoterms2020 { get; set; } = string.Empty;
|
||||
public string SalesResponsibleEmployee { get; set; } = string.Empty;
|
||||
public DateTime? InvoiceDate { get; set; }
|
||||
public DateTime? OrderDate { get; set; }
|
||||
public string Land { get; set; } = string.Empty;
|
||||
public string DocumentType { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TrafagSalesExporter.Models;
|
||||
|
||||
public class SharePointConfig
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
[Required]
|
||||
public string SiteUrl { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string ExportFolder { get; set; } = "/Shared Documents/Exports/";
|
||||
|
||||
[Required]
|
||||
public string TenantId { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string ClientId { get; set; } = string.Empty;
|
||||
|
||||
public string EncryptedClientSecret { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TrafagSalesExporter.Models;
|
||||
|
||||
public class Site
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int HanaServerId { get; set; }
|
||||
|
||||
public HanaServer? HanaServer { get; set; }
|
||||
|
||||
[Required]
|
||||
public string Schema { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string TSC { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string Land { get; set; } = string.Empty;
|
||||
|
||||
public bool IsActive { get; set; } = true;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MudBlazor.Services;
|
||||
using TrafagSalesExporter.Data;
|
||||
using TrafagSalesExporter.Services;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddRazorComponents()
|
||||
.AddInteractiveServerComponents();
|
||||
|
||||
builder.Services.AddMudServices();
|
||||
|
||||
builder.Services.AddDbContextFactory<AppDbContext>(options =>
|
||||
options.UseSqlite("Data Source=trafag_exporter.db"));
|
||||
|
||||
builder.Services.AddScoped<CryptoService>();
|
||||
builder.Services.AddScoped<HanaQueryService>();
|
||||
builder.Services.AddScoped<ExcelExportService>();
|
||||
builder.Services.AddScoped<SharePointUploadService>();
|
||||
builder.Services.AddScoped<ExportOrchestrationService>();
|
||||
builder.Services.AddHostedService<TimerBackgroundService>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var dbFactory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<AppDbContext>>();
|
||||
var cryptoService = scope.ServiceProvider.GetRequiredService<CryptoService>();
|
||||
await using var db = await dbFactory.CreateDbContextAsync();
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
await DbInitializer.SeedDefaultsAsync(db, cryptoService);
|
||||
}
|
||||
|
||||
if (!app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseExceptionHandler("/Error", createScopeForErrors: true);
|
||||
app.UseHsts();
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
app.UseStaticFiles();
|
||||
app.UseAntiforgery();
|
||||
|
||||
app.MapRazorComponents<TrafagSalesExporter.Components.App>()
|
||||
.AddInteractiveServerRenderMode();
|
||||
|
||||
app.Run();
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace TrafagSalesExporter.Services;
|
||||
|
||||
public class CryptoService
|
||||
{
|
||||
public string Encrypt(string plainText)
|
||||
{
|
||||
var input = Encoding.UTF8.GetBytes(plainText ?? string.Empty);
|
||||
var protectedBytes = ProtectedData.Protect(input, null, DataProtectionScope.CurrentUser);
|
||||
return Convert.ToBase64String(protectedBytes);
|
||||
}
|
||||
|
||||
public string Decrypt(string cipherText)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(cipherText))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var input = Convert.FromBase64String(cipherText);
|
||||
var unprotectedBytes = ProtectedData.Unprotect(input, null, DataProtectionScope.CurrentUser);
|
||||
return Encoding.UTF8.GetString(unprotectedBytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using ClosedXML.Excel;
|
||||
using TrafagSalesExporter.Models;
|
||||
|
||||
namespace TrafagSalesExporter.Services;
|
||||
|
||||
public class ExcelExportService
|
||||
{
|
||||
public string CreateFile(string baseDirectory, string land, string tsc, List<SalesRecord> records)
|
||||
{
|
||||
var outputDirectory = Path.Combine(baseDirectory, "exports", land);
|
||||
Directory.CreateDirectory(outputDirectory);
|
||||
|
||||
var fileName = $"Sales_{tsc}_{DateTime.UtcNow:yyyy-MM-dd}.xlsx";
|
||||
var filePath = Path.Combine(outputDirectory, fileName);
|
||||
|
||||
using var workbook = new XLWorkbook();
|
||||
var ws = workbook.AddWorksheet("Sales");
|
||||
|
||||
var headers = new[]
|
||||
{
|
||||
"extraction date",
|
||||
"TSC",
|
||||
"Invoice Number",
|
||||
"Position on invoice",
|
||||
"Material",
|
||||
"Name",
|
||||
"Product Group",
|
||||
"Quantity",
|
||||
"Supplier number",
|
||||
"Supplier name",
|
||||
"Supplier country",
|
||||
"Customer number",
|
||||
"Customer name",
|
||||
"Customer country",
|
||||
"Customer Industry",
|
||||
"Standard cost",
|
||||
"Standard Cost Currency",
|
||||
"Purchase Order number",
|
||||
"Sales Price/Value",
|
||||
"Sales Currency",
|
||||
"Incoterms 2020",
|
||||
"Sales responsible employee",
|
||||
"invoice date",
|
||||
"order date",
|
||||
"Land",
|
||||
"Document Type"
|
||||
};
|
||||
|
||||
for (var i = 0; i < headers.Length; i++)
|
||||
{
|
||||
ws.Cell(1, i + 1).Value = headers[i];
|
||||
ws.Cell(1, i + 1).Style.Font.Bold = true;
|
||||
}
|
||||
|
||||
var row = 2;
|
||||
foreach (var r in records)
|
||||
{
|
||||
ws.Cell(row, 1).Value = r.ExtractionDate.ToString("dd.MM.yyyy HH:mm:ss");
|
||||
ws.Cell(row, 2).Value = r.TSC;
|
||||
ws.Cell(row, 3).Value = r.InvoiceNumber;
|
||||
ws.Cell(row, 4).Value = r.PositionOnInvoice;
|
||||
ws.Cell(row, 5).Value = r.Material;
|
||||
ws.Cell(row, 6).Value = r.Name;
|
||||
ws.Cell(row, 7).Value = r.ProductGroup;
|
||||
ws.Cell(row, 8).Value = r.Quantity;
|
||||
ws.Cell(row, 9).Value = r.SupplierNumber;
|
||||
ws.Cell(row, 10).Value = r.SupplierName;
|
||||
ws.Cell(row, 11).Value = r.SupplierCountry;
|
||||
ws.Cell(row, 12).Value = r.CustomerNumber;
|
||||
ws.Cell(row, 13).Value = r.CustomerName;
|
||||
ws.Cell(row, 14).Value = r.CustomerCountry;
|
||||
ws.Cell(row, 15).Value = r.CustomerIndustry;
|
||||
ws.Cell(row, 16).Value = r.StandardCost;
|
||||
ws.Cell(row, 17).Value = r.StandardCostCurrency;
|
||||
ws.Cell(row, 18).Value = r.PurchaseOrderNumber;
|
||||
ws.Cell(row, 19).Value = r.SalesPriceValue;
|
||||
ws.Cell(row, 20).Value = r.SalesCurrency;
|
||||
ws.Cell(row, 21).Value = r.Incoterms2020;
|
||||
ws.Cell(row, 22).Value = r.SalesResponsibleEmployee;
|
||||
ws.Cell(row, 23).Value = r.InvoiceDate?.ToString("dd.MM.yyyy") ?? string.Empty;
|
||||
ws.Cell(row, 24).Value = r.OrderDate?.ToString("dd.MM.yyyy") ?? string.Empty;
|
||||
ws.Cell(row, 25).Value = r.Land;
|
||||
ws.Cell(row, 26).Value = r.DocumentType;
|
||||
row++;
|
||||
}
|
||||
|
||||
ws.Columns().AdjustToContents();
|
||||
workbook.SaveAs(filePath);
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrafagSalesExporter.Data;
|
||||
using TrafagSalesExporter.Models;
|
||||
|
||||
namespace TrafagSalesExporter.Services;
|
||||
|
||||
public class ExportOrchestrationService(
|
||||
IDbContextFactory<AppDbContext> dbFactory,
|
||||
CryptoService cryptoService,
|
||||
HanaQueryService hanaQueryService,
|
||||
ExcelExportService excelExportService,
|
||||
SharePointUploadService sharePointUploadService)
|
||||
{
|
||||
public async Task ExportAllActiveSitesAsync(CancellationToken ct = default)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
var siteIds = await db.Sites.Where(x => x.IsActive).Select(x => x.Id).ToListAsync(ct);
|
||||
|
||||
foreach (var siteId in siteIds)
|
||||
{
|
||||
await ExportSiteAsync(siteId, ct);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ExportSiteAsync(int siteId, CancellationToken ct = default)
|
||||
{
|
||||
var started = DateTime.UtcNow;
|
||||
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
var site = await db.Sites.Include(x => x.HanaServer).SingleAsync(x => x.Id == siteId, ct);
|
||||
var settings = await db.ExportSettings.OrderBy(x => x.Id).FirstAsync(ct);
|
||||
var sp = await db.SharePointConfigs.OrderBy(x => x.Id).FirstAsync(ct);
|
||||
|
||||
var log = new ExportLog
|
||||
{
|
||||
Timestamp = DateTime.UtcNow,
|
||||
SiteId = site.Id,
|
||||
Land = site.Land,
|
||||
TSC = site.TSC,
|
||||
Status = "Error",
|
||||
RowCount = 0,
|
||||
FileName = string.Empty,
|
||||
DurationSeconds = 0
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var hanaServer = site.HanaServer ?? throw new InvalidOperationException("HANA Server fehlt.");
|
||||
var hanaPassword = cryptoService.Decrypt(hanaServer.EncryptedPassword);
|
||||
var clientSecret = cryptoService.Decrypt(sp.EncryptedClientSecret);
|
||||
|
||||
var records = hanaQueryService.QuerySales(
|
||||
hanaServer.Host,
|
||||
hanaServer.Port,
|
||||
hanaServer.Username,
|
||||
hanaPassword,
|
||||
site.Schema,
|
||||
site.TSC,
|
||||
site.Land,
|
||||
settings.DateFilter);
|
||||
|
||||
var filePath = excelExportService.CreateFile(AppContext.BaseDirectory, site.Land, site.TSC, records);
|
||||
|
||||
await sharePointUploadService.UploadAsync(
|
||||
sp.SiteUrl,
|
||||
sp.ExportFolder,
|
||||
sp.TenantId,
|
||||
sp.ClientId,
|
||||
clientSecret,
|
||||
site.Land,
|
||||
filePath);
|
||||
|
||||
log.Status = "OK";
|
||||
log.RowCount = records.Count;
|
||||
log.FileName = Path.GetFileName(filePath);
|
||||
log.ErrorMessage = null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log.ErrorMessage = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
log.DurationSeconds = (DateTime.UtcNow - started).TotalSeconds;
|
||||
db.ExportLogs.Add(log);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<DateTime?> GetNextRunAsync(CancellationToken ct = default)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
var settings = await db.ExportSettings.OrderBy(x => x.Id).FirstOrDefaultAsync(ct);
|
||||
if (settings is null || !settings.TimerEnabled)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var now = DateTime.Now;
|
||||
var next = new DateTime(now.Year, now.Month, now.Day, settings.TimerHour, settings.TimerMinute, 0);
|
||||
if (next <= now)
|
||||
{
|
||||
next = next.AddDays(1);
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
public async Task<Dictionary<int, ExportLog?>> GetLatestLogsPerSiteAsync(CancellationToken ct = default)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
var grouped = await db.ExportLogs
|
||||
.OrderByDescending(x => x.Timestamp)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return grouped
|
||||
.GroupBy(x => x.SiteId ?? 0)
|
||||
.ToDictionary(g => g.Key, g => g.FirstOrDefault());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
using Sap.Data.Hana;
|
||||
using TrafagSalesExporter.Models;
|
||||
|
||||
namespace TrafagSalesExporter.Services;
|
||||
|
||||
public class HanaQueryService
|
||||
{
|
||||
public List<SalesRecord> QuerySales(string host, int port, string username, string password, string schema, string tsc, string land, string dateFilter)
|
||||
{
|
||||
var connectionString = $"ServerNode={host}:{port};UserName={username};Password={password}";
|
||||
var result = new List<SalesRecord>();
|
||||
|
||||
using var connection = new HanaConnection(connectionString);
|
||||
connection.Open();
|
||||
|
||||
var invoiceQuery = GetInvoiceQuery(schema, tsc, dateFilter);
|
||||
var creditQuery = GetCreditNoteQuery(schema, tsc, dateFilter);
|
||||
|
||||
result.AddRange(Read(connection, invoiceQuery, land));
|
||||
result.AddRange(Read(connection, creditQuery, land));
|
||||
|
||||
foreach (var record in result)
|
||||
{
|
||||
if (record.Material.Contains('/'))
|
||||
{
|
||||
var parts = record.Material.Split('/');
|
||||
record.Material = parts[^1];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public bool TestConnection(string host, int port, string username, string password)
|
||||
{
|
||||
var connectionString = $"ServerNode={host}:{port};UserName={username};Password={password}";
|
||||
using var connection = new HanaConnection(connectionString);
|
||||
connection.Open();
|
||||
return connection.State == System.Data.ConnectionState.Open;
|
||||
}
|
||||
|
||||
private static List<SalesRecord> Read(HanaConnection connection, string query, string land)
|
||||
{
|
||||
var records = new List<SalesRecord>();
|
||||
using var cmd = new HanaCommand(query, connection);
|
||||
using var reader = cmd.ExecuteReader();
|
||||
|
||||
while (reader.Read())
|
||||
{
|
||||
records.Add(new SalesRecord
|
||||
{
|
||||
ExtractionDate = reader.GetDateTime(reader.GetOrdinal("extraction_date")),
|
||||
TSC = reader["tsc"]?.ToString() ?? string.Empty,
|
||||
InvoiceNumber = reader["invoice_number"]?.ToString() ?? string.Empty,
|
||||
PositionOnInvoice = Convert.ToInt32(reader["invoice_position"]),
|
||||
InvoiceDate = reader.IsDBNull(reader.GetOrdinal("invoice_date")) ? null : reader.GetDateTime(reader.GetOrdinal("invoice_date")),
|
||||
Material = reader["material"]?.ToString() ?? string.Empty,
|
||||
Name = reader["material_name"]?.ToString() ?? string.Empty,
|
||||
ProductGroup = reader["product_group"]?.ToString() ?? string.Empty,
|
||||
Quantity = Convert.ToDecimal(reader["quantity"]),
|
||||
SupplierNumber = reader["supplier_number"]?.ToString() ?? string.Empty,
|
||||
SupplierName = reader["supplier_name"]?.ToString() ?? string.Empty,
|
||||
SupplierCountry = reader["supplier_country"]?.ToString() ?? string.Empty,
|
||||
CustomerNumber = reader["customer_number"]?.ToString() ?? string.Empty,
|
||||
CustomerName = reader["customer_name"]?.ToString() ?? string.Empty,
|
||||
CustomerCountry = reader["customer_country"]?.ToString() ?? string.Empty,
|
||||
CustomerIndustry = reader["customer_industry"]?.ToString() ?? string.Empty,
|
||||
StandardCost = Convert.ToDecimal(reader["standard_cost"]),
|
||||
StandardCostCurrency = reader["standard_cost_currency"]?.ToString() ?? string.Empty,
|
||||
PurchaseOrderNumber = reader["purchase_order_number"]?.ToString() ?? string.Empty,
|
||||
SalesPriceValue = Convert.ToDecimal(reader["sales_value"]),
|
||||
SalesCurrency = reader["sales_currency"]?.ToString() ?? string.Empty,
|
||||
Incoterms2020 = reader["incoterms_2020"]?.ToString() ?? string.Empty,
|
||||
SalesResponsibleEmployee = reader["sales_responsible"]?.ToString() ?? string.Empty,
|
||||
OrderDate = reader.IsDBNull(reader.GetOrdinal("order_date")) ? null : reader.GetDateTime(reader.GetOrdinal("order_date")),
|
||||
Land = land,
|
||||
DocumentType = reader["doc_type"]?.ToString() ?? string.Empty
|
||||
});
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
private static string GetInvoiceQuery(string schema, string tsc, string dateFilter) => $@"
|
||||
SELECT
|
||||
CURRENT_TIMESTAMP AS extraction_date,
|
||||
'{tsc}' AS tsc,
|
||||
h.""DocNum"" AS invoice_number,
|
||||
p.""LineNum"" AS invoice_position,
|
||||
h.""DocDate"" AS invoice_date,
|
||||
p.""ItemCode"" AS material,
|
||||
p.""Dscription"" AS material_name,
|
||||
COALESCE(grp.""ItmsGrpNam"", '') AS product_group,
|
||||
p.""Quantity"" AS quantity,
|
||||
COALESCE(itm.""CardCode"", '') AS supplier_number,
|
||||
COALESCE(sup.""CardName"", '') AS supplier_name,
|
||||
COALESCE(sup_adr.""Country"", '') AS supplier_country,
|
||||
h.""CardCode"" AS customer_number,
|
||||
h.""CardName"" AS customer_name,
|
||||
COALESCE(cust_adr.""Country"", '') AS customer_country,
|
||||
COALESCE(ind.""IndName"", '') AS customer_industry,
|
||||
p.""StockPrice"" AS standard_cost,
|
||||
COALESCE(p.""Currency"", h.""DocCur"") AS standard_cost_currency,
|
||||
CASE WHEN p.""BaseType"" = 22
|
||||
THEN CAST(p.""BaseRef"" AS NVARCHAR(20))
|
||||
ELSE '' END AS purchase_order_number,
|
||||
p.""LineTotal"" AS sales_value,
|
||||
COALESCE(p.""Currency"", h.""DocCur"") AS sales_currency,
|
||||
'' AS incoterms_2020,
|
||||
COALESCE(emp.""SlpName"", '') AS sales_responsible,
|
||||
CASE WHEN p.""BaseType"" = 17
|
||||
THEN (SELECT o.""DocDate"" FROM {schema}.""ORDR"" o
|
||||
WHERE o.""DocEntry"" = p.""BaseEntry"")
|
||||
ELSE NULL END AS order_date,
|
||||
'INV' AS doc_type
|
||||
FROM {schema}.""OINV"" h
|
||||
INNER JOIN {schema}.""INV1"" p ON h.""DocEntry"" = p.""DocEntry""
|
||||
LEFT JOIN {schema}.""OITM"" itm ON p.""ItemCode"" = itm.""ItemCode""
|
||||
LEFT JOIN {schema}.""OITB"" grp ON itm.""ItmsGrpCod"" = grp.""ItmsGrpCod""
|
||||
LEFT JOIN {schema}.""OCRD"" cust ON h.""CardCode"" = cust.""CardCode""
|
||||
LEFT JOIN {schema}.""CRD1"" cust_adr ON h.""CardCode"" = cust_adr.""CardCode""
|
||||
AND cust_adr.""AdresType"" = 'B' AND cust_adr.""Address"" = h.""PayToCode""
|
||||
LEFT JOIN {schema}.""OOND"" ind ON cust.""IndustryC"" = ind.""IndCode""
|
||||
LEFT JOIN {schema}.""OCRD"" sup ON itm.""CardCode"" = sup.""CardCode""
|
||||
AND sup.""CardType"" = 'S'
|
||||
LEFT JOIN {schema}.""CRD1"" sup_adr ON itm.""CardCode"" = sup_adr.""CardCode""
|
||||
AND sup_adr.""AdresType"" = 'B'
|
||||
LEFT JOIN {schema}.""OSLP"" emp ON h.""SlpCode"" = emp.""SlpCode""
|
||||
WHERE h.""CANCELED"" = 'N' AND h.""DocDate"" >= '{dateFilter}'
|
||||
ORDER BY h.""DocDate"" DESC, h.""DocNum"", p.""LineNum""";
|
||||
|
||||
private static string GetCreditNoteQuery(string schema, string tsc, string dateFilter) => $@"
|
||||
SELECT
|
||||
CURRENT_TIMESTAMP AS extraction_date,
|
||||
'{tsc}' AS tsc,
|
||||
h.""DocNum"" AS invoice_number,
|
||||
p.""LineNum"" AS invoice_position,
|
||||
h.""DocDate"" AS invoice_date,
|
||||
p.""ItemCode"" AS material,
|
||||
p.""Dscription"" AS material_name,
|
||||
COALESCE(grp.""ItmsGrpNam"", '') AS product_group,
|
||||
p.""Quantity"" * -1 AS quantity,
|
||||
COALESCE(itm.""CardCode"", '') AS supplier_number,
|
||||
COALESCE(sup.""CardName"", '') AS supplier_name,
|
||||
COALESCE(sup_adr.""Country"", '') AS supplier_country,
|
||||
h.""CardCode"" AS customer_number,
|
||||
h.""CardName"" AS customer_name,
|
||||
COALESCE(cust_adr.""Country"", '') AS customer_country,
|
||||
COALESCE(ind.""IndName"", '') AS customer_industry,
|
||||
p.""StockPrice"" AS standard_cost,
|
||||
COALESCE(p.""Currency"", h.""DocCur"") AS standard_cost_currency,
|
||||
'' AS purchase_order_number,
|
||||
p.""LineTotal"" * -1 AS sales_value,
|
||||
COALESCE(p.""Currency"", h.""DocCur"") AS sales_currency,
|
||||
'' AS incoterms_2020,
|
||||
COALESCE(emp.""SlpName"", '') AS sales_responsible,
|
||||
NULL AS order_date,
|
||||
'CRN' AS doc_type
|
||||
FROM {schema}.""ORIN"" h
|
||||
INNER JOIN {schema}.""RIN1"" p ON h.""DocEntry"" = p.""DocEntry""
|
||||
LEFT JOIN {schema}.""OITM"" itm ON p.""ItemCode"" = itm.""ItemCode""
|
||||
LEFT JOIN {schema}.""OITB"" grp ON itm.""ItmsGrpCod"" = grp.""ItmsGrpCod""
|
||||
LEFT JOIN {schema}.""OCRD"" cust ON h.""CardCode"" = cust.""CardCode""
|
||||
LEFT JOIN {schema}.""CRD1"" cust_adr ON h.""CardCode"" = cust_adr.""CardCode""
|
||||
AND cust_adr.""AdresType"" = 'B' AND cust_adr.""Address"" = h.""PayToCode""
|
||||
LEFT JOIN {schema}.""OOND"" ind ON cust.""IndustryC"" = ind.""IndCode""
|
||||
LEFT JOIN {schema}.""OCRD"" sup ON itm.""CardCode"" = sup.""CardCode""
|
||||
AND sup.""CardType"" = 'S'
|
||||
LEFT JOIN {schema}.""CRD1"" sup_adr ON itm.""CardCode"" = sup_adr.""CardCode""
|
||||
AND sup_adr.""AdresType"" = 'B'
|
||||
LEFT JOIN {schema}.""OSLP"" emp ON h.""SlpCode"" = emp.""SlpCode""
|
||||
WHERE h.""CANCELED"" = 'N' AND h.""DocDate"" >= '{dateFilter}'
|
||||
ORDER BY h.""DocDate"" DESC, h.""DocNum"", p.""LineNum""";
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using Azure.Identity;
|
||||
using Microsoft.Graph;
|
||||
using Microsoft.Graph.Models;
|
||||
|
||||
namespace TrafagSalesExporter.Services;
|
||||
|
||||
public class SharePointUploadService
|
||||
{
|
||||
public async Task UploadAsync(string siteUrl, string exportFolder, string tenantId, string clientId, string clientSecret, string land, string localFilePath)
|
||||
{
|
||||
var graph = CreateGraphClient(tenantId, clientId, clientSecret);
|
||||
var (siteId, driveId) = await ResolveSiteAndDriveAsync(graph, siteUrl);
|
||||
|
||||
var folderPath = $"{exportFolder.Trim('/')}/{land}";
|
||||
await EnsureFolderPathAsync(graph, driveId, folderPath);
|
||||
|
||||
var fileName = Path.GetFileName(localFilePath);
|
||||
var remotePath = $"{folderPath}/{fileName}";
|
||||
|
||||
await using var stream = File.OpenRead(localFilePath);
|
||||
await graph.Drives[driveId].Root.ItemWithPath(remotePath).Content.PutAsync(stream);
|
||||
}
|
||||
|
||||
public async Task<bool> TestConnectionAsync(string siteUrl, string tenantId, string clientId, string clientSecret)
|
||||
{
|
||||
var graph = CreateGraphClient(tenantId, clientId, clientSecret);
|
||||
var (siteId, _) = await ResolveSiteAndDriveAsync(graph, siteUrl);
|
||||
return !string.IsNullOrWhiteSpace(siteId);
|
||||
}
|
||||
|
||||
private static GraphServiceClient CreateGraphClient(string tenantId, string clientId, string clientSecret)
|
||||
{
|
||||
var credential = new ClientSecretCredential(tenantId, clientId, clientSecret);
|
||||
return new GraphServiceClient(credential, ["https://graph.microsoft.com/.default"]);
|
||||
}
|
||||
|
||||
private static async Task<(string siteId, string driveId)> ResolveSiteAndDriveAsync(GraphServiceClient graph, string siteUrl)
|
||||
{
|
||||
var uri = new Uri(siteUrl);
|
||||
var site = await graph.Sites[$"{uri.Host}:{uri.AbsolutePath}"].GetAsync();
|
||||
if (site?.Id is null)
|
||||
{
|
||||
throw new InvalidOperationException("SharePoint Site nicht gefunden.");
|
||||
}
|
||||
|
||||
var drive = await graph.Sites[site.Id].Drive.GetAsync();
|
||||
if (drive?.Id is null)
|
||||
{
|
||||
throw new InvalidOperationException("SharePoint Dokumentenbibliothek nicht gefunden.");
|
||||
}
|
||||
|
||||
return (site.Id, drive.Id);
|
||||
}
|
||||
|
||||
private static async Task EnsureFolderPathAsync(GraphServiceClient graph, string driveId, string folderPath)
|
||||
{
|
||||
var segments = folderPath.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
var currentPath = string.Empty;
|
||||
|
||||
foreach (var segment in segments)
|
||||
{
|
||||
currentPath = string.IsNullOrEmpty(currentPath) ? segment : $"{currentPath}/{segment}";
|
||||
|
||||
try
|
||||
{
|
||||
_ = await graph.Drives[driveId].Root.ItemWithPath(currentPath).GetAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
var parentPath = currentPath.Contains('/')
|
||||
? currentPath[..currentPath.LastIndexOf('/')]
|
||||
: string.Empty;
|
||||
|
||||
var parent = string.IsNullOrEmpty(parentPath)
|
||||
? await graph.Drives[driveId].Root.GetAsync()
|
||||
: await graph.Drives[driveId].Root.ItemWithPath(parentPath).GetAsync();
|
||||
|
||||
if (parent?.Id is null)
|
||||
{
|
||||
throw new InvalidOperationException("SharePoint Parent-Ordner konnte nicht ermittelt werden.");
|
||||
}
|
||||
|
||||
await graph.Drives[driveId].Items[parent.Id].Children.PostAsync(new DriveItem
|
||||
{
|
||||
Name = segment,
|
||||
Folder = new Folder(),
|
||||
AdditionalData = new Dictionary<string, object>
|
||||
{
|
||||
["@microsoft.graph.conflictBehavior"] = "replace"
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrafagSalesExporter.Data;
|
||||
|
||||
namespace TrafagSalesExporter.Services;
|
||||
|
||||
public class TimerBackgroundService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<TimerBackgroundService> logger) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var dbFactory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<AppDbContext>>();
|
||||
var exportService = scope.ServiceProvider.GetRequiredService<ExportOrchestrationService>();
|
||||
|
||||
await using var db = await dbFactory.CreateDbContextAsync(stoppingToken);
|
||||
var settings = await db.ExportSettings.OrderBy(x => x.Id).FirstOrDefaultAsync(stoppingToken);
|
||||
|
||||
if (settings is null || !settings.TimerEnabled)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
var now = DateTime.Now;
|
||||
var nextRun = new DateTime(now.Year, now.Month, now.Day, settings.TimerHour, settings.TimerMinute, 0);
|
||||
if (nextRun <= now)
|
||||
{
|
||||
nextRun = nextRun.AddDays(1);
|
||||
}
|
||||
|
||||
var delay = nextRun - now;
|
||||
logger.LogInformation("Nächster automatischer Export um {NextRun}", nextRun);
|
||||
await Task.Delay(delay, stoppingToken);
|
||||
|
||||
if (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
await exportService.ExportAllActiveSitesAsync(stoppingToken);
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Fehler im TimerBackgroundService");
|
||||
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" Version="1.13.1" />
|
||||
<PackageReference Include="ClosedXML" Version="0.104.2" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.0">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Graph" Version="5.80.0" />
|
||||
<PackageReference Include="MudBlazor" Version="7.15.0" />
|
||||
<PackageReference Include="Sap.Data.Hana.v2" Version="2.22.26" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,3 @@
|
||||
html, body {
|
||||
font-family: Roboto, Arial, sans-serif;
|
||||
}
|
||||
Reference in New Issue
Block a user