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.