From 3ac03a4782a31f48ac7d2aaa07c6b7af87e9930d Mon Sep 17 00:00:00 2001 From: metacube Date: Wed, 29 Apr 2026 07:00:29 +0200 Subject: [PATCH] Enhance management cockpit analysis --- TrafagSalesExporter/.tmp_sap_probe/Program.cs | 1659 ++++++++++++++++- .../.tmp_sap_probe/RunSapProbeInteractive.ps1 | 37 + .../.tmp_sap_probe/SapProbe.csproj | 20 +- .../Components/Pages/ManagementCockpit.razor | 409 ++-- TrafagSalesExporter/HANDOFF_2026-04-15.md | 111 ++ TrafagSalesExporter/LLM_SYSTEM_GUIDE.md | 39 +- .../Models/ManagementCockpitModels.cs | 52 + TrafagSalesExporter/NEXT_STEPS_2026-04-15.md | 48 + TrafagSalesExporter/Program.cs | 5 + .../Services/IManagementCockpitService.cs | 3 + .../Services/ManagementCockpitPageService.cs | 14 +- .../Services/ManagementCockpitService.cs | 479 ++++- .../Services/TimerBackgroundService.cs | 4 +- .../ManagementCockpitServiceTests.cs | 154 +- .../TrafagSalesExporter.csproj | 1 + 15 files changed, 2651 insertions(+), 384 deletions(-) create mode 100644 TrafagSalesExporter/.tmp_sap_probe/RunSapProbeInteractive.ps1 diff --git a/TrafagSalesExporter/.tmp_sap_probe/Program.cs b/TrafagSalesExporter/.tmp_sap_probe/Program.cs index bc5a03a..48fc662 100644 --- a/TrafagSalesExporter/.tmp_sap_probe/Program.cs +++ b/TrafagSalesExporter/.tmp_sap_probe/Program.cs @@ -1,34 +1,1635 @@ -using Microsoft.Data.Sqlite; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using SAP.Middleware.Connector; -var conn = new SqliteConnection(@"Data Source=C:\Users\koi\source\repos\Ai\TrafagSalesExporter\trafag_exporter.db"); -await conn.OpenAsync(); -string sapUsername = "", sapPassword = ""; -var cmd = conn.CreateCommand(); -cmd.CommandText = "select SapUsername, SapPassword from ExportSettings limit 1"; -using (var r = await cmd.ExecuteReaderAsync()) +namespace SapProbe { - if (await r.ReadAsync()) + internal static class Program { - sapUsername = r.IsDBNull(0) ? "" : r.GetString(0); - sapPassword = r.IsDBNull(1) ? "" : r.GetString(1); + private static int Main(string[] args) + { + Console.OutputEncoding = Encoding.UTF8; + + try + { + var options = CliOptions.Parse(args); + + if (options.ShowHelp) + { + PrintHelp(); + return 0; + } + + PrintBanner(options); + + if (Environment.Is64BitProcess) + { + Console.Error.WriteLine("ERROR: This tool must run as x86 because SAP NCo is installed in the 32-bit GAC."); + return 2; + } + + if (options.Command == SapCommand.LoadOnly) + { + Console.WriteLine("Connector load : OK"); + return 0; + } + + var destination = CreateDestination(options); + destination.Ping(); + + if (!options.Quiet) + { + Console.WriteLine("Target : " + options.AppServerHost + " / SYSNR " + options.SystemNumber + " / CLIENT " + options.Client + " / USER " + options.User); + Console.WriteLine("Ping : OK"); + Console.WriteLine(); + } + + switch (options.Command) + { + case SapCommand.SystemInfo: + RunSystemInfo(destination); + break; + case SapCommand.TableRead: + RunTableRead(destination, options); + break; + case SapCommand.TableFields: + RunTableFields(destination, options); + break; + case SapCommand.FieldExists: + RunFieldExists(destination, options); + break; + case SapCommand.FunctionInfo: + RunFunctionInfo(destination, options.FunctionName); + break; + case SapCommand.FunctionSearch: + RunFunctionSearch(destination, options.SearchPattern, options); + break; + case SapCommand.RfcCall: + RunRfcCall(destination, options); + break; + case SapCommand.AbapRead: + RunAbapRead(destination, options); + break; + case SapCommand.AbapCheck: + RunAbapCheck(destination, options); + break; + case SapCommand.AbapWrite: + RunAbapWrite(destination, options); + break; + case SapCommand.AbapActivate: + RunAbapActivate(destination, options); + break; + default: + throw new ArgumentOutOfRangeException("Command", "Unsupported command: " + options.Command); + } + + return 0; + } + catch (RfcLogonException ex) + { + return Fail("SAP logon failed", ex); + } + catch (RfcCommunicationException ex) + { + return Fail("SAP communication failed", ex); + } + catch (RfcAbapRuntimeException ex) + { + return Fail("ABAP runtime error", ex); + } + catch (RfcAbapException ex) + { + return Fail("ABAP exception", ex); + } + catch (Exception ex) + { + return Fail("SAP CLI failed", ex); + } + } + + private static RfcDestination CreateDestination(CliOptions options) + { + var password = Environment.GetEnvironmentVariable("SAP_NCO_PASSWORD"); + if (string.IsNullOrEmpty(password)) + { + password = Environment.GetEnvironmentVariable("SAP_T76_PASSWORD"); + } + + if (string.IsNullOrEmpty(password)) + { + if (options.NoPasswordPrompt) + { + throw new InvalidOperationException("No SAP password was provided. Set SAP_NCO_PASSWORD/SAP_T76_PASSWORD or allow the masked password prompt."); + } + + password = ReadPassword("Password for " + options.User + "@" + options.Client + ": "); + } + + var parameters = new RfcConfigParameters + { + { RfcConfigParameters.Name, options.Name }, + { RfcConfigParameters.AppServerHost, options.AppServerHost }, + { RfcConfigParameters.SystemNumber, options.SystemNumber }, + { RfcConfigParameters.Client, options.Client }, + { RfcConfigParameters.User, options.User }, + { RfcConfigParameters.Password, password }, + { RfcConfigParameters.Language, options.Language }, + { RfcConfigParameters.PoolSize, "1" }, + { RfcConfigParameters.PeakConnectionsLimit, "1" }, + { RfcConfigParameters.ConnectionIdleTimeout, "600" } + }; + + if (!string.IsNullOrWhiteSpace(options.SapRouter)) + { + parameters.Add(RfcConfigParameters.SAPRouter, options.SapRouter); + } + + if (!string.IsNullOrWhiteSpace(options.Trace)) + { + parameters.Add(RfcConfigParameters.Trace, options.Trace); + } + + return RfcDestinationManager.GetDestination(parameters); + } + + private static void RunSystemInfo(RfcDestination destination) + { + var function = destination.Repository.CreateFunction("RFC_SYSTEM_INFO"); + function.Invoke(destination); + + var info = function.GetStructure("RFCSI_EXPORT"); + Console.WriteLine("RFC_SYSTEM_INFO"); + PrintField(info, "RFCSYSID", "SAP System ID"); + PrintField(info, "RFCHOST", "RFC Host"); + PrintField(info, "RFCHOST2", "Host 2"); + PrintField(info, "RFCIPADDR", "IP Address"); + PrintField(info, "RFCOPSYS", "OS"); + PrintField(info, "RFCMACH", "Machine"); + PrintField(info, "RFCDBSYS", "DB System"); + PrintField(info, "RFCDBHOST", "DB Host"); + PrintField(info, "RFCDATABS", "DB Name"); + PrintField(info, "RFCKERNRL", "Kernel Release"); + } + + private static void RunTableRead(RfcDestination destination, CliOptions options) + { + if (string.IsNullOrWhiteSpace(options.TableName)) + { + throw new ArgumentException("Missing table name. Example: SapProbe.exe table-read T000 --fields MANDT,MTEXT --rowcount 5"); + } + + var function = destination.Repository.CreateFunction("RFC_READ_TABLE"); + function.SetValue("QUERY_TABLE", options.TableName.ToUpperInvariant()); + function.SetValue("DELIMITER", options.Delimiter); + function.SetValue("ROWSKIPS", options.RowSkip); + function.SetValue("ROWCOUNT", options.RowCount); + + var fields = function.GetTable("FIELDS"); + foreach (var field in options.Fields) + { + fields.Append(); + fields.SetValue("FIELDNAME", field.ToUpperInvariant()); + } + + var whereOptions = function.GetTable("OPTIONS"); + foreach (var where in options.WhereClauses) + { + foreach (var line in SplitAbapOptionLine(where, 72)) + { + whereOptions.Append(); + whereOptions.SetValue("TEXT", line); + } + } + + function.Invoke(destination); + + var resolvedFields = ReadReadTableFieldNames(fields); + var data = function.GetTable("DATA"); + var rows = new List>(); + + for (var i = 0; i < data.RowCount; i++) + { + data.CurrentIndex = i; + rows.Add(SplitDataRow(data.GetString("WA"), options.Delimiter, resolvedFields.Count)); + } + + RenderRows(resolvedFields, rows, options.OutputFormat); + } + + private static void RunTableFields(RfcDestination destination, CliOptions options) + { + if (string.IsNullOrWhiteSpace(options.TableName)) + { + throw new ArgumentException("Missing table name. Example: SapProbe.exe table-fields MARC"); + } + + var fields = GetTableFieldRows(destination, options.TableName, options.FieldName); + var headers = new[] { "FIELDNAME", "KEYFLAG", "DATATYPE", "LENG", "DECIMALS", "ROLLNAME", "FIELDTEXT" }; + + var visibleFields = fields.Take(options.MaxTableRows).ToList(); + RenderRows(headers, visibleFields, options.OutputFormat); + if (fields.Count > visibleFields.Count) + { + Console.WriteLine("... " + (fields.Count - visibleFields.Count) + " more fields not printed. Increase --max-table-rows if needed."); + } + } + + private static void RunFieldExists(RfcDestination destination, CliOptions options) + { + if (string.IsNullOrWhiteSpace(options.TableName) || string.IsNullOrWhiteSpace(options.FieldName)) + { + throw new ArgumentException("Missing table or field. Example: SapProbe.exe field-exists MARC MMSTA"); + } + + var fields = GetTableFieldRows(destination, options.TableName, options.FieldName); + Console.WriteLine("Table : " + options.TableName.ToUpperInvariant()); + Console.WriteLine("Field : " + options.FieldName.ToUpperInvariant()); + Console.WriteLine("Exists : " + (fields.Count > 0 ? "YES" : "NO")); + + if (fields.Count > 0) + { + Console.WriteLine(); + RenderRows(new[] { "FIELDNAME", "KEYFLAG", "DATATYPE", "LENG", "DECIMALS", "ROLLNAME", "FIELDTEXT" }, fields, options.OutputFormat); + } + } + + private static List> GetTableFieldRows(RfcDestination destination, string tableName, string fieldName) + { + var function = destination.Repository.CreateFunction("DDIF_FIELDINFO_GET"); + function.SetValue("TABNAME", tableName.ToUpperInvariant()); + function.SetValue("LANGU", "D"); + function.SetValue("DO_NOT_WRITE", "X"); + + if (!string.IsNullOrWhiteSpace(fieldName)) + { + function.SetValue("FIELDNAME", fieldName.ToUpperInvariant()); + } + + function.Invoke(destination); + + var table = function.GetTable("DFIES_TAB"); + var rows = new List>(); + for (var i = 0; i < table.RowCount; i++) + { + table.CurrentIndex = i; + rows.Add(new List + { + SafeGetString(table.CurrentRow, "FIELDNAME"), + SafeGetString(table.CurrentRow, "KEYFLAG"), + SafeGetString(table.CurrentRow, "DATATYPE"), + SafeGetString(table.CurrentRow, "LENG"), + SafeGetString(table.CurrentRow, "DECIMALS"), + SafeGetString(table.CurrentRow, "ROLLNAME"), + SafeGetString(table.CurrentRow, "FIELDTEXT") + }); + } + + return rows; + } + + private static void RunFunctionInfo(RfcDestination destination, string functionName) + { + if (string.IsNullOrWhiteSpace(functionName)) + { + throw new ArgumentException("Missing function name. Example: SapProbe.exe function-info RFC_SYSTEM_INFO"); + } + + var metadata = destination.Repository.GetFunctionMetadata(functionName.ToUpperInvariant()); + Console.WriteLine("Function: " + functionName.ToUpperInvariant()); + Console.WriteLine(); + Console.WriteLine("Direction Name Type Length Decimals Default"); + Console.WriteLine("--------- -------------------------------- --------- ------ -------- -------"); + + for (var i = 0; i < metadata.ParameterCount; i++) + { + var parameter = metadata[i]; + Console.WriteLine( + parameter.Direction.ToString().PadRight(9) + " " + + parameter.Name.PadRight(32) + " " + + parameter.DataType.ToString().PadRight(9) + " " + + parameter.NucLength.ToString().PadLeft(6) + " " + + parameter.Decimals.ToString().PadLeft(8) + " " + + (parameter.DefaultValue ?? string.Empty)); + } + } + + private static void RunFunctionSearch(RfcDestination destination, string pattern, CliOptions options) + { + if (string.IsNullOrWhiteSpace(pattern)) + { + throw new ArgumentException("Missing search pattern. Example: SapProbe.exe function-search RPY*PROGRAM*"); + } + + var function = destination.Repository.CreateFunction("RFC_FUNCTION_SEARCH"); + function.SetValue("FUNCNAME", pattern.ToUpperInvariant()); + function.Invoke(destination); + + Console.WriteLine("Function search: " + pattern.ToUpperInvariant()); + Console.WriteLine(); + DumpTable(function.GetTable("FUNCTIONS"), options.MaxTableRows, options.OutputFormat); + } + + private static void RunRfcCall(RfcDestination destination, CliOptions options) + { + if (string.IsNullOrWhiteSpace(options.FunctionName)) + { + throw new ArgumentException("Missing function name. Example: SapProbe.exe rfc-call STFC_CONNECTION --set REQUTEXT=hello"); + } + + var functionName = options.FunctionName.ToUpperInvariant(); + var metadata = destination.Repository.GetFunctionMetadata(functionName); + var function = metadata.CreateFunction(); + + foreach (var pair in options.SetValues) + { + function.SetValue(pair.Key.ToUpperInvariant(), pair.Value); + } + + function.Invoke(destination); + + Console.WriteLine("Function: " + functionName); + Console.WriteLine(); + DumpFunctionResult(function, metadata, options); + } + + private static void RunAbapRead(RfcDestination destination, CliOptions options) + { + var programName = RequireProgramName(options); + var lines = ReadAbapProgram(destination, programName, options); + + if (!string.IsNullOrWhiteSpace(options.OutputPath)) + { + var fullPath = Path.GetFullPath(options.OutputPath); + File.WriteAllLines(fullPath, lines, Encoding.UTF8); + Console.WriteLine("Program : " + programName); + Console.WriteLine("Lines : " + lines.Count); + Console.WriteLine("Output : " + fullPath); + return; + } + + foreach (var line in lines) + { + Console.WriteLine(line); + } + } + + private static void RunAbapCheck(RfcDestination destination, CliOptions options) + { + var programName = RequireProgramName(options); + AbapSyntaxResult result; + var lineCount = 0; + + if (string.IsNullOrWhiteSpace(options.SourceFile)) + { + result = CheckExistingAbapProgram(destination, programName, options); + } + else + { + var lines = LoadSourceForAbapCommand(destination, programName, options); + lineCount = lines.Count; + result = CheckAbapSyntax(destination, programName, lines); + } + + Console.WriteLine("Program : " + programName); + if (lineCount > 0) + { + Console.WriteLine("Lines : " + lineCount); + } + Console.WriteLine("Syntax status : " + (result.ErrorSubrc == 0 ? "OK" : "ERROR")); + Console.WriteLine("Error subrc : " + result.ErrorSubrc); + + if (result.ErrorSubrc != 0) + { + Console.WriteLine("Error include : " + result.ErrorInclude); + Console.WriteLine("Error line : " + result.ErrorLine); + Console.WriteLine("Error offset : " + result.ErrorOffset); + Console.WriteLine("Error word : " + result.ErrorWord); + Console.WriteLine("Error message : " + result.ErrorMessage); + } + + if (result.Warnings.Count > 0) + { + Console.WriteLine(); + Console.WriteLine("Warnings:"); + foreach (var warning in result.Warnings) + { + Console.WriteLine(" " + warning); + } + } + } + + private static void RunAbapWrite(RfcDestination destination, CliOptions options) + { + var programName = RequireProgramName(options); + if (string.IsNullOrWhiteSpace(options.SourceFile)) + { + throw new ArgumentException("abap-write requires --source-file ."); + } + + var lines = LoadSourceForAbapCommand(destination, programName, options); + Console.WriteLine("Program : " + programName); + Console.WriteLine("Source file : " + Path.GetFullPath(options.SourceFile)); + Console.WriteLine("Lines : " + lines.Count); + Console.WriteLine("Pre-check : skipped for local source; repository syntax can be checked after write."); + + if (options.DryRun) + { + Console.WriteLine("Dry run : no SAP repository changes were written."); + return; + } + + if (!options.ConfirmWrite) + { + throw new InvalidOperationException("abap-write is blocked unless --confirm-write is provided."); + } + + WriteAbapProgram(destination, options, programName, lines); + + Console.WriteLine("Lines written : " + lines.Count); + Console.WriteLine("Result : RPY_PROGRAM_INSERT returned without RFC exception."); + + var syntax = CheckExistingAbapProgram(destination, programName, options); + Console.WriteLine("Post-check : " + (syntax.ErrorSubrc == 0 ? "OK" : "ERROR")); + if (syntax.ErrorSubrc != 0) + { + Console.WriteLine("Error line : " + syntax.ErrorLine); + Console.WriteLine("Error message : " + syntax.ErrorMessage); + } + } + + private static void RunAbapActivate(RfcDestination destination, CliOptions options) + { + var programName = RequireProgramName(options); + var lines = LoadSourceForAbapCommand(destination, programName, options); + + Console.WriteLine("Program : " + programName); + Console.WriteLine("Lines : " + lines.Count); + Console.WriteLine("Activation : RPY_PROGRAM_INSERT with SAVE_INACTIVE blank"); + + if (options.DryRun) + { + Console.WriteLine("Dry run : no SAP repository changes were written."); + return; + } + + if (!options.ConfirmWrite) + { + throw new InvalidOperationException("abap-activate is blocked unless --confirm-write is provided."); + } + + var activateOptions = options.CloneForActiveWrite(); + WriteAbapProgram(destination, activateOptions, programName, lines); + Console.WriteLine("Result : activation write returned without RFC exception."); + + var syntax = CheckExistingAbapProgram(destination, programName, options); + Console.WriteLine("Post-check : " + (syntax.ErrorSubrc == 0 ? "OK" : "ERROR")); + if (syntax.ErrorSubrc != 0) + { + Console.WriteLine("Error line : " + syntax.ErrorLine); + Console.WriteLine("Error message : " + syntax.ErrorMessage); + } + } + + private static void WriteAbapProgram(RfcDestination destination, CliOptions options, string programName, IList lines) + { + var function = destination.Repository.CreateFunction("RPY_PROGRAM_INSERT"); + function.SetValue("PROGRAM_NAME", programName); + function.SetValue("TITLE_STRING", string.IsNullOrWhiteSpace(options.Title) ? programName : options.Title); + function.SetValue("SUPPRESS_DIALOG", "X"); + function.SetValue("UCCHECK", "X"); + + if (!string.IsNullOrWhiteSpace(options.DevelopmentClass)) + { + function.SetValue("DEVELOPMENT_CLASS", options.DevelopmentClass); + } + + if (!string.IsNullOrWhiteSpace(options.TransportNumber)) + { + function.SetValue("TRANSPORT_NUMBER", options.TransportNumber); + } + + if (options.SaveInactive) + { + function.SetValue("SAVE_INACTIVE", "X"); + } + + if (options.Temporary) + { + function.SetValue("TEMPORARY", "X"); + } + + FillSingleColumnTable(function.GetTable("SOURCE_EXTENDED"), lines); + function.Invoke(destination); + } + + + private static void DumpFunctionResult(IRfcFunction function, RfcFunctionMetadata metadata, CliOptions options) + { + for (var i = 0; i < metadata.ParameterCount; i++) + { + var parameter = metadata[i]; + if (parameter.Direction == RfcDirection.IMPORT && !options.DumpImports) + { + continue; + } + + Console.WriteLine(parameter.Direction + " " + parameter.Name + " (" + parameter.DataType + ")"); + + if (parameter.DataType == RfcDataType.TABLE) + { + DumpTable(function.GetTable(parameter.Name), options.MaxTableRows, options.OutputFormat); + } + else if (parameter.DataType == RfcDataType.STRUCTURE) + { + DumpStructure(function.GetStructure(parameter.Name)); + } + else + { + Console.WriteLine(" " + SafeGetString(function, parameter.Name)); + } + + Console.WriteLine(); + } + } + + private static string RequireProgramName(CliOptions options) + { + if (string.IsNullOrWhiteSpace(options.ProgramName)) + { + throw new ArgumentException("Missing ABAP program name."); + } + + return options.ProgramName.ToUpperInvariant(); + } + + private static List LoadSourceForAbapCommand(RfcDestination destination, string programName, CliOptions options) + { + if (!string.IsNullOrWhiteSpace(options.SourceFile)) + { + var fullPath = Path.GetFullPath(options.SourceFile); + if (!File.Exists(fullPath)) + { + throw new FileNotFoundException("ABAP source file was not found.", fullPath); + } + + return File.ReadAllLines(fullPath, Encoding.UTF8).ToList(); + } + + return ReadAbapProgram(destination, programName, options); + } + + private static List ReadAbapProgram(RfcDestination destination, string programName, CliOptions options) + { + var function = destination.Repository.CreateFunction("RPY_PROGRAM_READ"); + function.SetValue("PROGRAM_NAME", programName); + function.SetValue("LANGUAGE", options.Language); + function.SetValue("ONLY_SOURCE", "X"); + function.SetValue("ONLY_TEXTS", " "); + function.SetValue("WITH_INCLUDELIST", options.WithIncludeList ? "X" : " "); + function.SetValue("WITH_LOWERCASE", "X"); + function.SetValue("READ_LATEST_VERSION", options.ReadLatestVersion ? "X" : " "); + function.Invoke(destination); + + var extended = ReadSingleColumnTable(function.GetTable("SOURCE_EXTENDED")); + if (extended.Count > 0) + { + return extended; + } + + return ReadSingleColumnTable(function.GetTable("SOURCE")); + } + + private static AbapSyntaxResult CheckExistingAbapProgram(RfcDestination destination, string programName, CliOptions options) + { + var function = destination.Repository.CreateFunction("RS_ABAP_SYNTAX_CHECK_E"); + function.SetValue("P_PROGRAM", programName); + function.SetValue("P_LANGU", options.Language); + function.SetValue("P_NO_PACKAGE_CHECK", "X"); + function.Invoke(destination); + + var result = new AbapSyntaxResult + { + ErrorSubrc = SafeGetInt(function, "P_SUBRC"), + ErrorInclude = string.Empty, + ErrorWord = string.Empty, + ErrorMessage = string.Empty + }; + + var errors = function.GetTable("P_ERRORS"); + for (var i = 0; i < errors.RowCount; i++) + { + errors.CurrentIndex = i; + var kind = SafeGetString(errors.CurrentRow, "KIND"); + var message = SafeGetString(errors.CurrentRow, "MESSAGE"); + var line = SafeGetInt(errors.CurrentRow, "LINE"); + + if (result.ErrorSubrc != 0 && string.IsNullOrWhiteSpace(result.ErrorMessage)) + { + result.ErrorLine = line; + result.ErrorMessage = message; + result.ErrorInclude = SafeGetString(errors.CurrentRow, "INCNAME"); + result.ErrorWord = SafeGetString(errors.CurrentRow, "KEYWORD"); + continue; + } + + if (!string.IsNullOrWhiteSpace(message)) + { + result.Warnings.Add((string.IsNullOrWhiteSpace(kind) ? "INFO" : kind) + " line " + line + ": " + message); + } + } + + var warnings = function.GetTable("P_WARNINGS"); + for (var i = 0; i < warnings.RowCount; i++) + { + warnings.CurrentIndex = i; + var message = SafeGetString(warnings.CurrentRow, "MESSAGE"); + if (!string.IsNullOrWhiteSpace(message)) + { + result.Warnings.Add("WARN line " + SafeGetInt(warnings.CurrentRow, "LINE") + ": " + message); + } + } + + return result; + } + + private static AbapSyntaxResult CheckAbapSyntax(RfcDestination destination, string programName, IList lines) + { + var function = destination.Repository.CreateFunction("RFC_PROGRAM_CHECK_SYNTAX"); + function.SetValue("SOURCE_NAME", programName); + function.SetValue("GLOBAL_PROGRAM", programName); + function.SetValue("REPLACING", "X"); + FillSingleColumnTable(function.GetTable("SOURCE"), lines); + FillSingleColumnTable(function.GetTable("REPLACING_SOURCE"), lines); + function.Invoke(destination); + + var result = new AbapSyntaxResult + { + ErrorSubrc = SafeGetInt(function, "ERROR_SUBRC"), + ErrorInclude = SafeGetString(function, "ERROR_INCLUDE"), + ErrorLine = SafeGetInt(function, "ERROR_LINE"), + ErrorOffset = SafeGetInt(function, "ERROR_OFFSET"), + ErrorWord = SafeGetString(function, "ERROR_WORD"), + ErrorMessage = SafeGetString(function, "ERROR_MESSAGE") + }; + + result.Warnings.AddRange(ReadSingleColumnTable(function.GetTable("WARNINGS_TABLE"))); + if (result.ErrorSubrc != 0 && string.IsNullOrWhiteSpace(result.ErrorMessage)) + { + var fallback = CheckAbapSyntaxWithErrorSyntaxCheck(destination, programName, lines); + if (fallback != null) + { + return fallback; + } + } + + return result; + } + + private static AbapSyntaxResult CheckAbapSyntaxWithErrorSyntaxCheck(RfcDestination destination, string programName, IList lines) + { + try + { + var function = destination.Repository.CreateFunction("RS_ABAP_ERROR_SYNTAX_CHECK"); + function.SetValue("P_PROGRAM", programName); + function.SetValue("P_MODE", 0); + FillSingleColumnTable(function.GetTable("P_REPTAB"), lines); + function.Invoke(destination); + + var kind = SafeGetString(function, "P_KIND"); + var message = SafeGetString(function, "P_MESSAGE"); + var result = new AbapSyntaxResult + { + ErrorSubrc = string.Equals(kind, "E", StringComparison.OrdinalIgnoreCase) || !string.IsNullOrWhiteSpace(message) ? 4 : 0, + ErrorMessage = message, + ErrorInclude = string.Empty, + ErrorWord = string.Empty + }; + + result.Warnings.AddRange(ReadSingleColumnTable(function.GetTable("P_TRCTAB"))); + var error = function.GetStructure("P_ERROR"); + result.ErrorLine = SafeGetFirstInt(error, "LINE", "LINENO", "ROW"); + result.ErrorOffset = SafeGetFirstInt(error, "OFFSET", "COL", "COLUMN"); + result.ErrorWord = SafeGetFirstString(error, "WORD", "TOKEN"); + + return result; + } + catch (Exception) + { + return null; + } + } + + private static List ReadSingleColumnTable(IRfcTable table) + { + var lines = new List(); + var columnIndex = PickTextColumnIndex(table.Metadata.LineType); + + for (var i = 0; i < table.RowCount; i++) + { + table.CurrentIndex = i; + lines.Add(table.CurrentRow.GetString(columnIndex).TrimEnd()); + } + + return lines; + } + + private static void FillSingleColumnTable(IRfcTable table, IEnumerable lines) + { + var columnIndex = PickTextColumnIndex(table.Metadata.LineType); + + foreach (var line in lines) + { + table.Append(); + table.CurrentRow.SetValue(columnIndex, line ?? string.Empty); + } + } + + private static int PickTextColumnIndex(RfcStructureMetadata metadata) + { + var preferred = new[] { "LINE", "TEXT", "SOURCE", "WA" }; + foreach (var name in preferred) + { + var index = metadata.TryNameToIndex(name); + if (index >= 0) + { + return index; + } + } + + return 0; + } + + private static void DumpTable(IRfcTable table, int maxRows, OutputFormat format) + { + var headers = GetContainerFieldNames(table.Metadata.LineType); + var rows = new List>(); + var rowLimit = Math.Min(table.RowCount, Math.Max(0, maxRows)); + + for (var i = 0; i < rowLimit; i++) + { + table.CurrentIndex = i; + rows.Add(ReadStructureValues(table.CurrentRow, headers)); + } + + RenderRows(headers, rows, format); + + if (table.RowCount > rowLimit) + { + Console.WriteLine("... " + (table.RowCount - rowLimit) + " more rows not printed. Increase --max-table-rows if needed."); + } + } + + private static void DumpStructure(IRfcStructure structure) + { + var headers = GetContainerFieldNames(structure.Metadata); + foreach (var header in headers) + { + Console.WriteLine(" " + header.PadRight(32) + ": " + SafeGetString(structure, header)); + } + } + + private static List GetContainerFieldNames(RfcStructureMetadata metadata) + { + var names = new List(); + for (var i = 0; i < metadata.FieldCount; i++) + { + names.Add(metadata[i].Name); + } + + return names; + } + + private static List ReadStructureValues(IRfcStructure structure, IList headers) + { + var values = new List(); + foreach (var header in headers) + { + values.Add(SafeGetString(structure, header)); + } + + return values; + } + + private static List ReadReadTableFieldNames(IRfcTable fields) + { + var names = new List(); + for (var i = 0; i < fields.RowCount; i++) + { + fields.CurrentIndex = i; + var name = SafeGetString(fields.CurrentRow, "FIELDNAME").Trim(); + if (!string.IsNullOrEmpty(name)) + { + names.Add(name); + } + } + + return names; + } + + private static List SplitDataRow(string row, string delimiter, int expectedColumns) + { + var parts = row.Split(new[] { delimiter }, StringSplitOptions.None) + .Select(part => part.TrimEnd()) + .ToList(); + + while (parts.Count < expectedColumns) + { + parts.Add(string.Empty); + } + + if (expectedColumns > 0 && parts.Count > expectedColumns) + { + parts = parts.Take(expectedColumns).ToList(); + } + + return parts; + } + + private static IEnumerable SplitAbapOptionLine(string text, int maxLength) + { + if (string.IsNullOrWhiteSpace(text)) + { + yield break; + } + + var remaining = text.Trim(); + while (remaining.Length > maxLength) + { + var splitAt = remaining.LastIndexOf(' ', maxLength - 1, maxLength); + if (splitAt < 1) + { + splitAt = maxLength; + } + + yield return remaining.Substring(0, splitAt).TrimEnd(); + remaining = remaining.Substring(splitAt).TrimStart(); + } + + if (remaining.Length > 0) + { + yield return remaining; + } + } + + private static void RenderRows(IList headers, IList> rows, OutputFormat format) + { + if (format == OutputFormat.Json) + { + RenderJson(headers, rows); + return; + } + + if (format == OutputFormat.Csv) + { + Console.WriteLine(string.Join(",", headers.Select(EscapeCsv))); + foreach (var row in rows) + { + Console.WriteLine(string.Join(",", row.Select(EscapeCsv))); + } + + return; + } + + Console.WriteLine(string.Join(" | ", headers)); + Console.WriteLine(string.Join("-+-", headers.Select(header => new string('-', Math.Max(3, header.Length))))); + + foreach (var row in rows) + { + Console.WriteLine(string.Join(" | ", row)); + } + + if (rows.Count == 0) + { + Console.WriteLine("(no rows)"); + } + } + + private static void RenderJson(IList headers, IList> rows) + { + Console.WriteLine("["); + for (var rowIndex = 0; rowIndex < rows.Count; rowIndex++) + { + var row = rows[rowIndex]; + Console.Write(" {"); + + for (var columnIndex = 0; columnIndex < headers.Count; columnIndex++) + { + if (columnIndex > 0) + { + Console.Write(", "); + } + + var value = columnIndex < row.Count ? row[columnIndex] : string.Empty; + Console.Write("\"" + EscapeJson(headers[columnIndex]) + "\": \"" + EscapeJson(value) + "\""); + } + + Console.Write(rowIndex + 1 == rows.Count ? "}" : "},"); + Console.WriteLine(); + } + Console.WriteLine("]"); + } + + private static string EscapeCsv(string value) + { + value = value ?? string.Empty; + if (value.IndexOfAny(new[] { ',', '"', '\r', '\n' }) < 0) + { + return value; + } + + return "\"" + value.Replace("\"", "\"\"") + "\""; + } + + private static string EscapeJson(string value) + { + if (value == null) + { + return string.Empty; + } + + var builder = new StringBuilder(); + foreach (var ch in value) + { + switch (ch) + { + case '\\': + builder.Append("\\\\"); + break; + case '"': + builder.Append("\\\""); + break; + case '\r': + builder.Append("\\r"); + break; + case '\n': + builder.Append("\\n"); + break; + case '\t': + builder.Append("\\t"); + break; + default: + if (char.IsControl(ch)) + { + builder.Append("\\u" + ((int)ch).ToString("x4")); + } + else + { + builder.Append(ch); + } + break; + } + } + + return builder.ToString(); + } + + private static void PrintBanner(CliOptions options) + { + if (options.Quiet) + { + return; + } + + Console.WriteLine("SAP NCo CLI"); + Console.WriteLine("Architecture : " + (Environment.Is64BitProcess ? "x64" : "x86")); + Console.WriteLine("NCo Assembly : " + typeof(RfcDestinationManager).Assembly.FullName); + } + + private static int Fail(string title, Exception ex) + { + Console.Error.WriteLine(); + Console.Error.WriteLine("ERROR: " + title); + Console.Error.WriteLine(ex.GetType().FullName + ": " + ex.Message); + return 1; + } + + private static string ReadPassword(string prompt) + { + Console.Write(prompt); + var password = new StringBuilder(); + + while (true) + { + var key = Console.ReadKey(intercept: true); + + if (key.Key == ConsoleKey.Enter) + { + Console.WriteLine(); + return password.ToString(); + } + + if (key.Key == ConsoleKey.Backspace) + { + if (password.Length > 0) + { + password.Length--; + Console.Write("\b \b"); + } + + continue; + } + + if (key.Key == ConsoleKey.Escape) + { + Console.WriteLine(); + throw new OperationCanceledException("Password entry cancelled."); + } + + if (!char.IsControl(key.KeyChar)) + { + password.Append(key.KeyChar); + Console.Write('*'); + } + } + } + + private static void PrintField(IRfcStructure structure, string fieldName, string label) + { + Console.WriteLine(label.PadRight(16) + ": " + SafeGetString(structure, fieldName)); + } + + private static string SafeGetString(IRfcDataContainer container, string fieldName) + { + try + { + return container.GetString(fieldName).Trim(); + } + catch (Exception) + { + return ""; + } + } + + private static int SafeGetInt(IRfcDataContainer container, string fieldName) + { + try + { + return container.GetInt(fieldName); + } + catch (Exception) + { + return 0; + } + } + + private static string SafeGetFirstString(IRfcDataContainer container, params string[] fieldNames) + { + foreach (var fieldName in fieldNames) + { + try + { + var value = container.GetString(fieldName).Trim(); + if (!string.IsNullOrWhiteSpace(value)) + { + return value; + } + } + catch (Exception) + { + // Try the next known field name. + } + } + + return string.Empty; + } + + private static int SafeGetFirstInt(IRfcDataContainer container, params string[] fieldNames) + { + foreach (var fieldName in fieldNames) + { + try + { + var value = container.GetInt(fieldName); + if (value != 0) + { + return value; + } + } + catch (Exception) + { + // Try the next known field name. + } + } + + return 0; + } + + private static void PrintHelp() + { + Console.WriteLine("SAP NCo CLI for T76"); + Console.WriteLine(); + Console.WriteLine("Usage:"); + Console.WriteLine(" SapProbe.exe [global options] [command options]"); + Console.WriteLine(); + Console.WriteLine("Commands:"); + Console.WriteLine(" system-info Ping SAP and call RFC_SYSTEM_INFO. Default command."); + Console.WriteLine(" table-read Read table data via RFC_READ_TABLE."); + Console.WriteLine(" table-fields
[field] Show DDIC field metadata via DDIF_FIELDINFO_GET."); + Console.WriteLine(" field-exists
Check whether a DDIC table field exists."); + Console.WriteLine(" function-info Show RFC function interface metadata."); + Console.WriteLine(" function-search Search RFC functions via RFC_FUNCTION_SEARCH."); + Console.WriteLine(" rfc-call Call an RFC-enabled function module."); + Console.WriteLine(" abap-read Read ABAP report/source via RPY_PROGRAM_READ."); + Console.WriteLine(" abap-check Syntax-check an existing repository program."); + Console.WriteLine(" abap-write Write ABAP source via RPY_PROGRAM_INSERT; requires --confirm-write."); + Console.WriteLine(" abap-activate Activation attempt through active RPY_PROGRAM_INSERT write."); + Console.WriteLine(" load-only Only load the 32-bit NCo assembly; do not connect."); + Console.WriteLine(); + Console.WriteLine("Global options:"); + Console.WriteLine(" --ashost ABAP application server. Default: travt762.sap.trafag.com"); + Console.WriteLine(" --sysnr SAP system number. Default: 00"); + Console.WriteLine(" --client SAP client. Default: 100"); + Console.WriteLine(" --user SAP user. Default: KOI"); + Console.WriteLine(" --lang SAP logon language. Default: DE"); + Console.WriteLine(" --router Optional SAProuter string."); + Console.WriteLine(" --trace Optional NCo trace level."); + Console.WriteLine(" --quiet Suppress connection banner."); + Console.WriteLine(" --no-password-prompt Fail if no password env var is set."); + Console.WriteLine(" --help Show this help."); + Console.WriteLine(); + Console.WriteLine("table-read options:"); + Console.WriteLine(" --fields A,B,C Comma-separated fields. Recommended to avoid RFC_READ_TABLE row limits."); + Console.WriteLine(" --field FIELD Single field filter for table-fields."); + Console.WriteLine(" --where \"FIELD = 'VALUE'\" WHERE fragment; can be repeated."); + Console.WriteLine(" --rowcount Max rows. Default: 10."); + Console.WriteLine(" --rowskip Rows to skip. Default: 0."); + Console.WriteLine(" --format table|csv|json Output format. Default: table."); + Console.WriteLine(); + Console.WriteLine("rfc-call options:"); + Console.WriteLine(" --set NAME=VALUE Scalar import/changing value; can be repeated."); + Console.WriteLine(" --dump-imports Include import parameters in output."); + Console.WriteLine(" --max-table-rows Max rows printed for table parameters. Default: 20."); + Console.WriteLine(" --format table|csv|json Format for table outputs. Default: table."); + Console.WriteLine(); + Console.WriteLine("ABAP source options:"); + Console.WriteLine(" --out Write abap-read output to a local file."); + Console.WriteLine(" --source-file Use a local source file for abap-check/abap-write."); + Console.WriteLine(" --latest Read latest version in abap-read/abap-check."); + Console.WriteLine(" --with-includes Request include list while reading."); + Console.WriteLine(" --title Title used by abap-write."); + Console.WriteLine(" --devclass Package/development class for abap-write, e.g. $TMP."); + Console.WriteLine(" --transport Transport request for abap-write if required."); + Console.WriteLine(" --temporary Set TEMPORARY=X in abap-write."); + Console.WriteLine(" --save-inactive Set SAVE_INACTIVE=X in abap-write."); + Console.WriteLine(" --dry-run Syntax-check only; do not write."); + Console.WriteLine(" --confirm-write Required for any repository write."); + Console.WriteLine(); + Console.WriteLine("Examples:"); + Console.WriteLine(" SapProbe.exe system-info"); + Console.WriteLine(" SapProbe.exe table-read T000 --fields MANDT,MTEXT --where \"MANDT = '100'\" --rowcount 5"); + Console.WriteLine(" SapProbe.exe table-fields MARC MMSTA"); + Console.WriteLine(" SapProbe.exe field-exists MARC MMSTA"); + Console.WriteLine(" SapProbe.exe function-info RFC_SYSTEM_INFO"); + Console.WriteLine(" SapProbe.exe function-search RPY*PROGRAM*"); + Console.WriteLine(" SapProbe.exe rfc-call STFC_CONNECTION --set REQUTEXT=hello"); + Console.WriteLine(" SapProbe.exe abap-read Z_TEST3 --out C:\\Temp\\Z_TEST3.abap"); + Console.WriteLine(" SapProbe.exe abap-check Z_TEST3 --source-file C:\\Temp\\Z_TEST3.abap"); + Console.WriteLine(" SapProbe.exe abap-write Z_TEST3 --source-file C:\\Temp\\Z_TEST3.abap --confirm-write"); + Console.WriteLine(" SapProbe.exe abap-activate Z_TEST3 --dry-run"); + Console.WriteLine(); + Console.WriteLine("Password input:"); + Console.WriteLine(" This tool never accepts a password as a command-line argument."); + Console.WriteLine(" It reads SAP_NCO_PASSWORD or SAP_T76_PASSWORD if set; otherwise it prompts with masking."); + } + } + + internal enum SapCommand + { + SystemInfo, + TableRead, + TableFields, + FieldExists, + FunctionInfo, + FunctionSearch, + RfcCall, + AbapRead, + AbapCheck, + AbapWrite, + AbapActivate, + LoadOnly + } + + internal enum OutputFormat + { + Table, + Csv, + Json + } + + internal sealed class AbapSyntaxResult + { + public int ErrorSubrc { get; set; } + public string ErrorInclude { get; set; } + public int ErrorLine { get; set; } + public int ErrorOffset { get; set; } + public string ErrorWord { get; set; } + public string ErrorMessage { get; set; } + public List Warnings { get; } = new List(); + } + + internal sealed class CliOptions + { + private readonly List _positionals = new List(); + + public string Name { get; private set; } = "T76"; + public string AppServerHost { get; private set; } = "travt762.sap.trafag.com"; + public string SystemNumber { get; private set; } = "00"; + public string Client { get; private set; } = "100"; + public string User { get; private set; } = "KOI"; + public string Language { get; private set; } = "DE"; + public string SapRouter { get; private set; } + public string Trace { get; private set; } + public bool Quiet { get; private set; } + public bool NoPasswordPrompt { get; private set; } + public bool ShowHelp { get; private set; } + public SapCommand Command { get; private set; } = SapCommand.SystemInfo; + + public string TableName { get; private set; } + public string FieldName { get; private set; } + public List Fields { get; private set; } = new List(); + public List WhereClauses { get; private set; } = new List(); + public int RowCount { get; private set; } = 10; + public int RowSkip { get; private set; } + public string Delimiter { get; private set; } = "|"; + public OutputFormat OutputFormat { get; private set; } = OutputFormat.Table; + + public string FunctionName { get; private set; } + public string SearchPattern { get; private set; } + public Dictionary SetValues { get; private set; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + public bool DumpImports { get; private set; } + public int MaxTableRows { get; private set; } = 20; + public string ProgramName { get; private set; } + public string OutputPath { get; private set; } + public string SourceFile { get; private set; } + public string Title { get; private set; } + public string DevelopmentClass { get; private set; } + public string TransportNumber { get; private set; } + public bool ConfirmWrite { get; private set; } + public bool DryRun { get; private set; } + public bool SaveInactive { get; private set; } + public bool Temporary { get; private set; } + public bool WithIncludeList { get; private set; } + public bool ReadLatestVersion { get; private set; } + + public static CliOptions Parse(string[] args) + { + var options = new CliOptions(); + var commandExplicitlySet = false; + + for (var i = 0; i < (args ?? Array.Empty()).Length; i++) + { + var arg = args[i]; + var lower = arg.ToLowerInvariant(); + + switch (lower) + { + case "--help": + case "-h": + case "/?": + options.ShowHelp = true; + break; + case "--ashost": + options.AppServerHost = RequireValue(args, ref i, arg); + break; + case "--sysnr": + options.SystemNumber = RequireValue(args, ref i, arg); + break; + case "--client": + options.Client = RequireValue(args, ref i, arg); + break; + case "--user": + options.User = RequireValue(args, ref i, arg); + break; + case "--lang": + options.Language = RequireValue(args, ref i, arg).ToUpperInvariant(); + break; + case "--router": + options.SapRouter = RequireValue(args, ref i, arg); + break; + case "--trace": + options.Trace = RequireValue(args, ref i, arg); + break; + case "--quiet": + options.Quiet = true; + break; + case "--no-password-prompt": + options.NoPasswordPrompt = true; + break; + case "--load-only": + options.Command = SapCommand.LoadOnly; + commandExplicitlySet = true; + break; + case "--fields": + options.Fields.AddRange(SplitCsvList(RequireValue(args, ref i, arg)).Select(field => field.ToUpperInvariant())); + break; + case "--field": + options.FieldName = RequireValue(args, ref i, arg).ToUpperInvariant(); + break; + case "--where": + options.WhereClauses.Add(RequireValue(args, ref i, arg)); + break; + case "--rowcount": + options.RowCount = ParseNonNegativeInt(RequireValue(args, ref i, arg), arg); + break; + case "--rowskip": + case "--rowskips": + options.RowSkip = ParseNonNegativeInt(RequireValue(args, ref i, arg), arg); + break; + case "--delimiter": + options.Delimiter = RequireValue(args, ref i, arg); + if (options.Delimiter.Length != 1) + { + throw new ArgumentException("--delimiter must be exactly one character for RFC_READ_TABLE."); + } + break; + case "--format": + options.OutputFormat = ParseOutputFormat(RequireValue(args, ref i, arg)); + break; + case "--set": + case "--param": + AddSetValue(options, RequireValue(args, ref i, arg)); + break; + case "--dump-imports": + options.DumpImports = true; + break; + case "--max-table-rows": + options.MaxTableRows = ParseNonNegativeInt(RequireValue(args, ref i, arg), arg); + break; + case "--out": + case "--output": + options.OutputPath = RequireValue(args, ref i, arg); + break; + case "--source-file": + case "--file": + options.SourceFile = RequireValue(args, ref i, arg); + break; + case "--title": + options.Title = RequireValue(args, ref i, arg); + break; + case "--devclass": + case "--development-class": + options.DevelopmentClass = RequireValue(args, ref i, arg); + break; + case "--transport": + case "--transport-number": + options.TransportNumber = RequireValue(args, ref i, arg); + break; + case "--confirm-write": + options.ConfirmWrite = true; + break; + case "--dry-run": + options.DryRun = true; + break; + case "--save-inactive": + options.SaveInactive = true; + break; + case "--temporary": + options.Temporary = true; + break; + case "--with-includes": + options.WithIncludeList = true; + break; + case "--latest": + options.ReadLatestVersion = true; + break; + default: + if (arg.StartsWith("--", StringComparison.Ordinal)) + { + throw new ArgumentException("Unknown option: " + arg); + } + + if (!commandExplicitlySet && TryParseCommand(arg, out var command)) + { + options.Command = command; + commandExplicitlySet = true; + } + else + { + options._positionals.Add(arg); + } + break; + } + } + + options.ApplyPositionals(); + return options; + } + + private void ApplyPositionals() + { + switch (Command) + { + case SapCommand.TableRead: + TableName = TakeRequiredPositional(0, "table name"); + break; + case SapCommand.TableFields: + TableName = TakeRequiredPositional(0, "table name"); + if (_positionals.Count > 1) + { + FieldName = _positionals[1].ToUpperInvariant(); + } + break; + case SapCommand.FieldExists: + TableName = TakeRequiredPositional(0, "table name"); + FieldName = TakeRequiredPositional(1, "field name").ToUpperInvariant(); + break; + case SapCommand.FunctionInfo: + case SapCommand.RfcCall: + FunctionName = TakeRequiredPositional(0, "function name"); + break; + case SapCommand.FunctionSearch: + SearchPattern = TakeRequiredPositional(0, "function search pattern"); + break; + case SapCommand.AbapRead: + case SapCommand.AbapCheck: + case SapCommand.AbapWrite: + case SapCommand.AbapActivate: + ProgramName = TakeRequiredPositional(0, "ABAP program name"); + break; + case SapCommand.SystemInfo: + case SapCommand.LoadOnly: + if (_positionals.Count > 0) + { + throw new ArgumentException("Unexpected positional argument: " + _positionals[0]); + } + break; + default: + throw new ArgumentOutOfRangeException(); + } + + var allowedPositionals = Command == SapCommand.TableFields || Command == SapCommand.FieldExists ? 2 : 1; + if (_positionals.Count > allowedPositionals) + { + throw new ArgumentException("Unexpected positional argument: " + _positionals[allowedPositionals]); + } + } + + private string TakeRequiredPositional(int index, string description) + { + if (_positionals.Count <= index || string.IsNullOrWhiteSpace(_positionals[index])) + { + throw new ArgumentException("Missing " + description + "."); + } + + return _positionals[index]; + } + + private static bool TryParseCommand(string text, out SapCommand command) + { + switch (text.ToLowerInvariant()) + { + case "system-info": + case "info": + case "ping": + command = SapCommand.SystemInfo; + return true; + case "table-read": + case "read-table": + case "table": + command = SapCommand.TableRead; + return true; + case "table-fields": + case "fields": + case "ddic-fields": + command = SapCommand.TableFields; + return true; + case "field-exists": + case "has-field": + command = SapCommand.FieldExists; + return true; + case "function-info": + case "func-info": + case "interface": + command = SapCommand.FunctionInfo; + return true; + case "function-search": + case "func-search": + case "search-functions": + command = SapCommand.FunctionSearch; + return true; + case "rfc-call": + case "call": + case "rfc": + command = SapCommand.RfcCall; + return true; + case "abap-read": + case "read-program": + case "program-read": + command = SapCommand.AbapRead; + return true; + case "abap-check": + case "syntax-check": + case "program-check": + command = SapCommand.AbapCheck; + return true; + case "abap-write": + case "write-program": + case "program-write": + command = SapCommand.AbapWrite; + return true; + case "abap-activate": + case "activate-program": + case "program-activate": + command = SapCommand.AbapActivate; + return true; + case "load-only": + command = SapCommand.LoadOnly; + return true; + default: + command = SapCommand.SystemInfo; + return false; + } + } + + private static string RequireValue(string[] args, ref int index, string option) + { + if (index + 1 >= args.Length) + { + throw new ArgumentException("Missing value for " + option); + } + + index++; + return args[index]; + } + + private static int ParseNonNegativeInt(string value, string option) + { + if (!int.TryParse(value, out var number) || number < 0) + { + throw new ArgumentException(option + " expects a non-negative integer."); + } + + return number; + } + + private static OutputFormat ParseOutputFormat(string value) + { + switch (value.ToLowerInvariant()) + { + case "table": + return OutputFormat.Table; + case "csv": + return OutputFormat.Csv; + case "json": + return OutputFormat.Json; + default: + throw new ArgumentException("Unsupported output format: " + value); + } + } + + private static IEnumerable SplitCsvList(string value) + { + return value.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) + .Select(item => item.Trim()) + .Where(item => item.Length > 0); + } + + private static void AddSetValue(CliOptions options, string expression) + { + var separator = expression.IndexOf('='); + if (separator < 1) + { + throw new ArgumentException("--set expects NAME=VALUE."); + } + + var key = expression.Substring(0, separator).Trim(); + var value = expression.Substring(separator + 1); + if (key.Length == 0) + { + throw new ArgumentException("--set expects NAME=VALUE."); + } + + options.SetValues[key] = value; + } + + public CliOptions CloneForActiveWrite() + { + return new CliOptions + { + Name = Name, + AppServerHost = AppServerHost, + SystemNumber = SystemNumber, + Client = Client, + User = User, + Language = Language, + SapRouter = SapRouter, + Trace = Trace, + Quiet = Quiet, + NoPasswordPrompt = NoPasswordPrompt, + Command = Command, + ProgramName = ProgramName, + SourceFile = SourceFile, + Title = Title, + DevelopmentClass = DevelopmentClass, + TransportNumber = TransportNumber, + ConfirmWrite = ConfirmWrite, + DryRun = DryRun, + SaveInactive = false, + Temporary = Temporary + }; + } } } -if (string.IsNullOrWhiteSpace(sapUsername) || string.IsNullOrWhiteSpace(sapPassword)) throw new Exception("Central SAP credentials missing"); -var serviceUrl = @"http://travt762.sap.trafag.com:8000/sap/opu/odata/sap/ZPOWERBI_EINKAUF_SRV/"; -using var client = new HttpClient(); -client.Timeout = TimeSpan.FromSeconds(20); -client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes($"{sapUsername}:{sapPassword}"))); -foreach (var url in new[] { serviceUrl, serviceUrl + "" }) -{ - Console.WriteLine($"URL|{url}"); - using var response = await client.GetAsync(url); - Console.WriteLine($"STATUS|{(int)response.StatusCode}|{response.ReasonPhrase}"); - foreach (var header in response.Headers) - Console.WriteLine($"HEADER|{header.Key}|{string.Join(",", header.Value)}"); - foreach (var header in response.Content.Headers) - Console.WriteLine($"HEADER|{header.Key}|{string.Join(",", header.Value)}"); - var body = await response.Content.ReadAsStringAsync(); - Console.WriteLine("BODY_START"); - Console.WriteLine(body.Length > 5000 ? body[..5000] : body); - Console.WriteLine("BODY_END"); -} diff --git a/TrafagSalesExporter/.tmp_sap_probe/RunSapProbeInteractive.ps1 b/TrafagSalesExporter/.tmp_sap_probe/RunSapProbeInteractive.ps1 new file mode 100644 index 0000000..3a800f4 --- /dev/null +++ b/TrafagSalesExporter/.tmp_sap_probe/RunSapProbeInteractive.ps1 @@ -0,0 +1,37 @@ +$ErrorActionPreference = 'Stop' + +$exe = Join-Path $PSScriptRoot 'bin\x86\Release\net48\SapProbe.exe' +$log = Join-Path $PSScriptRoot 'sap_probe_last_run.log' + +if (-not (Test-Path -LiteralPath $exe)) { + Write-Host "SapProbe.exe was not found:" + Write-Host $exe + Read-Host "Press Enter to close" + exit 2 +} + +if (Test-Path -LiteralPath $log) { + Remove-Item -LiteralPath $log -Force +} + +Start-Transcript -Path $log -Force | Out-Null +try { + & $exe @args + $exitCode = $LASTEXITCODE + Write-Host '' + Write-Host "Exit code: $exitCode" +} +finally { + Stop-Transcript | Out-Null +} + +if (Test-Path -LiteralPath $log) { + $content = Get-Content -LiteralPath $log -Raw + $content = [regex]::Replace($content, '(?m)^Password for .*$','Password prompt: [masked input omitted]') + Set-Content -LiteralPath $log -Value $content -Encoding UTF8 +} + +Write-Host '' +Write-Host "Log file: $log" +Read-Host "Press Enter to close" +exit $exitCode diff --git a/TrafagSalesExporter/.tmp_sap_probe/SapProbe.csproj b/TrafagSalesExporter/.tmp_sap_probe/SapProbe.csproj index 2bdceb6..8a75c38 100644 --- a/TrafagSalesExporter/.tmp_sap_probe/SapProbe.csproj +++ b/TrafagSalesExporter/.tmp_sap_probe/SapProbe.csproj @@ -1,11 +1,23 @@ Exe - net8.0 - enable - enable + net48 + x86 + true + latest + disable + SapProbe + SapProbe + - + + C:\Windows\Microsoft.NET\assembly\GAC_32\sapnco\v4.0_3.1.0.42__50436dca5c7f7d23\sapnco.dll + false + + + C:\Windows\Microsoft.NET\assembly\GAC_32\sapnco_utils\v4.0_3.1.0.42__50436dca5c7f7d23\sapnco_utils.dll + false + diff --git a/TrafagSalesExporter/Components/Pages/ManagementCockpit.razor b/TrafagSalesExporter/Components/Pages/ManagementCockpit.razor index 548dfb0..91ed3af 100644 --- a/TrafagSalesExporter/Components/Pages/ManagementCockpit.razor +++ b/TrafagSalesExporter/Components/Pages/ManagementCockpit.razor @@ -11,7 +11,7 @@ - + @foreach (var file in _files) { @@ -19,7 +19,23 @@ } - + + + @foreach (var option in _valueFieldOptions) + { + @option.Label + } + + + + + @foreach (var option in _currencyOptions) + { + @option.Label + } + + + @@ -37,10 +53,10 @@ @T("Zentrale Roh-Auswertung", "Central raw analysis") - @T("Diese Sicht arbeitet direkt auf `CentralSalesRecords` und zeigt nur fachlich neutrale Rohkennzahlen. Kein Intercompany-Filter, keine CHF-Umrechnung, kein Budget, keine Spartenlogik.", "This view works directly on `CentralSalesRecords` and shows only neutral raw metrics. No intercompany filter, no CHF conversion, no budget, no divisional logic.") + @T("Diese Sicht arbeitet direkt auf `CentralSalesRecords`. Summenfeld und Anzeige-Waehrung koennen gewaehlt werden; fachliche Filter wie Intercompany, Budget und Spartenlogik sind weiterhin nicht enthalten.", "This view works directly on `CentralSalesRecords`. Value field and display currency can be selected; business filters such as intercompany, budget and divisional logic are still not included.") - + @foreach (var year in _centralYears) { @@ -48,7 +64,7 @@ } - + @foreach (var month in Enumerable.Range(1, 12)) { @@ -56,7 +72,36 @@ } - + + + @foreach (var option in _valueFieldOptions) + { + @option.Label + } + + + + + @foreach (var option in _valueFieldOptions) + { + @option.Label + } + + + + + @foreach (var option in _currencyOptions) + { + @option.Label + } + + + @(_analyzingCentral ? T("Analysiere...", "Analyzing...") : T("Zentrale Auswertung laden", "Load central analysis")) @@ -70,8 +115,8 @@ @T("Land", "Country")@_result.Summary.Land TSC@_result.Summary.Tsc - @T("Umsatz", "Sales")@_result.Summary.SalesValueTotal.ToString("N2") - @T("Geschaetzte Marge", "Estimated margin")@($"{_result.Summary.EstimatedMarginPercent:F1}%") + @_result.Summary.ValueFieldLabel@FormatValue(_result.Summary.AggregatedValueTotal, _result.Summary.DisplayCurrency) + @T("Nicht umgerechnet", "Not converted")@_result.Summary.MissingExchangeRateCount.ToString("N0") @@ -90,7 +135,7 @@ @T("Top Kunden", "Top customers") @foreach (var item in _result.TopCustomers) { - @($"{item.Label}: {item.Value:N2} ({item.SharePercent:F1}%)") + @($"{item.Label}: {FormatValue(item.Value, _result.Summary.DisplayCurrency)} ({item.SharePercent:F1}%)") } @@ -99,7 +144,7 @@ @T("Top Produktgruppen", "Top product groups") @foreach (var item in _result.TopProductGroups) { - @($"{item.Label}: {item.Value:N2} ({item.SharePercent:F1}%)") + @($"{item.Label}: {FormatValue(item.Value, _result.Summary.DisplayCurrency)} ({item.SharePercent:F1}%)") } @@ -108,7 +153,7 @@ @T("Top Sales Owner", "Top sales owner") @foreach (var item in _result.TopSalesEmployees) { - @($"{item.Label}: {item.Value:N2} ({item.SharePercent:F1}%)") + @($"{item.Label}: {FormatValue(item.Value, _result.Summary.DisplayCurrency)} ({item.SharePercent:F1}%)") } @@ -130,50 +175,10 @@ @T("Rechnungen", "Invoices")@_centralResult.Summary.InvoiceCount.ToString("N0") @T("Standorte", "Sites")@_centralResult.Summary.SiteCount.ToString("N0") @T("Laender", "Countries")@_centralResult.Summary.CountryCount.ToString("N0") - @T("Waehrungen", "Currencies")@_centralResult.Summary.CurrencyCount.ToString("N0") - @T("Periode", "Period")@BuildPeriodLabel(_centralResult) + @_centralResult.Summary.ValueFieldLabel@FormatValue(_centralResult.Summary.ValueTotal, _centralResult.Summary.DisplayCurrency) + @T("Nicht umgerechnet", "Not converted")@_centralResult.Summary.MissingExchangeRateCount.ToString("N0") - - @T("Cockpit Manometer", "Cockpit gauges") - - @T("Verdichtete Kennzahlen aus der zentralen Rohsicht. Die Manometer zeigen Anteile, Dichte und Abdeckung, ohne Waehrungsumrechnung oder Budgetlogik.", "Condensed metrics from the central raw view. The gauges show shares, density and coverage without currency conversion or budget logic.") - - - @foreach (var gauge in BuildCentralGauges(_centralResult)) - { - - - @gauge.Title -
- - - - - - @gauge.DisplayValue - @gauge.Subtitle - -
-
-
- } -
-
- @T("Hinweise", "Notes") @foreach (var notice in _centralResult.Notices) @@ -185,18 +190,26 @@ - @T("Jahresumsatz 2025/2026", "Yearly sales 2025/2026") + @T("Jahreswerte", "Yearly values") @T("Jahr", "Year") @T("Waehrung", "Currency") - @T("Umsatz", "Sales") + @_centralResult.Summary.ValueFieldLabel + @foreach (var field in _centralResult.AdditionalValueFields) + { + @field.Label + } @T("Zeilen", "Rows") @context.Year @context.Currency - @context.SalesValue.ToString("N2") + @FormatValue(context.SalesValue, context.Currency) + @foreach (var field in _centralResult.AdditionalValueFields) + { + @FormatAdditionalValue(context, field.Key) + } @context.RowCount.ToString("N0") @@ -204,18 +217,26 @@ - @T("Monatsumsatz", "Monthly sales") + @T("Monatswerte", "Monthly values") @T("Monat", "Month") @T("Waehrung", "Currency") - @T("Umsatz", "Sales") + @_centralResult.Summary.ValueFieldLabel + @foreach (var field in _centralResult.AdditionalValueFields) + { + @field.Label + } @T("Zeilen", "Rows") @context.Label @context.Currency - @context.SalesValue.ToString("N2") + @FormatValue(context.SalesValue, context.Currency) + @foreach (var field in _centralResult.AdditionalValueFields) + { + @FormatAdditionalValue(context, field.Key) + } @context.RowCount.ToString("N0") @@ -226,18 +247,26 @@ - @T("Tagesumsatz im ausgewaehlten Monat", "Daily sales in selected month") + @T("Tageswerte im ausgewaehlten Monat", "Daily values in selected month") @T("Tag", "Day") @T("Waehrung", "Currency") - @T("Umsatz", "Sales") + @_centralResult.Summary.ValueFieldLabel + @foreach (var field in _centralResult.AdditionalValueFields) + { + @field.Label + } @T("Zeilen", "Rows") @context.Label @context.Currency - @context.SalesValue.ToString("N2") + @FormatValue(context.SalesValue, context.Currency) + @foreach (var field in _centralResult.AdditionalValueFields) + { + @FormatAdditionalValue(context, field.Key) + } @context.RowCount.ToString("N0") @@ -248,18 +277,18 @@ - @T("Umsatz nach Quelle", "Sales by source") + @T("Werte nach Quelle", "Values by source") @T("Quelle", "Source") @T("Waehrung", "Currency") - @T("Umsatz", "Sales") + @_centralResult.Summary.ValueFieldLabel @T("Rechnungen", "Invoices") @context.Label @context.Currency - @context.SalesValue.ToString("N2") + @FormatValue(context.SalesValue, context.Currency) @context.InvoiceCount.ToString("N0") @@ -268,19 +297,19 @@ - @T("Umsatz nach Land", "Sales by country") + @T("Werte nach Land", "Values by country") @T("Land", "Country") @T("Waehrung", "Currency") - @T("Umsatz", "Sales") + @_centralResult.Summary.ValueFieldLabel @T("Rechnungen", "Invoices") @T("Zeilen", "Rows") @context.Label @context.Currency - @context.SalesValue.ToString("N2") + @FormatValue(context.SalesValue, context.Currency) @context.InvoiceCount.ToString("N0") @context.RowCount.ToString("N0") @@ -288,47 +317,26 @@ } - - @code { private List _files = []; private List _centralYears = []; - private const string GaugeArcPath = "M 30 110 A 80 80 0 0 1 190 110"; + private List _valueFieldOptions = []; + private readonly List _currencyOptions = + [ + new(ManagementCockpitCurrencyOptions.Eur, "EUR"), + new(ManagementCockpitCurrencyOptions.Usd, "USD"), + new(ManagementCockpitCurrencyOptions.Native, "Original") + ]; private string? _selectedFilePath; private ManagementCockpitResult? _result; private ManagementCockpitCentralResult? _centralResult; private int _selectedCentralYear; private int? _selectedCentralMonth; + private string _selectedFileValueField = ManagementCockpitValueFieldKeys.SalesPriceValue; + private string _selectedCentralValueField = ManagementCockpitValueFieldKeys.SalesPriceValue; + private IEnumerable _selectedCentralAdditionalValueFields = []; + private string _selectedFileTargetCurrency = ManagementCockpitCurrencyOptions.Eur; + private string _selectedCentralTargetCurrency = ManagementCockpitCurrencyOptions.Eur; private bool _loadingFiles; private bool _analyzing; private bool _analyzingCentral; @@ -337,6 +345,7 @@ { var state = await CockpitPageService.InitializeAsync(_selectedFilePath, _selectedCentralYear); _files = state.Files; + _valueFieldOptions = state.ValueFieldOptions; _centralYears = state.CentralYears; _selectedFilePath = state.SelectedFilePath; _selectedCentralYear = state.SelectedCentralYear; @@ -371,7 +380,11 @@ _analyzing = true; try { - _result = await CockpitPageService.AnalyzeAsync(_selectedFilePath); + _result = await CockpitPageService.AnalyzeAsync(_selectedFilePath, new ManagementCockpitAnalysisOptions + { + ValueField = _selectedFileValueField, + TargetCurrency = _selectedFileTargetCurrency + }); } catch (Exception ex) { @@ -391,7 +404,12 @@ _analyzingCentral = true; try { - _centralResult = await CockpitPageService.AnalyzeCentralAsync(_selectedCentralYear, _selectedCentralMonth); + _centralResult = await CockpitPageService.AnalyzeCentralAsync(_selectedCentralYear, _selectedCentralMonth, new ManagementCockpitAnalysisOptions + { + ValueField = _selectedCentralValueField, + AdditionalValueFields = _selectedCentralAdditionalValueFields.ToList(), + TargetCurrency = _selectedCentralTargetCurrency + }); } catch (Exception ex) { @@ -418,180 +436,31 @@ return $"{result.Summary.PeriodStart.Value:dd.MM.yyyy} - {result.Summary.PeriodEnd.Value:dd.MM.yyyy}"; } - private List BuildCentralGauges(ManagementCockpitCentralResult result) + private static string FormatValue(decimal value, string currency) + => string.IsNullOrWhiteSpace(currency) || currency == "-" + ? value.ToString("N2") + : $"{value:N2} {currency}"; + + private void SetSelectedCentralAdditionalValueFields(IEnumerable values) { - var invoiceDensity = result.Summary.RowCount == 0 ? 0m : result.Summary.InvoiceCount * 100m / result.Summary.RowCount; - var sourceDominance = result.SourceSystemTotals.Count == 0 - ? 0m - : result.SourceSystemTotals.Max(x => x.RowCount) * 100m / Math.Max(1, result.Summary.RowCount); - var countryDominance = result.CountryTotals.Count == 0 - ? 0m - : result.CountryTotals.Max(x => x.RowCount) * 100m / Math.Max(1, result.Summary.RowCount); - var periodCoverage = BuildPeriodCoveragePercent(result); - var topCountrySalesShare = BuildTopSalesSharePercent(result.CountryTotals); - var topSourceSalesShare = BuildTopSalesSharePercent(result.SourceSystemTotals); - var currencyComplexity = result.Summary.CurrencyCount <= 1 ? 0m : Math.Min(100m, (result.Summary.CurrencyCount - 1) * 25m); - var peakVsAverageMonth = BuildPeakVsAverageMonthPercent(result); - - return - [ - new CentralGaugeModel - { - Title = T("Rechnungsdichte", "Invoice density"), - Percent = invoiceDensity, - DisplayValue = $"{invoiceDensity:F0}%", - Subtitle = T("Rechnungen pro 100 Zeilen", "Invoices per 100 rows"), - Color = "#1f8a70" - }, - new CentralGaugeModel - { - Title = T("Quellen-Dominanz", "Source dominance"), - Percent = sourceDominance, - DisplayValue = $"{sourceDominance:F0}%", - Subtitle = T("Groesste Quelle nach Zeilen", "Largest source by rows"), - Color = "#d9822b" - }, - new CentralGaugeModel - { - Title = T("Land-Dominanz", "Country dominance"), - Percent = countryDominance, - DisplayValue = $"{countryDominance:F0}%", - Subtitle = T("Groesstes Land nach Zeilen", "Largest country by rows"), - Color = "#c4496b" - }, - new CentralGaugeModel - { - Title = T("Perioden-Abdeckung", "Period coverage"), - Percent = periodCoverage, - DisplayValue = $"{periodCoverage:F0}%", - Subtitle = BuildPeriodGaugeSubtitle(result), - Color = "#3d7ff0" - }, - new CentralGaugeModel - { - Title = T("Top-Land Umsatz", "Top country sales"), - Percent = topCountrySalesShare, - DisplayValue = $"{topCountrySalesShare:F0}%", - Subtitle = T("Anteil des umsatzstaerksten Landes", "Share of top-selling country"), - Color = "#7f56d9" - }, - new CentralGaugeModel - { - Title = T("Top-Quelle Umsatz", "Top source sales"), - Percent = topSourceSalesShare, - DisplayValue = $"{topSourceSalesShare:F0}%", - Subtitle = T("Anteil der staerksten Quelle", "Share of strongest source"), - Color = "#0f9fb5" - }, - new CentralGaugeModel - { - Title = T("Waehrungs-Komplexitaet", "Currency complexity"), - Percent = currencyComplexity, - DisplayValue = result.Summary.CurrencyCount.ToString("N0"), - Subtitle = T("Anzahl Waehrungen im Zeitraum", "Number of currencies in period"), - Color = "#b54708" - }, - new CentralGaugeModel - { - Title = T("Monat gegen Peak", "Month vs peak"), - Percent = peakVsAverageMonth, - DisplayValue = $"{peakVsAverageMonth:F0}%", - Subtitle = T("Durchschnittsmonat relativ zum Peak", "Average month relative to peak"), - Color = "#d92d20" - } - ]; - } - - private static decimal BuildPeriodCoveragePercent(ManagementCockpitCentralResult result) - { - if (result.Summary.PeriodStart is null || result.Summary.PeriodEnd is null) - return 0m; - - if (result.Filter.Month.HasValue) - { - var daysInMonth = DateTime.DaysInMonth(result.Filter.Year, result.Filter.Month.Value); - var coveredDays = result.DailyTotals - .Select(x => x.Day) - .Where(x => x.HasValue) - .Distinct() - .Count(); - return daysInMonth == 0 ? 0m : coveredDays * 100m / daysInMonth; - } - - var coveredMonths = result.MonthlyTotals - .Select(x => x.Month) - .Where(x => x.HasValue) - .Distinct() - .Count(); - return coveredMonths * 100m / 12m; - } - - private string BuildPeriodGaugeSubtitle(ManagementCockpitCentralResult result) - => result.Filter.Month.HasValue - ? T("Tage mit Daten im Monat", "Days with data in month") - : T("Monate mit Daten im Jahr", "Months with data in year"); - - private static decimal BuildTopSalesSharePercent(IEnumerable rows) - { - var materialized = rows.ToList(); - if (materialized.Count == 0) - return 0m; - - var total = materialized.Sum(x => x.SalesValue); - if (total == 0) - return 0m; - - return materialized.Max(x => x.SalesValue) * 100m / total; - } - - private static decimal BuildPeakVsAverageMonthPercent(ManagementCockpitCentralResult result) - { - var monthRows = result.MonthlyTotals.ToList(); - if (monthRows.Count == 0) - return 0m; - - var groupedMonths = monthRows - .GroupBy(x => x.Label, StringComparer.OrdinalIgnoreCase) - .Select(g => g.Sum(x => x.SalesValue)) + _selectedCentralAdditionalValueFields = values + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); - - if (groupedMonths.Count == 0) - return 0m; - - var peak = groupedMonths.Max(); - if (peak == 0) - return 0m; - - var average = groupedMonths.Average(); - return Math.Min(100m, average * 100m / peak); } - private static string BuildGaugeDashArray(decimal percent) - => $"{Math.Clamp(percent, 0m, 100m).ToString("F2", System.Globalization.CultureInfo.InvariantCulture)} 100"; - - private static string BuildGaugeNeedleX(decimal percent) - => GetGaugePoint(percent, 68d).X.ToString("F2", System.Globalization.CultureInfo.InvariantCulture); - - private static string BuildGaugeNeedleY(decimal percent) - => GetGaugePoint(percent, 68d).Y.ToString("F2", System.Globalization.CultureInfo.InvariantCulture); - - private static (double X, double Y) GetGaugePoint(decimal percent, double radius = 80d) + private static string FormatAdditionalValue(ManagementCockpitTimeValueRow row, string fieldKey) { - var clamped = Math.Clamp((double)percent, 0d, 100d); - var angle = Math.PI * (1d - clamped / 100d); - var x = 110d + radius * Math.Cos(angle); - var y = 110d - radius * Math.Sin(angle); - return (x, y); + if (!row.AdditionalValues.TryGetValue(fieldKey, out var value)) + return "-"; + + var formattedValue = FormatValue(value.Value, value.Currency); + return value.MissingExchangeRateCount == 0 + ? formattedValue + : $"{formattedValue} / {value.MissingExchangeRateCount} ohne Kurs"; } - private sealed class CentralGaugeModel - { - public string Title { get; set; } = string.Empty; - public decimal Percent { get; set; } - public string DisplayValue { get; set; } = string.Empty; - public string Subtitle { get; set; } = string.Empty; - public string Color { get; set; } = "#3d7ff0"; - } + private sealed record CurrencySelectOption(string Key, string Label); } @code { diff --git a/TrafagSalesExporter/HANDOFF_2026-04-15.md b/TrafagSalesExporter/HANDOFF_2026-04-15.md index 70cad64..643a286 100644 --- a/TrafagSalesExporter/HANDOFF_2026-04-15.md +++ b/TrafagSalesExporter/HANDOFF_2026-04-15.md @@ -2,6 +2,117 @@ Stand: 2026-04-15 +## Nachtrag 2026-04-29 Management-Cockpit-Auswertung + +Seit dem letzten dokumentierten Stand vom 2026-04-17 wurde das `Management Cockpit` weiter ausgebaut. Dieser Abschnitt rekonstruiert den aktuellen Stand aus dem Code, weil die Aenderungen nach einem PC-Absturz nicht direkt nachdokumentiert wurden. + +### Neue Auswertlogik + +Das Cockpit ist nicht mehr nur auf Umsatz als feste Kennzahl beschraenkt. + +Neu gibt es auswählbare Summenfelder: + +- `Sales Price/Value` +- `Quantity` +- `Standard cost` +- `Quantity * Standard cost` + +Diese Auswahl gilt fuer: + +- dateibasierte Analyse vorhandener Excel-Exporte +- zentrale Roh-Auswertung aus `CentralSalesRecords` + +### Anzeige-Waehrung und Wechselkurse + +Fuer betragliche Summenfelder kann jetzt eine Anzeige-Waehrung gewaehlt werden: + +- `EUR` +- `USD` +- `Original` + +Die Umrechnung nutzt `CurrencyExchangeRateService`. + +Wichtig: + +- nicht-betragliche Werte wie `Quantity` werden nicht umgerechnet +- bei `Original` bleiben Werte in der jeweiligen Quellwaehrung +- bei fehlendem Wechselkurs wird der betroffene Wert mit `0` in die Zielwaehrung eingerechnet +- fehlende Kurse werden als Anzahl `Nicht umgerechnet` bzw. in Hinweisen/Finding sichtbar gemacht +- Wechselkurse werden pro Quellwaehrung, Zielwaehrung und Datum gecacht, damit grosse Auswertungen nicht unnoetig oft die gleiche Rate aufloesen + +### Zusätzliche Summenfelder in der zentralen Sicht + +Die zentrale Roh-Auswertung kann neben dem Haupt-Summenfeld weitere Summenfelder anzeigen. + +Diese Zusatzwerte werden aktuell in den Zeitreihen ausgegeben: + +- Jahreswerte +- Monatswerte +- Tageswerte im gewaehlten Monat + +Beispiel: + +- Hauptwert: `Sales Price/Value` +- Zusatzwerte: `Quantity`, `Quantity * Standard cost` + +Damit kann die zentrale Sicht Umsatz, Mengen und Kostennaeherung nebeneinander darstellen. + +### UI-Stand + +`Components/Pages/ManagementCockpit.razor` hat neue Controls: + +- Summenfeld fuer Excel-Dateianalyse +- Anzeige-Waehrung fuer Excel-Dateianalyse +- Summenfeld fuer zentrale Roh-Auswertung +- weitere Summenfelder fuer zentrale Roh-Auswertung per Mehrfachauswahl +- Anzeige-Waehrung fuer zentrale Roh-Auswertung + +Die Tabellen wurden von festem Text `Umsatz` auf generische `Werte` / `Jahreswerte` / `Monatswerte` umgestellt. + +Die vorher dokumentierte Manometer-/Gauge-Sicht ist im aktuellen Arbeitsstand nicht mehr aktiv sichtbar. Stattdessen liegt der Fokus wieder auf Kennzahlen, Hinweisen und tabellarischen Auswertungen. + +### Technische Umsetzung + +Betroffene Dateien: + +- `Components/Pages/ManagementCockpit.razor` +- `Models/ManagementCockpitModels.cs` +- `Services/IManagementCockpitService.cs` +- `Services/ManagementCockpitPageService.cs` +- `Services/ManagementCockpitService.cs` +- `TrafagSalesExporter.Tests/ManagementCockpitServiceTests.cs` + +Neue bzw. erweiterte Modelle: + +- `ManagementCockpitValueFieldKeys` +- `ManagementCockpitCurrencyOptions` +- `ManagementCockpitValueFieldOption` +- `ManagementCockpitAnalysisOptions` +- `ManagementCockpitAggregatedFieldValue` + +Neue Felder in Ergebnissen: + +- gewaehltes Summenfeld +- Anzeige-Waehrung +- Anzahl fehlender Wechselkurse +- Zusatzwerte pro Zeitreihe + +### Testabdeckung + +Die `ManagementCockpitServiceTests` wurden erweitert um Tests fuer: + +- Umrechnung zentraler Werte in EUR +- Caching von Wechselkursauflösungen +- Mengen-Summe ohne Waehrungsumrechnung +- Zusatz-Summenfelder in Jahres- und Monatswerten + +Noch offen: + +- UI manuell pruefen +- genaue fachliche Zielwaehrung fuer Standardberichte bestaetigen +- entscheiden, ob `CHF` ebenfalls als direkte Anzeige-Waehrung angeboten werden soll +- klaeren, ob fehlende Wechselkurse langfristig mit `0`, Originalwert oder separater Fehlergruppe dargestellt werden sollen + ## Nachtrag 2026-04-17 Refactoring- und HANA-Stand Der Stand aus den frueheren Nachtraegen ist fuer Architektur und HANA-Zugriff nicht mehr vollstaendig. diff --git a/TrafagSalesExporter/LLM_SYSTEM_GUIDE.md b/TrafagSalesExporter/LLM_SYSTEM_GUIDE.md index 869760e..a51af32 100644 --- a/TrafagSalesExporter/LLM_SYSTEM_GUIDE.md +++ b/TrafagSalesExporter/LLM_SYSTEM_GUIDE.md @@ -228,12 +228,32 @@ Zwei Betriebsarten: 1. Dateibasiert - vorhandene `.xlsx` waehlen - Datei mit ClosedXML lesen + - Summenfeld waehlen + - Anzeige-Waehrung waehlen - Kennzahlen, Top-Listen, Datenqualitaet, Findings erzeugen 2. Zentraldatenbasiert - direkt aus `CentralSalesRecords` - Jahr/Monat Filter - - Rohsicht ohne Intercompany-, CHF-, Budget- oder Spartelogik + - Summenfeld waehlen + - optionale weitere Summenfelder fuer Zeitreihen waehlen + - Anzeige-Waehrung waehlen + - Rohsicht ohne Intercompany-, Budget- oder Spartelogik + +Aktuelle Summenfelder: + +- `Sales Price/Value` +- `Quantity` +- `Standard cost` +- `Quantity * Standard cost` + +Aktuelle Anzeige-Waehrungen: + +- `EUR` +- `USD` +- `Original` + +Die Waehrungsumrechnung nutzt `CurrencyExchangeRateService`. Bei `Original` bleiben Werte in Quellwaehrungen gruppiert. Nicht-betragliche Summenfelder wie `Quantity` haben keine Waehrung. Fehlende Wechselkurse werden gezaehlt und in Hinweisen bzw. Findings sichtbar; betroffene Werte werden in der Zielwaehrung mit `0` einbezogen. ## Quellsystemlogik @@ -323,11 +343,14 @@ Vorhanden: - `ExchangeRateImportService` fuer ECB-Tageskurse - `NormalizeCurrencyCode` - `ConvertCurrency` +- `ManagementCockpitService` kann betragliche Cockpit-Kennzahlen in `EUR` oder `USD` umrechnen Wichtig: -- die Rohsicht im `Management Cockpit` rechnet aktuell bewusst nicht in CHF um -- CHF ist derzeit Teil des allgemeinen Transformationssystems, nicht Default in der Cockpit-Rohsicht +- die Rohsicht im `Management Cockpit` kann jetzt Anzeige-Waehrungen nutzen +- `CHF` ist im Cockpit aktuell nicht als direkte Anzeige-Waehrung in der UI angeboten +- CHF bleibt weiterhin Teil des allgemeinen Transformationssystems +- fachlich ist noch zu klaeren, ob CHF als Standard- oder zusaetzliche Cockpit-Anzeige-Waehrung gebraucht wird ## SharePoint-Rolle im Gesamtsystem @@ -429,6 +452,16 @@ Aktuell vorhandene Schwerpunkte: - ConfigTransferService - DatabaseInitializationService +`ManagementCockpitServiceTests` decken inzwischen auch ab: + +- zentrale Analyse nach Jahr/Monat +- Tages-, Monats-, Jahres-, Quellen- und Laenderwerte +- waehlbare Summenfelder +- Waehrungsumrechnung in EUR +- Wechselkurs-Caching +- Mengen-Auswertung ohne Waehrungsumrechnung +- Zusatz-Summenfelder in Zeitreihen + Wichtig: - es gibt aktuell keine echten UI-Komponententests mit `bUnit` diff --git a/TrafagSalesExporter/Models/ManagementCockpitModels.cs b/TrafagSalesExporter/Models/ManagementCockpitModels.cs index 762d2b5..3bec7e2 100644 --- a/TrafagSalesExporter/Models/ManagementCockpitModels.cs +++ b/TrafagSalesExporter/Models/ManagementCockpitModels.cs @@ -7,6 +7,35 @@ public class ManagementCockpitFileOption public DateTime LastModified { get; set; } } +public static class ManagementCockpitValueFieldKeys +{ + public const string SalesPriceValue = nameof(SalesPriceValue); + public const string Quantity = nameof(Quantity); + public const string StandardCost = nameof(StandardCost); + public const string StandardCostTotal = nameof(StandardCostTotal); +} + +public static class ManagementCockpitCurrencyOptions +{ + public const string Native = "NATIVE"; + public const string Eur = "EUR"; + public const string Usd = "USD"; +} + +public class ManagementCockpitValueFieldOption +{ + public string Key { get; set; } = string.Empty; + public string Label { get; set; } = string.Empty; + public bool IsCurrencyAmount { get; set; } +} + +public class ManagementCockpitAnalysisOptions +{ + public string ValueField { get; set; } = ManagementCockpitValueFieldKeys.SalesPriceValue; + public List AdditionalValueFields { get; set; } = []; + public string TargetCurrency { get; set; } = ManagementCockpitCurrencyOptions.Native; +} + public class ManagementCockpitSummary { public string Land { get; set; } = string.Empty; @@ -15,6 +44,11 @@ public class ManagementCockpitSummary public int RowCount { get; set; } public int InvoiceCount { get; set; } public int CustomerCount { get; set; } + public string ValueFieldKey { get; set; } = ManagementCockpitValueFieldKeys.SalesPriceValue; + public string ValueFieldLabel { get; set; } = "Sales Price/Value"; + public string DisplayCurrency { get; set; } = string.Empty; + public int MissingExchangeRateCount { get; set; } + public decimal AggregatedValueTotal { get; set; } public decimal SalesValueTotal { get; set; } public decimal EstimatedCostTotal { get; set; } public decimal EstimatedMarginTotal { get; set; } @@ -53,6 +87,8 @@ public class ManagementCockpitCentralFilter { public int Year { get; set; } public int? Month { get; set; } + public string ValueField { get; set; } = ManagementCockpitValueFieldKeys.SalesPriceValue; + public string TargetCurrency { get; set; } = ManagementCockpitCurrencyOptions.Native; } public class ManagementCockpitCentralSummary @@ -62,6 +98,11 @@ public class ManagementCockpitCentralSummary public int SiteCount { get; set; } public int CountryCount { get; set; } public int CurrencyCount { get; set; } + public string ValueFieldKey { get; set; } = ManagementCockpitValueFieldKeys.SalesPriceValue; + public string ValueFieldLabel { get; set; } = "Sales Price/Value"; + public string DisplayCurrency { get; set; } = string.Empty; + public decimal ValueTotal { get; set; } + public int MissingExchangeRateCount { get; set; } public DateTime? PeriodStart { get; set; } public DateTime? PeriodEnd { get; set; } } @@ -74,9 +115,19 @@ public class ManagementCockpitTimeValueRow public int? Day { get; set; } public string Currency { get; set; } = string.Empty; public decimal SalesValue { get; set; } + public Dictionary AdditionalValues { get; set; } = new(StringComparer.OrdinalIgnoreCase); public int RowCount { get; set; } } +public class ManagementCockpitAggregatedFieldValue +{ + public string FieldKey { get; set; } = string.Empty; + public string Label { get; set; } = string.Empty; + public string Currency { get; set; } = string.Empty; + public decimal Value { get; set; } + public int MissingExchangeRateCount { get; set; } +} + public class ManagementCockpitDimensionValueRow { public string Label { get; set; } = string.Empty; @@ -91,6 +142,7 @@ public class ManagementCockpitCentralResult public ManagementCockpitCentralFilter Filter { get; set; } = new(); public ManagementCockpitCentralSummary Summary { get; set; } = new(); public List Notices { get; set; } = []; + public List AdditionalValueFields { get; set; } = []; public List YearlyTotals { get; set; } = []; public List MonthlyTotals { get; set; } = []; public List DailyTotals { get; set; } = []; diff --git a/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md b/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md index 27e5f24..c1c220e 100644 --- a/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md +++ b/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md @@ -2,6 +2,54 @@ Stand: 2026-04-15 +## Nachtrag 2026-04-29 Management Cockpit + +Seit dem 2026-04-17 wurden im `Management Cockpit` weitere Auswertmoeglichkeiten umgesetzt und nachtraeglich aus dem aktuellen Code rekonstruiert. + +Aktueller neuer Stand: + +- Summenfeld ist waehbar statt fest auf Umsatz: + - `Sales Price/Value` + - `Quantity` + - `Standard cost` + - `Quantity * Standard cost` +- Anzeige-Waehrung ist waehbar: + - `EUR` + - `USD` + - `Original` +- betragliche Werte werden ueber `CurrencyExchangeRateService` umgerechnet +- nicht-betragliche Werte wie `Quantity` bleiben ohne Waehrung +- fehlende Wechselkurse werden gezaehlt und in der UI/Hinweisen sichtbar +- zentrale Roh-Auswertung kann weitere Summenfelder als Zusatzspalten in Jahres-, Monats- und Tageswerten anzeigen +- dateibasierte Excel-Analyse nutzt ebenfalls Summenfeld und Anzeige-Waehrung + +Betroffene Dateien: + +- `Components/Pages/ManagementCockpit.razor` +- `Models/ManagementCockpitModels.cs` +- `Services/IManagementCockpitService.cs` +- `Services/ManagementCockpitPageService.cs` +- `Services/ManagementCockpitService.cs` +- `TrafagSalesExporter.Tests/ManagementCockpitServiceTests.cs` + +Neue Tests: + +- Umrechnung zentraler Werte in EUR +- Wechselkurs-Cache pro Waehrung/Ziel/Datum +- Mengen-Auswertung ohne Waehrungsumrechnung +- Zusatzwerte in Zeitreihen + +### Jetzt sinnvoll zu pruefen + +1. `dotnet test .\TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj --verbosity minimal` +2. Management Cockpit in der App oeffnen +3. zentrale Auswertung mit `Sales Price/Value` in `EUR` pruefen +4. zentrale Auswertung mit `Quantity` pruefen und bestaetigen, dass keine Waehrung angezeigt wird +5. Zusatzfelder `Quantity` und `Quantity * Standard cost` in Jahres-/Monatswerten pruefen +6. Dateianalyse einer exportierten Excel mit unterschiedlichen Summenfeldern pruefen +7. fachlich klaeren, ob `CHF` neben `EUR` und `USD` als Anzeige-Waehrung angeboten werden soll +8. fachlich klaeren, ob fehlende Wechselkurse als `0` in Zielwaehrung korrekt sind oder separat ausgewiesen werden sollen + ## Nachtrag 2026-04-17 Refactoring-Fortschritt Mehrere frueher als hoch priorisiert markierte Architekturpunkte sind inzwischen bereits umgesetzt. diff --git a/TrafagSalesExporter/Program.cs b/TrafagSalesExporter/Program.cs index 61ca2b7..d21e4d3 100644 --- a/TrafagSalesExporter/Program.cs +++ b/TrafagSalesExporter/Program.cs @@ -6,6 +6,11 @@ using TrafagSalesExporter.Services.DataSources; var builder = WebApplication.CreateBuilder(args); +builder.Logging.ClearProviders(); +builder.Logging.AddConsole(); +builder.Logging.AddDebug(); +builder.Logging.AddFilter("Microsoft.EntityFrameworkCore.Database.Command", LogLevel.Warning); + builder.Services.AddRazorComponents() .AddInteractiveServerComponents(); diff --git a/TrafagSalesExporter/Services/IManagementCockpitService.cs b/TrafagSalesExporter/Services/IManagementCockpitService.cs index 0fad099..7973a4d 100644 --- a/TrafagSalesExporter/Services/IManagementCockpitService.cs +++ b/TrafagSalesExporter/Services/IManagementCockpitService.cs @@ -5,7 +5,10 @@ namespace TrafagSalesExporter.Services; public interface IManagementCockpitService { Task> GetAvailableFilesAsync(); + IReadOnlyList GetValueFieldOptions(); Task AnalyzeAsync(string filePath); + Task AnalyzeAsync(string filePath, ManagementCockpitAnalysisOptions? options); Task> GetAvailableCentralYearsAsync(); Task AnalyzeCentralAsync(int year, int? month); + Task AnalyzeCentralAsync(int year, int? month, ManagementCockpitAnalysisOptions? options); } diff --git a/TrafagSalesExporter/Services/ManagementCockpitPageService.cs b/TrafagSalesExporter/Services/ManagementCockpitPageService.cs index 026a502..ca87c0f 100644 --- a/TrafagSalesExporter/Services/ManagementCockpitPageService.cs +++ b/TrafagSalesExporter/Services/ManagementCockpitPageService.cs @@ -7,8 +7,8 @@ public interface IManagementCockpitPageService Task InitializeAsync(string? selectedFilePath, int selectedCentralYear); Task> LoadFilesAsync(); Task> LoadCentralYearsAsync(); - Task AnalyzeAsync(string filePath); - Task AnalyzeCentralAsync(int year, int? month); + Task AnalyzeAsync(string filePath, ManagementCockpitAnalysisOptions options); + Task AnalyzeCentralAsync(int year, int? month, ManagementCockpitAnalysisOptions options); } public sealed class ManagementCockpitPageService : IManagementCockpitPageService @@ -28,6 +28,7 @@ public sealed class ManagementCockpitPageService : IManagementCockpitPageService return new ManagementCockpitPageState { Files = files, + ValueFieldOptions = _cockpitService.GetValueFieldOptions().ToList(), CentralYears = years, SelectedFilePath = selectedFilePath ?? files.FirstOrDefault()?.Path, SelectedCentralYear = selectedCentralYear == 0 ? years.LastOrDefault() : selectedCentralYear @@ -40,16 +41,17 @@ public sealed class ManagementCockpitPageService : IManagementCockpitPageService public Task> LoadCentralYearsAsync() => _cockpitService.GetAvailableCentralYearsAsync(); - public Task AnalyzeAsync(string filePath) - => _cockpitService.AnalyzeAsync(filePath); + public Task AnalyzeAsync(string filePath, ManagementCockpitAnalysisOptions options) + => _cockpitService.AnalyzeAsync(filePath, options); - public Task AnalyzeCentralAsync(int year, int? month) - => _cockpitService.AnalyzeCentralAsync(year, month); + public Task AnalyzeCentralAsync(int year, int? month, ManagementCockpitAnalysisOptions options) + => _cockpitService.AnalyzeCentralAsync(year, month, options); } public sealed class ManagementCockpitPageState { public List Files { get; set; } = []; + public List ValueFieldOptions { get; set; } = []; public List CentralYears { get; set; } = []; public string? SelectedFilePath { get; set; } public int SelectedCentralYear { get; set; } diff --git a/TrafagSalesExporter/Services/ManagementCockpitService.cs b/TrafagSalesExporter/Services/ManagementCockpitService.cs index 9dc17cf..441296e 100644 --- a/TrafagSalesExporter/Services/ManagementCockpitService.cs +++ b/TrafagSalesExporter/Services/ManagementCockpitService.cs @@ -8,12 +8,51 @@ namespace TrafagSalesExporter.Services; public class ManagementCockpitService : IManagementCockpitService { private readonly IDbContextFactory _dbFactory; + private readonly ICurrencyExchangeRateService _exchangeRateService; public ManagementCockpitService(IDbContextFactory dbFactory) + : this(dbFactory, new CurrencyExchangeRateService(dbFactory)) + { + } + + public ManagementCockpitService(IDbContextFactory dbFactory, ICurrencyExchangeRateService exchangeRateService) { _dbFactory = dbFactory; + _exchangeRateService = exchangeRateService; } + private static readonly List ValueFieldDefinitions = + [ + new() + { + Key = ManagementCockpitValueFieldKeys.SalesPriceValue, + Label = "Sales Price/Value", + IsCurrencyAmount = true, + CurrencySource = ValueCurrencySource.Sales + }, + new() + { + Key = ManagementCockpitValueFieldKeys.StandardCostTotal, + Label = "Quantity * Standard cost", + IsCurrencyAmount = true, + CurrencySource = ValueCurrencySource.StandardCost + }, + new() + { + Key = ManagementCockpitValueFieldKeys.StandardCost, + Label = "Standard cost", + IsCurrencyAmount = true, + CurrencySource = ValueCurrencySource.StandardCost + }, + new() + { + Key = ManagementCockpitValueFieldKeys.Quantity, + Label = "Quantity", + IsCurrencyAmount = false, + CurrencySource = ValueCurrencySource.None + } + ]; + public async Task> GetAvailableFilesAsync() { using var db = await _dbFactory.CreateDbContextAsync(); @@ -65,11 +104,20 @@ public class ManagementCockpitService : IManagementCockpitService .ToList(); } + public IReadOnlyList GetValueFieldOptions() + => ValueFieldDefinitions + .Select(ToValueFieldOption) + .ToList(); + public Task AnalyzeAsync(string filePath) + => AnalyzeAsync(filePath, null); + + public Task AnalyzeAsync(string filePath, ManagementCockpitAnalysisOptions? options) { if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath)) throw new InvalidOperationException("Die ausgewählte Excel-Datei wurde nicht gefunden."); + var aggregation = ResolveAggregation(options); using var workbook = new XLWorkbook(filePath); var worksheet = workbook.Worksheets.First(); var usedRange = worksheet.RangeUsed() ?? throw new InvalidOperationException("Die Excel-Datei enthält keine Daten."); @@ -92,14 +140,16 @@ public class ManagementCockpitService : IManagementCockpitService if (rows.Count == 0) throw new InvalidOperationException("Die Excel-Datei enthält keine auswertbaren Datenzeilen."); + ApplyAggregation(rows, aggregation); + var result = new ManagementCockpitResult { FilePath = filePath, - Summary = BuildSummary(rows), - Findings = BuildFindings(rows), - TopCustomers = BuildTopItems(rows, x => x.CustomerName, x => x.SalesValueTotal), - TopProductGroups = BuildTopItems(rows, x => x.ProductGroup, x => x.SalesValueTotal), - TopSalesEmployees = BuildTopItems(rows, x => x.SalesResponsibleEmployee, x => x.SalesValueTotal), + Summary = BuildSummary(rows, aggregation), + Findings = BuildFindings(rows, aggregation), + TopCustomers = BuildTopItems(rows, x => x.CustomerName, x => x.AggregatedValue), + TopProductGroups = BuildTopItems(rows, x => x.ProductGroup, x => x.AggregatedValue), + TopSalesEmployees = BuildTopItems(rows, x => x.SalesResponsibleEmployee, x => x.AggregatedValue), DataQualityCounts = BuildDataQualityCounts(rows) }; @@ -118,8 +168,13 @@ public class ManagementCockpitService : IManagementCockpitService return years; } - public async Task AnalyzeCentralAsync(int year, int? month) + public Task AnalyzeCentralAsync(int year, int? month) + => AnalyzeCentralAsync(year, month, null); + + public async Task AnalyzeCentralAsync(int year, int? month, ManagementCockpitAnalysisOptions? options) { + var aggregation = ResolveAggregation(options); + using var db = await _dbFactory.CreateDbContextAsync(); var baseRows = await db.CentralSalesRecords .Select(r => new CentralCockpitRow @@ -129,6 +184,9 @@ public class ManagementCockpitService : IManagementCockpitService Tsc = r.Tsc, InvoiceNumber = r.InvoiceNumber, SalesCurrency = string.IsNullOrWhiteSpace(r.SalesCurrency) ? "-" : r.SalesCurrency, + StandardCostCurrency = string.IsNullOrWhiteSpace(r.StandardCostCurrency) ? "-" : r.StandardCostCurrency, + Quantity = r.Quantity, + StandardCost = r.StandardCost, SalesValue = r.SalesPriceValue, PeriodDate = r.InvoiceDate ?? r.ExtractionDate }) @@ -137,16 +195,18 @@ public class ManagementCockpitService : IManagementCockpitService if (baseRows.Count == 0) throw new InvalidOperationException("Die zentrale Tabelle enthält noch keine Datensätze."); - var selectedRows = baseRows + var aggregatedRows = baseRows + .Select(row => BuildCentralAggregationRow(row, aggregation)) + .ToList(); + + var selectedRows = aggregatedRows .Where(r => r.PeriodDate.Year == year && (!month.HasValue || r.PeriodDate.Month == month.Value)) .ToList(); if (selectedRows.Count == 0) throw new InvalidOperationException("Für den gewählten Zeitraum gibt es keine Datensätze in der zentralen Tabelle."); - var yearlyRows = baseRows - .Where(r => r.PeriodDate.Year == 2025 || r.PeriodDate.Year == 2026) - .ToList(); + var yearlyRows = aggregatedRows; var dailyBaseRows = selectedRows .Where(r => month.HasValue) @@ -157,7 +217,9 @@ public class ManagementCockpitService : IManagementCockpitService Filter = new ManagementCockpitCentralFilter { Year = year, - Month = month + Month = month, + ValueField = aggregation.ValueField.Key, + TargetCurrency = aggregation.TargetCurrency }, Summary = new ManagementCockpitCentralSummary { @@ -165,86 +227,63 @@ public class ManagementCockpitService : IManagementCockpitService InvoiceCount = selectedRows.Select(x => x.InvoiceNumber).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase).Count(), SiteCount = selectedRows.Select(x => x.Tsc).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase).Count(), CountryCount = selectedRows.Select(x => x.Land).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase).Count(), - CurrencyCount = selectedRows.Select(x => x.SalesCurrency).Distinct(StringComparer.OrdinalIgnoreCase).Count(), + CurrencyCount = selectedRows.Select(x => x.DisplayCurrency).Distinct(StringComparer.OrdinalIgnoreCase).Count(), + ValueFieldKey = aggregation.ValueField.Key, + ValueFieldLabel = aggregation.ValueField.Label, + DisplayCurrency = BuildDisplayCurrencyLabel(selectedRows.Select(x => x.DisplayCurrency)), + ValueTotal = selectedRows.Sum(x => x.Value), + MissingExchangeRateCount = selectedRows.Count(x => x.MissingExchangeRate), PeriodStart = selectedRows.Min(x => x.PeriodDate), PeriodEnd = selectedRows.Max(x => x.PeriodDate) }, - Notices = - [ - "Roh-Auswertung aus CentralSalesRecords.", - "Keine Intercompany-Bereinigung angewendet.", - "Keine CHF-Umrechnung angewendet. Umsatz bleibt in Sales Currency.", - "Kein Budget- und kein Spartemapping angewendet.", - "Periodenlogik basiert auf Invoice Date, falls vorhanden, sonst auf Extraction Date." - ], + AdditionalValueFields = aggregation.AdditionalValueFields + .Select(ToValueFieldOption) + .ToList(), + Notices = BuildCentralNotices(aggregation, selectedRows.Count(x => x.MissingExchangeRate)), YearlyTotals = yearlyRows - .GroupBy(x => new { x.PeriodDate.Year, x.SalesCurrency }) + .GroupBy(x => new { x.PeriodDate.Year, x.DisplayCurrency }) .OrderBy(g => g.Key.Year) - .ThenBy(g => g.Key.SalesCurrency, StringComparer.OrdinalIgnoreCase) - .Select(g => new ManagementCockpitTimeValueRow - { - Label = g.Key.Year.ToString(), - Year = g.Key.Year, - Currency = g.Key.SalesCurrency, - SalesValue = g.Sum(x => x.SalesValue), - RowCount = g.Count() - }) + .ThenBy(g => g.Key.DisplayCurrency, StringComparer.OrdinalIgnoreCase) + .Select(g => BuildTimeValueRow(g, aggregation, g.Key.Year.ToString(), g.Key.Year, null, null, g.Key.DisplayCurrency)) .ToList(), MonthlyTotals = selectedRows - .GroupBy(x => new { x.PeriodDate.Year, x.PeriodDate.Month, x.SalesCurrency }) + .GroupBy(x => new { x.PeriodDate.Year, x.PeriodDate.Month, x.DisplayCurrency }) .OrderBy(g => g.Key.Year) .ThenBy(g => g.Key.Month) - .ThenBy(g => g.Key.SalesCurrency, StringComparer.OrdinalIgnoreCase) - .Select(g => new ManagementCockpitTimeValueRow - { - Label = $"{g.Key.Year:D4}-{g.Key.Month:D2}", - Year = g.Key.Year, - Month = g.Key.Month, - Currency = g.Key.SalesCurrency, - SalesValue = g.Sum(x => x.SalesValue), - RowCount = g.Count() - }) + .ThenBy(g => g.Key.DisplayCurrency, StringComparer.OrdinalIgnoreCase) + .Select(g => BuildTimeValueRow(g, aggregation, $"{g.Key.Year:D4}-{g.Key.Month:D2}", g.Key.Year, g.Key.Month, null, g.Key.DisplayCurrency)) .ToList(), DailyTotals = dailyBaseRows - .GroupBy(x => new { x.PeriodDate.Year, x.PeriodDate.Month, x.PeriodDate.Day, x.SalesCurrency }) + .GroupBy(x => new { x.PeriodDate.Year, x.PeriodDate.Month, x.PeriodDate.Day, x.DisplayCurrency }) .OrderBy(g => g.Key.Year) .ThenBy(g => g.Key.Month) .ThenBy(g => g.Key.Day) - .ThenBy(g => g.Key.SalesCurrency, StringComparer.OrdinalIgnoreCase) - .Select(g => new ManagementCockpitTimeValueRow - { - Label = $"{g.Key.Year:D4}-{g.Key.Month:D2}-{g.Key.Day:D2}", - Year = g.Key.Year, - Month = g.Key.Month, - Day = g.Key.Day, - Currency = g.Key.SalesCurrency, - SalesValue = g.Sum(x => x.SalesValue), - RowCount = g.Count() - }) + .ThenBy(g => g.Key.DisplayCurrency, StringComparer.OrdinalIgnoreCase) + .Select(g => BuildTimeValueRow(g, aggregation, $"{g.Key.Year:D4}-{g.Key.Month:D2}-{g.Key.Day:D2}", g.Key.Year, g.Key.Month, g.Key.Day, g.Key.DisplayCurrency)) .ToList(), SourceSystemTotals = selectedRows - .GroupBy(x => new { x.SourceSystem, x.SalesCurrency }) + .GroupBy(x => new { x.SourceSystem, x.DisplayCurrency }) .OrderBy(g => g.Key.SourceSystem, StringComparer.OrdinalIgnoreCase) - .ThenBy(g => g.Key.SalesCurrency, StringComparer.OrdinalIgnoreCase) + .ThenBy(g => g.Key.DisplayCurrency, StringComparer.OrdinalIgnoreCase) .Select(g => new ManagementCockpitDimensionValueRow { Label = g.Key.SourceSystem, - Currency = g.Key.SalesCurrency, - SalesValue = g.Sum(x => x.SalesValue), + Currency = g.Key.DisplayCurrency, + SalesValue = g.Sum(x => x.Value), RowCount = g.Count(), InvoiceCount = g.Select(x => x.InvoiceNumber).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase).Count() }) .ToList(), CountryTotals = selectedRows - .GroupBy(x => new { x.Land, x.SalesCurrency }) - .OrderByDescending(g => g.Sum(x => x.SalesValue)) + .GroupBy(x => new { x.Land, x.DisplayCurrency }) + .OrderByDescending(g => g.Sum(x => x.Value)) .ThenBy(g => g.Key.Land, StringComparer.OrdinalIgnoreCase) - .ThenBy(g => g.Key.SalesCurrency, StringComparer.OrdinalIgnoreCase) + .ThenBy(g => g.Key.DisplayCurrency, StringComparer.OrdinalIgnoreCase) .Select(g => new ManagementCockpitDimensionValueRow { Label = g.Key.Land, - Currency = g.Key.SalesCurrency, - SalesValue = g.Sum(x => x.SalesValue), + Currency = g.Key.DisplayCurrency, + SalesValue = g.Sum(x => x.Value), RowCount = g.Count(), InvoiceCount = g.Select(x => x.InvoiceNumber).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase).Count() }) @@ -263,12 +302,253 @@ public class ManagementCockpitService : IManagementCockpitService yield return settings.LocalConsolidatedExportFolder.Trim(); } + private AggregationSelection ResolveAggregation(ManagementCockpitAnalysisOptions? options) + { + var selectedField = ValueFieldDefinitions.FirstOrDefault(x => + string.Equals(x.Key, options?.ValueField, StringComparison.OrdinalIgnoreCase)) + ?? ValueFieldDefinitions.First(x => x.Key == ManagementCockpitValueFieldKeys.SalesPriceValue); + + var additionalFields = (options?.AdditionalValueFields ?? []) + .Select(key => ValueFieldDefinitions.FirstOrDefault(x => string.Equals(x.Key, key, StringComparison.OrdinalIgnoreCase))) + .Where(x => x is not null && !string.Equals(x.Key, selectedField.Key, StringComparison.OrdinalIgnoreCase)) + .Cast() + .GroupBy(x => x.Key, StringComparer.OrdinalIgnoreCase) + .Select(g => g.First()) + .ToList(); + + var targetCurrency = (options?.TargetCurrency ?? ManagementCockpitCurrencyOptions.Native).Trim().ToUpperInvariant(); + if (targetCurrency is not ManagementCockpitCurrencyOptions.Eur and not ManagementCockpitCurrencyOptions.Usd) + targetCurrency = ManagementCockpitCurrencyOptions.Native; + + return new AggregationSelection( + selectedField, + additionalFields, + targetCurrency, + new Dictionary(StringComparer.OrdinalIgnoreCase)); + } + + private void ApplyAggregation(List rows, AggregationSelection aggregation) + { + foreach (var row in rows) + { + var value = ResolveValue(row, aggregation.ValueField); + var currency = ResolveCurrency(row, aggregation.ValueField); + var converted = ConvertValue(value, currency, aggregation.ValueField, aggregation, row.InvoiceDate ?? row.OrderDate ?? row.ExtractionDate); + + row.AggregatedValue = converted.Value; + row.AggregatedCurrency = converted.DisplayCurrency; + row.MissingExchangeRate = converted.MissingExchangeRate; + } + } + + private CentralAggregationRow BuildCentralAggregationRow(CentralCockpitRow row, AggregationSelection aggregation) + { + var value = ResolveValue(row, aggregation.ValueField); + var currency = ResolveCurrency(row, aggregation.ValueField); + var converted = ConvertValue(value, currency, aggregation.ValueField, aggregation, row.PeriodDate); + var additionalValues = aggregation.AdditionalValueFields.ToDictionary( + field => field.Key, + field => + { + var additionalValue = ResolveValue(row, field); + var additionalCurrency = ResolveCurrency(row, field); + return ConvertValue(additionalValue, additionalCurrency, field, aggregation, row.PeriodDate); + }, + StringComparer.OrdinalIgnoreCase); + + return new CentralAggregationRow + { + SourceSystem = row.SourceSystem, + Land = row.Land, + Tsc = row.Tsc, + InvoiceNumber = row.InvoiceNumber, + PeriodDate = row.PeriodDate, + Value = converted.Value, + DisplayCurrency = converted.DisplayCurrency, + MissingExchangeRate = converted.MissingExchangeRate, + AdditionalValues = additionalValues + }; + } + + private ConvertedValue ConvertValue(decimal value, string sourceCurrency, ValueFieldDefinition field, AggregationSelection aggregation, DateTime? effectiveDate) + { + if (!field.IsCurrencyAmount) + return new ConvertedValue(value, "-", false); + + var normalizedSource = _exchangeRateService.NormalizeCurrencyCode(sourceCurrency); + if (string.IsNullOrWhiteSpace(normalizedSource) || normalizedSource == "-") + { + normalizedSource = "-"; + if (aggregation.TargetCurrency != ManagementCockpitCurrencyOptions.Native) + return new ConvertedValue(0m, aggregation.TargetCurrency, true); + } + + if (aggregation.TargetCurrency == ManagementCockpitCurrencyOptions.Native) + return new ConvertedValue(value, normalizedSource, false); + + if (string.Equals(normalizedSource, aggregation.TargetCurrency, StringComparison.OrdinalIgnoreCase)) + return new ConvertedValue(value, aggregation.TargetCurrency, false); + + var rateDate = (effectiveDate ?? DateTime.UtcNow).Date; + var cacheKey = BuildRateCacheKey(normalizedSource, aggregation.TargetCurrency, rateDate); + if (!aggregation.RateCache.TryGetValue(cacheKey, out var rate)) + { + rate = _exchangeRateService.ResolveRate(normalizedSource, aggregation.TargetCurrency, rateDate); + aggregation.RateCache[cacheKey] = rate; + } + + if (!rate.HasValue) + return new ConvertedValue(0m, aggregation.TargetCurrency, true); + + return new ConvertedValue(value * rate.Value, aggregation.TargetCurrency, false); + } + + private static string BuildRateCacheKey(string fromCurrency, string toCurrency, DateTime date) + => $"{fromCurrency}|{toCurrency}|{date:yyyy-MM-dd}"; + + private static decimal ResolveValue(CockpitRow row, ValueFieldDefinition field) + => field.Key switch + { + ManagementCockpitValueFieldKeys.Quantity => row.Quantity, + ManagementCockpitValueFieldKeys.StandardCost => row.StandardCost, + ManagementCockpitValueFieldKeys.StandardCostTotal => row.EstimatedCostTotal, + _ => row.SalesValueTotal + }; + + private static decimal ResolveValue(CentralCockpitRow row, ValueFieldDefinition field) + => field.Key switch + { + ManagementCockpitValueFieldKeys.Quantity => row.Quantity, + ManagementCockpitValueFieldKeys.StandardCost => row.StandardCost, + ManagementCockpitValueFieldKeys.StandardCostTotal => row.Quantity != 0m ? row.Quantity * row.StandardCost : row.StandardCost, + _ => row.SalesValue + }; + + private static string ResolveCurrency(CockpitRow row, ValueFieldDefinition field) + => field.CurrencySource switch + { + ValueCurrencySource.StandardCost => row.StandardCostCurrency, + ValueCurrencySource.Sales => row.SalesCurrency, + _ => "-" + }; + + private static string ResolveCurrency(CentralCockpitRow row, ValueFieldDefinition field) + => field.CurrencySource switch + { + ValueCurrencySource.StandardCost => row.StandardCostCurrency, + ValueCurrencySource.Sales => row.SalesCurrency, + _ => "-" + }; + + private static string BuildDisplayCurrencyLabel(IEnumerable currencies) + { + var distinct = currencies + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(x => x, StringComparer.OrdinalIgnoreCase) + .ToList(); + + return distinct.Count switch + { + 0 => "-", + 1 => distinct[0], + _ => "Mixed" + }; + } + + private static List BuildCentralNotices(AggregationSelection aggregation, int missingExchangeRateCount) + { + var notices = new List + { + "Roh-Auswertung aus CentralSalesRecords.", + $"Summenfeld: {aggregation.ValueField.Label}.", + "Keine Intercompany-Bereinigung angewendet.", + "Kein Budget- und kein Spartemapping angewendet.", + "Periodenlogik basiert auf Invoice Date, falls vorhanden, sonst auf Extraction Date." + }; + + if (aggregation.AdditionalValueFields.Count > 0) + notices.Add($"Weitere Summenfelder: {string.Join(", ", aggregation.AdditionalValueFields.Select(x => x.Label))}."); + + if (!aggregation.ValueField.IsCurrencyAmount) + { + notices.Add("Das gewaehlte Summenfeld ist kein Waehrungsbetrag; die Anzeige-Waehrung wird ignoriert."); + } + else if (aggregation.TargetCurrency == ManagementCockpitCurrencyOptions.Native) + { + notices.Add("Keine Waehrungsumrechnung angewendet; Werte bleiben in der jeweiligen Quellwaehrung."); + } + else + { + notices.Add($"Betragswerte werden in {aggregation.TargetCurrency} angezeigt."); + if (missingExchangeRateCount > 0) + notices.Add($"{missingExchangeRateCount} Zeilen hatten keinen passenden Wechselkurs und sind in den Summen mit 0 enthalten."); + } + + return notices; + } + + private static ManagementCockpitTimeValueRow BuildTimeValueRow( + IEnumerable groupRows, + AggregationSelection aggregation, + string label, + int? year, + int? month, + int? day, + string currency) + { + var rows = groupRows.ToList(); + return new ManagementCockpitTimeValueRow + { + Label = label, + Year = year, + Month = month, + Day = day, + Currency = currency, + SalesValue = rows.Sum(x => x.Value), + AdditionalValues = BuildAdditionalValues(rows, aggregation), + RowCount = rows.Count + }; + } + + private static Dictionary BuildAdditionalValues( + IReadOnlyCollection rows, + AggregationSelection aggregation) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var field in aggregation.AdditionalValueFields) + { + var values = rows + .Select(row => row.AdditionalValues.TryGetValue(field.Key, out var value) ? value : new ConvertedValue(0m, "-", false)) + .ToList(); + + result[field.Key] = new ManagementCockpitAggregatedFieldValue + { + FieldKey = field.Key, + Label = field.Label, + Currency = BuildDisplayCurrencyLabel(values.Select(x => x.DisplayCurrency)), + Value = values.Sum(x => x.Value), + MissingExchangeRateCount = values.Count(x => x.MissingExchangeRate) + }; + } + + return result; + } + + private static ManagementCockpitValueFieldOption ToValueFieldOption(ValueFieldDefinition field) + => new() + { + Key = field.Key, + Label = field.Label, + IsCurrencyAmount = field.IsCurrencyAmount + }; + private static CockpitRow ReadRow(IXLRangeRow row, IReadOnlyDictionary headers) { var quantity = GetDecimal(row, headers, "quantity"); var standardCost = GetDecimal(row, headers, "standardcost"); var salesValue = GetDecimal(row, headers, "salespricevalue"); - var estimatedCostTotal = quantity > 0 ? quantity * standardCost : standardCost; + var estimatedCostTotal = quantity != 0m ? quantity * standardCost : standardCost; return new CockpitRow { @@ -288,7 +568,9 @@ public class ManagementCockpitService : IManagementCockpitService CustomerCountry = GetText(row, headers, "customercountry"), CustomerIndustry = GetText(row, headers, "customerindustry"), StandardCost = standardCost, + StandardCostCurrency = GetText(row, headers, "standardcostcurrency"), SalesValueTotal = salesValue, + SalesCurrency = GetText(row, headers, "salescurrency"), Incoterms2020 = GetText(row, headers, "incoterms2020"), SalesResponsibleEmployee = GetText(row, headers, "salesresponsibleemployee"), InvoiceDate = GetDate(row, headers, "invoicedate"), @@ -299,8 +581,9 @@ public class ManagementCockpitService : IManagementCockpitService }; } - private static ManagementCockpitSummary BuildSummary(List rows) + private static ManagementCockpitSummary BuildSummary(List rows, AggregationSelection aggregation) { + var aggregatedTotal = rows.Sum(x => x.AggregatedValue); var salesTotal = rows.Sum(x => x.SalesValueTotal); var costTotal = rows.Sum(x => x.EstimatedCostTotal); var marginTotal = rows.Sum(x => x.EstimatedMarginTotal); @@ -317,7 +600,12 @@ public class ManagementCockpitService : IManagementCockpitService RowCount = rows.Count, InvoiceCount = rows.Select(x => x.InvoiceNumber).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase).Count(), CustomerCount = rows.Select(x => x.CustomerName).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase).Count(), - SalesValueTotal = salesTotal, + ValueFieldKey = aggregation.ValueField.Key, + ValueFieldLabel = aggregation.ValueField.Label, + DisplayCurrency = BuildDisplayCurrencyLabel(rows.Select(x => x.AggregatedCurrency)), + MissingExchangeRateCount = rows.Count(x => x.MissingExchangeRate), + AggregatedValueTotal = aggregatedTotal, + SalesValueTotal = aggregatedTotal, EstimatedCostTotal = costTotal, EstimatedMarginTotal = marginTotal, EstimatedMarginPercent = salesTotal == 0 ? 0 : marginTotal / salesTotal * 100m, @@ -327,14 +615,14 @@ public class ManagementCockpitService : IManagementCockpitService }; } - private static List BuildFindings(List rows) + private static List BuildFindings(List rows, AggregationSelection aggregation) { var findings = new List(); - var salesTotal = rows.Sum(x => x.SalesValueTotal); + var salesTotal = rows.Sum(x => x.AggregatedValue); var topCustomer = rows .Where(x => !string.IsNullOrWhiteSpace(x.CustomerName)) .GroupBy(x => x.CustomerName, StringComparer.OrdinalIgnoreCase) - .Select(g => new { Customer = g.Key, Sales = g.Sum(x => x.SalesValueTotal) }) + .Select(g => new { Customer = g.Key, Sales = g.Sum(x => x.AggregatedValue) }) .OrderByDescending(x => x.Sales) .FirstOrDefault(); @@ -349,6 +637,17 @@ public class ManagementCockpitService : IManagementCockpitService }); } + var missingExchangeRateRows = rows.Count(x => x.MissingExchangeRate); + if (missingExchangeRateRows > 0) + { + findings.Add(new ManagementCockpitFinding + { + Severity = "Warning", + Title = "Fehlende Wechselkurse", + Detail = $"{missingExchangeRateRows} Zeilen konnten nicht in die gewaehlte Anzeige-Waehrung umgerechnet werden." + }); + } + var zeroValueRows = rows.Where(x => x.SalesValueTotal == 0 || x.StandardCost == 0).ToList(); if (zeroValueRows.Count > 0) { @@ -521,7 +820,9 @@ public class ManagementCockpitService : IManagementCockpitService public string CustomerCountry { get; set; } = string.Empty; public string CustomerIndustry { get; set; } = string.Empty; public decimal StandardCost { get; set; } + public string StandardCostCurrency { get; set; } = string.Empty; public decimal SalesValueTotal { get; set; } + public string SalesCurrency { get; set; } = string.Empty; public string Incoterms2020 { get; set; } = string.Empty; public string SalesResponsibleEmployee { get; set; } = string.Empty; public DateTime? InvoiceDate { get; set; } @@ -529,6 +830,9 @@ public class ManagementCockpitService : IManagementCockpitService public string Land { get; set; } = string.Empty; public decimal EstimatedCostTotal { get; set; } public decimal EstimatedMarginTotal { get; set; } + public decimal AggregatedValue { get; set; } + public string AggregatedCurrency { get; set; } = string.Empty; + public bool MissingExchangeRate { get; set; } } private class CentralCockpitRow @@ -538,7 +842,46 @@ public class ManagementCockpitService : IManagementCockpitService public string Tsc { get; set; } = string.Empty; public string InvoiceNumber { get; set; } = string.Empty; public string SalesCurrency { get; set; } = string.Empty; + public string StandardCostCurrency { get; set; } = string.Empty; + public decimal Quantity { get; set; } + public decimal StandardCost { get; set; } public decimal SalesValue { get; set; } public DateTime PeriodDate { get; set; } } + + private class CentralAggregationRow + { + public string SourceSystem { get; set; } = string.Empty; + public string Land { get; set; } = string.Empty; + public string Tsc { get; set; } = string.Empty; + public string InvoiceNumber { get; set; } = string.Empty; + public DateTime PeriodDate { get; set; } + public decimal Value { get; set; } + public string DisplayCurrency { get; set; } = string.Empty; + public bool MissingExchangeRate { get; set; } + public Dictionary AdditionalValues { get; set; } = new(StringComparer.OrdinalIgnoreCase); + } + + private sealed record AggregationSelection( + ValueFieldDefinition ValueField, + IReadOnlyList AdditionalValueFields, + string TargetCurrency, + Dictionary RateCache); + + private sealed record ConvertedValue(decimal Value, string DisplayCurrency, bool MissingExchangeRate); + + private sealed class ValueFieldDefinition + { + public string Key { get; set; } = string.Empty; + public string Label { get; set; } = string.Empty; + public bool IsCurrencyAmount { get; set; } + public ValueCurrencySource CurrencySource { get; set; } + } + + private enum ValueCurrencySource + { + None, + Sales, + StandardCost + } } diff --git a/TrafagSalesExporter/Services/TimerBackgroundService.cs b/TrafagSalesExporter/Services/TimerBackgroundService.cs index 22d9463..22c6e19 100644 --- a/TrafagSalesExporter/Services/TimerBackgroundService.cs +++ b/TrafagSalesExporter/Services/TimerBackgroundService.cs @@ -26,7 +26,9 @@ public class TimerBackgroundService : BackgroundService { var dbFactory = _serviceProvider.GetRequiredService>(); using var db = await dbFactory.CreateDbContextAsync(); - var settings = await db.ExportSettings.FirstOrDefaultAsync(); + var settings = await db.ExportSettings + .OrderBy(x => x.Id) + .FirstOrDefaultAsync(); if (settings is null || !settings.TimerEnabled) { diff --git a/TrafagSalesExporter/TrafagSalesExporter.Tests/ManagementCockpitServiceTests.cs b/TrafagSalesExporter/TrafagSalesExporter.Tests/ManagementCockpitServiceTests.cs index bc66bdf..0b0cf4d 100644 --- a/TrafagSalesExporter/TrafagSalesExporter.Tests/ManagementCockpitServiceTests.cs +++ b/TrafagSalesExporter/TrafagSalesExporter.Tests/ManagementCockpitServiceTests.cs @@ -122,6 +122,111 @@ public class ManagementCockpitServiceTests : IDisposable Assert.Equal(2, result.MonthlyTotals.Count); } + [Fact] + public async Task AnalyzeCentralAsync_Can_Convert_Selected_Value_To_Eur() + { + await SeedRatesAsync( + CreateRate("EUR", "CHF", 2m), + CreateRate("EUR", "USD", 1.25m)); + await SeedCentralRowsAsync( + CreateRow("SAP", "Schweiz", "TRCH", "INV-1", "CHF", 100m, new DateTime(2025, 1, 10)), + CreateRow("SAP", "USA", "TRUS", "INV-2", "USD", 100m, new DateTime(2025, 1, 11)), + CreateRow("SAP", "Deutschland", "TRDE", "INV-3", "EUR", 100m, new DateTime(2025, 1, 12))); + + var result = await _service.AnalyzeCentralAsync(2025, null, new ManagementCockpitAnalysisOptions + { + ValueField = ManagementCockpitValueFieldKeys.SalesPriceValue, + TargetCurrency = ManagementCockpitCurrencyOptions.Eur + }); + + Assert.Equal("EUR", result.Summary.DisplayCurrency); + Assert.Equal(230m, result.Summary.ValueTotal); + Assert.Equal(0, result.Summary.MissingExchangeRateCount); + + Assert.All(result.CountryTotals, row => Assert.Equal("EUR", row.Currency)); + Assert.Equal(50m, Assert.Single(result.CountryTotals, x => x.Label == "Schweiz").SalesValue); + Assert.Equal(80m, Assert.Single(result.CountryTotals, x => x.Label == "USA").SalesValue); + Assert.Equal(100m, Assert.Single(result.CountryTotals, x => x.Label == "Deutschland").SalesValue); + } + + [Fact] + public async Task AnalyzeCentralAsync_Caches_Exchange_Rates_Per_Currency_Target_And_Date() + { + var exchangeRates = new CountingCurrencyExchangeRateService(); + var service = new ManagementCockpitService(_dbFactory, exchangeRates); + + await SeedCentralRowsAsync( + CreateRow("SAP", "USA", "TRUS", "INV-1", "USD", 100m, new DateTime(2025, 1, 10), quantity: 2m, standardCost: 10m), + CreateRow("SAP", "USA", "TRUS", "INV-2", "USD", 50m, new DateTime(2025, 1, 10), quantity: 3m, standardCost: 20m)); + + var result = await service.AnalyzeCentralAsync(2025, 1, new ManagementCockpitAnalysisOptions + { + ValueField = ManagementCockpitValueFieldKeys.SalesPriceValue, + AdditionalValueFields = [ManagementCockpitValueFieldKeys.StandardCostTotal], + TargetCurrency = ManagementCockpitCurrencyOptions.Eur + }); + + Assert.Equal(300m, result.Summary.ValueTotal); + Assert.Equal(160m, Assert.Single(result.MonthlyTotals).AdditionalValues[ManagementCockpitValueFieldKeys.StandardCostTotal].Value); + Assert.Equal(1, exchangeRates.ResolveRateCallCount); + } + + [Fact] + public async Task AnalyzeCentralAsync_Can_Sum_Quantity_Without_Currency_Conversion() + { + await SeedCentralRowsAsync( + CreateRow("SAP", "Schweiz", "TRCH", "INV-1", "CHF", 100m, new DateTime(2025, 1, 10), quantity: 2m), + CreateRow("SAP", "USA", "TRUS", "INV-2", "USD", 100m, new DateTime(2025, 1, 11), quantity: 3m)); + + var result = await _service.AnalyzeCentralAsync(2025, null, new ManagementCockpitAnalysisOptions + { + ValueField = ManagementCockpitValueFieldKeys.Quantity, + TargetCurrency = ManagementCockpitCurrencyOptions.Eur + }); + + Assert.Equal(ManagementCockpitValueFieldKeys.Quantity, result.Summary.ValueFieldKey); + Assert.Equal("-", result.Summary.DisplayCurrency); + Assert.Equal(5m, result.Summary.ValueTotal); + Assert.Equal(0, result.Summary.MissingExchangeRateCount); + Assert.Equal(2m, Assert.Single(result.CountryTotals, x => x.Label == "Schweiz").SalesValue); + Assert.Equal(3m, Assert.Single(result.CountryTotals, x => x.Label == "USA").SalesValue); + } + + [Fact] + public async Task AnalyzeCentralAsync_Adds_Selected_Additional_Value_Fields_To_Time_Rows() + { + await SeedCentralRowsAsync( + CreateRow("SAP", "Deutschland", "TRDE", "INV-1", "EUR", 100m, new DateTime(2025, 1, 10), quantity: 2m, standardCost: 5m), + CreateRow("SAP", "Deutschland", "TRDE", "INV-2", "EUR", 50m, new DateTime(2025, 2, 10), quantity: 3m, standardCost: 7m)); + + var result = await _service.AnalyzeCentralAsync(2025, null, new ManagementCockpitAnalysisOptions + { + ValueField = ManagementCockpitValueFieldKeys.SalesPriceValue, + AdditionalValueFields = + [ + ManagementCockpitValueFieldKeys.Quantity, + ManagementCockpitValueFieldKeys.StandardCostTotal + ], + TargetCurrency = ManagementCockpitCurrencyOptions.Eur + }); + + Assert.Equal(2, result.AdditionalValueFields.Count); + + var yearly = Assert.Single(result.YearlyTotals); + Assert.Equal(150m, yearly.SalesValue); + Assert.Equal(5m, yearly.AdditionalValues[ManagementCockpitValueFieldKeys.Quantity].Value); + Assert.Equal("-", yearly.AdditionalValues[ManagementCockpitValueFieldKeys.Quantity].Currency); + Assert.Equal(31m, yearly.AdditionalValues[ManagementCockpitValueFieldKeys.StandardCostTotal].Value); + Assert.Equal("EUR", yearly.AdditionalValues[ManagementCockpitValueFieldKeys.StandardCostTotal].Currency); + + Assert.Contains(result.MonthlyTotals, row => + row.Label == "2025-01" && + row.AdditionalValues[ManagementCockpitValueFieldKeys.Quantity].Value == 2m); + Assert.Contains(result.MonthlyTotals, row => + row.Label == "2025-02" && + row.AdditionalValues[ManagementCockpitValueFieldKeys.StandardCostTotal].Value == 21m); + } + [Fact] public async Task AnalyzeCentralAsync_Throws_When_No_Rows_Exist_For_Selected_Period() { @@ -142,7 +247,36 @@ public class ManagementCockpitServiceTests : IDisposable await db.SaveChangesAsync(); } - private static CentralSalesRecord CreateRow(string sourceSystem, string land, string tsc, string invoiceNumber, string currency, decimal salesValue, DateTime? invoiceDate, DateTime? extractionDate = null) + private async Task SeedRatesAsync(params CurrencyExchangeRate[] rates) + { + await using var db = await _dbFactory.CreateDbContextAsync(); + db.CurrencyExchangeRates.RemoveRange(db.CurrencyExchangeRates); + await db.SaveChangesAsync(); + db.CurrencyExchangeRates.AddRange(rates); + await db.SaveChangesAsync(); + } + + private static CurrencyExchangeRate CreateRate(string fromCurrency, string toCurrency, decimal rate) + => new() + { + FromCurrency = fromCurrency, + ToCurrency = toCurrency, + Rate = rate, + ValidFrom = new DateTime(2024, 1, 1), + IsActive = true + }; + + private static CentralSalesRecord CreateRow( + string sourceSystem, + string land, + string tsc, + string invoiceNumber, + string currency, + decimal salesValue, + DateTime? invoiceDate, + DateTime? extractionDate = null, + decimal quantity = 1m, + decimal standardCost = 1m) { return new CentralSalesRecord { @@ -156,7 +290,7 @@ public class ManagementCockpitServiceTests : IDisposable Material = "MAT", Name = "Article", ProductGroup = "PG", - Quantity = 1m, + Quantity = quantity, SupplierNumber = "SUP", SupplierName = "Supplier", SupplierCountry = "CH", @@ -164,7 +298,7 @@ public class ManagementCockpitServiceTests : IDisposable CustomerName = "Customer", CustomerCountry = "CH", CustomerIndustry = "Industry", - StandardCost = 1m, + StandardCost = standardCost, StandardCostCurrency = currency, PurchaseOrderNumber = "PO", SalesPriceValue = salesValue, @@ -192,4 +326,18 @@ public class ManagementCockpitServiceTests : IDisposable public Task CreateDbContextAsync(CancellationToken cancellationToken = default) => Task.FromResult(new AppDbContext(_options)); } + + private sealed class CountingCurrencyExchangeRateService : ICurrencyExchangeRateService + { + public int ResolveRateCallCount { get; private set; } + + public decimal? ResolveRate(string fromCurrency, string toCurrency, DateTime? effectiveDate) + { + ResolveRateCallCount++; + return 2m; + } + + public string NormalizeCurrencyCode(string? currencyCode) + => string.IsNullOrWhiteSpace(currencyCode) ? string.Empty : currencyCode.Trim().ToUpperInvariant(); + } } diff --git a/TrafagSalesExporter/TrafagSalesExporter.csproj b/TrafagSalesExporter/TrafagSalesExporter.csproj index 0f5ca2d..1ee37af 100644 --- a/TrafagSalesExporter/TrafagSalesExporter.csproj +++ b/TrafagSalesExporter/TrafagSalesExporter.csproj @@ -1,6 +1,7 @@ net8.0 + x64 enable enable