diff --git a/TrafagSalesExporter/Components/Pages/Standorte.razor b/TrafagSalesExporter/Components/Pages/Standorte.razor
index 4067228..891521c 100644
--- a/TrafagSalesExporter/Components/Pages/Standorte.razor
+++ b/TrafagSalesExporter/Components/Pages/Standorte.razor
@@ -191,15 +191,22 @@
- @if (IsSapSite())
+ @if (IsMappedSourceSite())
{
- SAP Gateway
+ @GetMappingSectionTitle()
- Die Service-URL zeigt auf den OData-Service. Die verfügbaren Entity Sets werden nur per Knopfdruck aktualisiert und lokal zwischengespeichert.
+ Quellen und Feldmappings werden grafisch gepflegt. Bei SAP Gateway sind Quellen Entity Sets; bei HANA sind Quellen Tabellen oder Views im gewaehlten Schema.
- Zentrale SAP Service URL: @GetCentralSapServiceUrlSummary(_editingSite.SourceSystem)
-
+ @if (IsSapSite())
+ {
+ Zentrale SAP Service URL: @GetCentralSapServiceUrlSummary(_editingSite.SourceSystem)
+
+ }
+ else
+ {
+ Zentrale HANA-Verbindung: @GetCentralHanaSummary(_editingSite.SourceSystem)
+ }
@@ -210,7 +217,7 @@
}
else
{
- @("Quellen refreshen")
+ @(IsSapSite() ? "Entity Sets refreshen" : "Tabellen/Views refreshen")
}
@if (_editingSite.SapEntitySetsRefreshedAtUtc.HasValue)
@@ -222,16 +229,16 @@
- SAP Quellen
+ Quellen
Quelle hinzufügen
- Pro Quelle Alias und Entity Set definieren. Joins verwenden links/rechts kommagetrennte Schlüsselfelder wie `VBELN,POSNR`. Feldmappings erwarten `Alias.Feldname` oder Konstanten wie `=SAP`.
+ Pro Quelle Alias und Entity Set bzw. HANA Tabelle/View definieren. Joins verwenden links/rechts kommagetrennte Schluesselfelder wie `VBELN,POSNR`. Feldmappings erwarten `Alias.Feldname` oder Konstanten wie `=SAP` / `=HANA`.
Alias
- Entity Set
+ @(IsSapSite() ? "Entity Set" : "Tabelle/View")
Primär
Aktiv
Aktionen
@@ -344,7 +351,7 @@
- Source Expressions werden aus den hinzugefügten SAP-Quellen als `Alias.Feldname` gelesen. Vorhandene manuelle Werte bleiben auswählbar.
+ Source Expressions werden aus den hinzugefuegten Quellen als `Alias.Feldname` gelesen. Vorhandene manuelle Werte bleiben auswaehlbar.
@@ -659,7 +666,7 @@
_savingSite = true;
try
{
- await StandortePageService.SaveSiteAsync(_editingSite, UsesHanaConnection(), IsSapSite(), IsManualExcelSite(), _sapSources, _sapJoins, _sapMappings, _manualExcelMappings, _sapEntitySetsCache);
+ await StandortePageService.SaveSiteAsync(_editingSite, UsesHanaConnection(), IsMappedSourceSite(), IsManualExcelSite(), _sapSources, _sapJoins, _sapMappings, _manualExcelMappings, _sapEntitySetsCache);
_siteDialogVisible = false;
await LoadDataAsync();
Snackbar.Add("Standort gespeichert", Severity.Success);
@@ -752,11 +759,17 @@
private bool IsSapSite()
=> string.Equals(GetSourceSystemConnectionKind(_editingSite.SourceSystem), SourceSystemConnectionKinds.SapGateway, StringComparison.OrdinalIgnoreCase);
+ private bool IsMappedSourceSite()
+ => IsSapSite() || UsesHanaConnection();
+
private bool IsManualExcelSite()
=> string.Equals(GetSourceSystemConnectionKind(_editingSite.SourceSystem), SourceSystemConnectionKinds.ManualExcel, StringComparison.OrdinalIgnoreCase);
private bool UsesHanaConnection() => IsHanaSourceSystem(_editingSite.SourceSystem);
+ private string GetMappingSectionTitle()
+ => IsSapSite() ? "SAP Gateway Mapping" : "HANA Quellen und Feldmapping";
+
private string GetSourceSystemLabel(SourceSystemDefinition definition)
=> string.IsNullOrWhiteSpace(definition.DisplayName) ? definition.Code : $"{definition.DisplayName} ({definition.Code})";
@@ -1182,7 +1195,7 @@
.ToList();
if (activeSources.Count == 0)
- throw new InvalidOperationException("Es gibt keine aktiven SAP-Quellen mit Alias und Entity Set.");
+ throw new InvalidOperationException("Es gibt keine aktiven Quellen mit Alias und Entity Set/Tabelle.");
var result = await StandortePageService.RefreshSapSourceFieldsAsync(_editingSite, activeSources, _sapMappings);
_sapAvailableSourceExpressions = result.SourceExpressions;
diff --git a/TrafagSalesExporter/Services/DataSources/HanaDataSourceAdapter.cs b/TrafagSalesExporter/Services/DataSources/HanaDataSourceAdapter.cs
index 2d2b99f..2cc4568 100644
--- a/TrafagSalesExporter/Services/DataSources/HanaDataSourceAdapter.cs
+++ b/TrafagSalesExporter/Services/DataSources/HanaDataSourceAdapter.cs
@@ -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 };
}
diff --git a/TrafagSalesExporter/Services/DatabaseSeedService.cs b/TrafagSalesExporter/Services/DatabaseSeedService.cs
index 3a61ce5..036a424 100644
--- a/TrafagSalesExporter/Services/DatabaseSeedService.cs
+++ b/TrafagSalesExporter/Services/DatabaseSeedService.cs
@@ -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();
+ }
}
diff --git a/TrafagSalesExporter/Services/HanaQueryService.cs b/TrafagSalesExporter/Services/HanaQueryService.cs
index 260feb1..fbc7679 100644
--- a/TrafagSalesExporter/Services/HanaQueryService.cs
+++ b/TrafagSalesExporter/Services/HanaQueryService.cs
@@ -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> 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();
+ while (await reader.ReadAsync(cancellationToken))
+ {
+ var table = reader["table_name"]?.ToString()?.Trim();
+ if (!string.IsNullOrWhiteSpace(table))
+ tables.Add(table);
+ }
+
+ return tables;
+ }
+
+ public async Task> 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();
+ while (await reader.ReadAsync(cancellationToken))
+ {
+ var field = reader["column_name"]?.ToString()?.Trim();
+ if (!string.IsNullOrWhiteSpace(field))
+ fields.Add(field);
+ }
+
+ return fields;
+ }
+
+ public async Task> GetMappedSalesRecordsAsync(
+ HanaServer server,
+ string schema,
+ Site site,
+ IReadOnlyList sources,
+ IReadOnlyList joins,
+ IReadOnlyList 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>>(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> ReadRecordsAsync(HanaConnection connection, string query, string tsc, DateTime dateFilter, string land, string queryName, CancellationToken cancellationToken)
{
var records = new List();
@@ -203,6 +323,209 @@ public class HanaQueryService : IHanaQueryService
return records;
}
+ private static async Task>> 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>();
+ while (await reader.ReadAsync(cancellationToken))
+ {
+ var row = new Dictionary(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 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 PrefixRow(string alias, Dictionary row)
+ => row.ToDictionary(kvp => $"{alias}.{kvp.Key}", kvp => kvp.Value, StringComparer.OrdinalIgnoreCase);
+
+ private static List> ApplyLeftJoin(
+ List> leftRows,
+ string leftAlias,
+ string leftKeys,
+ string rightAlias,
+ string rightKeys,
+ List> 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>();
+ 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(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 row, IReadOnlyList 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 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 row, IReadOnlyList keys)
+ => string.Join("||", keys.Select(k => NormalizeKeyValue(row.TryGetValue(k, out var value) ? value : null)));
+
+ private static string BuildKey(Dictionary row, string alias, IReadOnlyList 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 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
diff --git a/TrafagSalesExporter/Services/IHanaQueryService.cs b/TrafagSalesExporter/Services/IHanaQueryService.cs
index 94e9473..74d7a94 100644
--- a/TrafagSalesExporter/Services/IHanaQueryService.cs
+++ b/TrafagSalesExporter/Services/IHanaQueryService.cs
@@ -5,7 +5,10 @@ namespace TrafagSalesExporter.Services;
public interface IHanaQueryService
{
Task> GetSalesRecordsAsync(HanaServer server, string schema, string tsc, string land, string dateFilter, CancellationToken cancellationToken = default);
+ Task> GetMappedSalesRecordsAsync(HanaServer server, string schema, Site site, IReadOnlyList sources, IReadOnlyList joins, IReadOnlyList mappings, string dateFilter, CancellationToken cancellationToken = default);
Task> GetAvailableSchemasAsync(HanaServer server, CancellationToken cancellationToken = default);
+ Task> GetAvailableTablesAsync(HanaServer server, string schema, CancellationToken cancellationToken = default);
+ Task> GetTableFieldNamesAsync(HanaServer server, string schema, string tableName, CancellationToken cancellationToken = default);
Task TestConnectionDetailedAsync(HanaServer server, CancellationToken cancellationToken = default);
Task TestConnectionAsync(HanaServer server, CancellationToken cancellationToken = default);
}
diff --git a/TrafagSalesExporter/Services/StandortePageService.cs b/TrafagSalesExporter/Services/StandortePageService.cs
index 8cda642..2575505 100644
--- a/TrafagSalesExporter/Services/StandortePageService.cs
+++ b/TrafagSalesExporter/Services/StandortePageService.cs
@@ -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 { "=HANA" };
+ var hanaSourceFieldMap = new Dictionary>(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 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
diff --git a/TrafagSalesExporter/report.abap b/TrafagSalesExporter/report.abap
new file mode 100644
index 0000000..64bd9d6
--- /dev/null
+++ b/TrafagSalesExporter/report.abap
@@ -0,0 +1,386 @@
+*&---------------------------------------------------------------------*
+*& Report ZTRAFAG_SCHWEIZ_EXPORT
+*&---------------------------------------------------------------------*
+*& Zweck
+*& Ermittelt SD-Faktura-Positionen fuer Schweiz/Oesterreich aus
+*& Buchungskreis 1100/1200 und schreibt sie per Upsert in Tabelle
+*& ZSCHWEIZ.
+*& ZSCHWEIZ kann danach aus SAP HANA in TrafagSalesExporter geladen
+*& werden.
+*&
+*& HANA-Anbindung
+*& - Tabelle/View-Basis: ZSCHWEIZ
+*& - im .NET-Programm Standort mit Quellsystem SAP_HANA verwenden
+*& - HANA-Schema auf das ABAP/HANA-Schema setzen
+*& - grafische Quelle: Alias Z, Tabelle/View ZSCHWEIZ
+*& - grafische Feldmappings koennen auf die unten vorgeschlagenen
+*& Felder gemappt werden.
+*&
+*& Fachliche Annahmen
+*& - Hauswaehrung ist fuehrend.
+*& - Nettofakturawert wird pro Belegposition ermittelt.
+*& - Gutschriften/Stornos werden ueber den Fakturatyp negativ bewertet.
+*& - Buchungskreis 1100 = Schweiz.
+*& - Buchungskreis 1200 = Oesterreich.
+*& - TSC/Reporting-Land werden aus BUKRS abgeleitet; Kundenland
+*& (KNA1-LAND1) bleibt als Infofeld erhalten.
+*&
+*& DDIC-Vorschlag fuer ZSCHWEIZ
+*& Client-dependent Tabelle, Auslieferungsklasse A, Datenpflege erlaubt.
+*&
+*& Schluesselfelder:
+*& MANDT MANDT SAP Mandant
+*& BUKRS BUKRS Buchungskreis
+*& GJAHR GJAHR Geschaeftsjahr aus FKDAT
+*& VBELN VBELN_VF Fakturanummer
+*& POSNR POSNR_VF Fakturaposition
+*&
+*& Datenfelder:
+*& LAND1 LAND1 Reporting-Land aus BUKRS, z.B. CH/AT
+*& CUSTOMER_LAND LAND1 Kundenland aus KNA1-LAND1
+*& TSC CHAR10 Reporting-Standort, z.B. TRCH/TRAT
+*& FKDAT FKDAT Fakturadatum
+*& FKART FKART Fakturatyp
+*& VBTYP VBTYP SD-Belegkategorie
+*& KUNNR KUNNR Auftraggeber/Sold-to
+*& NAME1 NAME1_GP Kundenname
+*& MATNR MATNR Material
+*& ARKTX ARKTX Positionsbezeichnung
+*& PRODH PRODH_D Produkthierarchie
+*& FKIMG FKIMG Fakturamenge
+*& VRKME VRKME Verkaufsmengeneinheit
+*& WAERK WAERK Belegwaehrung
+*& HWAER WAERS Hauswaehrung aus T001
+*& NETWR_DC CURR 23,2 Positions-Netto in Belegwaehrung
+*& TAX_DC CURR 23,2 Positions-Steuer in Belegwaehrung
+*& NETWR_HC CURR 23,2 Positions-Netto in Hauswaehrung
+*& TAX_HC CURR 23,2 Positions-Steuer in Hauswaehrung
+*& KURRF KURRF Rechnungsumrechnungskurs
+*& IS_CREDIT BOOLE_D X = Gutschrift/Storno negativ bewertet
+*& PARTY_CLASS CHAR10 2ND/3RD fuer spaetere IC-Abgrenzung
+*& ERDAT_SRC ERDAT Anlage-/Quell-Erfassungsdatum
+*& AEDAT_SRC AEDAT Aenderungsdatum, falls vorhanden
+*& CREATED_AT TIMESTAMPL Insert-Zeitpunkt
+*& CHANGED_AT TIMESTAMPL Update-Zeitpunkt
+*& CREATED_BY SYUNAME Insert-User
+*& CHANGED_BY SYUNAME Update-User
+*&
+*& Sekundaerindex empfohlen:
+*& Z01: BUKRS, LAND1, GJAHR, FKDAT
+*& Z02: KUNNR, GJAHR
+*&---------------------------------------------------------------------*
+
+REPORT ztrafag_schweiz_export.
+
+TABLES: vbrk, vbrp, kna1.
+
+TYPES: BEGIN OF ty_billing,
+ bukrs TYPE vbrk-bukrs,
+ vbeln TYPE vbrk-vbeln,
+ fkdat TYPE vbrk-fkdat,
+ fkart TYPE vbrk-fkart,
+ vbtyp TYPE vbrk-vbtyp,
+ waerk TYPE vbrk-waerk,
+ kurrf TYPE vbrk-kurrf,
+ kunag TYPE vbrk-kunag,
+ erdat TYPE vbrk-erdat,
+ posnr TYPE vbrp-posnr,
+ matnr TYPE vbrp-matnr,
+ arktx TYPE vbrp-arktx,
+ prodh TYPE vbrp-prodh,
+ fkimg TYPE vbrp-fkimg,
+ vrkme TYPE vbrp-vrkme,
+ netwr TYPE vbrp-netwr,
+ mwsbp TYPE vbrp-mwsbp,
+ customer_land TYPE kna1-land1,
+ name1 TYPE kna1-name1,
+ hwaer TYPE t001-waers,
+ END OF ty_billing.
+
+TYPES: BEGIN OF ty_zschweiz,
+ mandt TYPE mandt,
+ bukrs TYPE bukrs,
+ gjahr TYPE gjahr,
+ vbeln TYPE vbeln_vf,
+ posnr TYPE posnr_vf,
+ land1 TYPE land1,
+ customer_land TYPE land1,
+ tsc TYPE c LENGTH 10,
+ fkdat TYPE fkdat,
+ fkart TYPE fkart,
+ vbtyp TYPE vbtyp,
+ kunnr TYPE kunnr,
+ name1 TYPE name1_gp,
+ matnr TYPE matnr,
+ arktx TYPE arktx,
+ prodh TYPE prodh_d,
+ fkimg TYPE fkimg,
+ vrkme TYPE vrkme,
+ waerk TYPE waerk,
+ hwaer TYPE waers,
+ netwr_dc TYPE p LENGTH 23 DECIMALS 2,
+ tax_dc TYPE p LENGTH 23 DECIMALS 2,
+ netwr_hc TYPE p LENGTH 23 DECIMALS 2,
+ tax_hc TYPE p LENGTH 23 DECIMALS 2,
+ kurrf TYPE kurrf,
+ is_credit TYPE boole_d,
+ party_class TYPE c LENGTH 10,
+ erdat_src TYPE erdat,
+ aedat_src TYPE aedat,
+ created_at TYPE timestampl,
+ changed_at TYPE timestampl,
+ created_by TYPE syuname,
+ changed_by TYPE syuname,
+ END OF ty_zschweiz.
+
+DATA: gt_billing TYPE STANDARD TABLE OF ty_billing WITH EMPTY KEY,
+ gt_zschweiz TYPE STANDARD TABLE OF zschweiz WITH EMPTY KEY,
+ gs_zschweiz TYPE zschweiz.
+
+SELECTION-SCREEN BEGIN OF BLOCK b01 WITH FRAME TITLE TEXT-t01.
+ PARAMETERS: p_gjahr TYPE gjahr DEFAULT sy-datum(4) OBLIGATORY.
+ SELECT-OPTIONS: s_bukrs FOR vbrk-bukrs,
+ s_fkart FOR vbrk-fkart,
+ s_vbeln FOR vbrk-vbeln.
+ PARAMETERS: p_test AS CHECKBOX DEFAULT abap_true.
+SELECTION-SCREEN END OF BLOCK b01.
+
+INITIALIZATION.
+ TEXT-t01 = 'Finance Export Selektion'.
+ s_bukrs-sign = 'I'.
+ s_bukrs-option = 'EQ'.
+ s_bukrs-low = '1100'.
+ APPEND s_bukrs.
+ s_bukrs-low = '1200'.
+ APPEND s_bukrs.
+
+START-OF-SELECTION.
+ PERFORM read_billing_data.
+ PERFORM map_to_zschweiz.
+ PERFORM persist_zschweiz.
+
+FORM read_billing_data.
+ DATA(lv_date_from) = CONV fkdat( |{ p_gjahr }0101| ).
+ DATA(lv_date_to) = CONV fkdat( |{ p_gjahr }1231| ).
+
+ SELECT
+ h~bukrs,
+ h~vbeln,
+ h~fkdat,
+ h~fkart,
+ h~vbtyp,
+ h~waerk,
+ h~kurrf,
+ h~kunag,
+ h~erdat,
+ i~posnr,
+ i~matnr,
+ i~arktx,
+ i~prodh,
+ i~fkimg,
+ i~vrkme,
+ i~netwr,
+ i~mwsbp,
+ k~land1 AS customer_land,
+ k~name1,
+ c~waers AS hwaer
+ FROM vbrk AS h
+ INNER JOIN vbrp AS i
+ ON i~vbeln = h~vbeln
+ LEFT OUTER JOIN kna1 AS k
+ ON k~kunnr = h~kunag
+ LEFT OUTER JOIN t001 AS c
+ ON c~bukrs = h~bukrs
+ WHERE h~bukrs IN @s_bukrs
+ AND h~fkdat BETWEEN @lv_date_from AND @lv_date_to
+ AND h~vbeln IN @s_vbeln
+ AND h~fkart IN @s_fkart
+ AND h~fksto = @space
+ INTO TABLE @gt_billing.
+
+ WRITE: / 'Gelesene Fakturapositionen:', lines( gt_billing ).
+ENDFORM.
+
+FORM map_to_zschweiz.
+ DATA: lv_sign TYPE i,
+ lv_netwr_hc TYPE p LENGTH 23 DECIMALS 2,
+ lv_tax_hc TYPE p LENGTH 23 DECIMALS 2,
+ lv_timestamp TYPE timestampl,
+ lv_party TYPE c LENGTH 10.
+
+ GET TIME STAMP FIELD lv_timestamp.
+
+ LOOP AT gt_billing ASSIGNING FIELD-SYMBOL().
+ CLEAR: gs_zschweiz, lv_netwr_hc, lv_tax_hc, lv_party.
+
+ lv_sign = 1.
+ IF -vbtyp = 'O'
+ OR -vbtyp = 'N'
+ OR -fkart CP 'G*'
+ OR -fkart CP 'S*'.
+ lv_sign = -1.
+ ENDIF.
+
+ PERFORM convert_to_house_currency
+ USING -netwr -waerk -hwaer -fkdat -kurrf
+ CHANGING lv_netwr_hc.
+
+ PERFORM convert_to_house_currency
+ USING -mwsbp -waerk -hwaer -fkdat -kurrf
+ CHANGING lv_tax_hc.
+
+ PERFORM classify_party
+ USING -kunnr -name1
+ CHANGING lv_party.
+
+ gs_zschweiz-mandt = sy-mandt.
+ gs_zschweiz-bukrs = -bukrs.
+ gs_zschweiz-gjahr = p_gjahr.
+ gs_zschweiz-vbeln = -vbeln.
+ gs_zschweiz-posnr = -posnr.
+ gs_zschweiz-land1 = SWITCH #( -bukrs WHEN '1100' THEN 'CH' WHEN '1200' THEN 'AT' ELSE -customer_land ).
+ gs_zschweiz-customer_land = -customer_land.
+ gs_zschweiz-tsc = SWITCH #( -bukrs WHEN '1100' THEN 'TRCH' WHEN '1200' THEN 'TRAT' ELSE -bukrs ).
+ gs_zschweiz-fkdat = -fkdat.
+ gs_zschweiz-fkart = -fkart.
+ gs_zschweiz-vbtyp = -vbtyp.
+ gs_zschweiz-kunnr = -kunag.
+ gs_zschweiz-name1 = -name1.
+ gs_zschweiz-matnr = -matnr.
+ gs_zschweiz-arktx = -arktx.
+ gs_zschweiz-prodh = -prodh.
+ gs_zschweiz-fkimg = -fkimg * lv_sign.
+ gs_zschweiz-vrkme = -vrkme.
+ gs_zschweiz-waerk = -waerk.
+ gs_zschweiz-hwaer = -hwaer.
+ gs_zschweiz-netwr_dc = -netwr * lv_sign.
+ gs_zschweiz-tax_dc = -mwsbp * lv_sign.
+ gs_zschweiz-netwr_hc = lv_netwr_hc * lv_sign.
+ gs_zschweiz-tax_hc = lv_tax_hc * lv_sign.
+ gs_zschweiz-kurrf = -kurrf.
+ gs_zschweiz-is_credit = COND #( WHEN lv_sign < 0 THEN abap_true ELSE abap_false ).
+ gs_zschweiz-party_class = lv_party.
+ gs_zschweiz-erdat_src = -erdat.
+ gs_zschweiz-aedat_src = sy-datum.
+ gs_zschweiz-created_at = lv_timestamp.
+ gs_zschweiz-changed_at = lv_timestamp.
+ gs_zschweiz-created_by = sy-uname.
+ gs_zschweiz-changed_by = sy-uname.
+
+ APPEND gs_zschweiz TO gt_zschweiz.
+ ENDLOOP.
+
+ WRITE: / 'Aufbereitete ZSCHWEIZ-Zeilen:', lines( gt_zschweiz ).
+ENDFORM.
+
+FORM convert_to_house_currency
+ USING iv_amount TYPE any
+ iv_from TYPE waerk
+ iv_to TYPE waers
+ iv_date TYPE fkdat
+ iv_kurrf TYPE kurrf
+ CHANGING cv_amount TYPE any.
+
+ IF iv_from = iv_to OR iv_from IS INITIAL OR iv_to IS INITIAL.
+ cv_amount = iv_amount.
+ RETURN.
+ ENDIF.
+
+ CALL FUNCTION 'CONVERT_TO_LOCAL_CURRENCY'
+ EXPORTING
+ date = iv_date
+ foreign_amount = iv_amount
+ foreign_currency = iv_from
+ local_currency = iv_to
+ rate = iv_kurrf
+ IMPORTING
+ local_amount = cv_amount
+ EXCEPTIONS
+ no_rate_found = 1
+ overflow = 2
+ no_factors_found = 3
+ no_spread_found = 4
+ derived_2_times = 5
+ OTHERS = 6.
+
+ IF sy-subrc <> 0.
+ "Fallback: Wenn SD bereits einen Rechnungsumrechnungskurs liefert,
+ "verwenden wir diesen, damit die Position nicht verloren geht.
+ IF iv_kurrf IS NOT INITIAL.
+ cv_amount = iv_amount * iv_kurrf.
+ ELSE.
+ cv_amount = 0.
+ ENDIF.
+ ENDIF.
+ENDFORM.
+
+FORM classify_party
+ USING iv_kunnr TYPE kunnr
+ iv_name1 TYPE name1_gp
+ CHANGING cv_party TYPE c.
+
+ DATA(lv_name) = to_upper( iv_name1 ).
+
+ IF lv_name CS 'TRAFAG'
+ OR lv_name CS 'MAGNETIC SENSE'
+ OR lv_name CS 'MAGNETS SENSE'
+ OR lv_name CS 'GESELLSCHAFT FUER SENSORIK'
+ OR lv_name CS 'GESELLSCHAFT FUR SENSORIK'.
+ cv_party = '2ND'.
+ ELSE.
+ cv_party = '3RD'.
+ ENDIF.
+ENDFORM.
+
+FORM persist_zschweiz.
+ IF p_test = abap_true.
+ WRITE: / 'Testlauf aktiv: keine Daten in ZSCHWEIZ geschrieben.'.
+ PERFORM write_totals.
+ RETURN.
+ ENDIF.
+
+ MODIFY zschweiz FROM TABLE gt_zschweiz.
+
+ IF sy-subrc = 0.
+ COMMIT WORK AND WAIT.
+ WRITE: / 'ZSCHWEIZ Upsert erfolgreich. Zeilen:', lines( gt_zschweiz ).
+ ELSE.
+ ROLLBACK WORK.
+ MESSAGE 'ZSCHWEIZ Upsert fehlgeschlagen' TYPE 'E'.
+ ENDIF.
+
+ PERFORM write_totals.
+ENDFORM.
+
+FORM write_totals.
+ TYPES: BEGIN OF ty_total,
+ land1 TYPE land1,
+ hwaer TYPE waers,
+ netwr_hc TYPE p LENGTH 23 DECIMALS 2,
+ tax_hc TYPE p LENGTH 23 DECIMALS 2,
+ rows TYPE i,
+ END OF ty_total.
+
+ DATA lt_totals TYPE HASHED TABLE OF ty_total WITH UNIQUE KEY land1 hwaer.
+
+ LOOP AT gt_zschweiz ASSIGNING FIELD-SYMBOL().
+ ASSIGN lt_totals[ land1 = -land1 hwaer = -hwaer ] TO FIELD-SYMBOL().
+ IF sy-subrc <> 0.
+ INSERT VALUE #( land1 = -land1 hwaer = -hwaer ) INTO TABLE lt_totals ASSIGNING .
+ ENDIF.
+
+ -netwr_hc = -netwr_hc + -netwr_hc.
+ -tax_hc = -tax_hc + -tax_hc.
+ -rows = -rows + 1.
+ ENDLOOP.
+
+ SKIP.
+ WRITE: / 'Summen nach Land/Hauswaehrung'.
+ LOOP AT lt_totals ASSIGNING FIELD-SYMBOL().
+ WRITE: / -land1,
+ -hwaer,
+ 'Netto:', -netwr_hc,
+ 'Steuer:', -tax_hc,
+ 'Zeilen:', -rows.
+ ENDLOOP.
+ENDFORM.