Add configurable HANA mapping for ZSCHWEIZ

This commit is contained in:
2026-05-07 14:04:17 +02:00
parent c862a559f6
commit 7442d45d9c
7 changed files with 1021 additions and 15 deletions
@@ -191,15 +191,22 @@
<MudDivider Class="my-4" />
@if (IsSapSite())
@if (IsMappedSourceSite())
{
<MudText Typo="Typo.h6" Class="mb-2">SAP Gateway</MudText>
<MudText Typo="Typo.h6" Class="mb-2">@GetMappingSectionTitle()</MudText>
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Outlined" Class="mb-3">
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.
</MudAlert>
<MudText Typo="Typo.body2">Zentrale SAP Service URL: @GetCentralSapServiceUrlSummary(_editingSite.SourceSystem)</MudText>
<MudTextField @bind-Value="_editingSite.SapServiceUrl" Label="SAP Service URL Override"
HelperText="Optional. Wenn leer, wird die zentrale SAP Service URL des Quellsystems verwendet." />
@if (IsSapSite())
{
<MudText Typo="Typo.body2">Zentrale SAP Service URL: @GetCentralSapServiceUrlSummary(_editingSite.SourceSystem)</MudText>
<MudTextField @bind-Value="_editingSite.SapServiceUrl" Label="SAP Service URL Override"
HelperText="Optional. Wenn leer, wird die zentrale SAP Service URL des Quellsystems verwendet." />
}
else
{
<MudText Typo="Typo.body2">Zentrale HANA-Verbindung: @GetCentralHanaSummary(_editingSite.SourceSystem)</MudText>
}
<MudStack Row Spacing="2" Class="mb-3">
<MudButton Variant="Variant.Outlined" Color="Color.Info" OnClick="RefreshSapEntitySets"
StartIcon="@Icons.Material.Filled.Refresh" Disabled="_refreshingSapEntitySets">
@@ -210,7 +217,7 @@
}
else
{
@("Quellen refreshen")
@(IsSapSite() ? "Entity Sets refreshen" : "Tabellen/Views refreshen")
}
</MudButton>
@if (_editingSite.SapEntitySetsRefreshedAtUtc.HasValue)
@@ -222,16 +229,16 @@
</MudStack>
<MudDivider Class="my-4" />
<MudStack Row Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Class="mb-2">
<MudText Typo="Typo.h6">SAP Quellen</MudText>
<MudText Typo="Typo.h6">Quellen</MudText>
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Add" OnClick="AddSapSource">Quelle hinzufügen</MudButton>
</MudStack>
<MudText Typo="Typo.caption" Class="mb-2">
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`.
</MudText>
<MudTable Items="_sapSources" Dense Hover Striped>
<HeaderContent>
<MudTh>Alias</MudTh>
<MudTh>Entity Set</MudTh>
<MudTh>@(IsSapSite() ? "Entity Set" : "Tabelle/View")</MudTh>
<MudTh>Primär</MudTh>
<MudTh>Aktiv</MudTh>
<MudTh>Aktionen</MudTh>
@@ -344,7 +351,7 @@
</MudStack>
</MudStack>
<MudText Typo="Typo.caption" Class="mb-2">
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.
</MudText>
<MudTable Items="_sapMappings" Dense Hover Striped>
<HeaderContent>
@@ -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;
@@ -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
+386
View File
@@ -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(<ls_billing>).
CLEAR: gs_zschweiz, lv_netwr_hc, lv_tax_hc, lv_party.
lv_sign = 1.
IF <ls_billing>-vbtyp = 'O'
OR <ls_billing>-vbtyp = 'N'
OR <ls_billing>-fkart CP 'G*'
OR <ls_billing>-fkart CP 'S*'.
lv_sign = -1.
ENDIF.
PERFORM convert_to_house_currency
USING <ls_billing>-netwr <ls_billing>-waerk <ls_billing>-hwaer <ls_billing>-fkdat <ls_billing>-kurrf
CHANGING lv_netwr_hc.
PERFORM convert_to_house_currency
USING <ls_billing>-mwsbp <ls_billing>-waerk <ls_billing>-hwaer <ls_billing>-fkdat <ls_billing>-kurrf
CHANGING lv_tax_hc.
PERFORM classify_party
USING <ls_billing>-kunnr <ls_billing>-name1
CHANGING lv_party.
gs_zschweiz-mandt = sy-mandt.
gs_zschweiz-bukrs = <ls_billing>-bukrs.
gs_zschweiz-gjahr = p_gjahr.
gs_zschweiz-vbeln = <ls_billing>-vbeln.
gs_zschweiz-posnr = <ls_billing>-posnr.
gs_zschweiz-land1 = SWITCH #( <ls_billing>-bukrs WHEN '1100' THEN 'CH' WHEN '1200' THEN 'AT' ELSE <ls_billing>-customer_land ).
gs_zschweiz-customer_land = <ls_billing>-customer_land.
gs_zschweiz-tsc = SWITCH #( <ls_billing>-bukrs WHEN '1100' THEN 'TRCH' WHEN '1200' THEN 'TRAT' ELSE <ls_billing>-bukrs ).
gs_zschweiz-fkdat = <ls_billing>-fkdat.
gs_zschweiz-fkart = <ls_billing>-fkart.
gs_zschweiz-vbtyp = <ls_billing>-vbtyp.
gs_zschweiz-kunnr = <ls_billing>-kunag.
gs_zschweiz-name1 = <ls_billing>-name1.
gs_zschweiz-matnr = <ls_billing>-matnr.
gs_zschweiz-arktx = <ls_billing>-arktx.
gs_zschweiz-prodh = <ls_billing>-prodh.
gs_zschweiz-fkimg = <ls_billing>-fkimg * lv_sign.
gs_zschweiz-vrkme = <ls_billing>-vrkme.
gs_zschweiz-waerk = <ls_billing>-waerk.
gs_zschweiz-hwaer = <ls_billing>-hwaer.
gs_zschweiz-netwr_dc = <ls_billing>-netwr * lv_sign.
gs_zschweiz-tax_dc = <ls_billing>-mwsbp * lv_sign.
gs_zschweiz-netwr_hc = lv_netwr_hc * lv_sign.
gs_zschweiz-tax_hc = lv_tax_hc * lv_sign.
gs_zschweiz-kurrf = <ls_billing>-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 = <ls_billing>-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(<ls_fin>).
ASSIGN lt_totals[ land1 = <ls_fin>-land1 hwaer = <ls_fin>-hwaer ] TO FIELD-SYMBOL(<ls_total>).
IF sy-subrc <> 0.
INSERT VALUE #( land1 = <ls_fin>-land1 hwaer = <ls_fin>-hwaer ) INTO TABLE lt_totals ASSIGNING <ls_total>.
ENDIF.
<ls_total>-netwr_hc = <ls_total>-netwr_hc + <ls_fin>-netwr_hc.
<ls_total>-tax_hc = <ls_total>-tax_hc + <ls_fin>-tax_hc.
<ls_total>-rows = <ls_total>-rows + 1.
ENDLOOP.
SKIP.
WRITE: / 'Summen nach Land/Hauswaehrung'.
LOOP AT lt_totals ASSIGNING FIELD-SYMBOL(<ls_sum>).
WRITE: / <ls_sum>-land1,
<ls_sum>-hwaer,
'Netto:', <ls_sum>-netwr_hc,
'Steuer:', <ls_sum>-tax_hc,
'Zeilen:', <ls_sum>-rows.
ENDLOOP.
ENDFORM.