Convert TrafagSalesExporter from console app to Blazor Server app with MudBlazor UI
- Replaced console app with .NET 8 Blazor Server architecture - Added EF Core SQLite database (trafag_exporter.db) with auto-seed data - Models: HanaServer, Site, SharePointConfig, ExportSettings, ExportLog, SalesRecord - Services: HanaQueryService (with configurable dateFilter), ExcelExportService, SharePointUploadService, ExportOrchestrationService (with live status events), TimerBackgroundService (scheduled daily export) - MudBlazor UI pages: Dashboard (export status + manual trigger), Standorte (HANA server + site CRUD), Settings (SharePoint + timer config), Logs (filtered view) - SAP HANA queries unchanged (INV + CRN with exact SAP B1 table joins) - SharePoint upload via Microsoft Graph with app registration auth https://claude.ai/code/session_012heAXNMbbyxqYf2S2HrKLj
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Diagnostics;
|
||||
using TrafagSalesExporter.Data;
|
||||
using TrafagSalesExporter.Models;
|
||||
|
||||
namespace TrafagSalesExporter.Services;
|
||||
|
||||
public class ExportOrchestrationService
|
||||
{
|
||||
private readonly IDbContextFactory<AppDbContext> _dbFactory;
|
||||
private readonly HanaQueryService _hanaService;
|
||||
private readonly ExcelExportService _excelService;
|
||||
private readonly SharePointUploadService _sharePointService;
|
||||
private readonly ILogger<ExportOrchestrationService> _logger;
|
||||
|
||||
public event Action? OnExportStatusChanged;
|
||||
|
||||
private readonly Dictionary<int, string> _runningExports = new();
|
||||
private readonly object _lock = new();
|
||||
|
||||
public ExportOrchestrationService(
|
||||
IDbContextFactory<AppDbContext> dbFactory,
|
||||
HanaQueryService hanaService,
|
||||
ExcelExportService excelService,
|
||||
SharePointUploadService sharePointService,
|
||||
ILogger<ExportOrchestrationService> logger)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_hanaService = hanaService;
|
||||
_excelService = excelService;
|
||||
_sharePointService = sharePointService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public bool IsExporting(int siteId)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _runningExports.ContainsKey(siteId);
|
||||
}
|
||||
}
|
||||
|
||||
public string GetExportStatus(int siteId)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _runningExports.TryGetValue(siteId, out var status) ? status : string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ExportAllAsync()
|
||||
{
|
||||
using var db = await _dbFactory.CreateDbContextAsync();
|
||||
var sites = await db.Sites.Include(s => s.HanaServer).Where(s => s.IsActive).ToListAsync();
|
||||
foreach (var site in sites)
|
||||
{
|
||||
await ExportSiteAsync(site);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ExportSiteByIdAsync(int siteId)
|
||||
{
|
||||
using var db = await _dbFactory.CreateDbContextAsync();
|
||||
var site = await db.Sites.Include(s => s.HanaServer).FirstOrDefaultAsync(s => s.Id == siteId);
|
||||
if (site is null) return;
|
||||
await ExportSiteAsync(site);
|
||||
}
|
||||
|
||||
private async Task ExportSiteAsync(Site site)
|
||||
{
|
||||
if (site.HanaServer is null) return;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (_runningExports.ContainsKey(site.Id)) return;
|
||||
_runningExports[site.Id] = "HANA Abfrage...";
|
||||
}
|
||||
NotifyChanged();
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var log = new ExportLog
|
||||
{
|
||||
Timestamp = DateTime.Now,
|
||||
SiteId = site.Id,
|
||||
Land = site.Land,
|
||||
TSC = site.TSC
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
using var db = await _dbFactory.CreateDbContextAsync();
|
||||
var settings = await db.ExportSettings.FirstOrDefaultAsync() ?? new ExportSettings();
|
||||
var spConfig = await db.SharePointConfigs.FirstOrDefaultAsync();
|
||||
|
||||
UpdateStatus(site.Id, "HANA Abfrage...");
|
||||
var records = await Task.Run(() => _hanaService.GetSalesRecords(
|
||||
site.HanaServer.Host, site.HanaServer.Port,
|
||||
site.HanaServer.Username, site.HanaServer.Password,
|
||||
site.Schema, site.TSC, site.Land, settings.DateFilter));
|
||||
|
||||
UpdateStatus(site.Id, "Excel erstellen...");
|
||||
var outputDir = Path.Combine(AppContext.BaseDirectory, "output");
|
||||
var filePath = _excelService.CreateExcelFile(outputDir, site.TSC, DateTime.UtcNow.Date, records);
|
||||
var fileName = Path.GetFileName(filePath);
|
||||
|
||||
if (spConfig is not null &&
|
||||
!string.IsNullOrWhiteSpace(spConfig.TenantId) &&
|
||||
!string.IsNullOrWhiteSpace(spConfig.ClientId) &&
|
||||
!string.IsNullOrWhiteSpace(spConfig.ClientSecret))
|
||||
{
|
||||
UpdateStatus(site.Id, "SharePoint Upload...");
|
||||
await _sharePointService.UploadAsync(
|
||||
spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret,
|
||||
spConfig.SiteUrl, spConfig.ExportFolder, site.Land, filePath);
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
log.Status = "OK";
|
||||
log.RowCount = records.Count;
|
||||
log.FileName = fileName;
|
||||
log.DurationSeconds = sw.Elapsed.TotalSeconds;
|
||||
|
||||
_logger.LogInformation("Export OK: {Land} ({TSC}) - {Rows} Zeilen in {Duration:F1}s",
|
||||
site.Land, site.TSC, records.Count, sw.Elapsed.TotalSeconds);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
log.Status = "Error";
|
||||
log.ErrorMessage = ex.Message;
|
||||
log.FileName = string.Empty;
|
||||
log.DurationSeconds = sw.Elapsed.TotalSeconds;
|
||||
|
||||
_logger.LogError(ex, "Export Fehler: {Land} ({TSC})", site.Land, site.TSC);
|
||||
}
|
||||
finally
|
||||
{
|
||||
using var db = await _dbFactory.CreateDbContextAsync();
|
||||
db.ExportLogs.Add(log);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_runningExports.Remove(site.Id);
|
||||
}
|
||||
NotifyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateStatus(int siteId, string status)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_runningExports[siteId] = status;
|
||||
}
|
||||
NotifyChanged();
|
||||
}
|
||||
|
||||
private void NotifyChanged()
|
||||
{
|
||||
OnExportStatusChanged?.Invoke();
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,8 @@ namespace TrafagSalesExporter.Services;
|
||||
|
||||
public class HanaQueryService
|
||||
{
|
||||
public List<SalesRecord> GetSalesRecords(string host, int port, string username, string password, string schema, string tsc, string land)
|
||||
public List<SalesRecord> GetSalesRecords(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>();
|
||||
@@ -13,8 +14,8 @@ public class HanaQueryService
|
||||
using var connection = new HanaConnection(connectionString);
|
||||
connection.Open();
|
||||
|
||||
var invoiceQuery = GetInvoiceQuery(schema, tsc);
|
||||
var creditNoteQuery = GetCreditNoteQuery(schema, tsc);
|
||||
var invoiceQuery = GetInvoiceQuery(schema, tsc, dateFilter);
|
||||
var creditNoteQuery = GetCreditNoteQuery(schema, tsc, dateFilter);
|
||||
|
||||
result.AddRange(ReadRecords(connection, invoiceQuery, land));
|
||||
result.AddRange(ReadRecords(connection, creditNoteQuery, land));
|
||||
@@ -31,6 +32,13 @@ public class HanaQueryService
|
||||
return result;
|
||||
}
|
||||
|
||||
public void 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();
|
||||
}
|
||||
|
||||
private static List<SalesRecord> ReadRecords(HanaConnection connection, string query, string land)
|
||||
{
|
||||
var records = new List<SalesRecord>();
|
||||
@@ -74,7 +82,7 @@ public class HanaQueryService
|
||||
return records;
|
||||
}
|
||||
|
||||
private static string GetInvoiceQuery(string schema, string tsc) => $@"
|
||||
private static string GetInvoiceQuery(string schema, string tsc, string dateFilter) => $@"
|
||||
SELECT
|
||||
CURRENT_TIMESTAMP AS extraction_date,
|
||||
'{tsc}' AS tsc,
|
||||
@@ -119,10 +127,10 @@ LEFT JOIN {schema}.""OCRD"" sup ON itm.""CardCode"" = sup.""CardCode""
|
||||
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"" >= '2025-01-01'
|
||||
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) => $@"
|
||||
private static string GetCreditNoteQuery(string schema, string tsc, string dateFilter) => $@"
|
||||
SELECT
|
||||
CURRENT_TIMESTAMP AS extraction_date,
|
||||
'{tsc}' AS tsc,
|
||||
@@ -162,6 +170,6 @@ LEFT JOIN {schema}.""OCRD"" sup ON itm.""CardCode"" = sup.""CardCode""
|
||||
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"" >= '2025-01-01'
|
||||
WHERE h.""CANCELED"" = 'N' AND h.""DocDate"" >= '{dateFilter}'
|
||||
ORDER BY h.""DocDate"" DESC, h.""DocNum"", p.""LineNum""";
|
||||
}
|
||||
|
||||
@@ -5,40 +5,41 @@ namespace TrafagSalesExporter.Services;
|
||||
|
||||
public class SharePointUploadService
|
||||
{
|
||||
private readonly GraphServiceClient _graphClient;
|
||||
private readonly string _siteUrl;
|
||||
private readonly string _exportFolder;
|
||||
|
||||
public SharePointUploadService(string tenantId, string clientId, string clientSecret, string siteUrl, string exportFolder)
|
||||
public async Task UploadAsync(string tenantId, string clientId, string clientSecret,
|
||||
string siteUrl, string exportFolder, string land, string localFilePath)
|
||||
{
|
||||
var credential = new ClientSecretCredential(tenantId, clientId, clientSecret);
|
||||
_graphClient = new GraphServiceClient(credential, ["https://graph.microsoft.com/.default"]);
|
||||
_siteUrl = siteUrl;
|
||||
_exportFolder = exportFolder;
|
||||
}
|
||||
var graphClient = new GraphServiceClient(credential, ["https://graph.microsoft.com/.default"]);
|
||||
|
||||
public async Task UploadAsync(string land, string localFilePath)
|
||||
{
|
||||
var uri = new Uri(_siteUrl);
|
||||
var uri = new Uri(siteUrl);
|
||||
var sitePath = uri.AbsolutePath;
|
||||
var site = await _graphClient.Sites[$"{uri.Host}:{sitePath}"].GetAsync();
|
||||
var site = await graphClient.Sites[$"{uri.Host}:{sitePath}"].GetAsync();
|
||||
|
||||
if (site?.Id is null)
|
||||
{
|
||||
throw new InvalidOperationException("SharePoint Site konnte nicht gefunden werden.");
|
||||
}
|
||||
|
||||
var drive = await _graphClient.Sites[site.Id].Drive.GetAsync();
|
||||
var drive = await graphClient.Sites[site.Id].Drive.GetAsync();
|
||||
if (drive?.Id is null)
|
||||
{
|
||||
throw new InvalidOperationException("SharePoint Dokumentenbibliothek konnte nicht gefunden werden.");
|
||||
}
|
||||
|
||||
var fileName = Path.GetFileName(localFilePath);
|
||||
var folderPath = $"{_exportFolder.Trim('/').Trim()}";
|
||||
var folderPath = exportFolder.Trim('/').Trim();
|
||||
var remotePath = $"{folderPath}/{land}/{fileName}";
|
||||
|
||||
await using var stream = File.OpenRead(localFilePath);
|
||||
await _graphClient.Drives[drive.Id].Root.ItemWithPath(remotePath).Content.PutAsync(stream);
|
||||
await graphClient.Drives[drive.Id].Root.ItemWithPath(remotePath).Content.PutAsync(stream);
|
||||
}
|
||||
|
||||
public async Task TestConnectionAsync(string tenantId, string clientId, string clientSecret, string siteUrl)
|
||||
{
|
||||
var credential = new ClientSecretCredential(tenantId, clientId, clientSecret);
|
||||
var graphClient = new GraphServiceClient(credential, ["https://graph.microsoft.com/.default"]);
|
||||
|
||||
var uri = new Uri(siteUrl);
|
||||
var sitePath = uri.AbsolutePath;
|
||||
var site = await graphClient.Sites[$"{uri.Host}:{sitePath}"].GetAsync();
|
||||
|
||||
if (site?.Id is null)
|
||||
throw new InvalidOperationException("SharePoint Site konnte nicht gefunden werden.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrafagSalesExporter.Data;
|
||||
|
||||
namespace TrafagSalesExporter.Services;
|
||||
|
||||
public class TimerBackgroundService : BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ILogger<TimerBackgroundService> _logger;
|
||||
private DateTime _nextRun = DateTime.MaxValue;
|
||||
|
||||
public DateTime NextRun => _nextRun;
|
||||
|
||||
public TimerBackgroundService(IServiceProvider serviceProvider, ILogger<TimerBackgroundService> logger)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public void Recalculate()
|
||||
{
|
||||
_ = RecalculateNextRunAsync();
|
||||
}
|
||||
|
||||
private async Task RecalculateNextRunAsync()
|
||||
{
|
||||
var dbFactory = _serviceProvider.GetRequiredService<IDbContextFactory<AppDbContext>>();
|
||||
using var db = await dbFactory.CreateDbContextAsync();
|
||||
var settings = await db.ExportSettings.FirstOrDefaultAsync();
|
||||
|
||||
if (settings is null || !settings.TimerEnabled)
|
||||
{
|
||||
_nextRun = DateTime.MaxValue;
|
||||
return;
|
||||
}
|
||||
|
||||
var now = DateTime.Now;
|
||||
var todayRun = new DateTime(now.Year, now.Month, now.Day, settings.TimerHour, settings.TimerMinute, 0);
|
||||
_nextRun = todayRun <= now ? todayRun.AddDays(1) : todayRun;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await RecalculateNextRunAsync();
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
|
||||
|
||||
if (DateTime.Now < _nextRun) continue;
|
||||
|
||||
_logger.LogInformation("Timer-Export gestartet um {Time}", DateTime.Now);
|
||||
|
||||
try
|
||||
{
|
||||
var orchestrator = _serviceProvider.GetRequiredService<ExportOrchestrationService>();
|
||||
await orchestrator.ExportAllAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Fehler beim Timer-Export");
|
||||
}
|
||||
|
||||
await RecalculateNextRunAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user