Add configurable HANA mapping for ZSCHWEIZ
This commit is contained in:
@@ -29,14 +29,32 @@ public sealed class HanaDataSourceAdapter : IDataSourceAdapter
|
||||
|
||||
using var db = await _dbFactory.CreateDbContextAsync();
|
||||
var exportServer = await BuildEffectiveServerAsync(db, site, sourceDefinition);
|
||||
var sourceMappings = await db.SapSourceDefinitions
|
||||
.Where(s => s.SiteId == site.Id)
|
||||
.OrderBy(s => s.SortOrder)
|
||||
.ThenBy(s => s.Id)
|
||||
.ToListAsync();
|
||||
var joins = await db.SapJoinDefinitions
|
||||
.Where(j => j.SiteId == site.Id)
|
||||
.OrderBy(j => j.SortOrder)
|
||||
.ThenBy(j => j.Id)
|
||||
.ToListAsync();
|
||||
var fieldMappings = await db.SapFieldMappings
|
||||
.Where(m => m.SiteId == site.Id)
|
||||
.OrderBy(m => m.SortOrder)
|
||||
.ThenBy(m => m.Id)
|
||||
.ToListAsync();
|
||||
|
||||
context.UpdateStatus?.Invoke("HANA Abfrage...");
|
||||
await _appEventLogService.WriteAsync("Export", "HANA Abfrage gestartet",
|
||||
siteId: site.Id, land: site.Land,
|
||||
details: exportServer.GetConnectionStringPreview());
|
||||
|
||||
var records = await _hanaService.GetSalesRecordsAsync(
|
||||
exportServer, site.Schema, site.TSC, site.Land, context.Settings.DateFilter);
|
||||
var records = sourceMappings.Count > 0 && fieldMappings.Count > 0
|
||||
? await _hanaService.GetMappedSalesRecordsAsync(
|
||||
exportServer, site.Schema, site, sourceMappings, joins, fieldMappings, context.Settings.DateFilter)
|
||||
: await _hanaService.GetSalesRecordsAsync(
|
||||
exportServer, site.Schema, site.TSC, site.Land, context.Settings.DateFilter);
|
||||
|
||||
return new DataSourceFetchResult { Records = records };
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ public class DatabaseSeedService : IDatabaseSeedService
|
||||
EnsureRecommendedTransformationRules(db);
|
||||
EnsureSourceSystemDefinitions(db);
|
||||
EnsureCentralHanaServerRecords(db);
|
||||
EnsureSpainManualExcelSite(db);
|
||||
EnsureSapHanaDachSite(db);
|
||||
}
|
||||
|
||||
private static void SeedIfEmpty(AppDbContext db)
|
||||
@@ -172,6 +174,7 @@ public class DatabaseSeedService : IDatabaseSeedService
|
||||
var defaults = new[]
|
||||
{
|
||||
new SourceSystemDefinition { Code = "SAP", DisplayName = "SAP", ConnectionKind = SourceSystemConnectionKinds.SapGateway, IsActive = true },
|
||||
new SourceSystemDefinition { Code = "SAP_HANA", DisplayName = "SAP HANA", ConnectionKind = SourceSystemConnectionKinds.Hana, IsActive = true },
|
||||
new SourceSystemDefinition { Code = "BI1", DisplayName = "BI1", ConnectionKind = SourceSystemConnectionKinds.Hana, IsActive = true },
|
||||
new SourceSystemDefinition { Code = "SAGE", DisplayName = "SAGE", ConnectionKind = SourceSystemConnectionKinds.Hana, IsActive = true },
|
||||
new SourceSystemDefinition { Code = "MANUAL_EXCEL", DisplayName = "Manual Excel", ConnectionKind = SourceSystemConnectionKinds.ManualExcel, IsActive = true }
|
||||
@@ -222,4 +225,173 @@ public class DatabaseSeedService : IDatabaseSeedService
|
||||
if (changed)
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
private static void EnsureSpainManualExcelSite(AppDbContext db)
|
||||
{
|
||||
if (db.Sites.Count() <= 1)
|
||||
return;
|
||||
|
||||
var existing = db.Sites
|
||||
.OrderBy(x => x.Id)
|
||||
.FirstOrDefault(x =>
|
||||
x.TSC == "TRSE" ||
|
||||
x.TSC == "TRES" ||
|
||||
x.Land == "Spanien" ||
|
||||
x.Land == "Spain");
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
var changed = false;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(existing.TSC))
|
||||
{
|
||||
existing.TSC = "TRES";
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(existing.Land))
|
||||
{
|
||||
existing.Land = "Spanien";
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(existing.SourceSystem))
|
||||
{
|
||||
existing.SourceSystem = "MANUAL_EXCEL";
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed)
|
||||
db.SaveChanges();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
db.Sites.Add(new Site
|
||||
{
|
||||
Schema = string.Empty,
|
||||
TSC = "TRES",
|
||||
Land = "Spanien",
|
||||
SourceSystem = "MANUAL_EXCEL",
|
||||
IsActive = false
|
||||
});
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
private static void EnsureSapHanaDachSite(AppDbContext db)
|
||||
{
|
||||
if (db.Sites.Count() <= 1)
|
||||
return;
|
||||
|
||||
var existing = db.Sites
|
||||
.OrderBy(x => x.Id)
|
||||
.FirstOrDefault(x =>
|
||||
x.TSC == "ZSCHWEIZ" ||
|
||||
x.Land == "Schweiz/Oesterreich" ||
|
||||
x.Land == "DACH");
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
var changed = false;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(existing.TSC))
|
||||
{
|
||||
existing.TSC = "ZSCHWEIZ";
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(existing.Land))
|
||||
{
|
||||
existing.Land = "Schweiz/Oesterreich";
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(existing.SourceSystem))
|
||||
{
|
||||
existing.SourceSystem = "SAP_HANA";
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed)
|
||||
db.SaveChanges();
|
||||
|
||||
EnsureSapHanaDachMapping(db, existing.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
var site = new Site
|
||||
{
|
||||
Schema = string.Empty,
|
||||
TSC = "ZSCHWEIZ",
|
||||
Land = "Schweiz/Oesterreich",
|
||||
SourceSystem = "SAP_HANA",
|
||||
IsActive = false
|
||||
};
|
||||
db.Sites.Add(site);
|
||||
db.SaveChanges();
|
||||
EnsureSapHanaDachMapping(db, site.Id);
|
||||
}
|
||||
|
||||
private static void EnsureSapHanaDachMapping(AppDbContext db, int siteId)
|
||||
{
|
||||
if (db.SapSourceDefinitions.Any(x => x.SiteId == siteId) ||
|
||||
db.SapFieldMappings.Any(x => x.SiteId == siteId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
db.SapSourceDefinitions.Add(new SapSourceDefinition
|
||||
{
|
||||
SiteId = siteId,
|
||||
Alias = "Z",
|
||||
EntitySet = "ZSCHWEIZ",
|
||||
IsPrimary = true,
|
||||
IsActive = true,
|
||||
SortOrder = 0
|
||||
});
|
||||
|
||||
var mappings = new (string Target, string Source, bool Required)[]
|
||||
{
|
||||
(nameof(SalesRecord.Tsc), "Z.TSC", true),
|
||||
(nameof(SalesRecord.Land), "Z.LAND1", true),
|
||||
(nameof(SalesRecord.DocumentEntry), "Z.VBELN", false),
|
||||
(nameof(SalesRecord.InvoiceNumber), "Z.VBELN", true),
|
||||
(nameof(SalesRecord.PositionOnInvoice), "Z.POSNR", true),
|
||||
(nameof(SalesRecord.InvoiceDate), "Z.FKDAT", true),
|
||||
(nameof(SalesRecord.Material), "Z.MATNR", false),
|
||||
(nameof(SalesRecord.Name), "Z.ARKTX", false),
|
||||
(nameof(SalesRecord.ProductGroup), "Z.PRODH", false),
|
||||
(nameof(SalesRecord.Quantity), "Z.FKIMG", false),
|
||||
(nameof(SalesRecord.CustomerNumber), "Z.KUNNR", false),
|
||||
(nameof(SalesRecord.CustomerName), "Z.NAME1", false),
|
||||
(nameof(SalesRecord.CustomerCountry), "Z.CUSTOMER_LAND", false),
|
||||
(nameof(SalesRecord.StandardCost), "=0", false),
|
||||
(nameof(SalesRecord.StandardCostCurrency), "Z.HWAER", false),
|
||||
(nameof(SalesRecord.SalesPriceValue), "Z.NETWR_HC", true),
|
||||
(nameof(SalesRecord.SalesCurrency), "Z.HWAER", true),
|
||||
(nameof(SalesRecord.DocumentCurrency), "Z.WAERK", false),
|
||||
(nameof(SalesRecord.DocumentTotalForeignCurrency), "Z.NETWR_DC", false),
|
||||
(nameof(SalesRecord.DocumentTotalLocalCurrency), "Z.NETWR_HC", false),
|
||||
(nameof(SalesRecord.VatSumForeignCurrency), "Z.TAX_DC", false),
|
||||
(nameof(SalesRecord.VatSumLocalCurrency), "Z.TAX_HC", false),
|
||||
(nameof(SalesRecord.DocumentRate), "Z.KURRF", false),
|
||||
(nameof(SalesRecord.CompanyCurrency), "Z.HWAER", true),
|
||||
(nameof(SalesRecord.DocumentType), "Z.FKART", false)
|
||||
};
|
||||
|
||||
for (var i = 0; i < mappings.Length; i++)
|
||||
{
|
||||
db.SapFieldMappings.Add(new SapFieldMapping
|
||||
{
|
||||
SiteId = siteId,
|
||||
TargetField = mappings[i].Target,
|
||||
SourceExpression = mappings[i].Source,
|
||||
IsRequired = mappings[i].Required,
|
||||
IsActive = true,
|
||||
SortOrder = i
|
||||
});
|
||||
}
|
||||
|
||||
db.SaveChanges();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using Sap.Data.Hana;
|
||||
using TrafagSalesExporter.Models;
|
||||
|
||||
@@ -142,6 +143,125 @@ public class HanaQueryService : IHanaQueryService
|
||||
return schemas;
|
||||
}
|
||||
|
||||
public async Task<List<string>> GetAvailableTablesAsync(HanaServer server, string schema, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var connectionString = server.BuildConnectionString();
|
||||
using var connection = new HanaConnection(connectionString);
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
|
||||
const string query = """
|
||||
SELECT table_name
|
||||
FROM sys.tables
|
||||
WHERE schema_name = :schema
|
||||
UNION
|
||||
SELECT view_name AS table_name
|
||||
FROM sys.views
|
||||
WHERE schema_name = :schema
|
||||
ORDER BY table_name;
|
||||
""";
|
||||
|
||||
using var command = new HanaCommand(query, connection);
|
||||
command.Parameters.Add(new HanaParameter("schema", HanaDbType.NVarChar) { Value = schema.Trim().ToUpperInvariant() });
|
||||
using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
|
||||
var tables = new List<string>();
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
var table = reader["table_name"]?.ToString()?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(table))
|
||||
tables.Add(table);
|
||||
}
|
||||
|
||||
return tables;
|
||||
}
|
||||
|
||||
public async Task<List<string>> GetTableFieldNamesAsync(HanaServer server, string schema, string tableName, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var connectionString = server.BuildConnectionString();
|
||||
using var connection = new HanaConnection(connectionString);
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
|
||||
const string query = """
|
||||
SELECT column_name
|
||||
FROM sys.table_columns
|
||||
WHERE schema_name = :schema AND table_name = :table
|
||||
UNION
|
||||
SELECT column_name
|
||||
FROM sys.view_columns
|
||||
WHERE schema_name = :schema AND view_name = :table
|
||||
ORDER BY column_name;
|
||||
""";
|
||||
|
||||
using var command = new HanaCommand(query, connection);
|
||||
command.Parameters.Add(new HanaParameter("schema", HanaDbType.NVarChar) { Value = schema.Trim().ToUpperInvariant() });
|
||||
command.Parameters.Add(new HanaParameter("table", HanaDbType.NVarChar) { Value = tableName.Trim().ToUpperInvariant() });
|
||||
using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
|
||||
var fields = new List<string>();
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
var field = reader["column_name"]?.ToString()?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(field))
|
||||
fields.Add(field);
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
public async Task<List<SalesRecord>> GetMappedSalesRecordsAsync(
|
||||
HanaServer server,
|
||||
string schema,
|
||||
Site site,
|
||||
IReadOnlyList<SapSourceDefinition> sources,
|
||||
IReadOnlyList<SapJoinDefinition> joins,
|
||||
IReadOnlyList<SapFieldMapping> mappings,
|
||||
string dateFilter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var activeSources = sources
|
||||
.Where(s => s.IsActive)
|
||||
.OrderBy(s => s.SortOrder)
|
||||
.ThenBy(s => s.Id)
|
||||
.ToList();
|
||||
|
||||
if (activeSources.Count == 0)
|
||||
throw new InvalidOperationException($"Standort '{site.Land}' hat keine aktiven HANA-Quellen.");
|
||||
if (!mappings.Any(m => m.IsActive))
|
||||
throw new InvalidOperationException($"Standort '{site.Land}' hat keine aktiven HANA-Feldmappings.");
|
||||
|
||||
var connectionString = server.BuildConnectionString();
|
||||
using var connection = new HanaConnection(connectionString);
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
|
||||
var parsedDateFilter = ParseDateFilter(dateFilter);
|
||||
var sourceRows = new Dictionary<string, List<Dictionary<string, object?>>>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var source in activeSources)
|
||||
{
|
||||
await _appEventLogService.WriteDebugAsync("HANA", "Mapping-Quelle wird gelesen", site.Id, site.Land,
|
||||
$"Alias={source.Alias} | Tabelle/View={source.EntitySet}");
|
||||
sourceRows[source.Alias] = await ReadMappedSourceRowsAsync(connection, schema, source.EntitySet, parsedDateFilter, cancellationToken);
|
||||
await _appEventLogService.WriteDebugAsync("HANA", "Mapping-Quelle gelesen", site.Id, site.Land,
|
||||
$"Alias={source.Alias} | Tabelle/View={source.EntitySet} | Zeilen={sourceRows[source.Alias].Count}");
|
||||
}
|
||||
|
||||
var primarySource = activeSources.FirstOrDefault(s => s.IsPrimary) ?? activeSources.First();
|
||||
var composedRows = sourceRows[primarySource.Alias]
|
||||
.Select(r => PrefixRow(primarySource.Alias, r))
|
||||
.ToList();
|
||||
|
||||
foreach (var join in joins.Where(j => j.IsActive).OrderBy(j => j.SortOrder).ThenBy(j => j.Id))
|
||||
{
|
||||
if (!sourceRows.TryGetValue(join.RightAlias, out var rightRows))
|
||||
continue;
|
||||
|
||||
composedRows = ApplyLeftJoin(composedRows, join.LeftAlias, join.LeftKeys, join.RightAlias, join.RightKeys, rightRows);
|
||||
}
|
||||
|
||||
return composedRows
|
||||
.Select(row => MapToSalesRecord(site, row, mappings))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private async Task<List<SalesRecord>> ReadRecordsAsync(HanaConnection connection, string query, string tsc, DateTime dateFilter, string land, string queryName, CancellationToken cancellationToken)
|
||||
{
|
||||
var records = new List<SalesRecord>();
|
||||
@@ -203,6 +323,209 @@ public class HanaQueryService : IHanaQueryService
|
||||
return records;
|
||||
}
|
||||
|
||||
private static async Task<List<Dictionary<string, object?>>> ReadMappedSourceRowsAsync(
|
||||
HanaConnection connection,
|
||||
string schema,
|
||||
string tableName,
|
||||
DateTime dateFilter,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var schemaPrefix = BuildSchemaPrefix(schema);
|
||||
var tableIdentifier = BuildIdentifier(tableName);
|
||||
var hasFkdat = await HasColumnAsync(connection, schema, tableName, "FKDAT", cancellationToken);
|
||||
var query = hasFkdat
|
||||
? $@"SELECT * FROM {schemaPrefix}{tableIdentifier} WHERE ""FKDAT"" >= :{DateFilterParameterName}"
|
||||
: $@"SELECT * FROM {schemaPrefix}{tableIdentifier}";
|
||||
|
||||
using var command = new HanaCommand(query, connection);
|
||||
if (hasFkdat)
|
||||
command.Parameters.Add(new HanaParameter(DateFilterParameterName, HanaDbType.Date) { Value = dateFilter.Date });
|
||||
|
||||
using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
var rows = new List<Dictionary<string, object?>>();
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
var row = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
|
||||
for (var i = 0; i < reader.FieldCount; i++)
|
||||
row[reader.GetName(i)] = reader.IsDBNull(i) ? null : reader.GetValue(i);
|
||||
rows.Add(row);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static async Task<bool> HasColumnAsync(HanaConnection connection, string schema, string tableName, string columnName, CancellationToken cancellationToken)
|
||||
{
|
||||
const string query = """
|
||||
SELECT COUNT(*) AS cnt
|
||||
FROM (
|
||||
SELECT column_name
|
||||
FROM sys.table_columns
|
||||
WHERE schema_name = :schema AND table_name = :table AND column_name = :column
|
||||
UNION ALL
|
||||
SELECT column_name
|
||||
FROM sys.view_columns
|
||||
WHERE schema_name = :schema AND view_name = :table AND column_name = :column
|
||||
) x;
|
||||
""";
|
||||
|
||||
using var command = new HanaCommand(query, connection);
|
||||
command.Parameters.Add(new HanaParameter("schema", HanaDbType.NVarChar) { Value = schema.Trim().ToUpperInvariant() });
|
||||
command.Parameters.Add(new HanaParameter("table", HanaDbType.NVarChar) { Value = tableName.Trim().ToUpperInvariant() });
|
||||
command.Parameters.Add(new HanaParameter("column", HanaDbType.NVarChar) { Value = columnName.Trim().ToUpperInvariant() });
|
||||
var count = await command.ExecuteScalarAsync(cancellationToken);
|
||||
return Convert.ToInt32(count) > 0;
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> PrefixRow(string alias, Dictionary<string, object?> row)
|
||||
=> row.ToDictionary(kvp => $"{alias}.{kvp.Key}", kvp => kvp.Value, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private static List<Dictionary<string, object?>> ApplyLeftJoin(
|
||||
List<Dictionary<string, object?>> leftRows,
|
||||
string leftAlias,
|
||||
string leftKeys,
|
||||
string rightAlias,
|
||||
string rightKeys,
|
||||
List<Dictionary<string, object?>> rightRows)
|
||||
{
|
||||
var leftKeyParts = SplitKeys(leftKeys);
|
||||
var rightKeyParts = SplitKeys(rightKeys);
|
||||
if (leftKeyParts.Count == 0 || leftKeyParts.Count != rightKeyParts.Count)
|
||||
return leftRows;
|
||||
|
||||
var rightLookup = rightRows
|
||||
.GroupBy(r => BuildKey(r, rightKeyParts))
|
||||
.ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var results = new List<Dictionary<string, object?>>();
|
||||
foreach (var leftRow in leftRows)
|
||||
{
|
||||
var leftKey = BuildKey(leftRow, leftAlias, leftKeyParts);
|
||||
if (rightLookup.TryGetValue(leftKey, out var matches) && matches.Count > 0)
|
||||
{
|
||||
foreach (var match in matches)
|
||||
{
|
||||
var merged = new Dictionary<string, object?>(leftRow, StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var kvp in PrefixRow(rightAlias, match))
|
||||
merged[kvp.Key] = kvp.Value;
|
||||
results.Add(merged);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
results.Add(leftRow);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static SalesRecord MapToSalesRecord(Site site, Dictionary<string, object?> row, IReadOnlyList<SapFieldMapping> mappings)
|
||||
{
|
||||
var record = new SalesRecord
|
||||
{
|
||||
ExtractionDate = DateTime.UtcNow,
|
||||
Tsc = site.TSC,
|
||||
Land = site.Land,
|
||||
DocumentType = "HANA"
|
||||
};
|
||||
|
||||
foreach (var mapping in mappings.Where(m => m.IsActive).OrderBy(m => m.SortOrder).ThenBy(m => m.Id))
|
||||
{
|
||||
var value = EvaluateExpression(row, mapping.SourceExpression);
|
||||
ApplyValue(record, mapping.TargetField, value);
|
||||
}
|
||||
|
||||
if (record.ExtractionDate == default)
|
||||
record.ExtractionDate = DateTime.UtcNow;
|
||||
if (string.IsNullOrWhiteSpace(record.Tsc))
|
||||
record.Tsc = site.TSC;
|
||||
if (string.IsNullOrWhiteSpace(record.Land))
|
||||
record.Land = site.Land;
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
private static object? EvaluateExpression(Dictionary<string, object?> row, string expression)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(expression))
|
||||
return null;
|
||||
|
||||
var value = expression.Trim();
|
||||
if (value.StartsWith('='))
|
||||
return value[1..];
|
||||
|
||||
if (row.TryGetValue(value, out var direct))
|
||||
return direct;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void ApplyValue(SalesRecord record, string targetField, object? value)
|
||||
{
|
||||
var property = typeof(SalesRecord).GetProperty(targetField);
|
||||
if (property is null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
if (property.PropertyType == typeof(string))
|
||||
{
|
||||
property.SetValue(record, value?.ToString() ?? string.Empty);
|
||||
return;
|
||||
}
|
||||
|
||||
if (property.PropertyType == typeof(int))
|
||||
{
|
||||
if (int.TryParse(value?.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var intValue))
|
||||
property.SetValue(record, intValue);
|
||||
return;
|
||||
}
|
||||
|
||||
if (property.PropertyType == typeof(decimal))
|
||||
{
|
||||
if (decimal.TryParse(value?.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var decimalValue))
|
||||
property.SetValue(record, decimalValue);
|
||||
return;
|
||||
}
|
||||
|
||||
if (property.PropertyType == typeof(DateTime?) || property.PropertyType == typeof(DateTime))
|
||||
{
|
||||
if (TryParseDate(value?.ToString(), out var date))
|
||||
property.SetValue(record, date);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Invalid field mappings should not stop the remaining row mapping.
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryParseDate(string? value, out DateTime date)
|
||||
{
|
||||
date = default;
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return false;
|
||||
|
||||
return DateTime.TryParse(value.Trim(), CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out date)
|
||||
|| DateTime.TryParse(value.Trim(), CultureInfo.GetCultureInfo("de-CH"), DateTimeStyles.AssumeLocal, out date);
|
||||
}
|
||||
|
||||
private static string BuildKey(Dictionary<string, object?> row, IReadOnlyList<string> keys)
|
||||
=> string.Join("||", keys.Select(k => NormalizeKeyValue(row.TryGetValue(k, out var value) ? value : null)));
|
||||
|
||||
private static string BuildKey(Dictionary<string, object?> row, string alias, IReadOnlyList<string> keys)
|
||||
=> string.Join("||", keys.Select(k =>
|
||||
{
|
||||
row.TryGetValue($"{alias}.{k}", out var value);
|
||||
return NormalizeKeyValue(value);
|
||||
}));
|
||||
|
||||
private static string NormalizeKeyValue(object? value) => value?.ToString()?.Trim() ?? string.Empty;
|
||||
|
||||
private static List<string> SplitKeys(string keys)
|
||||
=> keys.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
|
||||
|
||||
private static string GetInvoiceQuery(string schema)
|
||||
{
|
||||
var schemaPrefix = BuildSchemaPrefix(schema);
|
||||
@@ -345,6 +668,21 @@ ORDER BY h.""DocDate"" DESC, h.""DocNum"", p.""LineNum""";
|
||||
|
||||
return $"{value}.";
|
||||
}
|
||||
|
||||
private static string BuildIdentifier(string identifier)
|
||||
{
|
||||
var value = identifier?.Trim() ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
throw new InvalidOperationException("HANA-Identifier darf nicht leer sein.");
|
||||
|
||||
foreach (var ch in value)
|
||||
{
|
||||
if (!(char.IsLetterOrDigit(ch) || ch == '_'))
|
||||
throw new InvalidOperationException($"Ungueltiger HANA-Identifier: '{identifier}'.");
|
||||
}
|
||||
|
||||
return $"\"{value}\"";
|
||||
}
|
||||
}
|
||||
|
||||
public class ConnectionTestResult
|
||||
|
||||
@@ -5,7 +5,10 @@ namespace TrafagSalesExporter.Services;
|
||||
public interface IHanaQueryService
|
||||
{
|
||||
Task<List<SalesRecord>> GetSalesRecordsAsync(HanaServer server, string schema, string tsc, string land, string dateFilter, CancellationToken cancellationToken = default);
|
||||
Task<List<SalesRecord>> GetMappedSalesRecordsAsync(HanaServer server, string schema, Site site, IReadOnlyList<SapSourceDefinition> sources, IReadOnlyList<SapJoinDefinition> joins, IReadOnlyList<SapFieldMapping> mappings, string dateFilter, CancellationToken cancellationToken = default);
|
||||
Task<List<string>> GetAvailableSchemasAsync(HanaServer server, CancellationToken cancellationToken = default);
|
||||
Task<List<string>> GetAvailableTablesAsync(HanaServer server, string schema, CancellationToken cancellationToken = default);
|
||||
Task<List<string>> GetTableFieldNamesAsync(HanaServer server, string schema, string tableName, CancellationToken cancellationToken = default);
|
||||
Task<ConnectionTestResult> TestConnectionDetailedAsync(HanaServer server, CancellationToken cancellationToken = default);
|
||||
Task TestConnectionAsync(HanaServer server, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -282,6 +282,20 @@ public sealed class StandortePageService : IStandortePageService
|
||||
{
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
var sourceDefinition = await db.SourceSystemDefinitions.OrderBy(x => x.Id).FirstOrDefaultAsync(x => x.Code == site.SourceSystem);
|
||||
if (string.Equals(sourceDefinition?.ConnectionKind, SourceSystemConnectionKinds.Hana, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var server = await BuildEffectiveHanaServerAsync(db, site, sourceDefinition);
|
||||
if (string.IsNullOrWhiteSpace(site.Schema))
|
||||
throw new InvalidOperationException("Bitte zuerst ein HANA-Schema eintragen.");
|
||||
|
||||
var tables = await _hanaService.GetAvailableTablesAsync(server, site.Schema);
|
||||
return new SapEntitySetRefreshResult
|
||||
{
|
||||
EntitySets = tables,
|
||||
RefreshedAtUtc = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
var serviceUrl = string.IsNullOrWhiteSpace(site.SapServiceUrl) ? sourceDefinition?.CentralServiceUrl ?? string.Empty : site.SapServiceUrl;
|
||||
if (string.IsNullOrWhiteSpace(serviceUrl))
|
||||
throw new InvalidOperationException("Es ist weder eine zentrale SAP Service URL noch ein Standort-Override gesetzt.");
|
||||
@@ -315,6 +329,38 @@ public sealed class StandortePageService : IStandortePageService
|
||||
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
var sourceDefinition = await db.SourceSystemDefinitions.OrderBy(x => x.Id).FirstOrDefaultAsync(x => x.Code == site.SourceSystem);
|
||||
if (string.Equals(sourceDefinition?.ConnectionKind, SourceSystemConnectionKinds.Hana, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var server = await BuildEffectiveHanaServerAsync(db, site, sourceDefinition);
|
||||
if (string.IsNullOrWhiteSpace(site.Schema))
|
||||
throw new InvalidOperationException("Bitte zuerst ein HANA-Schema eintragen.");
|
||||
|
||||
var hanaExpressions = new List<string> { "=HANA" };
|
||||
var hanaSourceFieldMap = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var source in activeSources)
|
||||
{
|
||||
var fieldNames = await _hanaService.GetTableFieldNamesAsync(server, site.Schema, source.EntitySet);
|
||||
hanaSourceFieldMap[source.Alias] = fieldNames;
|
||||
hanaExpressions.AddRange(fieldNames.Select(field => $"{source.Alias}.{field}"));
|
||||
}
|
||||
|
||||
foreach (var current in sapMappings.Select(m => m.SourceExpression).Where(x => !string.IsNullOrWhiteSpace(x)))
|
||||
{
|
||||
if (!hanaExpressions.Contains(current, StringComparer.OrdinalIgnoreCase))
|
||||
hanaExpressions.Add(current);
|
||||
}
|
||||
|
||||
return new SapSourceFieldRefreshResult
|
||||
{
|
||||
SourceFieldMap = hanaSourceFieldMap,
|
||||
SourceExpressions = hanaExpressions
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList()
|
||||
};
|
||||
}
|
||||
|
||||
var serviceUrl = string.IsNullOrWhiteSpace(site.SapServiceUrl) ? sourceDefinition?.CentralServiceUrl ?? string.Empty : site.SapServiceUrl;
|
||||
if (string.IsNullOrWhiteSpace(serviceUrl))
|
||||
throw new InvalidOperationException("Es ist weder eine zentrale SAP Service URL noch ein Standort-Override gesetzt.");
|
||||
@@ -573,6 +619,36 @@ public sealed class StandortePageService : IStandortePageService
|
||||
|
||||
return centralServer.Id;
|
||||
}
|
||||
|
||||
private static async Task<HanaServer> BuildEffectiveHanaServerAsync(AppDbContext db, Site site, SourceSystemDefinition? sourceDefinition)
|
||||
{
|
||||
var normalizedSourceSystem = string.IsNullOrWhiteSpace(site.SourceSystem) ? string.Empty : site.SourceSystem.Trim().ToUpperInvariant();
|
||||
var centralServer = await db.HanaServers
|
||||
.AsNoTracking()
|
||||
.OrderBy(x => x.Id)
|
||||
.FirstOrDefaultAsync(x => x.SourceSystem == normalizedSourceSystem)
|
||||
?? throw new InvalidOperationException($"Fuer Quellsystem '{normalizedSourceSystem}' ist keine zentrale HANA-Konfiguration vorhanden.");
|
||||
|
||||
var username = string.IsNullOrWhiteSpace(site.UsernameOverride) ? sourceDefinition?.CentralUsername ?? string.Empty : site.UsernameOverride;
|
||||
var password = string.IsNullOrWhiteSpace(site.PasswordOverride) ? sourceDefinition?.CentralPassword ?? string.Empty : site.PasswordOverride;
|
||||
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
||||
throw new InvalidOperationException($"Fuer {normalizedSourceSystem} sind weder zentrale Zugangsdaten noch Standort-Overrides gesetzt.");
|
||||
|
||||
return 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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class StandortePageState
|
||||
|
||||
Reference in New Issue
Block a user