Add unit tests for Fuchs_DataService and related components

- Introduced comprehensive unit tests for the Fuchs_DataService library, covering DATEV header formatting, CSV/XML generation, and FdsMfrClient construction.
- Implemented tests for FdsMfr.UpdateNeed parsing and FdsShared utility helpers, ensuring correct functionality and stability.
- Added tests for FdsConfig and FdsMfrClient to validate configuration resolution and client construction.

Document decisions on backend-authoritative invoice and reminder handling

- Created ADR 0008 to clarify that all invoice types and reminder stages are backend-authoritative during drafting and previewing.
- Established that all calculations and settings must be processed server-side, ensuring consistency between online editor and PDF outputs.

Define irreversible mutations for set-price modes in invoices

- Documented ADR 0009 to specify that the "Set mit Preis" and "Nur Set mit Preis" operations are irreversible mutations affecting service request blocks.
- Clarified that these operations are not display toggles but actual data changes, ensuring clear expectations for invoice handling.

Transition MFR ERP sync to in-process execution within the web app

- Created ADR 0010 to outline the migration of Fuchs_DataService from a standalone service to an in-process library within the Fuchs web application.
- Updated configuration and logging management to be handled by the host application, streamlining the sync process.

Add publish profile and periodic hosted service for job scheduling

- Introduced a publish profile for deployment to a specified folder.
- Implemented PeriodicHostedService to manage multiple independent jobs, including the MFR ERP sync, with configurable execution intervals.

Add dotnet-tools.json for EF Core CLI tools

- Included dotnet-tools.json to manage the version of dotnet-ef for Entity Framework Core migrations and commands.
This commit is contained in:
Stefan
2026-07-16 13:34:23 +02:00
parent f724b9b59d
commit 49e3ed2673
102 changed files with 2501 additions and 8438 deletions
Binary file not shown.
-90
View File
@@ -1,90 +0,0 @@
using fds.Logging;
using Microsoft.Extensions.Logging;
using Topshelf;
namespace fds;
public class FdsService : ServiceControl
{
private readonly PeriodicHostedService _hostedService;
private readonly CancellationTokenSource _cts = new();
public FdsService()
{
var loggerFactory = LoggerFactory.Create(b =>
b.SetMinimumLevel(LogLevel.Debug).AddFdsLogging());
var mfr = new FdsMfr(
loggerFactory.CreateLogger<FdsMfr>(),
loggerFactory);
var interval = TimeSpan.FromMinutes(FdsConfig.ExecutionFrequency_Minutes);
var jobs = new[]
{
new PeriodicJobDefinition("MfrSync", interval, async ct =>
{
bool debug = FdsConfig.DebugDetails;
await mfr.UpdateIfNecessary_async(debug, ct);
await mfr.UpdateRequested_async(debug, ct);
await mfr.GetInvoiceFiles_async(debug, ct);
})
};
var logger = loggerFactory.CreateLogger<PeriodicHostedService>();
_hostedService = new PeriodicHostedService(jobs, logger);
}
public bool Start(HostControl hostControl)
{
_ = _hostedService.StartAsync(_cts.Token);
return true;
}
public bool Stop(HostControl hostControl)
{
_cts.Cancel();
_hostedService.StopAsync(CancellationToken.None).GetAwaiter().GetResult();
return true;
}
}
public static class FdsMainModule
{
public static void Main(string[] args)
{
FdsConfig.Initialize();
string machineName = Environment.MachineName.ToLower();
if (!new[] { "digital-pc", "digital-dpc" }.Contains(machineName))
{
HostFactory.Run(x =>
{
x.Service<FdsService>(s =>
{
s.ConstructUsing(name => new FdsService());
s.WhenStarted((tc, host) => tc.Start(host));
s.WhenStopped((tc, host) => tc.Stop(host));
s.BeforeStoppingService(ctx =>
{
if (FdsConfig.DebugDetails) Task.Run(() => FdsDebug.DebugToFile("fds__data_service - beforestop", filename: "DebugDetail.txt"));
});
s.WhenPaused((tc, host) => tc.Stop(host));
s.WhenContinued((tc, host) => tc.Start(host));
});
x.EnablePauseAndContinue();
x.StartAutomatically();
x.RunAsLocalSystem();
x.SetDescription("MFR Data Sync");
x.SetDisplayName("MFR Data Sync");
x.SetServiceName("MFR Data Sync");
});
}
else
{
var svc = new FdsService();
svc.Start(null!);
Console.WriteLine("Running locally — press any key to stop.");
Console.ReadKey();
svc.Stop(null!);
}
}
}
+7 -10
View File
@@ -319,16 +319,13 @@ public class FdsMfr : IFdsMfr
var archiveFile = new FileInfo(Path.GetTempPath() + $"DatevUpload_AR {startdate:yyyyMM}_{admin["mode"]}.zip");
try
{
using var archive = new Archive(archiveFile, type: SevenZip.OutArchiveFormat.Zip,
logger: _loggerFactory.CreateLogger<Archive>());
if (archive.CompressToStream(fls, targetstream: stream!))
{
stream!.Position = 0;
return archiveFile;
}
_logger.LogError(
"getDatevZip: CompressToStream returned false — archiveFile={ArchiveFile} fileCount={FileCount}",
archiveFile.FullName, fls.Count);
// Native .NET zip via the OCORE helper (System.IO.Compression) — no external
// 7-Zip dependency. The DATEV export is a plain, unencrypted zip, so ZipArchive
// covers it fully. filesToZipArchive skips null/empty entries.
var zipBytes = Task.Run(async () => await OCORE.zip.filesToZipArchive(fls)).Result;
stream!.Write(zipBytes, 0, zipBytes.Length);
stream.Position = 0;
return archiveFile;
}
catch (Exception ex)
{
+6 -27
View File
@@ -2,29 +2,22 @@
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
namespace fds;
/// <summary>
/// Holds the application <see cref="IConfiguration"/> built from appsettings.json.
/// Call <see cref="Initialize()"/> once at startup before accessing <see cref="Current"/>.
/// Holds the application <see cref="IConfiguration"/> supplied by the host (the Fuchs web app).
/// Fuchs_DataService is a library — it has no configuration of its own; the host owns the
/// connection strings and MFR credentials and injects them via <see cref="Initialize"/>,
/// which <c>Program.cs</c> calls once at startup before any <see cref="FdsMfr"/> use.
/// </summary>
public static class FdsConfig
{
private static IConfiguration? _config;
internal static IConfiguration Current =>
_config ?? throw new InvalidOperationException("FdsConfig has not been initialized. Call FdsConfig.Initialize() in Main().");
public static void Initialize()
{
_config = new ConfigurationBuilder()
.SetBasePath(AppDomain.CurrentDomain.BaseDirectory)
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: false)
.Build();
}
_config ?? throw new InvalidOperationException("FdsConfig has not been initialized. Call FdsConfig.Initialize(configuration) at host startup.");
public static void Initialize(IConfiguration configuration)
{
@@ -32,21 +25,11 @@ public static class FdsConfig
}
// -- Connection strings ---------------------------------------------------
internal static string SQLConnectionString() =>
Current.GetConnectionString("fuchs_ConnectionString")
?? throw new InvalidOperationException("Missing connection string: fuchs_ConnectionString");
internal static string FDSConnectionString() =>
Current.GetConnectionString("fuchs_fds_ConnectionString")
?? throw new InvalidOperationException("Missing connection string: fuchs_fds_ConnectionString");
// -- App settings ---------------------------------------------------------
internal static double ExecutionFrequency_Minutes =>
Current.GetValue<double>("Fds:ExecutionFrequency_Minutes", 15);
internal static bool DebugDetails =>
Current.GetValue<bool>("Fds:DebugDetails");
// -- MFR credentials (supplied by the host from Key Vault / appsettings) --
internal static string MFR_UserName =>
Current["Fds:MFR_UserName"] ?? "";
@@ -59,12 +42,8 @@ public static class FdsConfig
internal static class FdsShared
{
internal static string SQLConnectionString() => FdsConfig.SQLConnectionString();
internal static string FDSConnectionString() => FdsConfig.FDSConnectionString();
internal static SqlConnection SqlCon() =>
new(FdsConfig.SQLConnectionString());
public static string RandomString(byte length)
{
var r = new Random();
-243
View File
@@ -1,243 +0,0 @@
using Microsoft.Extensions.Logging;
using SevenZip;
namespace fds;
public class Archive : IDisposable
{
public event Action? FileSaved;
public event Action? FileStreamCreated;
private FileInfo _archiveFile;
private string _archivePassword;
private OutArchiveFormat _archiveFormat;
private readonly ILogger<Archive> _logger;
public string TempPath { get; set; } = AppDomain.CurrentDomain.BaseDirectory;
public Stream? ArchiveFileStream { get; set; }
private SevenZipExtractor? _zipOut;
private SevenZipCompressor? _zipIn;
public bool ZipAppend { get; set; } = true;
public bool ExitOK { get; set; }
public bool ZipInOK { get; set; }
public Archive(FileInfo archiveFile, string archivePassword = "", bool init = true,
OutArchiveFormat type = OutArchiveFormat.SevenZip, ILogger<Archive>? logger = null)
{
_logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<Archive>.Instance;
_archiveFormat = type;
_archiveFile = new FileInfo(archiveFile.FullName.Replace(archiveFile.Extension,
type == OutArchiveFormat.SevenZip ? ".7z" : archiveFile.Extension));
_archivePassword = archivePassword;
if (init) InitZipIn(type);
}
private void InitZipIn(OutArchiveFormat type)
{
if (string.IsNullOrEmpty(Zipping.SevenZipPath))
{
try
{
var assemblyDir = new DirectoryInfo(
new Uri(Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location)!).LocalPath);
var zip = assemblyDir.GetFiles("7z.dll", SearchOption.AllDirectories).FirstOrDefault();
Zipping.SevenZipPath = zip?.FullName ?? "";
}
finally
{
if (string.IsNullOrEmpty(Zipping.SevenZipPath))
{
var assemblyDir = new DirectoryInfo(AppDomain.CurrentDomain.BaseDirectory);
var zip = assemblyDir.GetFiles("7z.dll", SearchOption.AllDirectories).FirstOrDefault();
Zipping.SevenZipPath = zip?.FullName ?? "";
}
}
if (string.IsNullOrEmpty(Zipping.SevenZipPath))
_logger.LogError("SevenZipPath not found — 7z.dll is missing from the output directory.");
}
SevenZipCompressor.SetLibraryPath(Zipping.SevenZipPath);
_zipIn = new SevenZipCompressor
{
ArchiveFormat = (type == OutArchiveFormat.SevenZip && _archiveFile.Extension.Contains("7z", StringComparison.OrdinalIgnoreCase))
? OutArchiveFormat.SevenZip : type,
CompressionLevel = SevenZip.CompressionLevel.Ultra,
CompressionMode = ZipAppend ? SevenZip.CompressionMode.Append : SevenZip.CompressionMode.Create,
DirectoryStructure = false
};
_zipIn.CompressionMethod = _zipIn.ArchiveFormat switch
{
OutArchiveFormat.SevenZip => CompressionMethod.Lzma2,
OutArchiveFormat.Zip or OutArchiveFormat.GZip => CompressionMethod.Deflate,
_ => CompressionMethod.Default
};
ZipInOK = true;
}
public void Extract(FileInfo dataArchiveFilePath, DirectoryInfo tgtDirectory, OutArchiveFormat type = default)
{
if (!dataArchiveFilePath.Exists) return;
if (type == default)
type = dataArchiveFilePath.Extension.Contains("7z", StringComparison.OrdinalIgnoreCase)
? OutArchiveFormat.SevenZip : OutArchiveFormat.Zip;
if (!ZipInOK) InitZipIn(type);
_zipOut = string.IsNullOrEmpty(_archivePassword)
? new SevenZipExtractor(dataArchiveFilePath.FullName)
: new SevenZipExtractor(dataArchiveFilePath.FullName, _archivePassword);
try
{
if (!_zipOut.ArchiveFileData[0].Encrypted && !string.IsNullOrEmpty(_archivePassword))
_archivePassword = "";
}
catch (Exception ex)
{
_logger.LogError(ex, "Archive.Extract failed — path={Path}, target={Target}",
dataArchiveFilePath.FullName, tgtDirectory.FullName);
return;
}
_zipOut.ExtractArchive(tgtDirectory.FullName);
_zipOut.Dispose();
}
public bool Compress(List<FileInfo> files, FileInfo? archiveFile = null, string? archivePass = null, OutArchiveFormat type = OutArchiveFormat.SevenZip)
{
if (files.Count == 0) return true;
if (!ZipInOK) InitZipIn(type);
archiveFile ??= _archiveFile;
archivePass = string.IsNullOrEmpty(archivePass) ? _archivePassword : archivePass;
if (archiveFile.Exists && ZipAppend)
_zipIn!.CompressionMode = CompressionMode.Append;
else
{
if (archiveFile.Exists) archiveFile.Delete();
_zipIn!.CompressionMode = CompressionMode.Create;
}
try
{
var filesVerified = files.Where(f => f.Exists).ToArray();
var filePaths = filesVerified.Select(f => f.FullName).ToArray();
if (string.IsNullOrEmpty(archivePass))
_zipIn.CompressFiles(archiveFile.FullName, filePaths);
else
{
_zipIn.EncryptHeaders = true;
_zipIn.ZipEncryptionMethod = ZipEncryptionMethod.Aes256;
_zipIn.CompressFilesEncrypted(archiveFile.FullName, archivePass, filePaths);
}
FileSaved?.Invoke();
ExitOK = true;
_zipIn = null;
ZipInOK = false;
}
catch (Exception ex)
{
// Previously silent: callers (e.g. HandleDatevZip) saw only a bare failed result
// with no way to tell a compression error from any other reason ExitOK is false.
_logger.LogError(ex, "Archive.Compress failed — archiveFile={ArchiveFile} fileCount={FileCount}",
archiveFile.FullName, files.Count);
ExitOK = false;
}
archiveFile.Refresh();
return ExitOK && archiveFile.Exists;
}
public bool CompressToStream(Dictionary<string, byte[]> files, Stream? targetstream = null)
{
if (files.Count == 0) return true;
if (!ZipInOK) InitZipIn(_archiveFormat);
if (ArchiveFileStream == null)
{
_zipIn!.CompressionMode = CompressionMode.Create;
ArchiveFileStream = new MemoryStream();
}
else
_zipIn!.CompressionMode = CompressionMode.Append;
try
{
var filesStreams = files.ToDictionary(kv => kv.Key, kv => (Stream)new MemoryStream(kv.Value));
var target = targetstream ?? ArchiveFileStream;
if (string.IsNullOrEmpty(_archivePassword))
_zipIn.CompressStreamDictionary(filesStreams, target);
else
{
_zipIn.EncryptHeaders = true;
_zipIn.ZipEncryptionMethod = ZipEncryptionMethod.Aes256;
_zipIn.CompressStreamDictionary(filesStreams, target, _archivePassword);
}
ArchiveFileStream.Seek(0, SeekOrigin.Begin);
FileStreamCreated?.Invoke();
ExitOK = true;
_zipIn = null;
ZipInOK = false;
}
catch (Exception ex)
{
// Previously silent: the DATEV export ZIP-to-stream path failed with no
// trace anywhere, only a bare false return.
_logger.LogError(ex, "Archive.CompressToStream failed — fileCount={FileCount}", files.Count);
ExitOK = false;
}
return ExitOK;
}
public bool WriteArchiveStreamToDisk(FileInfo? archiveFile = null)
{
try { if (_archiveFile.Exists) _archiveFile.Delete(); } catch { }
FdsShared.WriteStreamToDisk(ArchiveFileStream!, (archiveFile ?? _archiveFile).FullName);
_archiveFile.Refresh();
return _archiveFile.Exists;
}
#region IDisposable
private bool _disposed;
protected virtual void Dispose(bool disposing)
{
if (!_disposed)
{
if (disposing)
{
try
{
ArchiveFileStream?.Dispose();
_zipOut?.Dispose();
_zipIn = null;
}
catch { }
}
_disposed = true;
}
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
#endregion
}
public static class Zipping
{
public static string SevenZipPath = "";
public static void FastAppend(FileInfo fileToZip, FileInfo archiveFile)
{
if (fileToZip.Exists && archiveFile?.Exists == true)
{
using var zip = new Archive(archiveFile) { ZipAppend = true };
zip.Compress(new List<FileInfo> { fileToZip });
}
}
}
+5 -17
View File
@@ -1,7 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Exe</OutputType>
<RootNamespace>fds</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
@@ -19,29 +18,18 @@
<NoWarn>1591;NU1608</NoWarn>
</PropertyGroup>
<ItemGroup>
<Content Include="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="install.bat" />
<Content Include="un-install.bat" />
<!-- Expose internal members (FdsConfig, FdsShared helpers) to the test project -->
<InternalsVisibleTo Include="Fuchs.Tests" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MFR_RESTClient\MFR_RESTClient.csproj" />
<ProjectReference Include="..\OCORE\OCORE\OCORE.csproj" />
<ProjectReference Include="..\OCORE_web\OCORE_web\OCORE_web.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="7z.dll">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="Squid-Box.SevenZipSharp" Version="1.6.2.24" />
<PackageReference Include="Topshelf" Version="4.3.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.9" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.2" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.9" />
<PackageReference Include="System.Configuration.ConfigurationManager" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.10" />
</ItemGroup>
</Project>
@@ -1,76 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Exe</OutputType>
<StartupObject>Sub Main</StartupObject>
<RootNamespace>fds</RootNamespace>
<MyType>Empty</MyType>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<Configurations>db-dev.processweb.de;Debug;Release;server02.processweb.de</Configurations>
</PropertyGroup>
<ItemGroup>
<Import Include="System" />
<Import Include="System.Data" />
<Import Include="Microsoft.Data" />
<Import Include="System.IO" />
</ItemGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<PlatformTarget>x64</PlatformTarget>
<DocumentationFile>Fuchs_DataService.xml</DocumentationFile>
<NoWarn>42016,41999,42017,42018,42019,42032,42036,42020,42021,42022</NoWarn>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
<DefineDebug>false</DefineDebug>
<DocumentationFile>Fuchs_DataService.xml</DocumentationFile>
<NoWarn>42016,41999,42017,42018,42019,42032,42036,42020,42021,42022</NoWarn>
</PropertyGroup>
<ItemGroup>
<Compile Update="My Project\Application.Designer.vb">
<AutoGen>True</AutoGen>
<DependentUpon>Application.myapp</DependentUpon>
<DesignTime>True</DesignTime>
</Compile>
<Compile Update="My Project\Resources.Designer.vb">
<AutoGen>True</AutoGen>
<DesignTime>True</DesignTime>
<DependentUpon>Resources.resx</DependentUpon>
</Compile>
<Compile Update="My Project\Settings.Designer.vb">
<AutoGen>True</AutoGen>
<DependentUpon>Settings.settings</DependentUpon>
<DesignTimeSharedInput>True</DesignTimeSharedInput>
</Compile>
</ItemGroup>
<ItemGroup>
<Content Include="install.bat" />
<None Update="My Project\Application.myapp">
<Generator>MyApplicationCodeGenerator</Generator>
<LastGenOutput>Application.Designer.vb</LastGenOutput>
</None>
<Content Include="un-install.bat" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MFR_RESTClient\MFR_RESTClient.csproj" />
<ProjectReference Include="..\..\..\WebProjectComponents\OCORE_web\OCORE_web.csproj" />
<ProjectReference Include="..\..\..\WebProjectComponents\OCORE\OCORE\OCORE.csproj" />
<ProjectReference Include="..\..\..\WebProjectComponents\OCMSsharp\OCMS\OCMS.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="7z.dll">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<None Include="Fuchs_DataService.csproj" />
</ItemGroup>
<ItemGroup>
<!-- Updated packages -->
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<!-- Compatible packages (kept) -->
<PackageReference Include="Squid-Box.SevenZipSharp" Version="1.6.1.23" />
<PackageReference Include="Topshelf" Version="4.3.0" />
<!-- New packages (needed for .NET 10) -->
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.0" />
<PackageReference Include="System.Configuration.ConfigurationManager" Version="10.0.5" />
</ItemGroup>
</Project>
+4 -32
View File
@@ -18,38 +18,10 @@
</member>
<member name="T:fds.FdsConfig">
<summary>
Holds the application <see cref="T:Microsoft.Extensions.Configuration.IConfiguration"/> built from appsettings.json.
Call <see cref="M:fds.FdsConfig.Initialize"/> once at startup before accessing <see cref="P:fds.FdsConfig.Current"/>.
</summary>
</member>
<member name="T:fds.Logging.FdsLoggerProvider">
<summary>
Writes log entries to Debug output and a rolling file.
Database logging is wired up but disabled — set <see cref="P:fds.Logging.FdsLoggerProvider.DatabaseLoggingEnabled"/> to true to activate.
</summary>
</member>
<member name="P:fds.Logging.FdsLoggerProvider.DatabaseLoggingEnabled">
<summary>Set to true to activate database logging via fds__admin_logdebug.</summary>
</member>
<member name="M:fds.Logging.FdsLogger.WriteToDatabase(System.String,System.String,System.Exception)">
<summary>
Prepared DB logging via fds__admin_logdebug.
Enable by setting <see cref="P:fds.Logging.FdsLoggerProvider.DatabaseLoggingEnabled"/> = true.
</summary>
</member>
<member name="T:fds.PeriodicJobDefinition">
<summary>
Defines a named job with its own execution schedule for use with <see cref="T:fds.PeriodicHostedService"/>.
</summary>
</member>
<member name="M:fds.PeriodicJobDefinition.#ctor(System.String,System.TimeSpan,System.Func{System.Threading.CancellationToken,System.Threading.Tasks.Task})">
<summary>
Defines a named job with its own execution schedule for use with <see cref="T:fds.PeriodicHostedService"/>.
</summary>
</member>
<member name="T:fds.PeriodicHostedService">
<summary>
A <see cref="T:Microsoft.Extensions.Hosting.BackgroundService"/> that runs multiple independent jobs, each on its own <see cref="T:System.Threading.PeriodicTimer"/>.
Holds the application <see cref="T:Microsoft.Extensions.Configuration.IConfiguration"/> supplied by the host (the Fuchs web app).
Fuchs_DataService is a library — it has no configuration of its own; the host owns the
connection strings and MFR credentials and injects them via <see cref="M:fds.FdsConfig.Initialize(Microsoft.Extensions.Configuration.IConfiguration)"/>,
which <c>Program.cs</c> calls once at startup before any <see cref="T:fds.FdsMfr"/> use.
</summary>
</member>
</members>
@@ -1,117 +0,0 @@
using Microsoft.Extensions.Logging;
using System.Diagnostics;
namespace fds.Logging;
/// <summary>
/// Writes log entries to Debug output and a rolling file.
/// Database logging is wired up but disabled — set <see cref="DatabaseLoggingEnabled"/> to true to activate.
/// </summary>
public sealed class FdsLoggerProvider : ILoggerProvider
{
private readonly string _logDirectory;
/// <summary>Set to true to activate database logging via fds__admin_logdebug.</summary>
public static bool DatabaseLoggingEnabled { get; set; } = false;
public FdsLoggerProvider(string? logDirectory = null)
{
_logDirectory = logDirectory
?? Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "tmp");
Directory.CreateDirectory(_logDirectory);
}
public ILogger CreateLogger(string categoryName) =>
new FdsLogger(categoryName, _logDirectory);
public void Dispose() { }
}
internal sealed class FdsLogger : ILogger
{
private readonly string _categoryName;
private readonly string _logDirectory;
private static readonly Lock _fileLock = new();
internal FdsLogger(string categoryName, string logDirectory)
{
_categoryName = categoryName;
_logDirectory = logDirectory;
}
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => logLevel != LogLevel.None;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state,
Exception? exception, Func<TState, Exception?, string> formatter)
{
if (!IsEnabled(logLevel)) return;
string message = formatter(state, exception);
string timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
string levelTag = logLevel switch
{
LogLevel.Trace => "TRC",
LogLevel.Debug => "DBG",
LogLevel.Information => "INF",
LogLevel.Warning => "WRN",
LogLevel.Error => "ERR",
LogLevel.Critical => "CRT",
_ => "???"
};
string line = $"{timestamp} [{levelTag}] {_categoryName}: {message}";
if (exception != null)
line += $"\r\n Exception: {exception.Message}\r\n Stack: {exception.StackTrace}";
// Always emit to Debug output
Debug.WriteLine(line);
// Always write to file
string filename = logLevel >= LogLevel.Error ? "ErrorLog.txt" : "DebugLog.txt";
AppendToFile(filename, line);
// Database logging — prepared, not activated
if (FdsLoggerProvider.DatabaseLoggingEnabled)
WriteToDatabase(_categoryName, message, exception);
}
private void AppendToFile(string filename, string line)
{
try
{
lock (_fileLock)
File.AppendAllText(Path.Combine(_logDirectory, filename), line + "\r\n");
}
catch { /* never throw from logger */ }
}
/// <summary>
/// Prepared DB logging via fds__admin_logdebug.
/// Enable by setting <see cref="FdsLoggerProvider.DatabaseLoggingEnabled"/> = true.
/// </summary>
private static void WriteToDatabase(string codeReference, string message, Exception? exception)
{
// Activate by setting FdsLoggerProvider.DatabaseLoggingEnabled = true in appsettings / startup.
//
// using var con = new Microsoft.Data.SqlClient.SqlConnection(FdsConfig.FDSConnectionString());
// using var cmd = new Microsoft.Data.SqlClient.SqlCommand(
// "EXECUTE [dbo].[fds__admin_logdebug] @CodeReference, @ExceptionMessage, @StackTrace, @Data;", con);
// cmd.Parameters.AddWithValue("@CodeReference", codeReference);
// cmd.Parameters.AddWithValue("@ExceptionMessage", (object?)exception?.Message ?? DBNull.Value);
// cmd.Parameters.AddWithValue("@StackTrace", (object?)exception?.StackTrace ?? DBNull.Value);
// cmd.Parameters.AddWithValue("@Data", message);
// con.Open();
// cmd.ExecuteNonQuery();
}
}
public static class FdsLoggingExtensions
{
public static ILoggingBuilder AddFdsLogging(this ILoggingBuilder builder, string? logDirectory = null)
{
builder.AddProvider(new FdsLoggerProvider(logDirectory));
return builder;
}
}
@@ -1,53 +0,0 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace fds;
/// <summary>
/// Defines a named job with its own execution schedule for use with <see cref="PeriodicHostedService"/>.
/// </summary>
public sealed record PeriodicJobDefinition(
string Name,
TimeSpan Interval,
Func<CancellationToken, Task> Execute);
/// <summary>
/// A <see cref="BackgroundService"/> that runs multiple independent jobs, each on its own <see cref="PeriodicTimer"/>.
/// </summary>
public sealed class PeriodicHostedService : BackgroundService
{
private readonly IReadOnlyList<PeriodicJobDefinition> _jobs;
private readonly ILogger<PeriodicHostedService> _logger;
public PeriodicHostedService(IEnumerable<PeriodicJobDefinition> jobs, ILogger<PeriodicHostedService> logger)
{
_jobs = jobs.ToList();
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var jobTasks = _jobs.Select(job => RunJobAsync(job, stoppingToken)).ToList();
await Task.WhenAll(jobTasks);
}
private async Task RunJobAsync(PeriodicJobDefinition job, CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(job.Interval);
_logger.LogInformation("Job '{Name}' scheduled with interval {Interval}.", job.Name, job.Interval);
while (await timer.WaitForNextTickAsync(stoppingToken))
{
_logger.LogDebug("Job '{Name}' starting.", job.Name);
try
{
await job.Execute(stoppingToken);
_logger.LogDebug("Job '{Name}' completed.", job.Name);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Job '{Name}' failed.", job.Name);
}
}
}
}
-13
View File
@@ -1,13 +0,0 @@
{
"ConnectionStrings": {
"fuchs_ConnectionString": "Data Source=MSSQL4.NBG4.DOMAINXYZ.DE,10439;Initial Catalog=site_fuchs_dev;Persist Security Info=False;TrustServerCertificate=true;Encrypt=true;User ID=fuchs_web;password='Bt5pL/cJg9oxb5';Connect Timeout=60;Load Balance Timeout=240;Max Pool Size=500;",
"fuchs_fds_ConnectionString": "Data Source=MSSQL4.NBG4.DOMAINXYZ.DE,10439;Initial Catalog=site_fuchs_dev;Persist Security Info=False;TrustServerCertificate=true;Encrypt=true;User ID=fuchs_fds;password='!Po@cGZ5bUn37khO';Connect Timeout=60;Load Balance Timeout=240;Max Pool Size=500;"
},
"Fds": {
"ExecutionFrequency_Minutes": 15,
"DebugDetails": true,
"MFR_UserName": "system@sebastian-fuchs---bad-und-heizung-gmbh-und-co-kg.com",
"MFR_Password": "0oT4G3H2",
"MFR_host": "portal.mobilefieldreport.com"
}
}
-1
View File
@@ -1 +0,0 @@
Fuchs_Dataservice.exe install --autostart
-1
View File
@@ -1 +0,0 @@
Fuchs_Dataservice.exe uninstall