Convert Trafag exporter to Blazor Server app with UI and scheduler

This commit is contained in:
2026-04-09 15:52:23 +02:00
parent 8d8b62f1f5
commit ec14b838e5
25 changed files with 1438 additions and 0 deletions
@@ -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);
}
}
}
}