using System.Globalization; using Sap.Data.Hana; using TrafagSalesExporter.Models; namespace TrafagSalesExporter.Services; public class HanaQueryService : IHanaQueryService { private const string TscParameterName = "tsc"; private const string DateFilterParameterName = "dateFilter"; private readonly IAppEventLogService _appEventLogService; public HanaQueryService(IAppEventLogService appEventLogService) { _appEventLogService = appEventLogService; } public async Task> GetSalesRecordsAsync(HanaServer server, string schema, string tsc, string land, string dateFilter, CancellationToken cancellationToken = default) { var connectionString = server.BuildConnectionString(); var result = new List(); try { await _appEventLogService.WriteAsync("HANA", "Verbindungsaufbau gestartet", land: land, details: $"Server={server.GetConnectionStringPreview()} | Schema={schema} | TSC={tsc}"); using var connection = new HanaConnection(connectionString); await connection.OpenAsync(cancellationToken); await _appEventLogService.WriteAsync("HANA", "Verbindung erfolgreich", land: land, details: $"Schema={schema} | TSC={tsc}"); var invoiceQuery = GetInvoiceQuery(schema); var creditNoteQuery = GetCreditNoteQuery(schema); var parsedDateFilter = ParseDateFilter(dateFilter); await _appEventLogService.WriteAsync("HANA", "Invoice-Query gestartet", land: land, details: BuildQueryLogDetails(invoiceQuery, schema, tsc, parsedDateFilter)); var invoiceRecords = await ReadRecordsAsync(connection, invoiceQuery, tsc, parsedDateFilter, land, "Invoice", cancellationToken); result.AddRange(invoiceRecords); await _appEventLogService.WriteAsync("HANA", "Invoice-Query beendet", land: land, details: $"Zeilen={invoiceRecords.Count}"); await _appEventLogService.WriteAsync("HANA", "Credit-Query gestartet", land: land, details: BuildQueryLogDetails(creditNoteQuery, schema, tsc, parsedDateFilter)); var creditRecords = await ReadRecordsAsync(connection, creditNoteQuery, tsc, parsedDateFilter, land, "Credit", cancellationToken); result.AddRange(creditRecords); await _appEventLogService.WriteAsync("HANA", "Credit-Query beendet", land: land, details: $"Zeilen={creditRecords.Count}"); } catch (Exception ex) { await _appEventLogService.WriteAsync("HANA", "HANA-Abfrage fehlgeschlagen", "Error", land: land, details: ex.ToString()); throw; } foreach (var record in result) { if (record.Material.Contains('/')) { var parts = record.Material.Split('/'); record.Material = parts[^1]; } } return result; } public async Task TestConnectionDetailedAsync(HanaServer server, CancellationToken cancellationToken = default) { var testResult = new ConnectionTestResult { TestedAtUtc = DateTime.UtcNow, ConnectionStringPreview = server.GetConnectionStringPreview(), Stage = "Verbindungsaufbau" }; try { await _appEventLogService.WriteAsync("HANA", "Verbindungstest gestartet", details: testResult.ConnectionStringPreview); var connectionString = server.BuildConnectionString(); using var connection = new HanaConnection(connectionString); await connection.OpenAsync(cancellationToken); testResult.Stage = "Ping-Query"; using var command = new HanaCommand("SELECT 1 FROM DUMMY", connection); await command.ExecuteScalarAsync(cancellationToken); testResult.Success = true; testResult.Stage = "OK"; await _appEventLogService.WriteAsync("HANA", "Verbindungstest erfolgreich", details: testResult.ConnectionStringPreview); return testResult; } catch (Exception ex) { testResult.Success = false; testResult.ErrorMessage = ex.Message; testResult.ExceptionType = ex.GetType().Name; await _appEventLogService.WriteAsync("HANA", "Verbindungstest fehlgeschlagen", "Error", details: $"{testResult.ConnectionStringPreview}{Environment.NewLine}{ex}"); return testResult; } } public async Task TestConnectionAsync(HanaServer server, CancellationToken cancellationToken = default) { var connectionString = server.BuildConnectionString(); using var connection = new HanaConnection(connectionString); await connection.OpenAsync(cancellationToken); } public async Task> GetAvailableSchemasAsync(HanaServer server, CancellationToken cancellationToken = default) { var connectionString = server.BuildConnectionString(); using var connection = new HanaConnection(connectionString); await connection.OpenAsync(cancellationToken); const string query = """ SELECT schema_name FROM ( SELECT schema_name, COUNT(DISTINCT table_name) AS required_table_count FROM sys.tables WHERE table_name IN ('OINV', 'INV1', 'ORIN', 'RIN1', 'OCRD', 'OITM') GROUP BY schema_name ) t WHERE required_table_count >= 4 ORDER BY schema_name; """; using var command = new HanaCommand(query, connection); using var reader = await command.ExecuteReaderAsync(cancellationToken); var schemas = new List(); while (await reader.ReadAsync(cancellationToken)) { var schema = reader["schema_name"]?.ToString()?.Trim(); if (!string.IsNullOrWhiteSpace(schema)) schemas.Add(schema); } 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(); using var command = new HanaCommand(query, connection); command.Parameters.Add(new HanaParameter(TscParameterName, HanaDbType.NVarChar) { Value = tsc }); command.Parameters.Add(new HanaParameter(DateFilterParameterName, HanaDbType.Date) { Value = dateFilter.Date }); using var reader = await command.ExecuteReaderAsync(cancellationToken); var counter = 0; while (await reader.ReadAsync(cancellationToken)) { records.Add(new SalesRecord { ExtractionDate = reader.GetDateTime(reader.GetOrdinal("extraction_date")), Tsc = reader.GetString(reader.GetOrdinal("tsc")), DocumentEntry = Convert.ToInt32(reader["document_entry"]), InvoiceNumber = reader["invoice_number"]?.ToString() ?? string.Empty, PositionOnInvoice = Convert.ToInt32(reader["invoice_position"]), InvoiceDate = reader.IsDBNull(reader.GetOrdinal("invoice_date")) ? null : reader.GetDateTime(reader.GetOrdinal("invoice_date")), Material = reader["material"]?.ToString() ?? string.Empty, Name = reader["material_name"]?.ToString() ?? string.Empty, ProductGroup = reader["product_group"]?.ToString() ?? string.Empty, Quantity = Convert.ToDecimal(reader["quantity"]), SupplierNumber = reader["supplier_number"]?.ToString() ?? string.Empty, SupplierName = reader["supplier_name"]?.ToString() ?? string.Empty, SupplierCountry = reader["supplier_country"]?.ToString() ?? string.Empty, CustomerNumber = reader["customer_number"]?.ToString() ?? string.Empty, CustomerName = reader["customer_name"]?.ToString() ?? string.Empty, CustomerCountry = reader["customer_country"]?.ToString() ?? string.Empty, CustomerIndustry = reader["customer_industry"]?.ToString() ?? string.Empty, StandardCost = Convert.ToDecimal(reader["standard_cost"]), StandardCostCurrency = reader["standard_cost_currency"]?.ToString() ?? string.Empty, PurchaseOrderNumber = reader["purchase_order_number"]?.ToString() ?? string.Empty, SalesPriceValue = Convert.ToDecimal(reader["sales_value"]), SalesCurrency = reader["sales_currency"]?.ToString() ?? string.Empty, DocumentCurrency = reader["document_currency"]?.ToString() ?? string.Empty, DocumentTotalForeignCurrency = Convert.ToDecimal(reader["document_total_fc"]), DocumentTotalLocalCurrency = Convert.ToDecimal(reader["document_total_lc"]), VatSumForeignCurrency = Convert.ToDecimal(reader["vat_sum_fc"]), VatSumLocalCurrency = Convert.ToDecimal(reader["vat_sum_lc"]), DocumentRate = Convert.ToDecimal(reader["document_rate"]), CompanyCurrency = reader["company_currency"]?.ToString() ?? string.Empty, Incoterms2020 = reader["incoterms_2020"]?.ToString() ?? string.Empty, SalesResponsibleEmployee = reader["sales_responsible"]?.ToString() ?? string.Empty, OrderDate = reader.IsDBNull(reader.GetOrdinal("order_date")) ? null : reader.GetDateTime(reader.GetOrdinal("order_date")), Land = land, DocumentType = reader["doc_type"]?.ToString() ?? string.Empty }); counter++; if (counter % 250 == 0) { await _appEventLogService.WriteDebugAsync("HANA", $"{queryName}-Query liest Daten", land: land, details: $"Bisher gelesene Zeilen={counter}"); } } 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); return $@" SELECT CURRENT_TIMESTAMP AS extraction_date, :{TscParameterName} AS tsc, h.""DocEntry"" AS document_entry, h.""DocNum"" AS invoice_number, p.""LineNum"" AS invoice_position, h.""DocDate"" AS invoice_date, p.""ItemCode"" AS material, p.""Dscription"" AS material_name, COALESCE(grp.""ItmsGrpNam"", '') AS product_group, p.""Quantity"" AS quantity, COALESCE(itm.""CardCode"", '') AS supplier_number, COALESCE(sup.""CardName"", '') AS supplier_name, COALESCE(sup_adr.""Country"", '') AS supplier_country, h.""CardCode"" AS customer_number, h.""CardName"" AS customer_name, COALESCE(cust_adr.""Country"", '') AS customer_country, COALESCE(ind.""IndName"", '') AS customer_industry, p.""StockPrice"" AS standard_cost, COALESCE(adm.""MainCurncy"", '') AS standard_cost_currency, CASE WHEN p.""BaseType"" = 22 THEN CAST(p.""BaseRef"" AS NVARCHAR(20)) ELSE '' END AS purchase_order_number, p.""LineTotal"" AS sales_value, COALESCE(p.""Currency"", h.""DocCur"") AS sales_currency, COALESCE(h.""DocCur"", '') AS document_currency, COALESCE(h.""DocTotalFC"", 0) AS document_total_fc, COALESCE(h.""DocTotal"", 0) AS document_total_lc, COALESCE(h.""VatSumFC"", 0) AS vat_sum_fc, COALESCE(h.""VatSum"", 0) AS vat_sum_lc, COALESCE(h.""DocRate"", 0) AS document_rate, COALESCE(adm.""MainCurncy"", '') AS company_currency, '' AS incoterms_2020, COALESCE(emp.""SlpName"", '') AS sales_responsible, CASE WHEN p.""BaseType"" = 17 THEN (SELECT o.""DocDate"" FROM {schemaPrefix}""ORDR"" o WHERE o.""DocEntry"" = p.""BaseEntry"") ELSE NULL END AS order_date, 'INV' AS doc_type FROM {schemaPrefix}""OINV"" h INNER JOIN {schemaPrefix}""INV1"" p ON h.""DocEntry"" = p.""DocEntry"" CROSS JOIN {schemaPrefix}""OADM"" adm LEFT JOIN {schemaPrefix}""OITM"" itm ON p.""ItemCode"" = itm.""ItemCode"" LEFT JOIN {schemaPrefix}""OITB"" grp ON itm.""ItmsGrpCod"" = grp.""ItmsGrpCod"" LEFT JOIN {schemaPrefix}""OCRD"" cust ON h.""CardCode"" = cust.""CardCode"" LEFT JOIN {schemaPrefix}""CRD1"" cust_adr ON h.""CardCode"" = cust_adr.""CardCode"" AND cust_adr.""AdresType"" = 'B' AND cust_adr.""Address"" = h.""PayToCode"" LEFT JOIN {schemaPrefix}""OOND"" ind ON cust.""IndustryC"" = ind.""IndCode"" LEFT JOIN {schemaPrefix}""OCRD"" sup ON itm.""CardCode"" = sup.""CardCode"" AND sup.""CardType"" = 'S' LEFT JOIN {schemaPrefix}""CRD1"" sup_adr ON itm.""CardCode"" = sup_adr.""CardCode"" AND sup_adr.""AdresType"" = 'B' LEFT JOIN {schemaPrefix}""OSLP"" emp ON h.""SlpCode"" = emp.""SlpCode"" WHERE h.""CANCELED"" = 'N' AND h.""DocDate"" >= :{DateFilterParameterName} ORDER BY h.""DocDate"" DESC, h.""DocNum"", p.""LineNum"""; } private static string GetCreditNoteQuery(string schema) { var schemaPrefix = BuildSchemaPrefix(schema); return $@" SELECT CURRENT_TIMESTAMP AS extraction_date, :{TscParameterName} AS tsc, h.""DocEntry"" AS document_entry, h.""DocNum"" AS invoice_number, p.""LineNum"" AS invoice_position, h.""DocDate"" AS invoice_date, p.""ItemCode"" AS material, p.""Dscription"" AS material_name, COALESCE(grp.""ItmsGrpNam"", '') AS product_group, p.""Quantity"" * -1 AS quantity, COALESCE(itm.""CardCode"", '') AS supplier_number, COALESCE(sup.""CardName"", '') AS supplier_name, COALESCE(sup_adr.""Country"", '') AS supplier_country, h.""CardCode"" AS customer_number, h.""CardName"" AS customer_name, COALESCE(cust_adr.""Country"", '') AS customer_country, COALESCE(ind.""IndName"", '') AS customer_industry, p.""StockPrice"" AS standard_cost, COALESCE(adm.""MainCurncy"", '') AS standard_cost_currency, '' AS purchase_order_number, p.""LineTotal"" * -1 AS sales_value, COALESCE(p.""Currency"", h.""DocCur"") AS sales_currency, COALESCE(h.""DocCur"", '') AS document_currency, COALESCE(h.""DocTotalFC"", 0) * -1 AS document_total_fc, COALESCE(h.""DocTotal"", 0) * -1 AS document_total_lc, COALESCE(h.""VatSumFC"", 0) * -1 AS vat_sum_fc, COALESCE(h.""VatSum"", 0) * -1 AS vat_sum_lc, COALESCE(h.""DocRate"", 0) AS document_rate, COALESCE(adm.""MainCurncy"", '') AS company_currency, '' AS incoterms_2020, COALESCE(emp.""SlpName"", '') AS sales_responsible, NULL AS order_date, 'CRN' AS doc_type FROM {schemaPrefix}""ORIN"" h INNER JOIN {schemaPrefix}""RIN1"" p ON h.""DocEntry"" = p.""DocEntry"" CROSS JOIN {schemaPrefix}""OADM"" adm LEFT JOIN {schemaPrefix}""OITM"" itm ON p.""ItemCode"" = itm.""ItemCode"" LEFT JOIN {schemaPrefix}""OITB"" grp ON itm.""ItmsGrpCod"" = grp.""ItmsGrpCod"" LEFT JOIN {schemaPrefix}""OCRD"" cust ON h.""CardCode"" = cust.""CardCode"" LEFT JOIN {schemaPrefix}""CRD1"" cust_adr ON h.""CardCode"" = cust_adr.""CardCode"" AND cust_adr.""AdresType"" = 'B' AND cust_adr.""Address"" = h.""PayToCode"" LEFT JOIN {schemaPrefix}""OOND"" ind ON cust.""IndustryC"" = ind.""IndCode"" LEFT JOIN {schemaPrefix}""OCRD"" sup ON itm.""CardCode"" = sup.""CardCode"" AND sup.""CardType"" = 'S' LEFT JOIN {schemaPrefix}""CRD1"" sup_adr ON itm.""CardCode"" = sup_adr.""CardCode"" AND sup_adr.""AdresType"" = 'B' LEFT JOIN {schemaPrefix}""OSLP"" emp ON h.""SlpCode"" = emp.""SlpCode"" WHERE h.""CANCELED"" = 'N' AND h.""DocDate"" >= :{DateFilterParameterName} ORDER BY h.""DocDate"" DESC, h.""DocNum"", p.""LineNum"""; } private static DateTime ParseDateFilter(string dateFilter) { if (DateTime.TryParse(dateFilter, out var parsed)) return parsed.Date; throw new InvalidOperationException($"Ungueltiger HANA-DateFilter: '{dateFilter}'. Erwartet wird ein parsebares Datum."); } private static string BuildQueryLogDetails(string query, string schema, string tsc, DateTime dateFilter) => $"{query}{Environment.NewLine}-- schema={schema}; tsc={tsc}; dateFilter={dateFilter:yyyy-MM-dd}"; private static string BuildSchemaPrefix(string identifier) { var value = identifier?.Trim() ?? string.Empty; if (string.IsNullOrWhiteSpace(value)) throw new InvalidOperationException("HANA-Schema darf nicht leer sein."); foreach (var ch in value) { if (!(char.IsLetterOrDigit(ch) || ch == '_')) throw new InvalidOperationException($"Ungueltiger HANA-Identifier: '{identifier}'."); } 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 { public bool Success { get; set; } public DateTime TestedAtUtc { get; set; } public string Stage { get; set; } = string.Empty; public string ErrorMessage { get; set; } = string.Empty; public string ExceptionType { get; set; } = string.Empty; public string ConnectionStringPreview { get; set; } = string.Empty; }