diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index bfc1512..bd94f65 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -47,8 +47,8 @@
## Services & Dependency Injection
- Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces; inject them into `IntranetController` (constructor injection). Do **not** reintroduce static God-classes or pass the whole controller into helpers.
- - `IComService` (email/SMS via ProcessWeb Mailer API, attachments sent inline as base64; honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService` (MigraDoc render), `IInvoiceService`, `IReminderService`, `IReportService` (SQL report engine via `FuchsVisualization`), `IWidgetService`, `IBankingService`, `IMfrClientFactory`.
- - Lifetimes: stateless services (`IPdfService`, `IBankingService`, `IMfrClientFactory`) are singletons; request-scoped DB services (`IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IComService`) are scoped. Register in `Program.cs`.
+ - `IComService` (email/SMS via ProcessWeb Mailer API, attachments sent inline as base64; honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService` (MigraDoc render), `IInvoiceService`, `IReminderService`, `IReportService` (SQL report engine via `FuchsVisualization`), `IWidgetService`, `IBankingService`, `IMfrClientFactory`, `ISystemStatusService` (Admin module diagnostics: config snapshot + DB/Key Vault/blob/MFR connectivity probes + test-email; restricted to `fds_sys` > 4).
+ - Lifetimes: stateless services (`IPdfService`, `IBankingService`, `IMfrClientFactory`) are singletons; request-scoped DB services (`IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IComService`, `ISystemStatusService`) are scoped. Register in `Program.cs`.
- `FdsInvoiceData` / `FdsReminderData` are **pure data holders** (parse + properties). Loading, persistence and PDF generation belong in the services — never `Task.Run(...).Wait()` sync-over-async.
- Data access stays SQL-first via OCORE helpers (`getSQLDataSet_async`, `setSQLValue_async`) + stored procedures; no EF Core.
diff --git a/CLAUDE.md b/CLAUDE.md
index c6b1216..9fb8c9f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -37,7 +37,7 @@
## Services & Dependency Injection
- Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces, injected into `IntranetController`. Do not reintroduce static God-classes or pass the controller into helpers.
-- Services: `IComService` (ProcessWeb Mailer API; honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`. Stateless ones are singletons; DB/request-scoped ones are scoped (see `Program.cs`).
+- Services: `IComService` (ProcessWeb Mailer API; honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`, `ISystemStatusService` (Admin module diagnostics: config snapshot + DB/Key Vault/blob/MFR connectivity probes + test-email; restricted to `fds_sys` > 4). Stateless ones are singletons; DB/request-scoped ones are scoped (see `Program.cs`).
- `FdsInvoiceData` / `FdsReminderData` are **pure data holders**; load/persist/render belongs in services. No `Task.Run(...).Wait()` sync-over-async.
- Data access is SQL-first via OCORE helpers + stored procedures (no EF Core).
diff --git a/CODEX.md b/CODEX.md
index 9058e9c..5d6495d 100644
--- a/CODEX.md
+++ b/CODEX.md
@@ -37,7 +37,7 @@
## Services & Dependency Injection
- Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces, injected into `IntranetController`. Do not reintroduce static God-classes or pass the controller into helpers.
-- Services: `IComService` (ProcessWeb Mailer API; honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`. Stateless ones are singletons; DB/request-scoped ones are scoped (see `Program.cs`).
+- Services: `IComService` (ProcessWeb Mailer API; honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`, `ISystemStatusService` (Admin module diagnostics: config snapshot + DB/Key Vault/blob/MFR connectivity probes + test-email; restricted to `fds_sys` > 4). Stateless ones are singletons; DB/request-scoped ones are scoped (see `Program.cs`).
- `FdsInvoiceData` / `FdsReminderData` are **pure data holders**; load/persist/render belongs in services. No `Task.Run(...).Wait()` sync-over-async.
- Data access is SQL-first via OCORE helpers + stored procedures (no EF Core).
diff --git a/DataService_Legacy/7z.dll b/DataService_Legacy/7z.dll
deleted file mode 100644
index b32d7bf..0000000
Binary files a/DataService_Legacy/7z.dll and /dev/null differ
diff --git a/DataService_Legacy/App.config b/DataService_Legacy/App.config
deleted file mode 100644
index eb4f30b..0000000
--- a/DataService_Legacy/App.config
+++ /dev/null
@@ -1,131 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 15
-
-
- True
-
-
- system@sebastian-fuchs---bad-und-heizung-gmbh-und-co-kg.com
-
-
- 0oT4G3H2
-
-
- portal.mobilefieldreport.com
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/DataService_Legacy/Fuchs_DataService.vbproj b/DataService_Legacy/Fuchs_DataService.vbproj
deleted file mode 100644
index 3a383f2..0000000
--- a/DataService_Legacy/Fuchs_DataService.vbproj
+++ /dev/null
@@ -1,186 +0,0 @@
-
-
-
-
- Debug
- AnyCPU
- {7A56E271-A6BE-4C34-A859-DADEBC4C7A54}
- Exe
- Sub Main
- fds
- Fuchs_DataService
- 512
- Console
- v4.8
- true
- true
- false
- publish\
- true
- Disk
- false
- Foreground
- 7
- Days
- false
- false
- true
- 0
- 1.0.0.%2a
- false
- true
-
-
- x64
- true
- full
- true
- true
- bin\Debug\
- Fuchs_DataService.xml
- 42016,41999,42017,42018,42019,42032,42036,42020,42021,42022
- false
-
-
- AnyCPU
- pdbonly
- false
- true
- true
- bin\Release\
- Fuchs_DataService.xml
- 42016,41999,42017,42018,42019,42032,42036,42020,42021,42022
-
-
- On
-
-
- Binary
-
-
- Off
-
-
- On
-
-
-
- ..\..\..\NugetPackages\Microsoft.Web.Infrastructure.2.0.0\lib\net40\Microsoft.Web.Infrastructure.dll
-
-
- ..\..\..\NugetPackages\Newtonsoft.Json.13.0.3\lib\net45\Newtonsoft.Json.dll
-
-
- ..\..\..\NugetPackages\Squid-Box.SevenZipSharp.1.6.1.23\lib\net472\SevenZipSharp.dll
-
-
-
-
-
-
-
- ..\..\..\NugetPackages\System.Runtime.InteropServices.RuntimeInformation.4.3.0\lib\net45\System.Runtime.InteropServices.RuntimeInformation.dll
- True
- True
-
-
-
-
- ..\..\..\NugetPackages\Microsoft.AspNet.Razor.3.2.9\lib\net45\System.Web.Razor.dll
-
-
-
-
-
-
-
- ..\..\..\NugetPackages\Topshelf.4.3.0\lib\net452\Topshelf.dll
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- True
- Application.myapp
- True
-
-
- True
- True
- Resources.resx
-
-
- True
- Settings.settings
- True
-
-
-
-
-
- VbMyResourcesResXFileCodeGenerator
- Resources.Designer.vb
- My.Resources
- Designer
-
-
-
-
-
- MyApplicationCodeGenerator
- Application.Designer.vb
-
-
- SettingsSingleFileGenerator
- My
- Settings.Designer.vb
-
-
-
-
-
-
-
- {ac8cba60-d786-48fd-a9f0-8b045a7bd505}
- OCMS
-
-
- {00c70b53-516d-4d56-ad25-6757094b4335}
- MFR_RESTClient
-
-
-
-
- Always
-
-
-
-
- False
- Microsoft .NET Framework 4.8 %28x86 and x64%29
- true
-
-
- False
- .NET Framework 3.5 SP1
- false
-
-
-
-
-
\ No newline at end of file
diff --git a/DataService_Legacy/My Project/Application.Designer.vb b/DataService_Legacy/My Project/Application.Designer.vb
deleted file mode 100644
index 88dd01c..0000000
--- a/DataService_Legacy/My Project/Application.Designer.vb
+++ /dev/null
@@ -1,13 +0,0 @@
-'------------------------------------------------------------------------------
-'
-' This code was generated by a tool.
-' Runtime Version:4.0.30319.42000
-'
-' Changes to this file may cause incorrect behavior and will be lost if
-' the code is regenerated.
-'
-'------------------------------------------------------------------------------
-
-Option Strict On
-Option Explicit On
-
diff --git a/DataService_Legacy/My Project/Application.myapp b/DataService_Legacy/My Project/Application.myapp
deleted file mode 100644
index e62f1a5..0000000
--- a/DataService_Legacy/My Project/Application.myapp
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
- false
- false
- 0
- true
- 0
- 2
- true
-
diff --git a/DataService_Legacy/My Project/AssemblyInfo.vb b/DataService_Legacy/My Project/AssemblyInfo.vb
deleted file mode 100644
index e65a15f..0000000
--- a/DataService_Legacy/My Project/AssemblyInfo.vb
+++ /dev/null
@@ -1,35 +0,0 @@
-Imports System
-Imports System.Reflection
-Imports System.Runtime.InteropServices
-
-' General Information about an assembly is controlled through the following
-' set of attributes. Change these attribute values to modify the information
-' associated with an assembly.
-
-' Review the values of the assembly attributes
-
-
-
-
-
-
-
-
-
-
-'The following GUID is for the ID of the typelib if this project is exposed to COM
-
-
-' Version information for an assembly consists of the following four values:
-'
-' Major Version
-' Minor Version
-' Build Number
-' Revision
-'
-' You can specify all the values or you can default the Build and Revision Numbers
-' by using the '*' as shown below:
-'
-
-
-
diff --git a/DataService_Legacy/My Project/Resources.Designer.vb b/DataService_Legacy/My Project/Resources.Designer.vb
deleted file mode 100644
index d04821b..0000000
--- a/DataService_Legacy/My Project/Resources.Designer.vb
+++ /dev/null
@@ -1,63 +0,0 @@
-'------------------------------------------------------------------------------
-'
-' This code was generated by a tool.
-' Runtime Version:4.0.30319.42000
-'
-' Changes to this file may cause incorrect behavior and will be lost if
-' the code is regenerated.
-'
-'------------------------------------------------------------------------------
-
-Option Strict On
-Option Explicit On
-
-Imports System
-
-Namespace My.Resources
-
- 'This class was auto-generated by the StronglyTypedResourceBuilder
- 'class via a tool like ResGen or Visual Studio.
- 'To add or remove a member, edit your .ResX file then rerun ResGen
- 'with the /str option, or rebuild your VS project.
- '''
- ''' A strongly-typed resource class, for looking up localized strings, etc.
- '''
- _
- Friend Module Resources
-
- Private resourceMan As Global.System.Resources.ResourceManager
-
- Private resourceCulture As Global.System.Globalization.CultureInfo
-
- '''
- ''' Returns the cached ResourceManager instance used by this class.
- '''
- _
- Friend ReadOnly Property ResourceManager() As Global.System.Resources.ResourceManager
- Get
- If Object.ReferenceEquals(resourceMan, Nothing) Then
- Dim temp As Global.System.Resources.ResourceManager = New Global.System.Resources.ResourceManager("fds.Resources", GetType(Resources).Assembly)
- resourceMan = temp
- End If
- Return resourceMan
- End Get
- End Property
-
- '''
- ''' Overrides the current thread's CurrentUICulture property for all
- ''' resource lookups using this strongly typed resource class.
- '''
- _
- Friend Property Culture() As Global.System.Globalization.CultureInfo
- Get
- Return resourceCulture
- End Get
- Set
- resourceCulture = value
- End Set
- End Property
- End Module
-End Namespace
diff --git a/DataService_Legacy/My Project/Resources.resx b/DataService_Legacy/My Project/Resources.resx
deleted file mode 100644
index af7dbeb..0000000
--- a/DataService_Legacy/My Project/Resources.resx
+++ /dev/null
@@ -1,117 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- text/microsoft-resx
-
-
- 2.0
-
-
- System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
-
-
- System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
-
-
\ No newline at end of file
diff --git a/DataService_Legacy/My Project/Settings.Designer.vb b/DataService_Legacy/My Project/Settings.Designer.vb
deleted file mode 100644
index 694c998..0000000
--- a/DataService_Legacy/My Project/Settings.Designer.vb
+++ /dev/null
@@ -1,118 +0,0 @@
-'------------------------------------------------------------------------------
-'
-' This code was generated by a tool.
-' Runtime Version:4.0.30319.42000
-'
-' Changes to this file may cause incorrect behavior and will be lost if
-' the code is regenerated.
-'
-'------------------------------------------------------------------------------
-
-Option Strict On
-Option Explicit On
-
-
-Namespace My
-
- _
- Partial Friend NotInheritable Class MySettings
- Inherits Global.System.Configuration.ApplicationSettingsBase
-
- Private Shared defaultInstance As MySettings = CType(Global.System.Configuration.ApplicationSettingsBase.Synchronized(New MySettings()),MySettings)
-
-#Region "My.Settings Auto-Save Functionality"
-#If _MyType = "WindowsForms" Then
- Private Shared addedHandler As Boolean
-
- Private Shared addedHandlerLockObject As New Object
-
- _
- Private Shared Sub AutoSaveSettings(sender As Global.System.Object, e As Global.System.EventArgs)
- If My.Application.SaveMySettingsOnExit Then
- My.Settings.Save()
- End If
- End Sub
-#End If
-#End Region
-
- Public Shared ReadOnly Property [Default]() As MySettings
- Get
-
-#If _MyType = "WindowsForms" Then
- If Not addedHandler Then
- SyncLock addedHandlerLockObject
- If Not addedHandler Then
- AddHandler My.Application.Shutdown, AddressOf AutoSaveSettings
- addedHandler = True
- End If
- End SyncLock
- End If
-#End If
- Return defaultInstance
- End Get
- End Property
-
- _
- Public ReadOnly Property ExecutionFrequency_Minutes() As String
- Get
- Return CType(Me("ExecutionFrequency_Minutes"),String)
- End Get
- End Property
-
- _
- Public ReadOnly Property DebugDetails() As Boolean
- Get
- Return CType(Me("DebugDetails"),Boolean)
- End Get
- End Property
-
- _
- Public ReadOnly Property MFR_UserName() As String
- Get
- Return CType(Me("MFR_UserName"),String)
- End Get
- End Property
-
- _
- Public ReadOnly Property MFR_Password() As String
- Get
- Return CType(Me("MFR_Password"),String)
- End Get
- End Property
-
- _
- Public ReadOnly Property MFR_host() As String
- Get
- Return CType(Me("MFR_host"),String)
- End Get
- End Property
- End Class
-End Namespace
-
-Namespace My
-
- _
- Friend Module MySettingsProperty
-
- _
- Friend ReadOnly Property Settings() As Global.fds.My.MySettings
- Get
- Return Global.fds.My.MySettings.Default
- End Get
- End Property
- End Module
-End Namespace
diff --git a/DataService_Legacy/My Project/Settings.settings b/DataService_Legacy/My Project/Settings.settings
deleted file mode 100644
index f920057..0000000
--- a/DataService_Legacy/My Project/Settings.settings
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
-
-
- 15
-
-
- True
-
-
- system@sebastian-fuchs---bad-und-heizung-gmbh-und-co-kg.com
-
-
- 0oT4G3H2
-
-
- portal.mobilefieldreport.com
-
-
-
\ No newline at end of file
diff --git a/DataService_Legacy/fds_debug.vb b/DataService_Legacy/fds_debug.vb
deleted file mode 100644
index 82ef857..0000000
--- a/DataService_Legacy/fds_debug.vb
+++ /dev/null
@@ -1,134 +0,0 @@
-Option Explicit On
-
-
-
-
-Partial Friend Module fds_debug
-
-
- Public Function LogFile(FileName As String) As IO.FileInfo
- Return New IO.FileInfo(AppBaseDirectory().FullName & FileName)
- End Function
-
-
-
- Public Function AppBaseDirectory() As IO.DirectoryInfo
- Dim path As String = AppDomain.CurrentDomain.BaseDirectory + "tmp\"
- Dim di As New IO.DirectoryInfo(path)
- If di.Exists = True Then
- Return di
- ElseIf My.Computer.FileSystem.DirectoryExists(AppDomain.CurrentDomain.BaseDirectory) = True Then
- di.Create()
- Return di
- Else : Return Nothing
- End If
- End Function
-
-
- Public Sub DebugLog_async(CodeReference As String, SQLConnectionString As String, Optional exc As Exception = Nothing, Optional data As String = "", Optional context As Object = Nothing)
- If CodeReference = "" OrElse SQLConnectionString = "" Then Exit Sub
- Try
- Threading.Tasks.Task.Run(Sub() Call DebugLog_sync(CodeReference:=CodeReference, SQLConnectionString:=SQLConnectionString, exc:=exc, data:=data, context:=context))
- Catch ex As Exception
- Call DebugLog_sync(CodeReference:="fds_debug DebugLog_async", SQLConnectionString:=SQLConnectionString, exc:=ex, data:="", context:=Nothing)
- End Try
- End Sub
-
-
- Public Sub DebugLog_sync(CodeReference As String, SQLConnectionString As String, Optional exc As Exception = Nothing, Optional data As String = "", Optional context As Object = Nothing)
- If CodeReference = "" OrElse SQLConnectionString = "" Then Exit Sub
- Using con As New SqlClient.SqlConnection(SQLConnectionString)
- Call DebugLog(CodeReference:=CodeReference, SQLConnection:=con, exc:=exc, data:=data, context:=context)
- End Using
- End Sub
-
-
-
- Public Sub DebugLog(CodeReference As String, SQLConnection As SqlClient.SqlConnection, Optional exc As Exception = Nothing, Optional data As String = "", Optional context As Object = Nothing)
- If CodeReference = "" OrElse IsNothing(SQLConnection) = True Then Exit Sub
- Dim note As String = Now.ToString("yyyy.MM.dd HH:mm:ss") & " - " & CodeReference
- Try
- Try
- If IsNothing(SQLConnection) = False Then
- Dim pl As New List(Of SqlClient.SqlParameter) From {
- New SqlClient.SqlParameter("@CodeReference", CodeReference),
- New SqlClient.SqlParameter("@ExceptionMessage", If(IsNothing(exc), DBNull.Value, exc.Message)),
- New SqlClient.SqlParameter("@StackTrace", If(IsNothing(exc), DBNull.Value, exc.StackTrace.ToString)),
- New SqlClient.SqlParameter("@data", If(data, DBNull.Value))
- }
- Try
- Dim w As Integer = 0
- If SQLConnection.State = ConnectionState.Broken Then SQLConnection.Close()
- If SQLConnection.State = ConnectionState.Connecting Then
- w = 0
- While SQLConnection.State = ConnectionState.Connecting And w < 10
- System.Threading.Thread.Sleep(100)
- w += 1
- End While
- ElseIf Not SQLConnection.State = ConnectionState.Open Then
- SQLConnection.Open()
- End If
- w = 0
- While Not SQLConnection.State = ConnectionState.Open And w < 10
- System.Threading.Thread.Sleep(100)
- w += 1
- End While
- Dim cmd As New SqlClient.SqlCommand("EXECUTE [dbo].[fds__admin_logdebug] @CodeReference,@ExceptionMessage,@StackTrace,@Data;", SQLConnection)
- cmd.Parameters.AddRange(pl.ToArray)
- Call cmd.ExecuteNonQuery()
- 'SQLConnection.Close()
- cmd.Parameters.Clear()
-
- Catch sqlex As Exception
- End Try
-
- End If
- Catch dbex As Exception
-
- End Try
-
- If IsNothing(exc) = False Then
- note &= (vbCrLf & "Exception:" & exc.Message & vbCrLf & "Stack:" & exc.StackTrace.ToString).Replace(vbLf, vbLf & " ")
- End If
- If data <> "" Then
- note &= (vbCrLf & "Data:" & data).Replace(vbLf, vbLf & " ")
- End If
- note &= vbCrLf
-
- Dim DebugLogfile As IO.FileInfo = LogFile("DebugLog.txt")
- If DebugLogfile.Directory.Exists = True Then
- IO.File.AppendAllText(DebugLogfile.FullName, note)
- End If
- Catch logex As Exception
-
- Finally
-
- Console.Write(note)
- Debug.Print(note)
- End Try
- End Sub
-
- Public Sub DebugToFile(note As String, Optional filename As String = "DebugLog.txt")
- Try
- Dim DebugLogfile As IO.FileInfo = LogFile(filename)
- If DebugLogfile.Directory.Exists = True Then
- IO.File.AppendAllText(DebugLogfile.FullName, Now.ToString & ": " & note & vbCrLf)
- End If
- Catch ex As Exception
- End Try
- End Sub
- Public Sub DebugToFile(CodeReference As String, exc As Exception, data As String, Optional filename As String = "DebugLog.txt")
- Dim note As String = CodeReference
- If IsNothing(exc) = False Then
- note &= (vbCrLf & "Exception:" & exc.Message & vbCrLf & "Stack:" & exc.StackTrace.ToString).Replace(vbLf, vbLf & " ")
- End If
- If data <> "" Then
- note &= (vbCrLf & "Data:" & data).Replace(vbLf, vbLf & " ")
- End If
-
- Call DebugToFile(note, filename:=filename)
- End Sub
-
-
-
-End Module
diff --git a/DataService_Legacy/fds_main.vb b/DataService_Legacy/fds_main.vb
deleted file mode 100644
index 8d5521c..0000000
--- a/DataService_Legacy/fds_main.vb
+++ /dev/null
@@ -1,146 +0,0 @@
-
-Imports Topshelf
-Imports json = Newtonsoft.Json.JsonConvert
-
-
-
-Public Class fds_service
- Implements Topshelf.ServiceControl
-
- Dim WithEvents _timer As System.Timers.Timer
-
- Public Sub New()
- Me._timer = New System.Timers.Timer(My.Settings.ExecutionFrequency_Minutes * 60 * 1000) With {.AutoReset = True}
-
- End Sub
-
-
- Public Function Start(hostControl As HostControl) As Boolean Implements ServiceControl.Start
- Me._timer.Start()
- Return True
- End Function
- Public Function StartImmediately(hostControl As HostControl) As Boolean
- Me._timer.Start()
- System.Threading.Tasks.Task.Run(Sub()
- If My.Settings.DebugDetails = True Then Call DebugToFile("fds__data_service - timer started with interval " & _timer.Interval.ToString, filename:="DebugDetail.txt")
- Call update_mfr() 'start right away and do not wait until first intervall period is over
- End Sub)
- 'do not wait and immediately return
- Return True
- End Function
-
- Public Function [Stop](hostControl As HostControl) As Boolean Implements ServiceControl.Stop
- Me._timer.Stop()
- If My.Settings.DebugDetails = True Then System.Threading.Tasks.Task.Run(Sub() Call DebugToFile("fds__data_service - timer stopped", filename:="DebugDetail.txt"))
-
- Return True
- End Function
-
-
- Public Sub timerElapsed() Handles _timer.Elapsed
- Call update_mfr()
- End Sub
-
- Friend Shared Sub update_mfr()
- If My.Settings.DebugDetails = True Then Call DebugToFile("fds__data_service update_mfr UpdateIfNecessary - timer elapsed", filename:="DebugDetail.txt")
- 'call update to data if necessary
- Try
- Dim t As Threading.Tasks.Task = Threading.Tasks.Task.Run(Async Function()
- 'Await UpdateIfNecessary_Single_async(et:=MFR_RESTClient.generic._generic.EntityTypes.Report, DebugDetails:=My.Settings.DebugDetails)
- Await UpdateIfNecessary_async(DebugDetails:=My.Settings.DebugDetails)
- Await UpdateRequested_async(DebugDetails:=My.Settings.DebugDetails)
- Await GetInvoiceFiles_async(DebugDetails:=My.Settings.DebugDetails)
- 'Await getDatevZip()
- End Function)
- t.Wait()
- Catch ex As Exception
- Call DebugLog("fds__data_service update_mfr UpdateIfNecessary", SQLConnection:=Nothing, exc:=ex)
- If My.Settings.DebugDetails = True Then Call DebugToFile("fds__data_service update_mfr UpdateIfNecessary", exc:=ex, data:="", filename:="DebugDetail.txt")
- End Try
- End Sub
-
-End Class
-
-
-
-Public Module fds_main
-
- Sub Main()
- Dim clArgs() As String = Environment.GetCommandLineArgs()
-
- If (New String() {"digital-pc", "digital-dpc"}).Contains(Environment.MachineName.ToLower) = False Then
-
- HostFactory.Run(Sub(x)
- x.Service(Of fds_service)(AddressOf ServiceConfiguratorCallback)
- x.EnablePauseAndContinue()
- x.StartAutomatically()
- x.RunAsLocalSystem()
- x.SetDescription("MFR Data Sync")
- x.SetDisplayName("MFR Data Sync")
- x.SetServiceName("MFR Data Sync")
- End Sub)
- Else
- Call fds_service.update_mfr()
- 'Call DEv()
- End If
- End Sub
-
- Private Sub ServiceConfiguratorCallback(s As ServiceConfigurators.ServiceConfigurator(Of fds_service))
- s.ConstructUsing(Function(name) New fds_service())
- s.WhenStarted(Function(tc, Host)
- Return tc.Start(Host)
- End Function)
- s.WhenStopped(Function(tc, Host)
- Return tc.Stop(Host)
- End Function)
- s.BeforeStoppingService(Sub(HostStopContext)
- If My.Settings.DebugDetails = True Then System.Threading.Tasks.Task.Run(Sub() Call DebugToFile("fds__data_service - beforestop", filename:="DebugDetail.txt"))
- End Sub)
- s.WhenPaused(Function(tc, Host)
- Return tc.Stop(Host)
- End Function)
- s.WhenContinued(Function(tc, Host)
- Return tc.StartImmediately(Host)
- End Function)
- End Sub
-
-
-
- Public Sub DEv()
- Using MFR As New fds_MFR_Client()
- 'Diagnostics.Debug.Print(MFR.ReadAnything(address:="https://portal.mobilefieldreport.com/odata/$metadata"))
- 'Diagnostics.Debug.Print(MFR.ReadAnything(address:="https://portal.mobilefieldreport.com/odata/Companies?$top=5&$expand=Contacts,Tags,ServiceObjects,MainContact"))
- 'Diagnostics.Debug.Print(MFR.ReadAnything(address:="https://portal.mobilefieldreport.com/odata/ServiceObjects?$expand=WarehouseManager,CustomValueSteps,Company,Product,Tags,ChildServiceObject,Contacts,Items"))
- 'Diagnostics.Debug.Print(MFR.ReadAnything(address:="https://portal.mobilefieldreport.com/odata/Contacts/$count"))
- 'Diagnostics.Debug.Print(MFR.getEntities())
- Dim fle As Byte()
- Try
- fle = MFR.GetFile("https://portal.mobilefieldreport.com/mfr/Report/19584712737/Content/")
- System.IO.File.WriteAllBytes("C:\Users\sailo\Desktop\Test.pdf", fle)
- Catch ex As Exception
-
- End Try
- End Using
-
- End Sub
-
-
-
-
-
-End Module
-
-
-
-Partial Friend Module fds_debug
-
-
- Public Sub DebugLog(CodeReference As String, Optional exc As Exception = Nothing, Optional data As String = "", Optional context As Object = Nothing, Optional execute_async As Boolean = True)
- If execute_async = True Then
- Call DebugLog_async(CodeReference:=CodeReference, SQLConnectionString:=FDSConnectionString(), exc:=exc, data:=data, context:=context)
- Else
- Call DebugLog_sync(CodeReference:=CodeReference, SQLConnectionString:=FDSConnectionString(), exc:=exc, data:=data, context:=context)
- End If
- End Sub
-
-End Module
diff --git a/DataService_Legacy/fds_mfr.vb b/DataService_Legacy/fds_mfr.vb
deleted file mode 100644
index 78d2eda..0000000
--- a/DataService_Legacy/fds_mfr.vb
+++ /dev/null
@@ -1,1046 +0,0 @@
-
-
-Imports System.Configuration
-Imports MFR_RESTClient
-Imports MFR_RESTClient.generic
-Imports Newtonsoft.Json.Linq
-Imports json = Newtonsoft.Json.JsonConvert
-Imports vbs = Microsoft.VisualBasic.Strings
-Imports OCMS
-Imports OCMS.SQLHandling
-Imports System.Text
-
-Namespace Global.fds
-
- Public Class fds_SQLOptions
- Inherits sqloptions
-
- Dim baseinfo As New Dictionary(Of String, Object)
-
- Public Sub New(Optional dic As Dictionary(Of String, Object) = Nothing)
- MyBase.New()
- Me.baseinfo = dic
- Me.OnError = Sub(procedure As String, ex As Exception, data As Object)
- Call debug_log(procedure:=procedure, ex:=ex, data:=New With {.ctrl_nfo = dic, .sql = data})
- End Sub
- End Sub
- End Class
-
- Public Module fds_mfr
-
- Friend Function AppDirectory() As String
- Return AppDomain.CurrentDomain.BaseDirectory
- End Function
-
- Public Enum UpdateNeed
- [Reset] = 5
- [Full] = 2
- [Short] = 1
- [None] = 0
- End Enum
-
-
-#Disable Warning BC42358
- Public Async Function UpdateIfNecessary_async(Optional DebugDetails As Boolean = False) As Threading.Tasks.Task
-
- Using mfr As New fds_MFR_Client
- Try
-
- If DebugDetails = True Then Call DebugToFile("UpdateIfNecessary_async - unn - start awaited", filename:="DebugDetail.txt")
- Await mfr.Update__Entitytables(DebugDetails:=DebugDetails)
- If DebugDetails = True Then Call DebugToFile("UpdateIfNecessary_async - unn - completed", filename:="DebugDetail.txt")
-
- Catch ex As Exception
- Call DebugLog("UpdateIfNecessary_async - main unn", exc:=ex)
- If DebugDetails = True Then Call DebugToFile("UpdateIfNecessary_async - unn", exc:=ex, data:="", filename:="DebugDetail.txt")
- End Try
- End Using
-
- End Function
- Public Async Function UpdateRequested_async(Optional DebugDetails As Boolean = False) As Threading.Tasks.Task
-
- Using mfr As New fds_MFR_Client
- Try
-
- If DebugDetails = True Then Call DebugToFile("UpdateRequested_async - unn - start awaited", filename:="DebugDetail.txt")
- Await mfr.Update__EntityRequests(DebugDetails:=DebugDetails)
- If DebugDetails = True Then Call DebugToFile("UpdateRequested_async - unn - completed", filename:="DebugDetail.txt")
-
- Catch ex As Exception
- Call DebugLog("UpdateRequested_async - main unn", exc:=ex)
- If DebugDetails = True Then Call DebugToFile("UpdateRequested_async - unn", exc:=ex, data:="", filename:="DebugDetail.txt")
- End Try
- End Using
-
- End Function
-
- Public Async Function GetInvoiceFiles_async(Optional DebugDetails As Boolean = False) As Threading.Tasks.Task
-
- Using mfr As New fds_MFR_Client
- Try
-
- If DebugDetails = True Then Call DebugToFile("GetInvoiceFiles_async - unn - start awaited", filename:="DebugDetail.txt")
- Dim dtbl As SQLDataTable = Await getSQLDatatable_async("EXECUTE [dbo].[fds__fn_getMFRInvoicesWithoutfiles];", FDSConnectionString, SqlParameterList:=Nothing, options:=New fds_SQLOptions())
- If dtbl.Count > 0 Then
- For Each ivrw As DataRow In dtbl.DataTable.Rows
- Dim id As String = ivrw.nz("id"), DocumentName As String = ivrw.nz("DocumentName"), fileurl As String = ivrw.nz("URI")
- If id <> "" AndAlso DocumentName <> "" AndAlso fileurl <> "" AndAlso DocumentName.EndsWith("pdf") Then
- Dim fl As Byte() = mfr.GetFile(fileurl)
- If IsNothing(fl) = False AndAlso fl.Length > 0 Then
- Try
- Await setSQLValue_async("EXECUTE [dbo].[fds__setMFRInvoiceFile] @Id, @filename, @file;", FDSConnectionString, SqlParameterList:=New ParamList(SQL_VarChar("@Id", id), SQL_VarChar("@filename", DocumentName), New SqlClient.SqlParameter("@file", fl) With {.SqlDbType = SqlDbType.VarBinary}), options:=New fds_SQLOptions())
- Catch fsex As Exception
- Call DebugLog("GetInvoiceFiles_async - mfr storefile", exc:=fsex)
- End Try
-
- End If
- End If
- Next
- End If
- If DebugDetails = True Then Call DebugToFile("GetInvoiceFiles_async - unn - completed", filename:="DebugDetail.txt")
-
- Catch ex As Exception
- Call DebugLog("GetInvoiceFiles_async - main unn", exc:=ex)
- If DebugDetails = True Then Call DebugToFile("GetInvoiceFiles_async - unn", exc:=ex, data:="", filename:="DebugDetail.txt")
- End Try
- End Using
-
- End Function
-
- Public Async Function UpdateIfNecessary_Single_async(et As EntityTypes, Optional DebugDetails As Boolean = False) As Threading.Tasks.Task(Of Boolean)
- Dim ret As Boolean = False
- Using mfr As New fds_MFR_Client
- Try
-
- If DebugDetails = True Then Call DebugToFile("UpdateIfNecessary_async - unn - start awaited", filename:="DebugDetail.txt")
- Await mfr.Update__Entitytables(DebugDetails:=DebugDetails, tgtEntityType:=et)
- If DebugDetails = True Then Call DebugToFile("UpdateIfNecessary_async - unn - completed", filename:="DebugDetail.txt")
- ret = True
- Catch ex As Exception
- Call DebugLog("UpdateIfNecessary_async - main unn", exc:=ex)
- If DebugDetails = True Then Call DebugToFile("UpdateIfNecessary_async - unn", exc:=ex, data:="", filename:="DebugDetail.txt")
- ret = False
- End Try
- End Using
- Return ret
- End Function
-
- Public Async Function getSchema() As Task(Of String)
- Dim ret As String = ""
- Using mfr As New fds_MFR_Client
- ret = Await mfr.ReadAnything(address:=mfr.clientConfig.BaseUrl & "$metadata")
- End Using
- Return ret
- End Function
-
- Public Async Function getAnything(query As String, Optional throwerror_if_nOK As Boolean = True) As Task(Of String)
- Dim ret As String = ""
- Using mfr As New fds_MFR_Client
- ret = Await mfr.ReadAnything(address:=mfr.clientConfig.BaseUrl & query, throwerror_if_nOK:=throwerror_if_nOK)
- End Using
- Return ret
- End Function
-
-
- Public Function getReportDoc(ByRef file As Byte(), reportid As String, Optional DebugDetails As Boolean = False) As IO.FileInfo
- Dim pl As New List(Of SqlClient.SqlParameter) From {SQL_VarChar("@reportid", reportid)}
- Dim sqldt As SQLDataTable = System.Threading.Tasks.Task.Run(Async Function() Await getSQLDatatable_async("EXECUTE [dbo].[fds__getReportDocument] @reportid;", FDSConnectionString, SqlParameterList:=pl, options:=New fds_SQLOptions())).Result()
- If sqldt.Count > 0 Then
- Dim irow As DataRow = sqldt.FirstRow()
- Dim fl As Byte() = Nothing, fln As String = irow.nz("DocumentName")
- Try
-
- If fln <> "" AndAlso irow.nz("fds") = False Then
- Using mfr As New fds_MFR_Client
- fl = mfr.GetFile(irow.nz("URI"))
- End Using
- If IsNothing(fl) = False AndAlso fl.Length > 0 Then
- Try
- Dim r = System.Threading.Tasks.Task.Run(Async Function() Await setSQLValue_async("EXECUTE [dbo].[fds__setMFRInvoiceFile] @Id, @filename, @file;", FDSConnectionString, SqlParameterList:=New ParamList(SQL_VarChar("@Id", reportid), SQL_VarChar("@filename", fln), New SqlClient.SqlParameter("@file", fl) With {.SqlDbType = SqlDbType.VarBinary}), options:=New fds_SQLOptions())).Result()
- Catch fsex As Exception
- Call DebugLog("getReportDoc - mfr storefile", exc:=fsex)
- End Try
-
- End If
- Else
- fl = irow.no("file", Nothing)
- End If
- Catch ex As Exception
- Call DebugLog("getReportDoc - mfr", exc:=ex)
- If DebugDetails = True Then Call DebugToFile("getReportDoc - mfr", exc:=ex, data:="", filename:="DebugDetail.txt")
- End Try
- If IsNothing(fl) = False Then
- file = fl
- Dim ReportFile As New IO.FileInfo(IO.Path.GetTempPath & fln.Replace("
", "").Replace("
", "").Replace("=", "_").Replace(">", "_").Replace("|", "_").Replace("!", "_").Replace("<", "_").Replace("/", "_"))
- Return ReportFile
- Else
- Return Nothing
- End If
- Else
- Return Nothing
- End If
- End Function
- Public Function getFDSDoc(ByRef file As Byte(), reportid As String, type As String) As IO.FileInfo
- Dim pl As New List(Of SqlClient.SqlParameter) From {SQL_VarChar("@type", type), SQL_VarChar("@reportid", reportid)}
- Dim sqldt As SQLDataTable = System.Threading.Tasks.Task.Run(Async Function() Await getSQLDatatable_async("EXECUTE [dbo].[fds__getFDSDocument] @type, @reportid;", FDSConnectionString, SqlParameterList:=pl, options:=New fds_SQLOptions())).Result()
- If sqldt.Count > 0 Then
- Dim irow As DataRow = sqldt.FirstRow()
- Dim fl As Byte() = irow.no("file", Nothing), fln As String = irow.nz("DocumentName")
- If IsNothing(fl) = False Then
- file = fl
- Dim ReportFile As New IO.FileInfo(IO.Path.GetTempPath & fln.Replace("=", "_").Replace(">", "_").Replace("|", "_").Replace("!", "_").Replace("?", "_"))
- Return ReportFile
- Else
- Return Nothing
- End If
- Else
- Return Nothing
- End If
- End Function
-
- Public Function DATEV(header As DATEV_HEADER, tbl As DataTable) As String
-
- Return header.toHeaderstring() & ControlChars.CrLf & tbl.ToCsv(includeHeaders:=True, quoteStrings:=False, fieldDelimiter:=";", cultureinfo:=New Globalization.CultureInfo("de-DE"), quoteHeader:=False)
- End Function
- Public Class DATEV_HEADER
-
- 'c.f. https://developer.datev.de/portal/de/dtvf/formate/header
-
- Public Kennzeichen As DATEV_Kennzeichen = DATEV_Kennzeichen.EXTF
- Public Versionsnummer As Integer = 700
- Public Formatkategorie As DATEV_Formatkategorie = DATEV_Formatkategorie.Debitoren__Kreditoren
- Public Formatversion As DATEV_Formatversion = DATEV_Formatversion.Debitoren__Kreditoren
- Public Beraternummer As Integer
- Public Mandantennummer As Integer
- Public WJBeginn As Date
- Public Sachkontenlänge As Integer
- Public Datum_von As Date
- Public Datum_bis As Date
- Public Bezeichnung As String
- Public Buchungstyp As DATEV_Buchungstyp = DATEV_Buchungstyp.Finanzbuchführung
- Public Rechnungsslegungszweck As DATEV_Rechnungslegungszweck = DATEV_Rechnungslegungszweck.unabhängig
- Public Festschreibung As Boolean = 1
- Public WKZ As String = "EUR"
-
-
- Public created As Date = Now()
- Public ReadOnly Property Formatname As String
- Get
- Return [Enum].GetName(GetType(DATEV_Formatkategorie), Me.Formatkategorie).Replace("__", "/").Replace("_", " ")
- End Get
- End Property
-
- Dim EmptyDblQuote As String = Microsoft.VisualBasic.ChrW(34) & Microsoft.VisualBasic.ChrW(34)
-
- Public ReadOnly Property toHeaderstring As String
- Get
- Dim sb As New List(Of String)
- sb.Add(Microsoft.VisualBasic.ChrW(34) & [Enum].GetName(GetType(DATEV_Kennzeichen), Me.Kennzeichen) & Microsoft.VisualBasic.ChrW(34))
- sb.Add(Me.Versionsnummer.ToString)
- sb.Add(CInt(Me.Formatkategorie).ToString)
- sb.Add(Microsoft.VisualBasic.ChrW(34) & Me.Formatname & Microsoft.VisualBasic.ChrW(34))
- sb.Add(CInt(Me.Formatversion).ToString)
- sb.Add(created.ToString("yyyyMMddHHmmssfff"))
- sb.Add("") '7 Leerfeld
- sb.Add(EmptyDblQuote) '8 Leerfeld
- sb.Add(EmptyDblQuote) '9 Leerfeld
- sb.Add(EmptyDblQuote) '10 Leerfeld
- sb.Add(Me.Beraternummer.ToString) '11 Beraternummer
- sb.Add(Me.Mandantennummer.ToString) '12 Mandantennummer
- sb.Add(Me.WJBeginn.ToString("yyyyMMdd"))
- sb.Add(Me.Sachkontenlänge.ToString)
- sb.Add(Me.Datum_von.ToString("yyyyMMdd"))
- sb.Add(Me.Datum_bis.ToString("yyyyMMdd"))
- sb.Add(Microsoft.VisualBasic.ChrW(34) & Me.Bezeichnung & Microsoft.VisualBasic.ChrW(34))
- sb.Add(EmptyDblQuote) '18 Diktatkürzel
- sb.Add(CInt(Me.Buchungstyp).ToString)
- sb.Add(CInt(Me.Rechnungsslegungszweck).ToString)
- sb.Add(If(Me.Festschreibung = False, 0, 1))
- sb.Add(Microsoft.VisualBasic.ChrW(34) & Me.WKZ & Microsoft.VisualBasic.ChrW(34))
- sb.Add("") '23 Leerfeld
- sb.Add("") '24 Leerfeld
- sb.Add("") '25 Leerfeld
- sb.Add("") '26 Leerfeld
- sb.Add("") '27 Sachkontenrahmen
- sb.Add("") '28 ID der Branchenlösung
- sb.Add("") '29 Leerfeld
- sb.Add("") '30 Leerfeld
- sb.Add("") '31 Verarbeitungskennzeichen
- Return vbs.Join(sb.ToArray, ";")
- End Get
- End Property
-
-
- End Class
-
-
-
- Public Enum DATEV_Rechnungslegungszweck
- unabhängig = 0
- Steuerrecht = 30
- Kalkulatorik = 40
- Handelsrecht = 50
- IFRS = 64
- End Enum
-
- Public Enum DATEV_Buchungstyp
- Finanzbuchführung = 1
- Jahresabschluss = 2
- End Enum
-
- Public Enum DATEV_Kennzeichen
- EXTF
- DTVF
- End Enum
-
- Public Enum DATEV_Formatkategorie
- [Debitoren__Kreditoren] = 16
- [Sachkontenbeschriftungen] = 20
- [Buchungsstapel] = 21
- [Zahlungsbedingungen] = 46
- [Diverse_Adressen] = 48
- [Wiederkehrende_Buchungen] = 65
- End Enum
- Public Enum DATEV_Formatversion
- [Debitoren__Kreditoren] = 5
- [Sachkontenbeschriftungen] = 3
- [Buchungsstapel_9] = 9
- [Buchungsstapel] = 12
- [Zahlungsbedingungen] = 2
- [Wiederkehrende_Buchungen] = 4
- [Diverse_Adressen] = 2
- End Enum
-
- Public Class DATEV_Document
- Public guid As String
- Public type As Integer = 2
- Public processID As Integer = 2
- Public Filename As String
- Public KeyWords As String
- Public Repository As String
- Public Register As String
- Public Sub New(guid As String, filename As String, keywords As String, Repository As String, Register As String)
- Me.guid = guid
- Me.Filename = filename
- Me.KeyWords = keywords
- Me.Repository = Repository
- Me.Register = Register
- End Sub
- End Class
-
-
- Public Function getDatevZip(ByRef stream As IO.Stream, ByVal tgtdate As Date, mode As String, ByVal AuthUser As String, ByVal includeFiles As Boolean, Optional DebugDetails As Boolean = False) As IO.FileInfo
-
- Using mfr As New fds_MFR_Client
- Try
- Dim DSet As SQLDataSet = System.Threading.Tasks.Task.Run(Async Function() Await getSQLDataSet_async("EXECUTE [dbo].[fds__getDatevExports] @tgtdate, @mode, @files, @authuser;", FDSConnectionString, SqlParameterList:=New List(Of SqlClient.SqlParameter) From {SQL_date("@tgtdate", tgtdate), SQL_VarChar("@mode", mode), SQL_bit("@files", includeFiles), SQL_VarChar("@authuser", AuthUser)}, tablenames:=New String() {"admin", "inv", "buchungen", "debitoren"}, options:=New fds_SQLOptions())).Result()
-
- Dim bedi_files As New List(Of DATEV_Document)
-
- If DSet.Count >= 4 Then
- Dim admin As Dictionary(Of String, Object) = DSet.Tables("admin").Rows(0).toObjectDictionary()
- Dim startdate As Date = admin("startdate"), enddate As Date = admin("enddate")
- Dim Datev_Level3_Register As String = tgtdate.ToString("yyyy\/MM") & If(mode.ToLower = "w", "_w" & tgtdate.ToString("dd"), "")
- Dim fls As New Dictionary(Of String, Byte())
- Dim header As New DATEV_HEADER With {
- .Formatkategorie = DATEV_Formatkategorie.Buchungsstapel,
- .Formatversion = DATEV_Formatversion.Buchungsstapel_9,
- .Beraternummer = admin("beraternummer"),
- .Mandantennummer = admin("mandantennummer"),
- .WJBeginn = admin("WJ-Beginn"),
- .Sachkontenlänge = admin("Sachkontenlänge"),
- .Datum_von = startdate,
- .Datum_bis = enddate,
- .Bezeichnung = "fds_" & mode & tgtdate.ToString("yyMMdd")
- }
- fls.Add("EXTF_PCW_Buchungen_0_" & startdate.ToString("yyMMdd HHmmss") & ".csv", DATEV(header, DSet.Tables("buchungen")).ToByteArray(encoding:=Encoding.GetEncoding("ISO-8859-1")))
-
- ''only for debug
- 'Using fsw As New IO.StreamWriter("C:\Users\sailo\Desktop\Fuchs\DatevUpload_AR 202107_w___DATEVNEU\EXTF_PCW_Buchungen_0_" & startdate.ToString("yyMMdd HHmmss") & ".csv", False, Encoding.GetEncoding("ISO-8859-1"))
- ' fsw.Write(value:=DATEV(header, DSet.Tables("buchungen")))
- ' fsw.Close()
- 'End Using
-
-
- With header
- .Formatkategorie = DATEV_Formatkategorie.Debitoren__Kreditoren
- .Formatversion = DATEV_Formatversion.Debitoren__Kreditoren
- End With
- fls.Add("EXTF_PCW_Debitoren_0_" & startdate.ToString("yyMMdd HHmmss") & ".csv", DATEV(header, DSet.Tables("debitoren")).ToByteArray(encoding:=Encoding.GetEncoding("ISO-8859-1")))
-
-
- ''only for debug
- 'Using fsw As New IO.StreamWriter("C:\Users\sailo\Desktop\Fuchs\DatevUpload_AR 202107_w___DATEVNEU\EXTF_PCW_Debitoren_0_" & startdate.ToString("yyMMdd HHmmss") & ".csv", False, Encoding.GetEncoding("ISO-8859-1"))
- ' fsw.Write(value:=DATEV(header, DSet.Tables("debitoren")))
- ' fsw.Close()
- 'End Using
-
-
- 'add csv files to bedi list
- For Each fl As String In fls.Keys
- bedi_files.Add(New DATEV_Document(Guid.NewGuid().ToString.ToLower, filename:=fl, keywords:="", Repository:="Processweb_Daten", Register:=Datev_Level3_Register))
- Next
-
- If includeFiles = True Then
- For Each irow As DataRow In DSet.Tables("inv").Rows
- Dim fl As Byte() = Nothing, fln As String = irow.nz("DocumentName")
- Try
- If fln <> "" AndAlso irow.no("fds", True) = False Then
- fl = mfr.GetFile(irow.nz("URI"))
- Else
- fl = irow.no("file", Nothing)
- End If
- Catch ex As Exception
-
- End Try
- If IsNothing(fl) = False AndAlso fln <> "" Then
- fls.Add(fln, fl)
- bedi_files.Add(New DATEV_Document(irow("file_guid"), filename:=fln, keywords:="RgNr: " & irow.nz("InvoiceId") & " RgDatum: " & irow.ndt_string("DateOfCreation", "dd.MM.yyyy"), Repository:="ProcessWeb_Belege", Register:=Datev_Level3_Register))
- End If
- 'If fls.Count > 7 Then Exit For
- Next
-
- fls.Add("document.xml", createDATEV_document_xml(bedi_files).ToByteArray())
- End If
-
- If fls.Count > 0 Then
- Dim ArchiveFile As New IO.FileInfo(IO.Path.GetTempPath & "DatevUpload_AR " & startdate.ToString("yyyyMM") & "_" & admin("mode").ToString & ".zip")
- Try
- Using Archive As New fds.Archive(ArchiveFile, Type:=SevenZip.OutArchiveFormat.Zip)
- If Archive.CompressToStream(fls, targetstream:=stream) = True Then
- Diagnostics.Debug.Print("ok")
- 'Archive.WriteArchiveStreamToDisk()
- stream.Position = 0
- Return ArchiveFile
- Else
- Return Nothing
- End If
- End Using
- Catch aex As Exception
- End Try
-
- End If
-
- End If
-
- Catch ex As Exception
- Call DebugLog("getDatevZip - mfr", exc:=ex)
- If DebugDetails = True Then Call DebugToFile("getDatevZip - mfr", exc:=ex, data:="", filename:="DebugDetail.txt")
- End Try
- End Using
- Return Nothing
- End Function
-#Enable Warning BC42358
-
- Public Function createDATEV_document_xml(files As List(Of DATEV_Document)) As String
- Dim xmldoc As New Xml.XmlDocument
-
- 'Write down the XML declaration
- Dim XmlDeclaration As Xml.XmlDeclaration = xmldoc.CreateXmlDeclaration("1.0", "UTF-8", Nothing)
- xmldoc.InsertBefore(XmlDeclaration, xmldoc.DocumentElement)
-
- 'create root
- Dim RootNode As Xml.XmlElement = xmldoc.CreateElement("archive", "http://xml.datev.de/bedi/tps/document/v04.0")
- xmldoc.AppendChild(RootNode)
-
- With RootNode
- Dim schemaLocation As Xml.XmlAttribute = xmldoc.CreateAttribute("xsi", "schemaLocation", "http://www.w3.org/2001/XMLSchema-instance")
- schemaLocation.Value = "http://xml.datev.de/bedi/tps/document/v04.0 document_v040.xsd"
- .Attributes.Append(schemaLocation)
- .SetAttribute("generatingSystem", "ProcessWeb")
- .SetAttribute("version", "4.0")
- End With
-
- Dim header As Xml.XmlElement = xmldoc.CreateElement("header")
- xmldoc.DocumentElement.PrependChild(header)
- Dim headerelement As Xml.XmlElement = xmldoc.CreateElement("date")
- headerelement.InnerText = Now().ToString("yyyy-MM-ddTHH:mm:ss")
- header.AppendChild(headerelement)
- headerelement = xmldoc.CreateElement("description")
- headerelement.InnerText = "DATEV Schnittstelle ProcessWeb"
- header.AppendChild(headerelement)
-
- Dim content As Xml.XmlElement = xmldoc.CreateElement("content")
- xmldoc.DocumentElement.AppendChild(content)
- For Each fl As DATEV_Document In files
- Dim docelement As Xml.XmlElement = xmldoc.CreateElement("document")
- docelement.SetAttribute("guid", fl.guid)
- docelement.SetAttribute("type", fl.type)
- docelement.SetAttribute("processID", fl.processID)
-
- If If(fl.KeyWords, "") <> "" Then
- Dim keyword As Xml.XmlElement = xmldoc.CreateElement("keywords")
- keyword.InnerText = fl.KeyWords
- docelement.AppendChild(keyword)
- End If
-
- Dim extension As Xml.XmlElement = xmldoc.CreateElement("extension")
- Dim ext_attr As Xml.XmlAttribute = xmldoc.CreateAttribute("xsi", "type", "http://www.w3.org/2001/XMLSchema-instance")
- ext_attr.Value = "File"
- extension.Attributes.Append(ext_attr)
- extension.SetAttribute("name", fl.Filename)
- docelement.AppendChild(extension)
-
- Dim repository As Xml.XmlElement = xmldoc.CreateElement("repository")
- Dim level As Xml.XmlElement
- level = xmldoc.CreateElement("level")
- level.SetAttribute("id", "1") 'Kategorie
- level.SetAttribute("name", "ProcessWeb")
- repository.AppendChild(level)
- level = xmldoc.CreateElement("level")
- level.SetAttribute("id", "2") 'Ordner
- level.SetAttribute("name", fl.Repository)
- repository.AppendChild(level)
- level = xmldoc.CreateElement("level")
- level.SetAttribute("id", "3") 'Register e.g. 2020/12
- level.SetAttribute("name", fl.Register)
- repository.AppendChild(level)
- docelement.AppendChild(repository)
-
- content.AppendChild(docelement)
- Next
-
- Dim xmlasstring As String = ""
- Using sw As New IO.StringWriter
- Using tw As Xml.XmlWriter = Xml.XmlWriter.Create(sw)
- xmldoc.WriteTo(tw)
- tw.Flush()
- xmlasstring = sw.GetStringBuilder().ToString()
- End Using
- End Using
- Return xmlasstring
- End Function
-
-
- Public Function UpdateNeedValue(need As String) As UpdateNeed
- Return [Enum].Parse(GetType(UpdateNeed), need)
- End Function
-
-
-
- End Module
-
-
-
-
-
- Public Class fds_MFR_Client
- Implements IDisposable
-
-
- Dim MFRClient As Global.MFR_RESTClient.MFRClient
-
- Private Shared Function DefaultCredentials()
- Return New MFRClientCredentials(My.Settings.MFR_UserName, My.Settings.MFR_Password)
- End Function
-
- Public Sub New(Credentials As MFRClientCredentials)
- Dim config As New MFRClientConfig(My.Settings.MFR_host)
- Me.MFRClient = New MFRClient(config, credentials:=Credentials)
- End Sub
- Public Sub New()
- Me.New(fds_MFR_Client.DefaultCredentials())
- End Sub
-
- Public Property IsReadonly() As Boolean
- Get
- Return Me.MFRClient.IsReadonly
- End Get
- Set(value As Boolean)
- Me.MFRClient.IsReadonly = value
- End Set
- End Property
-
- Public ReadOnly Property clientConfig As MFRClientConfig
- Get
- Return Me.MFRClient.clientConfig
- End Get
- End Property
-
- Public Async Function ReadAnything(address As String, Optional throwerror_if_nOK As Boolean = True) As Task(Of String)
- Return Await Me.MFRClient.ReadAnything(address:=address, throwerror_if_nOK:=throwerror_if_nOK)
- End Function
- Public Function GetFile(address As String, Optional throwerror_if_nOK As Boolean = True) As Byte()
- Return Me.MFRClient.GetFile(address:=address, throwerror_if_nOK:=throwerror_if_nOK)
- End Function
- Public Async Function ReadOData(address As String, Optional throwerror_if_nOK As Boolean = True) As Task(Of ODataEnvelope)
- Return Await Me.MFRClient.ReadOData(address:=address, throwerror_if_nOK:=throwerror_if_nOK)
- End Function
- Public Async Function getEntities(Optional throwerror_if_nOK As Boolean = True) As Task(Of String)
- Return Await Me.MFRClient.getEntities(throwerror_if_nOK:=throwerror_if_nOK)
- End Function
-
- Public Class DatabaseSchema
- Private _et As EntityTypes
-
- Public ReadOnly Property isValid As Boolean = False
- Public ReadOnly Property hasEntity As Boolean = False
-
- Public ReadOnly Property ThisEntityName As String
- Get
- Return EntityName(Me._et)
- End Get
- End Property
-
- Public ReadOnly Property EntityConfig As Dictionary(Of String, String)
- Public ReadOnly Property Entitytablename As String
- Public ReadOnly Property Complextypes As New Dictionary(Of String, Dictionary(Of String, String))
- Public ReadOnly Property NavProperties As New Dictionary(Of String, Dictionary(Of String, String))
-
- Private _tableset As DataSet
-
-
-
- Public Sub New(et As EntityTypes)
- Me._et = et
- Dim DSet As SQLDataSet = System.Threading.Tasks.Task.Run(Async Function()
- Return Await getSQLDataSet_async("EXECUTE [dbo].[mfr__getSchema] 'table', @tgttype;", FDSConnectionString, SqlParameterList:=New List(Of SqlClient.SqlParameter) From {New SqlClient.SqlParameter("@tgttype", ThisEntityName)}, tablenames:=New String() {"entity", "complex_types", "navigation_properties", "tables"}, options:=New fds_SQLOptions())
- End Function).Result()
-
- Me._isValid = DSet.Count > 0
- Me._hasEntity = DSet.Tables("entity").Rows.Count = 1
-
- Me._EntityConfig = If(DSet.Tables("entity").Rows.Count > 0, DSet.Tables("entity").Rows(0).toStringDictionary(), New Dictionary(Of String, String))
- Me._Entitytablename = If(Me.EntityConfig.ContainsKey("tablename"), Me.EntityConfig("tablename"), "")
-
- For Each ctrw As DataRow In DSet.Tables("complex_types").Rows()
- Me._Complextypes.Add(ctrw.nz("name").ToLower, ctrw.toStringDictionary())
- Next
- For Each nprw As DataRow In DSet.Tables("navigation_properties").Rows()
- Me._NavProperties.Add(nprw.nz("name").ToLower, nprw.toStringDictionary())
- Next
- Dim sql As New List(Of String) From {newDatatable_SQL(Me.Entitytablename)} 'The target entitytype's table MUST be the first
- Dim tablenames As New List(Of String) From {Me.Entitytablename}
-
- For Each ttrw As DataRow In DSet.Tables("tables").Rows
- sql.Add(newDatatable_SQL(ttrw.nz("tablename")))
- tablenames.Add(ttrw.nz("tablename"))
- Next
- Me._tableset = System.Threading.Tasks.Task.Run(Async Function()
- Return Await getSQLDataSet_async(vbs.Join(sql.ToArray(), vbNewLine), FDSConnectionString, tablenames:=tablenames.ToArray, options:=New fds_SQLOptions())
- End Function).Result().DataSet
- End Sub
-
- Public Function tgtDataset(SetID As String) As DataSet
- Dim dset As New DataSet
- For Each tbl As DataTable In Me._tableset.Tables
- Dim t As DataTable = tbl.Clone
- If t.Columns.Contains("setid") Then t.Columns("setid").DefaultValue = SetID
- dset.Tables.Add(t)
- Next
- Return dset
- End Function
-
- End Class
-
-
-#Disable Warning BC42356 ' This async method lacks 'Await' operators and so will run synchronously
- Public Async Function Update__entitytable(et As EntityTypes, UpdateNeed As fds.fds_mfr.UpdateNeed, EntityID As Long(), Optional DebugDetails As Boolean = False, Optional SchemaDic As Dictionary(Of String, DatabaseSchema) = Nothing, Optional AdditionalCommandAfter As String = "") As Threading.Tasks.Task(Of Boolean)
- If et = EntityTypes.none Then Return False
- EntityID = If(EntityID, New Long() {}) ''make sure is not nothing
- If UpdateNeed = UpdateNeed.None AndAlso If(EntityID, New Long() {}).Length = 0 Then Return True
-
- Dim StartTime As DateTime = Now()
- Diagnostics.Debug.Print(StartTime.ToLongTimeString() & " - " & "Start before Schema " & et.ToString)
- Dim SetID As String = RandomString(5) 'generate a unique ID to identify the submitted data and prevent mixup. A table can be accessed by several batch processes in parallel.
- Dim ThisEntityName As String = EntityName(et)
-
-
- Dim DtF As New Action(Of String, String, String)(Sub(note As String, info As String, data As String)
- If DebugDetails = True Then Call DebugToFile("Update__entitytable - " & note & " For " & ThisEntityName & "(" & SetID & ")" & If(If(info, "") <> "", ": " & info, ""), filename:="DebugDetail.txt")
- End Sub)
- Dim Dlg As New Action(Of String, String, String)(Sub(note As String, info As String, data As String)
- Dim str As String = "Update__entitytable - " & note & " for " & ThisEntityName & "(" & SetID & ")" & If(If(info, "") <> "", ": " & info, "")
- Call DebugLog(str, data:=If(data, ""))
- If DebugDetails = True Then Call DebugToFile(str, filename:="DebugDetail.txt")
- Diagnostics.Debug.Print(str)
- End Sub)
-
-
-
- 'check if update processes not already in progress and set lock
- Dim params As New List(Of SqlClient.SqlParameter) From {
- New SqlClient.SqlParameter("@tbl", ThisEntityName),
- New SqlClient.SqlParameter("@state", True),
- New SqlClient.SqlParameter("@override", False),
- New SqlClient.SqlParameter("@setid", SetID)
- }
- Dim lockstate As Object = True 'getSQLValue("EXECUTE [dbo].[ctm__admin_getTableLock] @tbl, @state, @override, @setid;", con, SqlParameterList:=params, options:=New fds_SQLOptions())
- If DebugDetails = True AndAlso lockstate.GetType() = GetType(System.Boolean) Then Call DebugToFile("Update__entitytable - lock received for " & ThisEntityName & "(" & SetID & ") - " & lockstate.ToString, filename:="DebugDetail.txt")
- 'true means lock was successful
- If lockstate.GetType() = GetType(System.Boolean) AndAlso DirectCast(lockstate, Boolean) = True Then
- Try
- 'get Schema
- Dim Schema As DatabaseSchema = If(IsNothing(SchemaDic) = False AndAlso SchemaDic.ContainsKey(ThisEntityName), SchemaDic(ThisEntityName), New DatabaseSchema(et))
- If Schema.isValid = False Then
- Dlg("Schema not found", "", Nothing)
- ElseIf Schema.hasEntity = True Then
- Dim tgtDataset As DataSet = Schema.tgtDataset(SetID:=SetID)
-
- Dim lastDate As SQLObject, Filter As New List(Of String), EntityIDFilter As String = "", isProcessed As Boolean = False
- If If(EntityID, New Long() {}).Length = 1 Then
- EntityIDFilter = "(" & EntityID(0).ToString() & "L)"
- Filter.Add("")
- UpdateNeed = UpdateNeed.Reset
- ElseIf If(EntityID, New Long() {}).Length > 1 Then
- EntityIDFilter = ""
- For idx As Integer = 0 To EntityID.Length - 1 Step 50
- Filter.Add("$filter=" & EntityID.Skip(idx).Take(50).Convert(Of String)(Function(l As Long) "Id eq " & l.ToString() & "L").Join(" or "))
- Next
- UpdateNeed = UpdateNeed.Reset
- ElseIf UpdateNeed > UpdateNeed.Short Then
- EntityIDFilter = ""
- Filter.Add("")
- ElseIf Schema.EntityConfig.ContainsKey("DateColumn") AndAlso If(Schema.EntityConfig("DateColumn"), "") <> "" Then
- lastDate = Await getSQLValue_async(Schema.EntityConfig.nz("DateSQL").ne("SELECT MAX([" & Schema.EntityConfig("DateColumn") & "]) FROM [dbo].[" & Schema.Entitytablename & "];"), FDSConnectionString, options:=New fds_SQLOptions())
- Filter.Add(If(UpdateNeed = fds.fds_mfr.UpdateNeed.Short AndAlso IsNothing(lastDate.Result) = False AndAlso lastDate.Result.GetType() = GetType(System.DateTime), "$filter=" & Schema.EntityConfig("DateColumn") & " gt DateTime'" & DirectCast(lastDate.Result, System.DateTime).ToString(format:="yyyy\-MM\-dd") & "T00:00:00'", ""))
- End If
- For Each iFilter As String In Filter
- If (Schema.EntityConfig.ContainsKey("url") AndAlso Schema.EntityConfig("url") <> "") AndAlso (UpdateNeed = UpdateNeed.Short AndAlso iFilter <> "") OrElse (UpdateNeed > UpdateNeed.Short) Then
- isProcessed = True
- Diagnostics.Debug.Print(Now.ToLongTimeString() & " - " & "Start MFR " & Schema.Entitytablename)
- Dim MFR_Response As ODataEnvelope = Await ReadOData(Me.MFRClient.clientConfig.BaseUrl & Schema.EntityConfig("url").Replace("?", EntityIDFilter & "?") & If(iFilter <> "" AndAlso Schema.EntityConfig("url").Contains("?") = False, "?", If(iFilter <> "", "&", "")) & iFilter)
- Diagnostics.Debug.Print(Now.ToLongTimeString() & " - " & "End MFR " & Schema.Entitytablename)
- Do Until IsNothing(MFR_Response) = True
- If MFR_Response.value.GetType() = GetType(JObject) Then
- MFR_Response.ConvertToArray()
- End If
- Try
-
- If MFR_Response.value.GetType() = GetType(JArray) AndAlso IsNothing(DirectCast(MFR_Response.value, Newtonsoft.Json.Linq.JContainer).First) = False AndAlso DirectCast(DirectCast(DirectCast(MFR_Response.value, Newtonsoft.Json.Linq.JContainer).First, Newtonsoft.Json.Linq.JContainer).First, Newtonsoft.Json.Linq.JProperty).Name = "odata.error" AndAlso DirectCast(MFR_Response.value, Newtonsoft.Json.Linq.JContainer).First.ToString().IndexOf("Sequence contains no elements", 0, StringComparison.InvariantCultureIgnoreCase) > -1 Then
- MFR_Response.value = New JArray()
- End If
- Catch jbex As Exception
- End Try
- If MFR_Response.value.GetType() = GetType(JArray) Then
- Diagnostics.Debug.Print(Now.ToLongTimeString() & " - " & DirectCast(MFR_Response.value, JArray).Count & ":" & tgtDataset.Tables(Schema.Entitytablename).Rows.Count & "- this " & Strings.Right(MFR_Response.url, 10))
- For Each vi As JObject In MFR_Response.value
- Dim vdic As Dictionary(Of String, Object) = vi.ToObject(Of Dictionary(Of String, Object))
- Dim etrow As DataRow = tgtDataset.Tables(Schema.Entitytablename).NewRow
- etrow.Item("setid") = SetID
- etrow.Item("Id") = vdic("Id")
- For Each vkey As String In vdic.Keys
- If IsNothing(vdic(vkey)) = False Then
- If Schema.Complextypes.ContainsKey(vkey.ToLower) AndAlso Schema.Complextypes(vkey.ToLower)("name") <> "CustomValues" Then
- Call StoreCP(tgtDataset, etrow, Schema.Complextypes, CP:=vdic(vkey), EntityProperty:=ThisEntityName & ":" & vkey, vkey:=vkey, setid:=SetID, vdic:=vdic)
- ElseIf Schema.Complextypes.ContainsKey(vkey.ToLower) AndAlso Schema.Complextypes(vkey.ToLower)("name") = "CustomValues" Then
- If etrow.Table.Columns.Contains(vkey) Then etrow.Item(vkey) = json.SerializeObject(vdic(vkey))
- ElseIf Schema.NavProperties.ContainsKey(vkey.ToLower) Then
- Call StoreNP(tgtDataset, Schema.Complextypes, Schema.NavProperties(vkey.ToLower), NP:=vdic(vkey), EntityProperty:=ThisEntityName & ":" & vkey, setid:=SetID, vdic:=vdic)
- Else
- If etrow.Table.Columns.Contains(vkey) Then
- If vdic(vkey).GetType() = GetType(System.DateTime) Then
- If Year(vdic(vkey)) > 1900 Then etrow.Item(vkey) = vdic(vkey)
- ElseIf vdic(vkey).GetType = etrow.Table.Columns(vkey).DataType Then
- etrow.Item(vkey) = vdic(vkey)
- ElseIf vdic(vkey).GetType = GetType(String) And vdic(vkey).GetType() <> etrow.Table.Columns(vkey).DataType Then
- Try
- etrow.Item(vkey) = DirectCast(vdic(vkey), System.String).TryConvert(etrow.Table.Columns(vkey).DataType)
- Catch cgvex As Exception
- Try
- etrow.Item(vkey) = vdic(vkey)
- Catch cvex As Exception
- Diagnostics.Debug.Print(Now.ToLongTimeString() & " - " & "cvex inner " & cvex.Message)
- End Try
- End Try
- Else
- Try
- etrow.Item(vkey) = vdic(vkey)
- Catch cvex As Exception
- Diagnostics.Debug.Print(Now.ToLongTimeString() & " - " & "cvex outer " & cvex.Message)
- End Try
- End If
- End If
- End If
- End If
- Next
- tgtDataset.Tables(Schema.Entitytablename).Rows.Add(etrow)
- tgtDataset.Tables(Schema.Entitytablename).AcceptChanges()
- Next
- Else
- Diagnostics.Debug.Print(Now.ToLongTimeString() & " - " & " MFR_Response not jArray")
- End If
- If IsNothing(MFR_Response.nextlink) = False AndAlso MFR_Response.nextlink.ToString <> "" Then
- MFR_Response = Await ReadOData(MFR_Response.nextlink.ToString)
- Else
- MFR_Response = Nothing
- End If
-
- Loop
- End If
- Next iFilter
-
- Diagnostics.Debug.Print(Now.ToLongTimeString() & " - " & "Start SQL " & Schema.Entitytablename)
- If isProcessed = True Then
- For Each tbl As DataTable In tgtDataset.Tables
- Dim trc As Integer = tbl.Rows.Count
- If trc > 0 OrElse If(EntityID, New Long() {}).Length > 0 Then
- Try
- tbl.AcceptChanges()
- Dim dtwa As New DatatableWriterAsync(tbl, FDSConnectionString)
- dtwa.CommandAfterError = New SqlClient.SqlCommand("SELECT * INTO [t_" & SetID.ToLower & "_" & tbl.TableName & "] FROM " & dtwa.DestinationTableName & ";")
-
- dtwa.CommandAfter = New SqlClient.SqlCommand('"SELECT * INTO [t_" & SetID.ToLower & "_" & tbl.TableName & "] FROM " & dtwa.DestinationTableName & ";" &
- "EXECUTE [dbo].[" & (tbl.TableName).Replace("__", "__updt__") & "] @tblname, @referencetable, @tgtid;" &
- "EXECUTE [dbo].[fds__setStatus] @table,@action,@setid,@info;" & If(AdditionalCommandAfter, ""))
- dtwa.CommandAfter.Parameters.AddRange(New SqlClient.SqlParameter() {
- New SqlClient.SqlParameter("@tblname", dtwa.DestinationTableName),
- New SqlClient.SqlParameter("@table", tbl.TableName),
- New SqlClient.SqlParameter("@action", If(tbl.TableName.ToLower = Schema.Entitytablename.ToLower, "update_", "imdu_") & UpdateNeed.ToString().ToLower()),
- New SqlClient.SqlParameter("@setid", SetID.ToLower),
- New SqlClient.SqlParameter("@entityname", Schema.ThisEntityName),
- New SqlClient.SqlParameter("@info", json.SerializeObject(New With {.count = trc, .reference = Schema.Entitytablename, .id = If(If(EntityID, New Long() {}).Length > 0, vbs.Join(EntityID.Convert(Of String)(Function(x As Long) x.ToString).ToArray, ","), Nothing)})),
- New SqlClient.SqlParameter("@referencetable", Schema.Entitytablename),
- New SqlClient.SqlParameter("@tgtid", If(If(EntityID, New Long() {}).Length > 0, EntityID(0).ToString, DBNull.Value))
- })
- Diagnostics.Debug.Print("tbs:" & tbl.TableName & " - " & dtwa.DestinationTableName & " - " & SetID.ToLower)
- dtwa.DoSubmit()
- Catch dsex As Exception
- Dlg("Submission issue", dsex.Message, "table: " & tbl.TableName)
- End Try
- Diagnostics.Debug.Print("OK")
- End If
- Next
- Diagnostics.Debug.Print(Now.ToLongTimeString() & " - " & "End SQL " & Schema.Entitytablename)
- End If
- End If
- Catch ex As Exception
- Dlg(ex.Message, "", ex.StackTrace)
- Finally
- 'release lock
- params = New List(Of SqlClient.SqlParameter) From {
- New SqlClient.SqlParameter("@tbl", ThisEntityName),
- New SqlClient.SqlParameter("@state", False),
- New SqlClient.SqlParameter("@override", False),
- New SqlClient.SqlParameter("@setid", SetID)
- }
- Dim Exceptionmessages_gtl As String = ""
- 'setSQLValue("EXECUTE [dbo].[ctm__admin_getTableLock] @tbl, @state, @override, @setid;", connectionstring:=FDSConnectionString, SqlParameterList:=params, ExceptionMessage:=Exceptionmessages_gtl)
- If DebugDetails = True Then Call DebugToFile("Update__entitytable - locked released for " & ThisEntityName & "(" & SetID & ")", filename:="DebugDetail.txt")
- End Try
- Else
- 'abort action if locked = true
- Debug.Print("Updates already locked for " & ThisEntityName & "(" & SetID & ")")
- If DebugDetails = True Then Call DebugToFile("Update__entitytable - Updates already locked For " & ThisEntityName & "(" & SetID & ")", filename:="DebugDetail.txt")
- End If
- Return True
- End Function
-
-
-
- Private Function StoreCP(ByRef TgtDataset As DataSet, ByRef etrow As DataRow, Complextypes As Dictionary(Of String, Dictionary(Of String, String)), ByVal CP As Object, ByVal EntityProperty As String, ByVal vkey As String, ByVal setid As String, ByVal vdic As Dictionary(Of String, Object)) As Boolean
- If CP.GetType = GetType(JArray) Then
- For Each vitm As JObject In vdic(vkey)
- Call StoreCP(TgtDataset, etrow, Complextypes, CP:=vitm, EntityProperty:=EntityProperty, vkey:=vkey, setid:=setid, vdic:=vdic)
- Next
- ElseIf CP.GetType = GetType(JObject) Then
- Dim CxType As Dictionary(Of String, String) = Complextypes(vkey.ToLower)
- Dim cto As Dictionary(Of String, Object) = CP.ToObject(Of Dictionary(Of String, Object))
- Dim cnr As DataRow = TgtDataset.Tables(CxType("tablename")).NewRow
- cnr.Item("setid") = setid
- For Each cky As String In cto.Keys
- If IsNothing(cto(cky)) = False AndAlso cnr.Table.Columns.Contains(cky) Then
- 'cnr.Item(cky) = cto(cky)
- If cto(cky).GetType() = GetType(System.DateTime) Then
- If Year(cto(cky)) > 1900 Then cnr.Item(cky) = cto(cky)
- ElseIf cto(cky).GetType = cnr.Table.Columns(cky).DataType Then
- cnr.Item(cky) = cto(cky)
- ElseIf cto(cky).GetType = GetType(String) And cto(cky).GetType() <> cnr.Table.Columns(cky).DataType Then
- Try
- cnr.Item(cky) = DirectCast(cto(cky), System.String).TryConvert(cnr.Table.Columns(cky).DataType)
- Catch cgvex As Exception
- Try
- cnr.Item(cky) = cto(cky)
- Catch cvex As Exception
- End Try
- End Try
- Else
- Try
- cnr.Item(cky) = cto(cky)
- Catch cvex As Exception
- End Try
- End If
- End If
- Next
- If cnr.Table.Columns.Contains("EntityId") Then cnr.Item("EntityId") = vdic("Id")
- If cnr.Table.Columns.Contains("Property") Then cnr.Item("Property") = EntityProperty
- TgtDataset.Tables(CxType("tablename")).Rows.Add(cnr)
- If etrow.Table.Columns.Contains(vkey & "#ID") AndAlso cto.ContainsKey("id") Then etrow.Item(vkey & "#ID") = vdic("Id")
- End If
-
- Return True
- End Function
-
- Private Function StoreNP(ByRef TgtDataset As DataSet, Complextypes As Dictionary(Of String, Dictionary(Of String, String)), NavProp As Dictionary(Of String, String), ByVal NP As Object, ByVal EntityProperty As String, ByVal setid As String, ByVal vdic As Dictionary(Of String, Object)) As Boolean
- If NP.GetType() = GetType(JArray) Then
- For Each vitm As JObject In NP
- Call StoreNP(TgtDataset:=TgtDataset, Complextypes:=Complextypes, NavProp:=NavProp, NP:=vitm, EntityProperty:=EntityProperty, setid:=setid, vdic:=vdic)
- Next
- ElseIf NP.GetType() = GetType(JObject) Then
- Dim nto As Dictionary(Of String, Object) = NP.ToObject(Of Dictionary(Of String, Object))
- If nto.ContainsKey("Id") Then
- Dim nnr As DataRow = TgtDataset.Tables(NavProp("countertable")).NewRow
- Dim ncr As DataRow = TgtDataset.Tables(NavProp("tablename")).NewRow
- nnr.Item("setid") = setid
- For Each cky As String In nto.Keys
- If IsNothing(nto(cky)) = False Then
- If Complextypes.ContainsKey(cky.ToLower) AndAlso Complextypes(cky.ToLower)("name") <> "CustomValues" Then
- Call StoreCP(TgtDataset:=TgtDataset, etrow:=nnr, Complextypes:=Complextypes, CP:=nto(cky), EntityProperty:=Complextypes(cky.ToLower)("EntityType") & ":" & cky, vkey:=cky, setid:=setid, vdic:=vdic)
- Else
- If IsNothing(nto(cky)) = False AndAlso nnr.Table.Columns.Contains(cky) Then
- 'nnr.Item(cky) = nto(cky)
- If nto(cky).GetType() = GetType(System.DateTime) Then
- If Year(nto(cky)) > 1900 Then nnr.Item(cky) = nto(cky)
- ElseIf nto(cky).GetType = nnr.Table.Columns(cky).DataType Then
- nnr.Item(cky) = nto(cky)
- ElseIf nto(cky).GetType = GetType(String) And nto(cky).GetType() <> nnr.Table.Columns(cky).DataType Then
- Try
- nnr.Item(cky) = DirectCast(nto(cky), System.String).TryConvert(nnr.Table.Columns(cky).DataType)
- Catch cgvex As Exception
- Try
- nnr.Item(cky) = nto(cky)
- Catch cvex As Exception
- End Try
- End Try
- Else
- Try
- nnr.Item(cky) = nto(cky)
- Catch cvex As Exception
- End Try
- End If
- End If
- End If
- End If
- Next
- ncr.Item("EntityId") = vdic("Id")
- ncr.Item("PartnerType") = NavProp("countertype")
- ncr.Item("PartnerId") = nto("Id")
- ncr.Item("Property") = EntityProperty
- TgtDataset.Tables(NavProp("tablename")).Rows.Add(ncr)
- TgtDataset.Tables(NavProp("countertable")).Rows.Add(nnr)
- End If
- End If
- Return True
- End Function
-
- Private Shared Function newDatatable_SQL(tablename As String) As String
- Return System.String.Format("Select TOP(0) [setid] = CAST('' as varchar(50)), * FROM [dbo].[{0}];", tablename)
- End Function
- Private Async Function newDatatable(tablename As String, setid As String) As Task(Of DataTable)
- Dim sqldt As SQLDataTable = Await getSQLDatatable_async(newDatatable_SQL(tablename:=tablename), SqlConnectionString:=FDSConnectionString, options:=New fds_SQLOptions())
- Dim dt As DataTable = sqldt.DataTable.Clone
- dt.TableName = tablename
- If dt.Columns.Contains("setid") = False Then dt.Columns.Add(New DataColumn("setid", GetType(System.String)) With {.MaxLength = 5})
- dt.Columns("setid").DefaultValue = setid
- Return dt
- End Function
-#Enable Warning BC42356 ' This async method lacks 'Await' operators and so will run synchronously
-
- Public Async Function Update__Entitytables(Optional DebugDetails As Boolean = False, Optional tgtEntityType As Nullable(Of EntityTypes) = Nothing) As Threading.Tasks.Task
- Dim UpdateTasks As New List(Of Threading.Tasks.Task)
-
- Dim DtF As New Action(Of String, String, String, Exception)(Sub(note As String, info As String, data As String, ex As Exception)
- If DebugDetails = True Then Call DebugToFile("Update__AllEntitytables - " & note & If(If(info, "") <> "", ": " & info, ""), filename:="DebugDetail.txt", exc:=ex, data:=data)
- End Sub)
- Dim Dlg As New Action(Of String, String, String, Exception)(Sub(note As String, info As String, data As String, ex As Exception)
- Dim str As String = "Update__AllEntitytables - " & note & If(If(info, "") <> "", ": " & info, "")
- Call DebugLog(str, data:=If(data, ""), exc:=ex)
- If DebugDetails = True Then Call DebugToFile(str, filename:="DebugDetail.txt", exc:=ex, data:=data)
- Debug.Print(str)
- End Sub)
-
- Try
- 'get updateable tables
- 'the SQL query returns all tables that have corresponding temp tables and update procedures
- 'a lock status is included to determine if update processes are running
- Dim UpdateableTables As SQLDataTable = Await getSQLDatatable_async("SELECT * FROM [dbo].[fds__getUpdateableTables]()" & If(tgtEntityType.HasValue, " WHERE [entity_name] = '" & tgtEntityType.Value.ToString & "'", "") & ";", FDSConnectionString, options:=New fds_SQLOptions())
- Call DtF("UpdateableTables ", UpdateableTables.Exception, "(" & If(UpdateableTables.Count > 0, UpdateableTables.DataTable.Rows.Count.ToString, " no") & " Rows)", Nothing)
-
- If UpdateableTables.Count > 0 AndAlso UpdateableTables.DataTable.Columns.Contains("updateneed") = True Then 'just to avoid exceptions in next steps
- For Each UpdateableTable_rw As DataRow In UpdateableTables.Select("updateneed > 0", "updateneed DESC")
- Dim etname As String = UpdateableTable_rw.nz("entity_name", "")
- Try 'create capsule for each cycle to prevent everything fails if anything fails
- If etname = "" Then
- Call DtF("no entity_name received", "", json.SerializeObject(UpdateableTable_rw), Nothing)
- ElseIf UpdateableTable_rw.nbool("locked", True) = False Then
- Dim Tbl_UpdateNeed As UpdateNeed = UpdateableTable_rw.nint("updateneed", 0)
- Dim et As generic.EntityTypes = _generic.EntityValue(etname)
- If Not et = EntityTypes.none Then
- Call DtF("updating: " & etname, "", json.SerializeObject(UpdateableTable_rw), Nothing)
-
- Await Update__entitytable(et, UpdateNeed:=Tbl_UpdateNeed, EntityID:=Nothing, DebugDetails:=DebugDetails)
- Call DtF("updating task completed", "", "", Nothing)
- Else
- Call DtF("entitytype not known", "", json.SerializeObject(New With {.etname = etname}), Nothing)
- End If
- Else
- Call DtF("Table update locked" & etname, "", json.SerializeObject(UpdateableTable_rw), Nothing)
- End If
- Catch tableupdate_ex As Exception
- Call Dlg("updatepart failed - " & etname, "", json.SerializeObject(UpdateableTable_rw), tableupdate_ex)
- End Try
- Next UpdateableTable_rw
- End If
- Catch exa As Exception
- Call Dlg("outer frame", "", "", Nothing)
- End Try
- End Function
- Public Async Function Update__EntityRequests(Optional DebugDetails As Boolean = False) As Threading.Tasks.Task
- Dim UpdateTasks As New List(Of Threading.Tasks.Task)
-
- Dim DtF As New Action(Of String, String, String, Exception)(Sub(note As String, info As String, data As String, ex As Exception)
- If DebugDetails = True Then Call DebugToFile("Update__EntityRequests - " & note & If(If(info, "") <> "", ": " & info, ""), filename:="DebugDetail.txt", exc:=ex, data:=data)
- End Sub)
- Dim Dlg As New Action(Of String, String, String, Exception)(Sub(note As String, info As String, data As String, ex As Exception)
- Dim str As String = "Update__EntityRequests - " & note & If(If(info, "") <> "", ": " & info, "")
- Call DebugLog(str, data:=If(data, ""), exc:=ex)
- If DebugDetails = True Then Call DebugToFile(str, filename:="DebugDetail.txt", exc:=ex, data:=data)
- Debug.Print(str)
- End Sub)
-
- Try
- 'get updateable tables
- 'the SQL query returns all tables that have corresponding temp tables and update procedures
- 'a lock status is included to determine if update processes are running
- Dim UpdateableRequests As SQLDataTable = Await getSQLDatatable_async("SELECT * FROM [dbo].[fds__getUpdateableRequests]();", FDSConnectionString, options:=New fds_SQLOptions())
- Call DtF("UpdateableRequests ", UpdateableRequests.Exception, "(" & If(UpdateableRequests.Count > 0, UpdateableRequests.DataTable.Rows.Count.ToString, " no") & " Rows)", Nothing)
-
- If UpdateableRequests.Count > 0 Then 'just to avoid exceptions in next steps
- For Each UpdateableRequest_rw As DataRow In UpdateableRequests.Select("", "order")
- Dim etname As String = UpdateableRequest_rw.nz("entity_name", "")
- Dim tgtid As Long = UpdateableRequest_rw.nint("Id", -1)
- If tgtid > -1 AndAlso etname.IsNullOrWhiteSpace = False Then
- Try 'create capsule for each cycle to prevent everything fails if anything fails
- Dim et As generic.EntityTypes = _generic.EntityValue(etname)
- Await Update__entitytable(et, UpdateNeed:=UpdateNeed.Reset, EntityID:=New Long() {tgtid}, DebugDetails:=DebugDetails, AdditionalCommandAfter:="DELETE FROM [dbo].[fds__mfr_updaterequests] WHERE LOWER([entity_name]) = LOWER(ISNULL(@entityname,'')) AND CAST(ISNULL([Id],'') as varchar(1000)) = @tgtid;")
-
- Catch tableupdate_ex As Exception
- Call Dlg("Update__EntityRequest single failed - " & etname, "", json.SerializeObject(UpdateableRequest_rw), tableupdate_ex)
- End Try
- End If
- Next UpdateableRequest_rw
- End If
- Catch exa As Exception
- Call Dlg("outer frame", "", "", Nothing)
- End Try
- End Function
-
- Public Enum PostSubmissionActionMode
- full
- incremental
- reset
- End Enum
-
-
-
- Public Sub Dispose() Implements IDisposable.Dispose
- ''Me.MFRClient.Logout()
- DirectCast(Me.MFRClient, IDisposable).Dispose()
- End Sub
-
-
-
-
- End Class
-
-End Namespace
\ No newline at end of file
diff --git a/DataService_Legacy/fds_shared.vb b/DataService_Legacy/fds_shared.vb
deleted file mode 100644
index c12d49e..0000000
--- a/DataService_Legacy/fds_shared.vb
+++ /dev/null
@@ -1,226 +0,0 @@
-
-
-Friend Module fds_shared
-
- Friend Function SQLConnectionString() As String
- Return Configuration.ConfigurationManager.ConnectionStrings("fuchs_ConnectionString").ConnectionString
- End Function
- Friend Function FDSConnectionString() As String
- Return Configuration.ConfigurationManager.ConnectionStrings("fuchs_fds_ConnectionString").ConnectionString
- End Function
- Friend Function SqlCon() As SqlClient.SqlConnection
- Return New SqlClient.SqlConnection(Configuration.ConfigurationManager.ConnectionStrings("fuchs_ConnectionString").ConnectionString)
- End Function
-
-
-
- Public Function RandomString(rs_length As Byte) As String
- Dim r As New Random()
- Dim s As String = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
- Dim sb As New Text.StringBuilder
- For i As Byte = 1 To rs_length
- Dim idx As Integer = r.Next(0, s.Length)
- sb.Append(s.Substring(idx, 1))
- Next
- Return sb.ToString()
- End Function
-
-
- ''''
- '''' Returns a delimited containing the field values from a .
- ''''
- ''''
- '''' The input .
- ''''
- ''''
- '''' The delimiter placed between field values. the default value is a comma.
- ''''
- ''''
- '''' A containing the field values from the row separated by the specified delimiter.
- ''''
- '
- 'Public Function ToCsv(source As DataRow,
- ' Optional delimiter As String = ",") As String
- ' Return String.Join(delimiter, source.ItemArray)
- 'End Function
-
- '''
- ''' Returns a delimited containing the field values from a .
- '''
- '''
- ''' The input .
- '''
- '''
- ''' True to wrap values in double-quotes; otherwise, False.
- ''' If double-quotes are added, double-quotes within text are escaped with another double-quote.
- '''
- '''
- ''' The delimiter placed between field values. the default value is a comma.
- '''
- '''
- ''' A containing the field values from the row separated by the specified delimiter.
- '''
-
- Public Function ToCsv(source As DataRow,
- quoteStrings As Boolean,
- cultureinfo As Globalization.CultureInfo,
- Optional delimiter As String = ",") As String
- Dim fieldValues = source.ItemArray
-
-
- Dim rx As New Text.RegularExpressions.Regex("(\"")")
- 'Wrap any String values in double-quotes and also escape any double-quotes in the String with another double-quote.
- 'replace array by converted array
- fieldValues = fieldValues.Select(Function(o)
- If IsNothing(o) OrElse IsDBNull(o) Then
- Return ""
- ElseIf o.GetType = GetType(String) Then
- If quoteStrings = True Then
- Return Microsoft.VisualBasic.ChrW(34) & rx.Replace(o.ToString, Microsoft.VisualBasic.ChrW(34) & Microsoft.VisualBasic.ChrW(34)) & Microsoft.VisualBasic.ChrW(34)
- Else
- Return o.ToString
- End If
- Else
- Select Case o.GetType
- Case GetType(Decimal)
- Return DirectCast(o, Decimal).ToString(cultureinfo)
- Case GetType(Single)
- Return DirectCast(o, Single).ToString(cultureinfo)
- Case GetType(Double)
- Return DirectCast(o, Double).ToString(cultureinfo)
- Case GetType(Boolean)
- Return DirectCast(o, Boolean).ToString(cultureinfo)
- Case GetType(System.DateTime)
- Return DirectCast(o, DateTime).ToUniversalTime.ToString("U")
- Case Else
- Return o.ToString()
- End Select
- End If
- End Function).ToArray()
-
-
- Return String.Join(delimiter, fieldValues)
- End Function
-
-
-
- '''
- ''' Returns a delimited containing the field values from the rows a .
- '''
- ''' The input .
- ''' True to include a row of column headers; otherwise, False
- ''' True to wrap values in double-quotes; otherwise, False.
- ''' If double-quotes are added, double-quotes within text are escaped with another double-quote.
- ''' The delimiter placed between rows. the default value is a line break comprising a carriage return and a line feed.
- ''' The delimiter placed between field values. the default value is a comma.
- ''' The culture that is used to convert float-point numbers like or to string. This falls back to InvariantCulture, if not provided.
- ''' True to wrap column header names in double-quotes; otherwise, False.
- ''' If no value is provided, the settings falls back to quoteStrings parameter.
- ''' A containing the field values from the rows of the table separated by the specified delimiters.
-
- Public Function ToCsv(source As DataTable,
- includeHeaders As Boolean,
- quoteStrings As Boolean,
- Optional rowDelimiter As String = ControlChars.CrLf,
- Optional fieldDelimiter As String = ",",
- Optional cultureinfo As Globalization.CultureInfo = Nothing,
- Optional quoteHeader As Boolean? = Nothing) As String
- If quoteHeader.HasValue = False Then quoteHeader = quoteStrings
- cultureinfo = If(cultureinfo, Globalization.CultureInfo.InvariantCulture) 'fallback if not provided
- Dim rows = source.Rows.
- Cast(Of DataRow)().
- Select(Function(row) row.ToCsv(quoteStrings:=quoteStrings, cultureinfo:=cultureinfo, delimiter:=fieldDelimiter))
-
- If includeHeaders = True Then
- Dim rx As New Text.RegularExpressions.Regex("(\"")")
- Dim headers = String.Join(fieldDelimiter,
- source.Columns.
- Cast(Of DataColumn)().
- Select(Function(column) If(quoteHeader.Value,
- Microsoft.VisualBasic.ChrW(34) & rx.Replace(column.ColumnName.ToString, Microsoft.VisualBasic.ChrW(34) & Microsoft.VisualBasic.ChrW(34)) & Microsoft.VisualBasic.ChrW(34),
- column.ColumnName)))
-
- rows = {headers}.Concat(rows)
- End If
-
- Return String.Join(rowDelimiter, rows)
- End Function
-
- '''
- ''' Returns a text-file containing the string, created by streamwriter.
- '''
- ''' The input .
- ''' The encoding used with streamwriter for the textfile. This falls back to , if not provided.
- ''' A file as byte-array.
-
- Public Function ToByteArray(input As String, Optional encoding As System.Text.Encoding = Nothing) As Byte()
- Dim content As Byte() = Nothing
- Using ms As New IO.MemoryStream
- Using sw As New IO.StreamWriter(ms, encoding:=If(encoding, System.Text.Encoding.UTF8))
- sw.Write(input)
- sw.Flush()
- ms.Position = 0
- content = ms.ToArray()
- End Using
- End Using
- Return content
- End Function
-
-
- Public Function WriteStreamToDisk(ByVal StreamToWrite As IO.Stream, ByVal FilePath As String) As Boolean
- 'Dim tmpFilePath As String = Left(FilePath, Len(FilePath) - 4) & ".tmp"
- Dim cnt = 0
-restart:
- Try
- If My.Computer.FileSystem.FileExists(FilePath) Then My.Computer.FileSystem.DeleteFile(FilePath)
- Using FleStream As System.IO.FileStream = New System.IO.FileStream(FilePath, System.IO.FileMode.Create, System.IO.FileAccess.Write, System.IO.FileShare.Delete)
- ReadWriteStream(StreamToWrite, FleStream, True)
- End Using
- Catch ex As Exception
- System.Diagnostics.Debug.WriteLine($"{"WriteStreamToDisk - " & ex.Message}")
- cnt += 1
- If cnt = 6 Then
- Return False
- Exit Function
- Else
- Threading.Thread.Sleep(500)
- GoTo restart
- End If
- End Try
- Return True
- End Function
-
- Public Function ReadWriteStream(ByVal readStream As IO.Stream, ByVal writeStream As IO.Stream, ByVal closeWriteStream As Boolean) As Boolean
- Try
- Dim Length As Integer = 256
- Dim buffer(Length - 1) As Byte
- readStream.Seek(0, System.IO.SeekOrigin.Begin)
- Dim bytesRead As Integer = readStream.Read(buffer, 0, Length)
- 'write the required bytes
- While (bytesRead > 0)
- writeStream.Write(buffer, 0, bytesRead)
- bytesRead = readStream.Read(buffer, 0, Length)
- End While
- readStream.Close()
- If closeWriteStream = True Then writeStream.Close()
- Return True
- Catch ex As Exception
- System.Diagnostics.Debug.WriteLine($"{"ReadWriteStream - " & ex.Message}")
- Call OCMS.debug_log("files_folders ReadWriteStream", ex)
- Return False
- End Try
- End Function
-
-
-
- Public Function NameBase(ByVal FI As System.IO.FileInfo) As String
- Return FI.Name.Substring(startIndex:=0, length:=FI.Name.Length - FI.Extension.Length)
- End Function
-
-
-
-
- Public Function MimeType(ByVal FI As System.IO.FileInfo) As String
- Return System.Web.MimeMapping.GetMimeMapping(FI.Name)
- End Function
-End Module
diff --git a/DataService_Legacy/fds_zip.vb b/DataService_Legacy/fds_zip.vb
deleted file mode 100644
index 523953f..0000000
--- a/DataService_Legacy/fds_zip.vb
+++ /dev/null
@@ -1,500 +0,0 @@
-Imports SevenZip 'Squid-Box.SevenZipSharp
-Imports System.IO
-
-Namespace Global.fds
- Public Class Archive
- Implements IDisposable
-
- 'Private Declare Function WaitForSingleObject Lib "kernel32" (ByVal hHandle As Long, ByVal dwMilliseconds As Long) As Long
- 'Private Declare Sub Sleep Lib "kernel32" (ByVal dwMilliseconds As Long)
-
- 'Public Enum Timeunit As Long
- ' Milliseconds = 0
- ' Seconds = 1000
- ' Minutes = 60000
- 'End Enum
- 'Public Sub Wait(ByVal No As Integer, ByVal unit As Timeunit)
- ' Dim tme As Long = CLng(No * unit)
- ' Sleep(tme)
- 'End Sub
-
- Public Event Saving()
- Public Event FileSaved()
- Public Event FileStreamCreated()
-
- Private _ArchiveFile As FileInfo
- Private _ArchivePassword As String
- Private _ArchiveFormat As OutArchiveFormat
- Public TempPath As String = System.AppDomain.CurrentDomain.BaseDirectory
- Public Property ArchiveFileStream As IO.Stream
-
- Private ZipOut As SevenZipExtractor
- Private ZipIn As SevenZipCompressor
- Public ZipAppend As Boolean = True
-
- Public ExitOK As Boolean = False
- Public ZipInOK As Boolean = False
-
- Public Sub New(ByVal ArchiveFile As FileInfo, Optional ByVal ArchivePassword As String = "", Optional ByVal INIT As Boolean = True, Optional ByVal Type As OutArchiveFormat = OutArchiveFormat.SevenZip)
- Me._ArchiveFormat = Type
- Me._ArchiveFile = New FileInfo(ArchiveFile.FullName.Replace(ArchiveFile.Extension, If(Type = OutArchiveFormat.SevenZip, ".7z", ArchiveFile.Extension)))
- Me._ArchivePassword = ArchivePassword
- If INIT = True Then Call InitZipIn(Type)
- End Sub
-
- Private Sub InitZipIn(ByVal Type As OutArchiveFormat)
- Dim assemblydirectory As IO.DirectoryInfo
- If Zipping.SevenZipPath = "" Then
- Try
- assemblydirectory = New IO.DirectoryInfo(New Uri(System.IO.Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().GetName().CodeBase)).LocalPath)
- Dim zip As IO.FileInfo = assemblydirectory.GetFiles("7z.dll", SearchOption.AllDirectories).FirstOrDefault
- Zipping.SevenZipPath = If(IsNothing(zip), "", zip.FullName)
- Finally
- If Zipping.SevenZipPath = "" Then
- assemblydirectory = New IO.DirectoryInfo(System.AppDomain.CurrentDomain.BaseDirectory)
- Dim zip As IO.FileInfo = assemblydirectory.GetFiles("7z.dll", SearchOption.AllDirectories).FirstOrDefault
- Zipping.SevenZipPath = If(IsNothing(zip), "", zip.FullName)
- End If
- End Try
- If Zipping.SevenZipPath = "" Then
- OCMS.debug_log("DDA.intranet.Zipping Archive InitZipIn", error:="SevenZipPath not found")
- End If
- End If
- SevenZipCompressor.SetLibraryPath(SevenZipPath)
-
- Me.ZipIn = New SevenZipCompressor
- With Me.ZipIn
- If Type = OutArchiveFormat.SevenZip AndAlso Me._ArchiveFile.Extension.ToLower.Contains("7z") = True Then
- .ArchiveFormat = OutArchiveFormat.SevenZip
- Else
- .ArchiveFormat = Type
- End If
-
- .CompressionLevel = SevenZip.CompressionLevel.Ultra
- Select Case .ArchiveFormat
- Case OutArchiveFormat.SevenZip
- .CompressionMethod = SevenZip.CompressionMethod.Lzma2
- Case OutArchiveFormat.Zip, OutArchiveFormat.GZip
- .CompressionMethod = CompressionMethod.Deflate
- Case Else
- .CompressionMethod = CompressionMethod.Default
- End Select
-
- If ZipAppend = True Then
- .CompressionMode = SevenZip.CompressionMode.Append
- Else
- .CompressionMode = SevenZip.CompressionMode.Create
- End If
- .DirectoryStructure = False
- End With
- Me.ZipInOK = True
- End Sub
-
- Public Sub Extract(ByVal DataArchiveFilePath As FileInfo, ByVal TgtDirectory As DirectoryInfo, Optional ByVal Type As OutArchiveFormat = Nothing)
- If DataArchiveFilePath.Exists Then
- If IsNothing(Type) = True AndAlso DataArchiveFilePath.Extension.ToLower.Contains("7z") = True Then
- Type = OutArchiveFormat.SevenZip
- ElseIf IsNothing(Type) = True Then
- Type = OutArchiveFormat.Zip
- End If
-
- If Me.ZipInOK = False Then Call InitZipIn(Type)
-
- If Me._ArchivePassword = "" Then
- Me.ZipOut = New SevenZipExtractor(DataArchiveFilePath.FullName)
- Else
- Me.ZipOut = New SevenZipExtractor(DataArchiveFilePath.FullName, Me._ArchivePassword)
- End If
-
- Try
- If Me.ZipOut.ArchiveFileData(0).Encrypted = False And Not Me._ArchivePassword = "" Then
- Me._ArchivePassword = ""
- End If
- Catch ex As Exception
- System.Diagnostics.Debug.WriteLine($"{"zip Extract - " & ex.Message}")
- OCMS.debug_log("DDA.intranet.Zipping Archive InitZipIn", ex:=ex, data:=New With {.DataArchiveFilePath = DataArchiveFilePath.FullName, .TgtDirectory = TgtDirectory.FullName})
- Exit Sub
- End Try
-
- If Not ZipOut Is Nothing Then
- Me.ZipOut.ExtractArchive(TgtDirectory.FullName)
- Me.ZipOut.Dispose()
- End If
- End If
- End Sub
-
- Public Function FileInfo_to_Filepaths_Converter() As Converter(Of FileInfo, String)
- Return New Converter(Of FileInfo, String)(Function(filepath As FileInfo) filepath.FullName())
- End Function
- Public Function Filepaths_to_FileInfo_Converter() As Converter(Of String, FileInfo)
- Return New Converter(Of String, FileInfo)(Function(filepath As String) New FileInfo(filepath))
- End Function
-
- Public Function Compress(ByVal FilePaths As List(Of String), Optional ByVal ArchiveFilePath As String = Nothing, Optional ByVal ArchivePass As String = Nothing, Optional ByVal Type As OutArchiveFormat = OutArchiveFormat.SevenZip) As Boolean
- Return Compress(Files:=FilePaths.ConvertAll(Filepaths_to_FileInfo_Converter()), ArchiveFile:=If(IsNothing(ArchiveFilePath), Nothing, New FileInfo(ArchiveFilePath)), ArchivePass:=ArchivePass, Type:=Type)
- End Function
- Public Function Compress(ByVal Files As List(Of FileInfo), Optional ByVal ArchiveFile As FileInfo = Nothing, Optional ByVal ArchivePass As String = Nothing, Optional ByVal Type As OutArchiveFormat = OutArchiveFormat.SevenZip) As Boolean
- If Files.Count = 0 Then Return True
- If Me.ZipInOK = False Then Call InitZipIn(Type)
-
- If IsNothing(ArchiveFile) = True Then ArchiveFile = Me._ArchiveFile
- If If(IsNothing(ArchivePass), "", ArchivePass) = "" Then ArchivePass = Me._ArchivePassword
-
- If ArchiveFile.Exists() AndAlso Me.ZipAppend = True Then
- Me.ZipIn.CompressionMode = CompressionMode.Append
- Else
- If ArchiveFile.Exists = True Then ArchiveFile.Delete()
- Me.ZipIn.CompressionMode = CompressionMode.Create
- End If
-
- Try
- Dim FilesVerified As FileInfo() = Files.Where(Function(f As FileInfo) f.Exists).ToArray()
- Dim FilePaths As String() = Array.ConvertAll(Of FileInfo, String)(FilesVerified, FileInfo_to_Filepaths_Converter())
- If ArchivePass = "" Then
- Me.ZipIn.CompressFiles(ArchiveFile.FullName, FilePaths)
- Else
- Me.ZipIn.EncryptHeaders = True
- Me.ZipIn.ZipEncryptionMethod = ZipEncryptionMethod.Aes256
- Me.ZipIn.CompressFilesEncrypted(ArchiveFile.FullName, ArchivePass, FilePaths)
- End If
- RaiseEvent FileSaved()
-
- 'Debug.Print("Saved: " & Now().ToString)
- Me.ExitOK = True
- 'Disposing...
- Me.ZipIn = Nothing
- Me.ZipInOK = False
- Catch ex As Exception
- 'Debug.Print("NOT Saved: " & Now().ToString)
- Me.ExitOK = False
- End Try
- Return Me.ExitOK AndAlso ArchiveFile.Exists
- End Function
- Public Function Compress(ByVal FilePath As String) As Boolean
- Dim FL As New List(Of String) From {
- FilePath
- }
- Call Compress(FL)
- Return True
- End Function
- Public Function CompressToStream(ByVal FilePath As String) As Boolean
- Dim FL As New List(Of String) From {
- FilePath
- }
- Call CompressToStream(FL)
- Return True
- End Function
-
- Public Function CompressToStream(ByVal FilePaths As List(Of String)) As Boolean
- Return CompressToStream(Files:=FilePaths.ConvertAll(Filepaths_to_FileInfo_Converter()))
- End Function
- Public Function CompressToStream(ByVal Files As List(Of FileInfo)) As Boolean
- If Files.Count = 0 Then Return True
- If Me.ZipInOK = False Then Call InitZipIn(Me._ArchiveFormat)
-
- If IsNothing(_ArchiveFileStream) Then 'nur wenn der interne leer ist...
- Me.ZipIn.CompressionMode = CompressionMode.Create
- Me._ArchiveFileStream = New MemoryStream
- Else
- Me.ZipIn.CompressionMode = CompressionMode.Append
- End If
-
- Try
- Dim FilesVerified As FileInfo() = Files.Where(Function(f As FileInfo) f.Exists).ToArray()
- Dim FilePaths As String() = Array.ConvertAll(Of FileInfo, String)(FilesVerified, FileInfo_to_Filepaths_Converter())
- If Me._ArchivePassword = "" Then
- Me.ZipIn.CompressFiles(Me._ArchiveFileStream, FilePaths)
- Else
- Me.ZipIn.EncryptHeaders = True
- Me.ZipIn.ZipEncryptionMethod = ZipEncryptionMethod.Aes256
- Me.ZipIn.CompressFilesEncrypted(Me._ArchiveFileStream, Me._ArchivePassword, FilePaths)
- End If
- Me._ArchiveFileStream.Seek(0, SeekOrigin.Begin)
- RaiseEvent FileStreamCreated()
-
- 'Debug.Print("Saved: " & Now().ToString)
- Me.ExitOK = True
- 'Disposing...
- Me.ZipIn = Nothing
- Me.ZipInOK = False
- Catch ex As Exception
- 'Debug.Print("NOT Saved: " & Now().ToString)
- Me.ExitOK = False
- End Try
- Return Me.ExitOK
- End Function
-
- Public Function CompressToStream(ByVal Files As List(Of FileInfo), ByRef TargetStream As IO.Stream) As Boolean
- If Files.Count = 0 Then Return True
- If Me.ZipInOK = False Then Call InitZipIn(Me._ArchiveFormat)
-
- If IsNothing(TargetStream) = True Then
- TargetStream = New MemoryStream
- End If
- Me.ZipIn.CompressionMode = CompressionMode.Create
- Dim FilePaths As String() = New String() {}
- Try
- Dim FilesVerified As FileInfo() = Files.Where(Function(f As FileInfo) f.Exists).ToArray()
- FilePaths = Array.ConvertAll(Of FileInfo, String)(FilesVerified, FileInfo_to_Filepaths_Converter())
- If Me._ArchivePassword = "" Then
- Me.ZipIn.CompressFiles(TargetStream, FilePaths)
- Else
- Me.ZipIn.EncryptHeaders = True
- Me.ZipIn.ZipEncryptionMethod = ZipEncryptionMethod.Aes256
- Me.ZipIn.CompressFilesEncrypted(TargetStream, Me._ArchivePassword, FilePaths)
- End If
- TargetStream.Seek(0, SeekOrigin.Begin)
-
- 'Debug.Print("Saved: " & Now().ToString)
- Me.ExitOK = True
- 'Disposing...
- Me.ZipIn = Nothing
- Me.ZipInOK = False
- Catch ex As Exception
- 'Debug.Print("NOT Saved: " & Now().ToString)
- OCMS.debug_log("IntranetController zip", ex, data:=New With {.filepaths = FilePaths})
- Me.ExitOK = False
- End Try
- Return Me.ExitOK
- End Function
-
-
- Public Function CompressToStream(ByVal Files As Dictionary(Of String, IO.Stream)) As Boolean
- If Files.Count = 0 Then Return True
- If Me.ZipInOK = False Then Call InitZipIn(Me._ArchiveFormat)
-
- If IsNothing(_ArchiveFileStream) Then 'nur wenn der interne leer ist...
- Me.ZipIn.CompressionMode = CompressionMode.Create
- Me._ArchiveFileStream = New MemoryStream
- Else
- Me.ZipIn.CompressionMode = CompressionMode.Append
- End If
-
- Try
- If Me._ArchivePassword = "" Then
- Me.ZipIn.CompressStreamDictionary(streamDictionary:=Files, Me._ArchiveFileStream)
- Else
- Me.ZipIn.EncryptHeaders = True
- Me.ZipIn.ZipEncryptionMethod = ZipEncryptionMethod.Aes256
- Me.ZipIn.CompressStreamDictionary(streamDictionary:=Files, Me._ArchiveFileStream, password:=Me._ArchivePassword)
- End If
- Me._ArchiveFileStream.Seek(0, SeekOrigin.Begin)
- RaiseEvent FileStreamCreated()
-
- 'Debug.Print("Saved: " & Now().ToString)
- Me.ExitOK = True
- 'Disposing...
- Me.ZipIn = Nothing
- Me.ZipInOK = False
- Catch ex As Exception
- 'Debug.Print("NOT Saved: " & Now().ToString)
- Me.ExitOK = False
- End Try
- Return Me.ExitOK
- End Function
- Public Function CompressToStream(ByVal Files As Dictionary(Of String, Byte()), Optional targetstream As IO.Stream = Nothing) As Boolean
- If Files.Count = 0 Then Return True
- If Me.ZipInOK = False Then Call InitZipIn(Me._ArchiveFormat)
-
- If IsNothing(_ArchiveFileStream) Then 'nur wenn der interne leer ist...
- Me.ZipIn.CompressionMode = CompressionMode.Create
- Me._ArchiveFileStream = New MemoryStream
- Else
- Me.ZipIn.CompressionMode = CompressionMode.Append
- End If
-
- Try
- Dim FilesStreams As New Dictionary(Of String, IO.Stream)
- For Each fy As String In Files.Keys
- FilesStreams.Add(fy, New IO.MemoryStream(Files(fy)))
- Next
-
- If Me._ArchivePassword = "" Then
- Me.ZipIn.CompressStreamDictionary(streamDictionary:=FilesStreams, If(IsNothing(targetstream), Me._ArchiveFileStream, targetstream))
- Else
- Me.ZipIn.EncryptHeaders = True
- Me.ZipIn.ZipEncryptionMethod = ZipEncryptionMethod.Aes256
- Me.ZipIn.CompressStreamDictionary(streamDictionary:=FilesStreams, If(IsNothing(targetstream), Me._ArchiveFileStream, targetstream), password:=Me._ArchivePassword)
- End If
- Me._ArchiveFileStream.Seek(0, SeekOrigin.Begin)
- RaiseEvent FileStreamCreated()
-
- 'Debug.Print("Saved: " & Now().ToString)
- Me.ExitOK = True
- 'Disposing...
- Me.ZipIn = Nothing
- Me.ZipInOK = False
- Catch ex As Exception
- 'Debug.Print("NOT Saved: " & Now().ToString)
- Me.ExitOK = False
- End Try
- Return Me.ExitOK
- End Function
-
-
- Public Function WriteArchiveStreamToDisk(Optional ByVal ArchiveFile As IO.FileInfo = Nothing) As Boolean
- Try
- If Me._ArchiveFile.Exists() Then Me._ArchiveFile.Delete()
- Catch ex As Exception
- End Try
- If IsNothing(ArchiveFile) = False Then 'Wenn ein DateiPfad hier übergeben wurde...
- WriteStreamToDisk(Me._ArchiveFileStream, ArchiveFile.FullName)
- Else ' sonst wird der interne genommen
- WriteStreamToDisk(Me._ArchiveFileStream, Me._ArchiveFile.FullName)
- End If
- Return Me._ArchiveFile.Exists
- End Function
-
-
-
- 'Private ArchiveMail As EMail = Nothing
- 'Public Function SendZip(Optional ByVal Subject As String = Nothing, Optional ByVal BodyText As String = Nothing) As Boolean
- ' Try
- ' If ArchiveMail Is Nothing Then Exit Function
- ' If Not Subject = Nothing Then
- ' ArchiveMail.Subject = Subject
- ' End If
- ' If Not BodyText = Nothing Then
- ' ArchiveMail.Body = BodyText
- ' End If
- ' 'Send with archive as attachment
- ' Return ArchiveMail.Send(_ArchivePath.Path)
- ' Catch ex As Exception
- ' Return False
- ' End Try
- 'End Function
- 'Public Function SendZipStream(Optional ByVal Subject As String = Nothing, Optional ByVal BodyText As String = Nothing) As Boolean
- ' Try
- ' If ArchiveMail Is Nothing Then Exit Function
- ' If Not Subject = Nothing Then
- ' ArchiveMail.Subject = Subject
- ' End If
- ' If Not BodyText = Nothing Then
- ' ArchiveMail.Body = BodyText
- ' End If
- ' 'Send with archive as attachment
- ' Return ArchiveMail.Send(_ArchivePath.Name, _ArchiveFileStream)
- ' Catch ex As Exception
- ' Return False
- ' End Try
- 'End Function
- 'Public Sub SetMailSettings(ByVal SMTP As MailServer_Settings, ByVal MAIL As Mail_Settings)
- ' ArchiveMail = New EMail(SMTP, MAIL)
- 'End Sub
-
-
-#Region "IDisposable Support"
- Private disposedValue As Boolean ' To detect redundant calls
-
- ' IDisposable
- Protected Overridable Sub Dispose(disposing As Boolean)
- If Not disposedValue Then
- If disposing Then
- ' TODO: dispose managed state (managed objects).
- Try
- If IsNothing(Me._ArchiveFileStream) = False Then Me._ArchiveFileStream.Dispose()
- If IsNothing(Me.ZipOut) = False Then Me.ZipOut.Dispose()
- Me.ZipIn = Nothing
- Catch ex As Exception
-
- End Try
- End If
-
- ' TODO: free unmanaged resources (unmanaged objects) and override Finalize() below.
- ' TODO: set large fields to null.
- End If
- disposedValue = True
- End Sub
-
- ' TODO: override Finalize() only if Dispose(disposing As Boolean) above has code to free unmanaged resources.
- 'Protected Overrides Sub Finalize()
- ' ' Do not change this code. Put cleanup code in Dispose(disposing As Boolean) above.
- ' Dispose(False)
- ' MyBase.Finalize()
- 'End Sub
-
- ' This code added by Visual Basic to correctly implement the disposable pattern.
- Public Sub Dispose() Implements IDisposable.Dispose
- ' Do not change this code. Put cleanup code in Dispose(disposing As Boolean) above.
- Dispose(True)
- ' TODO: uncomment the following line if Finalize() is overridden above.
- ' GC.SuppressFinalize(Me)
- End Sub
-#End Region
-
-
- End Class
-
-
- Public Module Zipping
-
- Public SevenZipPath As String = ""
-
- Public Sub FastAppend(ByVal FileToZip As FileInfo, ByVal ArchiveFile As FileInfo)
- If FileToZip.Exists AndAlso IsNothing(ArchiveFile) = False AndAlso ArchiveFile.Exists Then
- Dim Zip As New Archive(ArchiveFile) With {
- .ZipAppend = True
- }
- Dim FL As New List(Of String) From {
- FileToZip.FullName
- }
- Zip.Compress(FL)
- End If
- End Sub
- Public Sub FastAppend(ByVal FileToZip As FileInfo, ByVal TgtArchiveDirectory As DirectoryInfo, ByVal ArchiveName As String)
- Dim ArchiveFile As New FileInfo(TgtArchiveDirectory.FullName & If(Strings.Right(TgtArchiveDirectory.FullName, 1) = "\", "", "\") & ArchiveName)
- If ArchiveFile.Exists Then Call FastAppend(FileToZip, ArchiveFile:=ArchiveFile)
- End Sub
- Public Sub FastAppend(ByVal FileToZip As FileInfo, ByVal TgtArchiveDirectoryPath As String, ByVal ArchiveName As String)
- Dim ArchiveFile As New FileInfo(TgtArchiveDirectoryPath & If(Strings.Right(TgtArchiveDirectoryPath, 1) = "\", "", "\") & ArchiveName)
- If ArchiveFile.Exists Then Call FastAppend(FileToZip, ArchiveFile:=ArchiveFile)
- End Sub
- Public Function FastZip(ByVal DirectoryToZip As DirectoryInfo, Optional ByVal Append As Boolean = True) As IO.FileInfo
- If DirectoryToZip.Exists Then
- Dim ZipFile As New FileInfo(DirectoryToZip.Name + ".7z"), cnt As Integer = 0
- If Append = False Then
- Do Until ZipFile.Exists = False
- cnt += 1
- ZipFile = New FileInfo(DirectoryToZip.Name & "_" & CStr(cnt) & ".7z")
- Loop
- End If
- Dim Zip As New Archive(ZipFile) With {
- .ZipAppend = Append
- }
- Zip.Compress(New List(Of FileInfo)(DirectoryToZip.GetFiles))
- Return If(ZipFile.Exists, ZipFile, Nothing)
- Else
- Return Nothing
- End If
- End Function
- Public Function FastZip(ByVal FileToZip As IO.FileInfo, Optional ByVal Append As Boolean = True) As IO.FileInfo
- If FileToZip.Exists Then
- Dim ZipFile As New FileInfo(FileToZip.Name.Replace("." & FileToZip.Extension, "") + ".7z"), cnt As Integer = 0
- If Append = False Then
- Do Until ZipFile.Exists = False
- cnt += 1
- ZipFile = New FileInfo(FileToZip.Name.Replace("." & FileToZip.Extension, "") & "_" & CStr(cnt) & ".7z")
- Loop
- End If
- Dim Zip As New Archive(ZipFile) With {
- .ZipAppend = Append
- }
- Dim FL As New List(Of FileInfo) From {
- FileToZip
- }
- Zip.Compress(FL)
- Return If(ZipFile.Exists, ZipFile, Nothing)
- Else
- Return Nothing
- End If
- End Function
- Public Function FastZip(ByVal TgtDirectory As String, ByVal Filename As String, Optional ByVal Append As Boolean = True) As IO.FileInfo
- Return FastZip(New FileInfo(TgtDirectory & If(Strings.Right(TgtDirectory, 1) = "\", "", "\") & Filename), Append)
- End Function
-
-
-
-
- End Module
-
-End Namespace
diff --git a/DataService_Legacy/install.bat b/DataService_Legacy/install.bat
deleted file mode 100644
index bd9c444..0000000
--- a/DataService_Legacy/install.bat
+++ /dev/null
@@ -1 +0,0 @@
-Fuchs_Dataservice.exe install --autostart
\ No newline at end of file
diff --git a/DataService_Legacy/packages.config b/DataService_Legacy/packages.config
deleted file mode 100644
index 3823bf2..0000000
--- a/DataService_Legacy/packages.config
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/DataService_Legacy/un-install.bat b/DataService_Legacy/un-install.bat
deleted file mode 100644
index 1312ccc..0000000
--- a/DataService_Legacy/un-install.bat
+++ /dev/null
@@ -1 +0,0 @@
-Fuchs_Dataservice.exe uninstall
\ No newline at end of file
diff --git a/Fuchs.Tests/DocumentMetadataBuilderTests.cs b/Fuchs.Tests/DocumentMetadataBuilderTests.cs
index 7cd869b..357a24c 100644
--- a/Fuchs.Tests/DocumentMetadataBuilderTests.cs
+++ b/Fuchs.Tests/DocumentMetadataBuilderTests.cs
@@ -110,6 +110,20 @@ public class DocumentMetadataBuilderTests
Assert.Equal(guid.ToString(), metadata["file_guid"]);
}
+ [Theory]
+ [InlineData("Zahlungserinnerung für Müller", "Zahlungserinnerung f%C3%BCr M%C3%BCller")]
+ [InlineData("Rechnung\r\nKunde", "Rechnung%0D%0AKunde")]
+ public void Build_NonAsciiOrControlCharacters_PercentEncodesUtf8ForHttpHeaders(
+ string value, string expected)
+ {
+ var row = new Dictionary { ["DocumentName"] = value };
+
+ var metadata = DocumentMetadataBuilder.Build(row, new[] { "DocumentName" });
+
+ Assert.Equal(expected, metadata["DocumentName"]);
+ Assert.All(metadata["DocumentName"], c => Assert.InRange(c, '\u0020', '\u007e'));
+ }
+
// ── Field-list edge cases ────────────────────────────────────────────────
[Theory]
[InlineData("")]
diff --git a/Fuchs.Tests/FdsDataServiceTests.cs b/Fuchs.Tests/FdsDataServiceTests.cs
new file mode 100644
index 0000000..e4ea278
--- /dev/null
+++ b/Fuchs.Tests/FdsDataServiceTests.cs
@@ -0,0 +1,446 @@
+using System;
+using System.Collections.Generic;
+using System.Data;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Xml;
+using fds;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+
+namespace Fuchs.Tests;
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Tests for the Fuchs_DataService library (MFR ERP sync).
+//
+// Since Topshelf/own-config were removed and the library is hosted in-process by
+// Fuchs, these tests exercise the parts that are pure/deterministic and do not
+// require a live SQL Server or MFR endpoint: DATEV header/CSV/XML formatting,
+// UpdateNeed parsing, the FdsShared utility helpers, config resolution, and the
+// (network-free) FdsMfrClient construction.
+// ─────────────────────────────────────────────────────────────────────────────
+
+/// DATEV header line formatting — pure string assembly, no I/O.
+public class DatevHeaderTests
+{
+ [Fact]
+ public void ToHeaderString_Defaults_ProducesThirtySemicolonFields()
+ {
+ var header = new DatevHeader();
+ var parts = header.ToHeaderString().Split(';');
+
+ Assert.Equal(30, parts.Length);
+ Assert.Equal("\"EXTF\"", parts[0]); // Kennzeichen quoted
+ Assert.Equal("700", parts[1]); // Versionsnummer
+ Assert.Equal("1", parts[20]); // Festschreibung true → "1"
+ Assert.Equal("\"EUR\"", parts[21]); // WKZ quoted
+ }
+
+ [Fact]
+ public void ToHeaderString_MapsNumericAndDateFields()
+ {
+ var header = new DatevHeader
+ {
+ Beraternummer = 12345,
+ Mandantennummer = 678,
+ Sachkontenlänge = 4,
+ WJBeginn = new DateTime(2026, 1, 1),
+ DatumVon = new DateTime(2026, 7, 1),
+ DatumBis = new DateTime(2026, 7, 31),
+ Bezeichnung = "fds_m260731",
+ };
+ var parts = header.ToHeaderString().Split(';');
+
+ Assert.Equal("12345", parts[10]);
+ Assert.Equal("678", parts[11]);
+ Assert.Equal("20260101", parts[12]);
+ Assert.Equal("4", parts[13]);
+ Assert.Equal("20260701", parts[14]);
+ Assert.Equal("20260731", parts[15]);
+ Assert.Equal("\"fds_m260731\"", parts[16]);
+ }
+
+ [Theory]
+ [InlineData(true, "1")]
+ [InlineData(false, "0")]
+ public void ToHeaderString_Festschreibung_MapsToFlag(bool festschreibung, string expected)
+ {
+ var header = new DatevHeader { Festschreibung = festschreibung };
+ Assert.Equal(expected, header.ToHeaderString().Split(';')[20]);
+ }
+
+ [Fact]
+ public void ToHeaderString_DtvfKennzeichen_IsHonoured()
+ {
+ var header = new DatevHeader { Kennzeichen = DatevKennzeichen.DTVF };
+ Assert.Equal("\"DTVF\"", header.ToHeaderString().Split(';')[0]);
+ }
+
+ [Theory]
+ [InlineData(DatevFormatkategorie.Debitoren__Kreditoren, "Debitoren/Kreditoren")]
+ [InlineData(DatevFormatkategorie.Buchungsstapel, "Buchungsstapel")]
+ [InlineData(DatevFormatkategorie.Wiederkehrende_Buchungen, "Wiederkehrende Buchungen")]
+ public void Formatname_TranslatesEnumUnderscores(DatevFormatkategorie kat, string expected)
+ {
+ var header = new DatevHeader { Formatkategorie = kat };
+ Assert.Equal(expected, header.Formatname);
+ // Field 3 embeds the same, quoted.
+ Assert.Equal($"\"{expected}\"", header.ToHeaderString().Split(';')[3]);
+ }
+}
+
+/// DATEV CSV + document XML generation on FdsMfr (no DB / no MFR).
+public class FdsMfrDatevTests
+{
+ private static FdsMfr NewMfr() =>
+ new(NullLogger.Instance, NullLoggerFactory.Instance);
+
+ [Fact]
+ public void DATEV_PrependsHeaderLine_AndSemicolonCsvWithColumnHeaders()
+ {
+ var header = new DatevHeader { Bezeichnung = "test" };
+ var tbl = new DataTable();
+ tbl.Columns.Add("Konto", typeof(string));
+ tbl.Columns.Add("Betrag", typeof(decimal));
+ tbl.Rows.Add("1200", 119.50m);
+
+ var result = NewMfr().DATEV(header, tbl);
+ var lines = result.Split("\r\n");
+
+ Assert.Equal(header.ToHeaderString(), lines[0]); // header line first
+ Assert.Equal("Konto;Betrag", lines[1]); // column headers, semicolon-delimited, unquoted
+ Assert.Contains("1200", lines[2]);
+ // de-DE culture → decimal comma
+ Assert.Contains("119,5", lines[2]);
+ }
+
+ // Note: only the root is in the DATEV namespace; the generator emits child
+ // elements in the empty namespace (existing production behavior). Child lookups therefore
+ // use namespace-agnostic local-name() XPath rather than the datev namespace prefix.
+
+ [Fact]
+ public void CreateDatevDocumentXml_BuildsArchiveWithNamespaceAndOneDocumentPerInput()
+ {
+ var docs = new List
+ {
+ new("guid-1", "invoice1.pdf", "RgNr: 100", "ProcessWeb_Belege", "2026/07"),
+ new("guid-2", "invoice2.pdf", "", "ProcessWeb_Belege", "2026/07"),
+ };
+
+ var xml = NewMfr().CreateDatevDocumentXml(docs);
+ var doc = new XmlDocument();
+ doc.LoadXml(xml);
+
+ Assert.Equal("archive", doc.DocumentElement!.LocalName);
+ Assert.Equal("http://xml.datev.de/bedi/tps/document/v04.0", doc.DocumentElement.NamespaceURI);
+ Assert.Equal("ProcessWeb", doc.DocumentElement.GetAttribute("generatingSystem"));
+ Assert.Equal("4.0", doc.DocumentElement.GetAttribute("version"));
+
+ Assert.NotNull(doc.SelectSingleNode("//*[local-name()='header']/*[local-name()='date']"));
+ Assert.NotNull(doc.SelectSingleNode("//*[local-name()='header']/*[local-name()='description']"));
+
+ var documentNodes = doc.SelectNodes("//*[local-name()='content']/*[local-name()='document']");
+ Assert.Equal(2, documentNodes!.Count);
+ }
+
+ [Fact]
+ public void CreateDatevDocumentXml_OmitsKeywordsWhenEmpty_IncludesWhenPresent()
+ {
+ var docs = new List
+ {
+ new("g-withkw", "a.pdf", "keyword-here", "ProcessWeb_Belege", "2026/07"),
+ new("g-nokw", "b.pdf", "", "ProcessWeb_Belege", "2026/07"),
+ };
+
+ var doc = new XmlDocument();
+ doc.LoadXml(NewMfr().CreateDatevDocumentXml(docs));
+
+ var withKw = doc.SelectSingleNode("//*[local-name()='document'][@guid='g-withkw']")!;
+ var noKw = doc.SelectSingleNode("//*[local-name()='document'][@guid='g-nokw']")!;
+
+ var withKwNode = withKw.SelectSingleNode("*[local-name()='keywords']");
+ Assert.NotNull(withKwNode);
+ Assert.Equal("keyword-here", withKwNode.InnerText);
+ Assert.Null(noKw.SelectSingleNode("*[local-name()='keywords']"));
+ }
+
+ [Fact]
+ public void CreateDatevDocumentXml_EmitsThreeRepositoryLevels()
+ {
+ var docs = new List
+ {
+ new("g1", "a.pdf", "", "ProcessWeb_Belege", "2026/07_w03"),
+ };
+ var doc = new XmlDocument();
+ doc.LoadXml(NewMfr().CreateDatevDocumentXml(docs));
+
+ var levels = doc.SelectNodes(
+ "//*[local-name()='document']/*[local-name()='repository']/*[local-name()='level']")!;
+ Assert.Equal(3, levels.Count);
+ Assert.Equal("ProcessWeb", ((XmlElement)levels[0]!).GetAttribute("name"));
+ Assert.Equal("ProcessWeb_Belege", ((XmlElement)levels[1]!).GetAttribute("name"));
+ Assert.Equal("2026/07_w03", ((XmlElement)levels[2]!).GetAttribute("name"));
+ }
+}
+
+/// FdsMfr.UpdateNeed parsing + enum contract.
+public class FdsMfrUpdateNeedTests
+{
+ [Theory]
+ [InlineData("Reset", FdsMfr.UpdateNeed.Reset)]
+ [InlineData("Full", FdsMfr.UpdateNeed.Full)]
+ [InlineData("Short", FdsMfr.UpdateNeed.Short)]
+ [InlineData("None", FdsMfr.UpdateNeed.None)]
+ public void UpdateNeedValue_ValidName_Parses(string name, FdsMfr.UpdateNeed expected)
+ {
+ Assert.Equal(expected, FdsMfr.UpdateNeedValue(name));
+ }
+
+ [Fact]
+ public void UpdateNeedValue_UnknownName_Throws()
+ {
+ Assert.ThrowsAny(() => FdsMfr.UpdateNeedValue("Bogus"));
+ }
+
+ [Fact]
+ public void UpdateNeed_NumericValues_AreStable()
+ {
+ // These map to SQL updateneed codes — must not drift.
+ Assert.Equal(5, (int)FdsMfr.UpdateNeed.Reset);
+ Assert.Equal(2, (int)FdsMfr.UpdateNeed.Full);
+ Assert.Equal(1, (int)FdsMfr.UpdateNeed.Short);
+ Assert.Equal(0, (int)FdsMfr.UpdateNeed.None);
+ }
+}
+
+/// FdsShared utility helpers — pure formatting + local file I/O.
+public class FdsSharedTests
+{
+ [Theory]
+ [InlineData((byte)0)]
+ [InlineData((byte)1)]
+ [InlineData((byte)16)]
+ [InlineData((byte)32)]
+ public void RandomString_ReturnsRequestedLength_AlphanumericOnly(byte length)
+ {
+ var s = FdsShared.RandomString(length);
+ Assert.Equal(length, s.Length);
+ Assert.All(s, c => Assert.True(char.IsLetterOrDigit(c), $"unexpected char '{c}'"));
+ }
+
+ [Fact]
+ public void ToCsv_DataRow_QuotesAndEscapesStringsWhenRequested()
+ {
+ var tbl = new DataTable();
+ tbl.Columns.Add("Name", typeof(string));
+ var row = tbl.NewRow();
+ row["Name"] = "Say \"hi\"";
+ tbl.Rows.Add(row);
+
+ Assert.Equal("\"Say \"\"hi\"\"\"", row.ToCsv(quoteStrings: true, CultureInfo.InvariantCulture));
+ Assert.Equal("Say \"hi\"", row.ToCsv(quoteStrings: false, CultureInfo.InvariantCulture));
+ }
+
+ [Fact]
+ public void ToCsv_DataRow_NullAndDbNull_RenderAsEmpty()
+ {
+ var tbl = new DataTable();
+ tbl.Columns.Add("A", typeof(string));
+ tbl.Columns.Add("B", typeof(string));
+ var row = tbl.NewRow();
+ row["A"] = DBNull.Value;
+ row["B"] = "x";
+
+ Assert.Equal(";x", row.ToCsv(quoteStrings: false, CultureInfo.InvariantCulture, delimiter: ";"));
+ }
+
+ [Fact]
+ public void ToCsv_DataRow_FormatsDecimalWithSuppliedCulture()
+ {
+ var tbl = new DataTable();
+ tbl.Columns.Add("V", typeof(decimal));
+ var row = tbl.NewRow();
+ row["V"] = 1234.5m;
+ tbl.Rows.Add(row);
+
+ Assert.Equal("1234.5", row.ToCsv(quoteStrings: false, CultureInfo.InvariantCulture));
+ Assert.Equal("1234,5", row.ToCsv(quoteStrings: false, new CultureInfo("de-DE")));
+ }
+
+ [Fact]
+ public void ToCsv_DataTable_IncludesHeaderRow_WhenRequested()
+ {
+ var tbl = new DataTable();
+ tbl.Columns.Add("Konto", typeof(string));
+ tbl.Columns.Add("Betrag", typeof(decimal));
+ tbl.Rows.Add("1200", 5m);
+ tbl.Rows.Add("1400", 6m);
+
+ var csv = tbl.ToCsv(includeHeaders: true, quoteStrings: false, fieldDelimiter: ";");
+ var lines = csv.Split("\r\n");
+
+ Assert.Equal("Konto;Betrag", lines[0]);
+ Assert.Equal("1200;5", lines[1]);
+ Assert.Equal("1400;6", lines[2]);
+ }
+
+ [Fact]
+ public void ToCsv_DataTable_CanOmitHeaders()
+ {
+ var tbl = new DataTable();
+ tbl.Columns.Add("X", typeof(string));
+ tbl.Rows.Add("a");
+
+ var csv = tbl.ToCsv(includeHeaders: false, quoteStrings: false);
+ Assert.Equal("a", csv);
+ }
+
+ [Theory]
+ [InlineData("archive.zip", "archive")]
+ [InlineData("archive.tar.gz", "archive.tar")]
+ [InlineData("noext", "noext")]
+ public void NameBase_StripsFinalExtension(string fileName, string expected)
+ {
+ var fi = new FileInfo(Path.Combine(Path.GetTempPath(), fileName));
+ Assert.Equal(expected, fi.NameBase());
+ }
+
+ [Theory]
+ [InlineData("Hello, DATEV!")]
+ [InlineData("Ümläute & Straße")]
+ public void ToByteArray_Utf8_RoundTrips(string input)
+ {
+ // Default encoding is Encoding.UTF8, so the StreamWriter emits a leading BOM.
+ var bytes = input.ToByteArray();
+ var decoded = Encoding.UTF8.GetString(bytes).TrimStart('');
+ Assert.Equal(input, decoded);
+ }
+
+ [Fact]
+ public void ToByteArray_Iso8859_1_RoundTrips()
+ {
+ const string input = "Grüße";
+ var latin1 = Encoding.GetEncoding("ISO-8859-1");
+ var bytes = input.ToByteArray(latin1);
+ Assert.Equal(input, latin1.GetString(bytes));
+ }
+
+ [Fact]
+ public void ReadWriteStream_CopiesFullContent()
+ {
+ var payload = Encoding.UTF8.GetBytes("stream-copy-payload-0123456789");
+ using var src = new MemoryStream(payload);
+ using var dst = new MemoryStream();
+
+ Assert.True(FdsShared.ReadWriteStream(src, dst, closeWriteStream: false));
+ Assert.Equal(payload, dst.ToArray());
+ }
+
+ [Fact]
+ public void WriteStreamToDisk_PersistsStreamToFile()
+ {
+ var payload = Encoding.UTF8.GetBytes("disk-payload");
+ var path = Path.Combine(Path.GetTempPath(), $"fds_test_{Guid.NewGuid():N}.bin");
+ try
+ {
+ using var src = new MemoryStream(payload);
+ Assert.True(FdsShared.WriteStreamToDisk(src, path));
+ Assert.True(File.Exists(path));
+ Assert.Equal(payload, File.ReadAllBytes(path));
+ }
+ finally
+ {
+ if (File.Exists(path)) File.Delete(path);
+ }
+ }
+}
+
+///
+/// FdsConfig + FdsMfrClient share process-global config state (FdsConfig._config),
+/// so their tests are grouped into one collection to run sequentially and never
+/// clobber each other's call.
+///
+[CollectionDefinition("FdsConfig")]
+public class FdsConfigCollection { }
+
+[Collection("FdsConfig")]
+public class FdsConfigTests
+{
+ private static IConfiguration BuildConfig(Dictionary values) =>
+ new ConfigurationBuilder().AddInMemoryCollection(values).Build();
+
+ [Fact]
+ public void FDSConnectionString_ReturnsConfiguredValue()
+ {
+ FdsConfig.Initialize(BuildConfig(new()
+ {
+ ["ConnectionStrings:fuchs_fds_ConnectionString"] = "Server=.;Database=fds;",
+ }));
+ Assert.Equal("Server=.;Database=fds;", FdsConfig.FDSConnectionString());
+ }
+
+ [Fact]
+ public void FDSConnectionString_Missing_Throws()
+ {
+ FdsConfig.Initialize(BuildConfig(new()));
+ Assert.Throws(() => FdsConfig.FDSConnectionString());
+ }
+
+ [Fact]
+ public void MfrSettings_ResolveFromFdsSection()
+ {
+ FdsConfig.Initialize(BuildConfig(new()
+ {
+ ["Fds:MFR_UserName"] = "system@example.com",
+ ["Fds:MFR_Password"] = "secret",
+ ["Fds:MFR_host"] = "portal.mobilefieldreport.com",
+ }));
+
+ Assert.Equal("system@example.com", FdsConfig.MFR_UserName);
+ Assert.Equal("secret", FdsConfig.MFR_Password);
+ Assert.Equal("portal.mobilefieldreport.com", FdsConfig.MFR_host);
+ }
+
+ [Fact]
+ public void MfrSettings_Absent_FallBackToEmptyString()
+ {
+ FdsConfig.Initialize(BuildConfig(new()));
+ Assert.Equal("", FdsConfig.MFR_UserName);
+ Assert.Equal("", FdsConfig.MFR_Password);
+ Assert.Equal("", FdsConfig.MFR_host);
+ }
+}
+
+[Collection("FdsConfig")]
+public class FdsMfrClientTests
+{
+ private static void InitConfig(string host) =>
+ FdsConfig.Initialize(new ConfigurationBuilder().AddInMemoryCollection(new Dictionary
+ {
+ ["Fds:MFR_host"] = host,
+ ["Fds:MFR_UserName"] = "u",
+ ["Fds:MFR_Password"] = "p",
+ }).Build());
+
+ [Fact]
+ public void Construction_BuildsClientConfig_FromHost()
+ {
+ InitConfig("portal.mobilefieldreport.com");
+ using var client = new FdsMfrClient();
+ Assert.Contains("portal.mobilefieldreport.com", client.ClientConfig.BaseUrl);
+ Assert.EndsWith("/", client.ClientConfig.BaseUrl);
+ }
+
+ [Fact]
+ public void IsReadonly_DefaultsTrue_AndIsSettable()
+ {
+ InitConfig("portal.mobilefieldreport.com");
+ using var client = new FdsMfrClient();
+ Assert.True(client.IsReadonly);
+ client.IsReadonly = false;
+ Assert.False(client.IsReadonly);
+ }
+}
diff --git a/Fuchs.Tests/FdsInvoiceDataBlocksTests.cs b/Fuchs.Tests/FdsInvoiceDataBlocksTests.cs
new file mode 100644
index 0000000..0af1bc9
--- /dev/null
+++ b/Fuchs.Tests/FdsInvoiceDataBlocksTests.cs
@@ -0,0 +1,52 @@
+using System.Linq;
+using Fuchs.intranet;
+using Newtonsoft.Json.Linq;
+using Xunit;
+
+namespace Fuchs.Tests;
+
+///
+/// Verifies the block projection that feeds the PDF item table: each service-request
+/// group exposes its heading (the section title the editor shows) and its line items,
+/// so the PDF can print a heading row per block and the flat item list still works.
+///
+public class FdsInvoiceDataBlocksTests
+{
+ private static FdsInvoiceData FromReq(string reqJson) =>
+ new(JObject.Parse(@"{'admin':{'type':'r'},'new':{},'sms':{},'req':" + reqJson + "}"));
+
+ [Fact]
+ public void InvoiceBlocks_ExposesHeadingFromTextThenNme_AndItems()
+ {
+ var inv = FromReq(@"[
+ {'Id':'1','text':'Sektion A','items':[{'id':'a','type':'material','title':'X','price_net':10,'total_net':10}]},
+ {'Id':'2','nme':'Sektion B','items':[{'id':'b','type':'material','title':'Y','price_net':20,'total_net':20}]}
+ ]");
+
+ var blocks = inv.InvoiceBlocks;
+
+ Assert.Equal(2, blocks.Count);
+ Assert.Equal("Sektion A", blocks[0].Heading);
+ Assert.Equal("Sektion B", blocks[1].Heading); // falls back to nme
+ Assert.Single(blocks[0].Items);
+ Assert.Equal("X", blocks[0].Items[0]["title"]);
+ }
+
+ [Fact]
+ public void InvoiceBlocks_MissingHeading_IsEmpty()
+ {
+ var inv = FromReq(@"[{'Id':'1','items':[{'id':'a','type':'material','total_net':5}]}]");
+ Assert.Equal("", Assert.Single(inv.InvoiceBlocks).Heading);
+ }
+
+ [Fact]
+ public void InvoiceItems_StillFlattensAcrossBlocks()
+ {
+ var inv = FromReq(@"[
+ {'Id':'1','text':'A','items':[{'id':'a','type':'material','total_net':10}]},
+ {'Id':'2','text':'B','items':[{'id':'b','type':'material','total_net':20},{'id':'c','type':'material','total_net':30}]}
+ ]");
+
+ Assert.Equal(new[] { "a", "b", "c" }, inv.InvoiceItems.Select(i => i["id"]!.ToString()).ToArray());
+ }
+}
diff --git a/Fuchs.Tests/Fuchs.Tests.csproj b/Fuchs.Tests/Fuchs.Tests.csproj
index 1e28bd3..0c6f5cc 100644
--- a/Fuchs.Tests/Fuchs.Tests.csproj
+++ b/Fuchs.Tests/Fuchs.Tests.csproj
@@ -9,8 +9,8 @@
-
-
+
+ runtime; build; native; contentfiles; analyzers; buildtransitiveall
diff --git a/Fuchs.Tests/InvoiceDraftCacheTests.cs b/Fuchs.Tests/InvoiceDraftCacheTests.cs
new file mode 100644
index 0000000..220ba80
--- /dev/null
+++ b/Fuchs.Tests/InvoiceDraftCacheTests.cs
@@ -0,0 +1,113 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Fuchs.intranet;
+using Fuchs.Notifications;
+using Fuchs.Services;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+
+namespace Fuchs.Tests;
+
+///
+/// Covers the in-memory draft cache (storage + idle sliding TTL) and the background
+/// expiry monitor that warns before eviction and closes the editor on eviction (ADR 0006).
+///
+public class InvoiceDraftCacheTests
+{
+ private static IConfiguration Config(int idle = 30, int warn = 5) =>
+ new ConfigurationBuilder().AddInMemoryCollection(new Dictionary
+ {
+ ["Fuchs:DraftEditing:IdleMinutes"] = idle.ToString(),
+ ["Fuchs:DraftEditing:ExpiryWarnMinutes"] = warn.ToString()
+ }).Build();
+
+ private sealed class FakeNotifier : IDraftNotifier
+ {
+ public readonly List<(string token, int version)> Ready = new();
+ public readonly List<(string token, int secondsLeft)> Expiring = new();
+ public readonly List<(string token, string reason)> Closed = new();
+ public Task SignalDraftReadyAsync(string token, int version, CancellationToken ct = default) { Ready.Add((token, version)); return Task.CompletedTask; }
+ public Task SignalExpiringAsync(string token, int secondsLeft, CancellationToken ct = default) { Expiring.Add((token, secondsLeft)); return Task.CompletedTask; }
+ public Task SignalClosedAsync(string token, string reason, CancellationToken ct = default) { Closed.Add((token, reason)); return Task.CompletedTask; }
+ }
+
+ // ── Cache storage ─────────────────────────────────────────────────────────
+ [Fact]
+ public void SetGet_RoundTrips_AndUnknownTokenIsNull()
+ {
+ var cache = new InvoiceDraftCache(Config());
+ var s = new InvoiceDraftSession { Token = "abc" };
+ cache.Set(s);
+ Assert.Same(s, cache.Get("abc"));
+ Assert.Null(cache.Get("nope"));
+ }
+
+ [Fact]
+ public void Remove_EvictsSession()
+ {
+ var cache = new InvoiceDraftCache(Config());
+ cache.Set(new InvoiceDraftSession { Token = "x" });
+ Assert.NotNull(cache.Remove("x"));
+ Assert.Null(cache.Get("x"));
+ Assert.Null(cache.Remove("x"));
+ }
+
+ [Fact]
+ public void Get_ResetsExpiryWarningFlag_SoAFreshWarningIsDue()
+ {
+ var cache = new InvoiceDraftCache(Config());
+ var s = new InvoiceDraftSession { Token = "x", ExpiryWarningSent = true };
+ cache.Set(s);
+ cache.Get("x");
+ Assert.False(s.ExpiryWarningSent);
+ }
+
+ // ── Expiry monitor ────────────────────────────────────────────────────────
+ [Fact]
+ public async Task Sweep_NearTtl_WarnsOnceThenEvictsWithReason()
+ {
+ var cfg = Config(idle: 30, warn: 5);
+ var cache = new InvoiceDraftCache(cfg);
+ var notifier = new FakeNotifier();
+ var svc = new InvoiceDraftExpiryService(cache, notifier, cfg, NullLogger.Instance);
+
+ var s = new InvoiceDraftSession { Token = "a" };
+ cache.Set(s);
+
+ // Idle 26 min → inside the 5-min warning window (30-5=25) but not yet expired.
+ s.LastAccessUtc = DateTime.UtcNow.AddMinutes(-26);
+ await svc.SweepAsync(CancellationToken.None);
+ Assert.Single(notifier.Expiring);
+ Assert.Empty(notifier.Closed);
+ Assert.True(s.ExpiryWarningSent);
+
+ // Another sweep while still idle must not spam a second warning.
+ await svc.SweepAsync(CancellationToken.None);
+ Assert.Single(notifier.Expiring);
+
+ // Past the TTL → evicted and the editor is told to close with a reason.
+ s.LastAccessUtc = DateTime.UtcNow.AddMinutes(-31);
+ await svc.SweepAsync(CancellationToken.None);
+ Assert.Single(notifier.Closed);
+ Assert.Equal(("a", "expired"), notifier.Closed[0]);
+ Assert.Null(cache.Get("a"));
+ }
+
+ [Fact]
+ public async Task Sweep_FreshSession_DoesNothing()
+ {
+ var cfg = Config(idle: 30, warn: 5);
+ var cache = new InvoiceDraftCache(cfg);
+ var notifier = new FakeNotifier();
+ var svc = new InvoiceDraftExpiryService(cache, notifier, cfg, NullLogger.Instance);
+ cache.Set(new InvoiceDraftSession { Token = "fresh" });
+
+ await svc.SweepAsync(CancellationToken.None);
+
+ Assert.Empty(notifier.Expiring);
+ Assert.Empty(notifier.Closed);
+ }
+}
diff --git a/Fuchs.Tests/InvoiceDraftCalculatorTests.cs b/Fuchs.Tests/InvoiceDraftCalculatorTests.cs
new file mode 100644
index 0000000..22352c6
--- /dev/null
+++ b/Fuchs.Tests/InvoiceDraftCalculatorTests.cs
@@ -0,0 +1,294 @@
+using Fuchs.intranet;
+using Newtonsoft.Json.Linq;
+using Xunit;
+
+namespace Fuchs.Tests;
+
+///
+/// Verifies the server-side aggregation of invoice draft totals (the port of the
+/// former client-side invSumUpdate footer math). The truth now lives in the
+/// backend (ADR 0006), so this is unit-testable directly. Line values are read from
+/// each block's itm array (the editor's co objects: vt=net,
+/// vv=VAT, vs=service-net, vsv=service-VAT, vat=rate).
+///
+public class InvoiceDraftCalculatorTests
+{
+ private static InvoiceDraftSession SessionWith(string reqJson, bool p13b = false)
+ {
+ var s = new InvoiceDraftSession { Token = "t" };
+ s.Admin = new JObject { ["p13b"] = p13b };
+ s.New = new JObject { ["invoiceemail"] = "kunde@example.de", ["invoiceaddress"] = "Weg 1" };
+ s.Req = JArray.Parse(reqJson);
+ return s;
+ }
+
+ // ── RecomputeLineValues ────────────────────────────────────────────────────
+ private static JObject Line(InvoiceDraftSession s, int block, int line) =>
+ (JObject)((JArray)((JObject)s.Req[block])["itm"]!)[line];
+
+ [Fact]
+ public void RecomputeLineValues_ComputesNetAndVatFromRawQtyPriceAndRate()
+ {
+ var s = SessionWith(@"[
+ { 'Id':'10','itm':[ {'id':'a','typ':'material','qn':2,'v':50,'vat':'19%'} ] }
+ ]");
+
+ InvoiceDraftCalculator.RecomputeLineValues(s);
+
+ var l = Line(s, 0, 0);
+ Assert.Equal(100m, l["vt"]!.Value());
+ Assert.Equal(19m, l["vv"]!.Value());
+ Assert.Equal(0m, l["vs"]!.Value());
+ Assert.Equal(0m, l["vsv"]!.Value());
+ }
+
+ [Fact]
+ public void RecomputeLineValues_ServiceType_AlsoFillsServiceNetAndVat()
+ {
+ var s = SessionWith(@"[
+ { 'Id':'10','itm':[ {'id':'a','typ':'Service','qn':3,'v':10,'vat':'19%'} ] }
+ ]");
+
+ InvoiceDraftCalculator.RecomputeLineValues(s);
+
+ var l = Line(s, 0, 0);
+ Assert.Equal(30m, l["vt"]!.Value());
+ Assert.Equal(5.7m, l["vv"]!.Value());
+ Assert.Equal(30m, l["vs"]!.Value());
+ Assert.Equal(5.7m, l["vsv"]!.Value());
+ }
+
+ [Theory]
+ [InlineData(0, 50)] // no quantity posted -> leave value untouched
+ [InlineData(2, 0)] // no price posted -> leave value untouched
+ public void RecomputeLineValues_MissingQtyOrPrice_LeavesExistingValueUntouched(decimal qty, decimal price)
+ {
+ var s = SessionWith($@"[
+ {{ 'Id':'10','itm':[ {{'id':'a','typ':'material','qn':{qty},'v':{price},'vat':'19%','vt':777,'vv':111}} ] }}
+ ]");
+
+ InvoiceDraftCalculator.RecomputeLineValues(s);
+
+ var l = Line(s, 0, 0);
+ Assert.Equal(777m, l["vt"]!.Value()); // untouched — mirrors quantChange's own guard
+ Assert.Equal(111m, l["vv"]!.Value());
+ }
+
+ [Fact]
+ public void RecomputeLineValues_ZeroVatRate_ComputesNetWithNoVat()
+ {
+ var s = SessionWith(@"[
+ { 'Id':'10','itm':[ {'id':'a','typ':'material','qn':4,'v':25,'vat':'0%'} ] }
+ ]");
+
+ InvoiceDraftCalculator.RecomputeLineValues(s);
+
+ var l = Line(s, 0, 0);
+ Assert.Equal(100m, l["vt"]!.Value());
+ Assert.Equal(0m, l["vv"]!.Value());
+ }
+
+ [Fact]
+ public void RecomputeLineValues_SetHeaderConvertedSum_IsNotClobberedByRecompute()
+ {
+ // A converted set header carries a synthesised sum (no raw qty/price of its own) —
+ // RecomputeLineValues must never overwrite it, mirroring quantChange's guard.
+ var s = SessionWith(@"[
+ { 'Id':'10','itm':[ {'id':'h','typ':'set','vt':1000,'vv':190,'vat':'19%'} ] }
+ ]");
+
+ InvoiceDraftCalculator.RecomputeLineValues(s);
+
+ var l = Line(s, 0, 0);
+ Assert.Equal(1000m, l["vt"]!.Value());
+ Assert.Equal(190m, l["vv"]!.Value());
+ }
+
+ [Fact]
+ public void RecomputeTotals_SumsNetVatServiceAndPerBlock()
+ {
+ var s = SessionWith(@"[
+ { 'Id':'10','itm':[
+ {'vt':100,'vv':19,'vs':0,'vsv':0,'vat':'19%'},
+ {'vt':50,'vv':9.5,'vs':50,'vsv':9.5,'vat':'19%'} ] },
+ { 'Id':'11','itm':[
+ {'vt':200,'vv':14,'vs':0,'vsv':0,'vat':'7%'} ] }
+ ]");
+
+ InvoiceDraftCalculator.RecomputeTotals(s);
+
+ Assert.Equal(350m, s.Sums.TotalNet);
+ Assert.Equal(42.5m, s.Sums.TotalVat);
+ Assert.Equal(392.5m, s.Sums.TotalGross);
+ Assert.Equal(50m, s.Sums.ServiceNet);
+ Assert.Equal(9.5m, s.Sums.ServiceVat);
+ Assert.Equal(28.5m, s.Sums.VatByRate["19"]);
+ Assert.Equal(14m, s.Sums.VatByRate["7"]);
+ Assert.Equal(150m, s.Sums.NetByBlock["10"]);
+ Assert.Equal(200m, s.Sums.NetByBlock["11"]);
+ }
+
+ [Fact]
+ public void RecomputeTotals_ReverseCharge_SuppressesVatAndGrossEqualsNet()
+ {
+ var s = SessionWith(@"[{ 'Id':'1','itm':[ {'vt':100,'vv':19,'vat':'19%'} ] }]", p13b: true);
+
+ InvoiceDraftCalculator.RecomputeTotals(s);
+
+ Assert.Equal(100m, s.Sums.TotalNet);
+ Assert.Equal(100m, s.Sums.TotalGross);
+ Assert.Equal(0m, s.Sums.TotalVat);
+ Assert.Empty(s.Sums.VatByRate);
+ }
+
+ [Fact]
+ public void RecomputeTotals_EmptyDraft_AllZero()
+ {
+ var s = SessionWith("[]");
+ InvoiceDraftCalculator.RecomputeTotals(s);
+ Assert.Equal(0m, s.Sums.TotalNet);
+ Assert.Equal(0m, s.Sums.TotalGross);
+ Assert.Empty(s.Sums.VatByRate);
+ }
+
+ [Theory]
+ [InlineData("19,0%", "19")]
+ [InlineData("7%", "7")]
+ [InlineData("19", "19")]
+ [InlineData("", "")]
+ [InlineData("0", "")]
+ [InlineData("7,5", "7.5")]
+ public void NormalizeRate_CanonicalisesRateStrings(string raw, string expected)
+ => Assert.Equal(expected, InvoiceDraftCalculator.NormalizeRate(raw));
+
+ [Fact]
+ public void Validate_ValidDraft_NoErrors()
+ {
+ var s = SessionWith(@"[{ 'Id':'1','itm':[ {'vt':100,'vv':19,'vat':'19%'} ] }]");
+ InvoiceDraftCalculator.RecomputeTotals(s);
+ InvoiceDraftCalculator.Validate(s);
+ Assert.DoesNotContain(s.ValidationMessages, m => m.Severity == "error");
+ }
+
+ [Theory]
+ [InlineData("", "warning")]
+ [InlineData("not-an-email", "error")]
+ public void Validate_EmailProblems_AreFlagged(string email, string severity)
+ {
+ var s = SessionWith(@"[{ 'Id':'1','itm':[{'vt':10,'vat':'19%'}] }]");
+ s.New["invoiceemail"] = email;
+ InvoiceDraftCalculator.RecomputeTotals(s);
+ InvoiceDraftCalculator.Validate(s);
+ Assert.Contains(s.ValidationMessages, m => m.Field == "email" && m.Severity == severity);
+ }
+
+ [Fact]
+ public void Validate_NoItems_IsError()
+ {
+ var s = SessionWith("[]");
+ InvoiceDraftCalculator.RecomputeTotals(s);
+ InvoiceDraftCalculator.Validate(s);
+ Assert.Contains(s.ValidationMessages, m => m.Field == "items" && m.Severity == "error");
+ }
+
+ [Fact]
+ public void Validate_UnknownVatRate_IsWarning()
+ {
+ var s = SessionWith(@"[{ 'Id':'1','itm':[{'vt':10,'vv':0.5,'vat':'5%'}] }]");
+ InvoiceDraftCalculator.RecomputeTotals(s);
+ InvoiceDraftCalculator.Validate(s);
+ Assert.Contains(s.ValidationMessages, m => m.Field == "vat" && m.Severity == "warning");
+ }
+
+ [Fact]
+ public void Validate_MissingAddress_IsWarning()
+ {
+ var s = SessionWith(@"[{ 'Id':'1','itm':[{'vt':10,'vat':'19%'}] }]");
+ s.New["invoiceaddress"] = "";
+ InvoiceDraftCalculator.RecomputeTotals(s);
+ InvoiceDraftCalculator.Validate(s);
+ Assert.Contains(s.ValidationMessages, m => m.Field == "address" && m.Severity == "warning");
+ }
+
+ [Fact]
+ public void Validate_NegativeTotal_IsWarning()
+ {
+ var s = SessionWith(@"[{ 'Id':'1','itm':[{'vt':-50,'vv':0,'vat':''}] }]");
+ InvoiceDraftCalculator.RecomputeTotals(s);
+ InvoiceDraftCalculator.Validate(s);
+ Assert.Contains(s.ValidationMessages, m => m.Field == "total" && m.Severity == "warning");
+ }
+
+ // ── RecomputePositions ────────────────────────────────────────────────────
+ private static string Pos(InvoiceDraftSession s, int block, int line) =>
+ ((JObject)((JArray)((JObject)s.Req[block])["itm"]!)[line])["p"]!.ToString();
+
+ [Fact]
+ public void RecomputePositions_NumbersPricedLinesContinuouslyAcrossBlocks()
+ {
+ var s = SessionWith(@"[
+ { 'Id':'10','itm':[ {'id':'a','typ':'material','vt':1}, {'id':'b','typ':'service','vt':2} ] },
+ { 'Id':'11','itm':[ {'id':'c','typ':'material','vt':3} ] }
+ ]");
+
+ InvoiceDraftCalculator.RecomputePositions(s);
+
+ Assert.Equal("1", Pos(s, 0, 0));
+ Assert.Equal("2", Pos(s, 0, 1));
+ Assert.Equal("3", Pos(s, 1, 0)); // continuous, not restarting per block
+ }
+
+ [Fact]
+ public void RecomputePositions_SkipsHeadingAndFreeTextLines()
+ {
+ var s = SessionWith(@"[
+ { 'Id':'10','itm':[
+ {'id':'t','typ':'Title','vt':0},
+ {'id':'a','typ':'material','vt':1},
+ {'id':'x','typ':'Text','vt':0},
+ {'id':'b','typ':'material','vt':2} ] }
+ ]");
+
+ InvoiceDraftCalculator.RecomputePositions(s);
+
+ Assert.Equal("", Pos(s, 0, 0)); // title carries no number
+ Assert.Equal("1", Pos(s, 0, 1));
+ Assert.Equal("", Pos(s, 0, 2)); // free text carries no number
+ Assert.Equal("2", Pos(s, 0, 3));
+ }
+
+ [Fact]
+ public void RecomputePositions_NumbersSetHeaderLikeAnyItem()
+ {
+ // A set header is numbered just like the editor numbers it — only text/title lines are skipped.
+ var s = SessionWith(@"[
+ { 'Id':'10','itm':[
+ {'id':'h','typ':'set','vt':1000},
+ {'id':'a','typ':'material','vt':600},
+ {'id':'b','typ':'material','vt':400} ] }
+ ]");
+
+ InvoiceDraftCalculator.RecomputePositions(s);
+
+ Assert.Equal("1", Pos(s, 0, 0)); // set header keeps position 1 (matches the editor)
+ Assert.Equal("2", Pos(s, 0, 1));
+ Assert.Equal("3", Pos(s, 0, 2));
+ }
+
+ [Fact]
+ public void RecomputePositions_AfterBlockOrderChange_RenumbersToNewSequence()
+ {
+ var s = SessionWith(@"[
+ { 'Id':'10','itm':[ {'id':'a','typ':'material','vt':1} ] },
+ { 'Id':'11','itm':[ {'id':'b','typ':'material','vt':2} ] }
+ ]");
+ // Simulate a section reorder: swap the two blocks.
+ var b0 = s.Req[0]; var b1 = s.Req[1];
+ s.Req = new JArray(b1.DeepClone(), b0.DeepClone());
+
+ InvoiceDraftCalculator.RecomputePositions(s);
+
+ Assert.Equal("1", Pos(s, 0, 0)); // formerly block 11's item is now position 1
+ Assert.Equal("2", Pos(s, 1, 0));
+ }
+}
diff --git a/Fuchs.Tests/InvoiceDraftNotifierTests.cs b/Fuchs.Tests/InvoiceDraftNotifierTests.cs
new file mode 100644
index 0000000..3b1093b
--- /dev/null
+++ b/Fuchs.Tests/InvoiceDraftNotifierTests.cs
@@ -0,0 +1,126 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Fuchs.Notifications;
+using Microsoft.AspNetCore.SignalR;
+using Microsoft.Extensions.Logging.Abstractions;
+using Newtonsoft.Json.Linq;
+using Xunit;
+
+namespace Fuchs.Tests;
+
+///
+/// Verifies targets the draft's SignalR group (keyed by
+/// session token, ADR 0007) with the right method/payload, and — like the other
+/// notification path — swallows hub failures so a missed coordination ping never
+/// fails the underlying operation.
+///
+public class InvoiceDraftNotifierTests
+{
+ private sealed class CapturingClientProxy : IClientProxy
+ {
+ private readonly bool _throw;
+ public string? Method { get; private set; }
+ public object?[]? Args { get; private set; }
+ public CapturingClientProxy(bool doThrow = false) => _throw = doThrow;
+ public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
+ {
+ if (_throw) throw new InvalidOperationException("hub down");
+ Method = method;
+ Args = args;
+ return Task.CompletedTask;
+ }
+ }
+
+ private sealed class StubHubClients : IHubClients
+ {
+ private readonly IClientProxy _proxy;
+ public string? RequestedGroup { get; private set; }
+ public StubHubClients(IClientProxy proxy) => _proxy = proxy;
+ public IClientProxy Group(string groupName) { RequestedGroup = groupName; return _proxy; }
+ public IClientProxy All => throw new NotImplementedException();
+ public IClientProxy AllExcept(IReadOnlyList e) => throw new NotImplementedException();
+ public IClientProxy Client(string c) => throw new NotImplementedException();
+ public IClientProxy Clients(IReadOnlyList c) => throw new NotImplementedException();
+ public IClientProxy Groups(IReadOnlyList g) => throw new NotImplementedException();
+ public IClientProxy GroupExcept(string g, IReadOnlyList e) => throw new NotImplementedException();
+ public IClientProxy User(string u) => throw new NotImplementedException();
+ public IClientProxy Users(IReadOnlyList u) => throw new NotImplementedException();
+ }
+
+ private sealed class StubHubContext : IHubContext
+ {
+ public StubHubContext(IHubClients clients) => Clients = clients;
+ public IHubClients Clients { get; }
+ public IGroupManager Groups => throw new NotImplementedException();
+ }
+
+ private static (DraftNotifier notifier, StubHubClients clients, CapturingClientProxy proxy) Create(bool doThrow = false)
+ {
+ var proxy = new CapturingClientProxy(doThrow);
+ var clients = new StubHubClients(proxy);
+ var notifier = new DraftNotifier(new StubHubContext(clients), NullLogger.Instance);
+ return (notifier, clients, proxy);
+ }
+
+ [Fact]
+ public async Task SignalDraftReadyAsync_SendsToTokenGroupWithVersion()
+ {
+ var (notifier, clients, proxy) = Create();
+
+ await notifier.SignalDraftReadyAsync("tok-1", 7);
+
+ Assert.Equal("tok-1", clients.RequestedGroup);
+ Assert.Equal("draftReady", proxy.Method);
+ var payload = JObject.FromObject(proxy.Args![0]!);
+ Assert.Equal("tok-1", payload["token"]!.Value());
+ Assert.Equal(7, payload["version"]!.Value());
+ }
+
+ [Fact]
+ public async Task SignalExpiringAsync_SendsSecondsLeft()
+ {
+ var (notifier, _, proxy) = Create();
+
+ await notifier.SignalExpiringAsync("tok-2", 120);
+
+ Assert.Equal("draftExpiring", proxy.Method);
+ var payload = JObject.FromObject(proxy.Args![0]!);
+ Assert.Equal(120, payload["secondsLeft"]!.Value());
+ }
+
+ [Fact]
+ public async Task SignalClosedAsync_SendsReason()
+ {
+ var (notifier, _, proxy) = Create();
+
+ await notifier.SignalClosedAsync("tok-3", "expired");
+
+ Assert.Equal("draftClosed", proxy.Method);
+ var payload = JObject.FromObject(proxy.Args![0]!);
+ Assert.Equal("expired", payload["reason"]!.Value());
+ }
+
+ [Fact]
+ public async Task EmptyToken_DoesNotSend()
+ {
+ var (notifier, clients, proxy) = Create();
+
+ await notifier.SignalDraftReadyAsync("", 1);
+
+ Assert.Null(clients.RequestedGroup);
+ Assert.Null(proxy.Method);
+ }
+
+ [Fact]
+ public async Task HubFailure_IsSwallowed()
+ {
+ var (notifier, _, _) = Create(doThrow: true);
+
+ // Must not throw — a failed coordination ping cannot fail the caller's operation.
+ await notifier.SignalDraftReadyAsync("tok", 1);
+ await notifier.SignalExpiringAsync("tok", 60);
+ await notifier.SignalClosedAsync("tok", "expired");
+ }
+}
diff --git a/Fuchs.Tests/InvoiceDraftServiceTests.cs b/Fuchs.Tests/InvoiceDraftServiceTests.cs
new file mode 100644
index 0000000..222836d
--- /dev/null
+++ b/Fuchs.Tests/InvoiceDraftServiceTests.cs
@@ -0,0 +1,842 @@
+using System.Linq;
+using System.Threading.Tasks;
+using Fuchs.intranet;
+using Fuchs.Services;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging.Abstractions;
+using MigraDoc.DocumentObjectModel;
+using Newtonsoft.Json.Linq;
+using OCORE.security;
+using Xunit;
+using static OCORE.OCORE_dictionaries;
+
+namespace Fuchs.Tests;
+
+///
+/// Exercises the draft edit orchestrator's pure paths (open/patch/history/flush)
+/// without a database, proving the backend-authoritative model behaves correctly at
+/// the service seam (ADR 0006). Blocks use the editor's itm/items shape.
+///
+public class InvoiceDraftServiceTests
+{
+ /// Captures the invoice handed to registration and returns it with a fake DB id — no SQL.
+ private sealed class FakeInvoiceService : IInvoiceService
+ {
+ public FdsInvoiceData? Registered;
+ public bool? LastChange;
+ public Task RegisterInvoiceAsync(FdsInvoiceData invoice, bool change, string invId, string userAccountId, DatabaseSecurity dbSec)
+ {
+ Registered = invoice;
+ LastChange = change;
+ invoice.InvoiceRegistration = new GenericObjectDictionary(new System.Collections.Generic.Dictionary { ["Id"] = "INV42" });
+ return Task.FromResult(invoice);
+ }
+ public FdsInvoiceData? PreviewInvoice;
+ public bool? PreviewDraft;
+ public Task LoadInvoiceAsync(string id, string u, DatabaseSecurity s) => throw new System.NotSupportedException();
+ public Document GenerateInvoicePdf(FdsInvoiceData i, bool d) { PreviewInvoice = i; PreviewDraft = d; return new Document(); }
+ public Task RenderInvoicePdfBytesAsync(FdsInvoiceData i, bool d) => throw new System.NotSupportedException();
+ public Task StoreInvoiceDocumentFileAsync(FdsInvoiceData i, bool d, string u, DatabaseSecurity s) => throw new System.NotSupportedException();
+ public Task GetInvoiceFileAsync(FdsInvoiceData i, bool d, fds.IFdsMfr m) => throw new System.NotSupportedException();
+ }
+
+ private static (InvoiceDraftEditService svc, FakeInvoiceService inv, InvoiceDraftCache cache) NewService()
+ {
+ var cache = new InvoiceDraftCache(new ConfigurationBuilder().Build());
+ var inv = new FakeInvoiceService();
+ var svc = new InvoiceDraftEditService(cache, inv, NullLogger.Instance);
+ return (svc, inv, cache);
+ }
+
+ private static JObject Payload() => JObject.Parse(@"{
+ 'admin':{'p13b':false,'type':'r','paymentterms':'10wd'},
+ 'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','invoicetitle':'Rechnung'},
+ 'req':[{'Id':'1','text':'Auftrag','itm':[{'id':'900','typ':'material','vt':100,'vv':19,'vs':0,'vsv':0,'vat':'19%'}],
+ 'items':[{'id':'900','type':'material','total_net':100,'vat':'19%'}]}]
+ }");
+
+ [Fact]
+ public void OpenFromPayload_SeedsSessionAndComputesTotals()
+ {
+ var (svc, _, _) = NewService();
+ var s = svc.OpenFromPayload(Payload(), "user1");
+ Assert.False(string.IsNullOrEmpty(s.Token));
+ Assert.Equal(0, s.Version);
+ Assert.Equal(100m, s.Sums.TotalNet);
+ Assert.Equal(119m, s.Sums.TotalGross);
+ }
+
+ [Fact]
+ public void ApplyPatch_Email_MutatesBumpsVersionAndRecordsHistory()
+ {
+ var (svc, _, _) = NewService();
+ var s = svc.OpenFromPayload(Payload(), "user1");
+
+ var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "email", Value = JToken.FromObject("neu@x.de") });
+
+ Assert.NotNull(s2);
+ Assert.Equal(1, s2!.Version);
+ Assert.Equal("neu@x.de", s2.New["invoiceemail"]!.Value());
+ var h = Assert.Single(s2.History);
+ Assert.Equal("email", h.Target);
+ Assert.Equal("a@b.de", h.OldValue);
+ Assert.Equal("neu@x.de", h.NewValue);
+ Assert.Equal(1, h.Version);
+ }
+
+ [Fact]
+ public void ApplyPatch_BlockReplace_RecomputesTotals()
+ {
+ var (svc, _, _) = NewService();
+ var s = svc.OpenFromPayload(Payload(), "user1");
+
+ var newBlock = JObject.Parse(@"{'Id':'1','text':'Auftrag','itm':[{'id':'900','typ':'material','vt':50,'vv':9.5,'vat':'19%'}],
+ 'items':[{'id':'900','type':'material','total_net':50,'vat':'19%'}]}");
+ var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.replace", Ref = "1", Value = newBlock });
+
+ Assert.Equal(50m, s2!.Sums.TotalNet);
+ Assert.Equal(9.5m, s2.Sums.VatByRate["19"]);
+ }
+
+ [Fact]
+ public void ApplyPatch_BlockRemove_EmptiesDraftAndFlagsNoItems()
+ {
+ var (svc, _, _) = NewService();
+ var s = svc.OpenFromPayload(Payload(), "user1");
+
+ var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.remove", Ref = "1" });
+
+ Assert.Equal(0m, s2!.Sums.TotalNet);
+ Assert.Contains(s2.ValidationMessages, m => m.Field == "items" && m.Severity == "error");
+ }
+
+ [Fact]
+ public void ApplyPatch_P13bToggle_FlipsAndSuppressesVat()
+ {
+ var (svc, _, _) = NewService();
+ var s = svc.OpenFromPayload(Payload(), "user1");
+
+ var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "p13b" }); // no value → toggle
+
+ Assert.Equal(100m, s2!.Sums.TotalGross); // reverse-charge → gross == net
+ Assert.Empty(s2.Sums.VatByRate);
+ }
+
+ [Fact]
+ public void ApplyPatch_UnknownToken_ReturnsNull()
+ {
+ var (svc, _, _) = NewService();
+ Assert.Null(svc.ApplyPatch("ghost", new InvoiceDraftDelta { Target = "email", Value = JToken.FromObject("x@y.de") }));
+ }
+
+ [Fact]
+ public async Task FlushToDbAsync_RegistersWithMappedTotals_AndSetsInvId()
+ {
+ var (svc, inv, _) = NewService();
+ var s = svc.OpenFromPayload(Payload(), "user1");
+
+ var result = await svc.FlushToDbAsync(s.Token, "user1", null!);
+
+ Assert.NotNull(result);
+ Assert.Equal("INV42", result!.Id);
+ Assert.False(inv.LastChange); // new draft (no prior InvId) → create, not update
+ Assert.Equal("INV42", svc.Get(s.Token)!.InvId);
+
+ var prms = inv.Registered!.BuildInvoiceParams(change: false, invId: "");
+ var balance = prms.First(p => p.ParameterName == "@InvoiceBalance");
+ Assert.Equal("119", System.Convert.ToString(balance.Value, System.Globalization.CultureInfo.InvariantCulture));
+ var vatRate = prms.First(p => p.ParameterName == "@InvoiceVAT_1");
+ Assert.Equal("19", vatRate.Value);
+ }
+
+ [Fact]
+ public void GetHistory_UnknownToken_IsEmpty()
+ {
+ var (svc, _, _) = NewService();
+ Assert.Empty(svc.GetHistory("ghost"));
+ }
+
+ [Theory]
+ [InlineData("address", "invoiceaddress")]
+ [InlineData("title", "invoicetitle")]
+ [InlineData("provisionperiod", "provisionperiod")]
+ public void ApplyPatch_ScalarFieldDeltas_UpdateNew(string target, string newKey)
+ {
+ var (svc, _, _) = NewService();
+ var s = svc.OpenFromPayload(Payload(), "user1");
+
+ var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = target, Value = JToken.FromObject("X-VALUE") });
+
+ Assert.Equal("X-VALUE", s2!.New[newKey]!.Value());
+ }
+
+ [Fact]
+ public void ApplyPatch_ProvisionLocation_MirrorsLocAndProvisionlocation()
+ {
+ var (svc, _, _) = NewService();
+ var s = svc.OpenFromPayload(Payload(), "user1");
+
+ var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "provisionlocation", Value = JToken.FromObject("Baustelle 7") });
+
+ Assert.Equal("Baustelle 7", s2!.New["provisionlocation"]!.Value());
+ Assert.Equal("Baustelle 7", s2.New["loc"]!.Value());
+ }
+
+ [Fact]
+ public void ApplyPatch_Contact_BuildsCustomValuesJson()
+ {
+ var (svc, _, _) = NewService();
+ var s = svc.OpenFromPayload(Payload(), "user1");
+
+ var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta
+ {
+ Target = "contact",
+ Value = JObject.Parse(@"{'name':'Max Mustermann','email':'max@kunde.de'}")
+ });
+
+ var cv = JObject.Parse(s2!.New["CustomValues"]!.Value()!);
+ Assert.Equal("Max Mustermann", cv["contactName"]!.Value());
+ Assert.Equal("max@kunde.de", cv["contactEmail"]!.Value());
+ }
+
+ [Fact]
+ public void ApplyPatch_SetmodeDelta_UpdatesAdmin()
+ {
+ var (svc, _, _) = NewService();
+ var s = svc.OpenFromPayload(Payload(), "user1");
+
+ var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "setmode", Value = JToken.FromObject("setonly") });
+
+ Assert.Equal("setonly", s2!.Admin["setmode"]!.Value());
+ }
+
+ [Fact]
+ public void ApplyPatch_P13bExplicitFalse_TurnsOffAndRestoresVat()
+ {
+ var (svc, _, _) = NewService();
+ var payload = Payload();
+ payload["admin"]!["p13b"] = true; // start reverse-charge
+ var s = svc.OpenFromPayload(payload, "user1");
+ Assert.Empty(s.Sums.VatByRate);
+
+ var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "p13b", Value = JToken.FromObject(false) });
+
+ Assert.Equal(119m, s2!.Sums.TotalGross); // VAT restored
+ Assert.Equal(19m, s2.Sums.VatByRate["19"]);
+ }
+
+ [Fact]
+ public void ApplyPatch_BlockReplace_InsertsWhenBlockIsNew()
+ {
+ var (svc, _, _) = NewService();
+ var s = svc.OpenFromPayload(Payload(), "user1");
+
+ var newBlock = JObject.Parse(@"{'Id':'2','text':'Zusatz','itm':[{'id':'950','typ':'material','vt':30,'vv':5.7,'vat':'19%'}],
+ 'items':[{'id':'950','type':'material','total_net':30,'vat':'19%'}]}");
+ var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.replace", Ref = "2", Value = newBlock });
+
+ Assert.Equal(2, s2!.Req.Count);
+ Assert.Equal(130m, s2.Sums.TotalNet); // 100 (block 1) + 30 (new block 2)
+ Assert.Equal(30m, s2.Sums.NetByBlock["2"]);
+ }
+
+ // fds__prepInvoice's [SetItmID] window function anchors on the still-unconverted (price 0)
+ // Set header: the header row's own SetItmId self-references its own id ('1'), never null —
+ // ApplyItemSetPrice must still recognize id-equality first and never treat the header as its
+ // own member (see ApplyItemSetPrice's id == Ref check).
+ private static JObject SetPayload() => JObject.Parse(@"{
+ 'admin':{'p13b':false,'type':'r','paymentterms':'10wd'},
+ 'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','invoicetitle':'Rechnung'},
+ 'req':[{'Id':'1','text':'Auftrag','itm':[
+ {'id':'1','typ':'set','vt':0,'vv':0,'vs':0,'vsv':0,'vat':'19%','SetItmId':'1'},
+ {'id':'2','typ':'material','vt':60,'vv':11.4,'vs':0,'vsv':0,'vat':'19%','SetItmId':'1'},
+ {'id':'3','typ':'material','vt':40,'vv':7.6,'vs':10,'vsv':1.9,'vat':'19%','SetItmId':'1'}],
+ 'items':[{'id':'1','type':'set'},{'id':'2','type':'material','setId':'1','total_net':60,'vat':'19%'},
+ {'id':'3','type':'material','setId':'1','total_net':40,'vat':'19%'}]}]
+ }");
+
+ // A fourth, unrelated item ('4') follows the set's members in the same block but was never
+ // attributed a SetItmId by fds__prepInvoice (it isn't part of the set) — it must stay untouched
+ // by the conversion, proving membership is driven purely by SetItmId, not row order/position.
+ private static JObject SetPayloadWithTrailingUnrelatedItem() => JObject.Parse(@"{
+ 'admin':{'p13b':false,'type':'r','paymentterms':'10wd'},
+ 'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','invoicetitle':'Rechnung'},
+ 'req':[{'Id':'1','text':'Auftrag','itm':[
+ {'id':'1','typ':'set','vt':0,'vv':0,'vs':0,'vsv':0,'vat':'19%','SetItmId':'1'},
+ {'id':'2','typ':'material','vt':60,'vv':11.4,'vs':0,'vsv':0,'vat':'19%','SetItmId':'1'},
+ {'id':'3','typ':'material','vt':40,'vv':7.6,'vs':10,'vsv':1.9,'vat':'19%','SetItmId':'1'},
+ {'id':'4','typ':'material','vt':25,'vv':4.75,'vs':0,'vsv':0,'vat':'19%','SetItmId':null}],
+ 'items':[{'id':'1','type':'set'},{'id':'2','type':'material','setId':'1','total_net':60,'vat':'19%'},
+ {'id':'3','type':'material','setId':'1','total_net':40,'vat':'19%'},
+ {'id':'4','type':'material','total_net':25,'vat':'19%'}]}]
+ }");
+
+ [Fact]
+ public void ApplyPatch_ItemSetPrice_SumsMembersOntoHeaderAndNullsMembers()
+ {
+ var (svc, _, _) = NewService();
+ var s = svc.OpenFromPayload(SetPayload(), "user1");
+
+ var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "item.setprice", Ref = "1" });
+
+ Assert.NotNull(s2);
+ Assert.Equal(1, s2!.Version);
+ var block = (JObject)s2.Req[0];
+ var lines = (JArray)block["itm"]!;
+ var header = lines.OfType().Single(l => (string)l["id"]! == "1");
+ var m2 = lines.OfType().Single(l => (string)l["id"]! == "2");
+ var m3 = lines.OfType().Single(l => (string)l["id"]! == "3");
+
+ Assert.Equal(100m, header["vt"]!.Value()); // 60 + 40
+ Assert.Equal(19m, header["vv"]!.Value()); // 11.4 + 7.6
+ Assert.Equal(10m, header["vs"]!.Value());
+ Assert.Equal(1.9m, header["vsv"]!.Value());
+ Assert.Equal(JTokenType.Null, m2["vt"]!.Type); // ADR 0009: members nulled (empty cell), not 0
+ Assert.Equal(JTokenType.Null, m2["vv"]!.Type);
+ Assert.Equal(JTokenType.Null, m3["vt"]!.Type);
+ Assert.Equal(JTokenType.Null, m3["vv"]!.Type);
+
+ // Total invoice sum is unchanged by the conversion (set price == sum of members).
+ Assert.Equal(100m, s2.Sums.TotalNet);
+ Assert.Equal(19m, s2.Sums.TotalVat);
+
+ var h = Assert.Single(s2.History);
+ Assert.Equal("item.setprice", h.Target);
+ Assert.Equal("1", h.Ref);
+ Assert.Equal("0", h.OldValue);
+ Assert.Equal("100", h.NewValue);
+ }
+
+ [Fact]
+ public void ApplyPatch_ItemSetPrice_UnknownRef_IsNoOp()
+ {
+ var (svc, _, _) = NewService();
+ var s = svc.OpenFromPayload(SetPayload(), "user1");
+
+ var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "item.setprice", Ref = "999" });
+
+ Assert.NotNull(s2);
+ Assert.Equal(0, s2!.Version);
+ Assert.Empty(s2.History);
+ }
+
+ [Fact]
+ public void ApplyPatch_ItemSetPrice_RefNotASetHeader_IsNoOp()
+ {
+ var (svc, _, _) = NewService();
+ var s = svc.OpenFromPayload(SetPayload(), "user1");
+
+ var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "item.setprice", Ref = "2" });
+
+ Assert.NotNull(s2);
+ Assert.Equal(0, s2!.Version);
+ Assert.Empty(s2.History);
+ }
+
+ [Fact]
+ public void ApplyPatch_ItemSetPrice_HeaderSelfReferencingSetItmId_NeverCountsHeaderAsOwnMember()
+ {
+ // Regression test: fds__prepInvoice no longer nulls out the header's own SetItmId (it
+ // self-references its own id). ApplyItemSetPrice must still sum exactly the two real
+ // members (100 net), not 3x by also including the header as if it were a member of itself.
+ var (svc, _, _) = NewService();
+ var s = svc.OpenFromPayload(SetPayload(), "user1");
+
+ var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "item.setprice", Ref = "1" });
+
+ Assert.NotNull(s2);
+ var block = (JObject)s2!.Req[0];
+ var lines = (JArray)block["itm"]!;
+ var header = lines.OfType().Single(l => (string)l["id"]! == "1");
+
+ Assert.Equal(100m, header["vt"]!.Value()); // 60 + 40, not tripled by self-inclusion
+ Assert.Equal(100m, s2.Sums.TotalNet);
+ }
+
+ [Fact]
+ public void ApplyPatch_ItemSetPrice_UnrelatedItemAfterMembers_IsNeverSweptIntoSet()
+ {
+ // Regression test for the reported bug: an item after the set's real members in the same
+ // block, but with no SetItmId of its own, must stay fully priced and untouched — only
+ // items the server actually tagged with SetItmId == the header id are members.
+ var (svc, _, _) = NewService();
+ var s = svc.OpenFromPayload(SetPayloadWithTrailingUnrelatedItem(), "user1");
+
+ var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "item.setprice", Ref = "1" });
+
+ Assert.NotNull(s2);
+ var block = (JObject)s2!.Req[0];
+ var lines = (JArray)block["itm"]!;
+ var header = lines.OfType().Single(l => (string)l["id"]! == "1");
+ var m2 = lines.OfType().Single(l => (string)l["id"]! == "2");
+ var m3 = lines.OfType().Single(l => (string)l["id"]! == "3");
+ var other = lines.OfType().Single(l => (string)l["id"]! == "4");
+
+ Assert.Equal(100m, header["vt"]!.Value()); // only the real members (60 + 40)
+ Assert.Equal(JTokenType.Null, m2["vt"]!.Type); // ADR 0009: nulled, not 0
+ Assert.Equal(JTokenType.Null, m3["vt"]!.Type);
+ Assert.Equal(25m, other["vt"]!.Value()); // untouched — never part of the set
+ Assert.Equal(4.75m, other["vv"]!.Value());
+
+ // Total invoice sum unaffected: 100 (set) + 25 (unrelated item) = 125.
+ Assert.Equal(125m, s2.Sums.TotalNet);
+ }
+
+ // ── Block set-price menu modes (ADR 0009): per-service-request-block, irreversible ──
+ private static JObject BlocksPayload() => JObject.Parse(@"{
+ 'admin':{'p13b':false,'type':'r'},
+ 'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','invoicetitle':'Rechnung'},
+ 'req':[
+ {'Id':'1','text':'Auftrag A','itm':[
+ {'id':'11','typ':'material','vt':60,'vv':11.4,'vs':0,'vsv':0,'vat':'19%'},
+ {'id':'12','typ':'service','vt':40,'vv':7.6,'vs':40,'vsv':7.6,'vat':'19%'}],
+ 'items':[{'id':'11','type':'material','total_net':60,'vat':'19%'},
+ {'id':'12','type':'service','total_net':40,'vat':'19%'}]},
+ {'Id':'2','text':'Auftrag B','itm':[
+ {'id':'21','typ':'material','vt':30,'vv':5.7,'vs':0,'vsv':0,'vat':'19%'}],
+ 'items':[{'id':'21','type':'material','total_net':30,'vat':'19%'}]}]
+ }");
+
+ [Fact]
+ public void ApplyPatch_BlockSetPrice_InsertsSetRowPerBlock_NullsMembers_TotalUnchanged()
+ {
+ var (svc, _, _) = NewService();
+ var s = svc.OpenFromPayload(BlocksPayload(), "user1");
+ Assert.Equal(130m, s.Sums.TotalNet); // 100 (A) + 30 (B)
+
+ var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.setprice" });
+
+ Assert.NotNull(s2);
+ Assert.Equal(1, s2!.Version);
+
+ var linesA = (JArray)((JObject)s2.Req[0])["itm"]!;
+ Assert.Equal(3, linesA.Count); // set row + the two (nulled) members, kept
+ var setA = (JObject)linesA[0];
+ Assert.Equal("set", (string)setA["typ"]!);
+ Assert.Equal("bset_1", (string)setA["id"]!);
+ Assert.Equal(100m, setA["vt"]!.Value()); // block sum
+ Assert.Equal(19m, setA["vv"]!.Value());
+ Assert.Equal(40m, setA["vs"]!.Value()); // service-net split preserved
+ Assert.Equal(7.6m, setA["vsv"]!.Value());
+ var m11 = linesA.OfType().Single(l => (string)l["id"]! == "11");
+ Assert.Equal(JTokenType.Null, m11["vt"]!.Type); // ADR 0009: null (empty cell), not 0
+ Assert.Equal(JTokenType.Null, m11["vv"]!.Type);
+
+ // items contract mirrors it (PDF reads total_net from here).
+ var itemsA = (JArray)((JObject)s2.Req[0])["items"]!;
+ Assert.Equal("set", (string)((JObject)itemsA[0])["type"]!);
+ Assert.Equal(100m, ((JObject)itemsA[0])["total_net"]!.Value());
+ Assert.Equal(JTokenType.Null, itemsA.OfType().Single(i => (string)i["id"]! == "11")["total_net"]!.Type);
+
+ // second block converted too; total conserved.
+ Assert.Equal(30m, ((JObject)((JArray)((JObject)s2.Req[1])["itm"]!)[0])["vt"]!.Value());
+ Assert.Equal(130m, s2.Sums.TotalNet);
+ Assert.Equal(24.7m, s2.Sums.TotalVat);
+ }
+
+ [Fact]
+ public void ApplyPatch_BlockSetOnly_InsertsSetRowPerBlock_RemovesMembers_TotalUnchanged()
+ {
+ var (svc, _, _) = NewService();
+ var s = svc.OpenFromPayload(BlocksPayload(), "user1");
+
+ var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.setonly" });
+
+ Assert.NotNull(s2);
+ var linesA = (JArray)((JObject)s2!.Req[0])["itm"]!;
+ Assert.Single(linesA); // members removed, only the set row remains
+ Assert.Equal("set", (string)((JObject)linesA[0])["typ"]!);
+ Assert.Equal(100m, ((JObject)linesA[0])["vt"]!.Value());
+ Assert.Single((JArray)((JObject)s2.Req[0])["items"]!);
+ Assert.Equal(130m, s2.Sums.TotalNet); // total unchanged
+ }
+
+ [Fact]
+ public void ApplyPatch_BlockSetPrice_SetRowNotSweptIntoBuildSetDisplay()
+ {
+ // The block set row has no setId members -> HasSetMembers is false -> it renders flat
+ // (emphasised on its own, members null to blank), so BuildSetDisplay must not emit flags for it.
+ var (svc, _, _) = NewService();
+ var s = svc.OpenFromPayload(BlocksPayload(), "user1");
+ svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.setprice" });
+
+ var state = JObject.FromObject(svc.BuildState(svc.Get(s.Token)!));
+ var setDisplay = (JObject)state["setDisplay"]!;
+ Assert.False(setDisplay.ContainsKey("bset_1"));
+ Assert.Empty(setDisplay);
+ }
+
+ [Fact]
+ public void ApplyPatch_BlockSetPrice_EmptyOrUnpricedBlocks_IsNoOp()
+ {
+ var (svc, _, _) = NewService();
+ var s = svc.OpenFromPayload(JObject.Parse(@"{
+ 'admin':{'p13b':false,'type':'r'},
+ 'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','invoicetitle':'Rechnung'},
+ 'req':[{'Id':'1','text':'Leer','itm':[],'items':[]}]
+ }"), "user1");
+
+ var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.setprice" });
+
+ Assert.NotNull(s2);
+ Assert.Equal(0, s2!.Version); // nothing priced -> no-op
+ Assert.Empty(s2.History);
+ }
+
+ [Fact]
+ public void ApplyPatch_UnknownTarget_IsNoOp_NoVersionBumpNoHistory()
+ {
+ var (svc, _, _) = NewService();
+ var s = svc.OpenFromPayload(Payload(), "user1");
+
+ var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "nonsense", Value = JToken.FromObject("x") });
+
+ Assert.NotNull(s2);
+ Assert.Equal(0, s2!.Version);
+ Assert.Empty(s2.History);
+ }
+
+ [Fact]
+ public void ApplyPatch_MultipleEdits_AccumulateHistoryInOrder()
+ {
+ var (svc, _, _) = NewService();
+ var s = svc.OpenFromPayload(Payload(), "user1");
+
+ svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "email", Value = JToken.FromObject("a1@x.de") });
+ svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "title", Value = JToken.FromObject("Titel 2") });
+ var s3 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "address", Value = JToken.FromObject("Adr 3") });
+
+ Assert.Equal(3, s3!.Version);
+ Assert.Equal(3, s3.History.Count);
+ Assert.Equal(new[] { "email", "title", "address" }, s3.History.Select(h => h.Target).ToArray());
+ Assert.Equal(new[] { 1, 2, 3 }, s3.History.Select(h => h.Version).ToArray());
+ }
+
+ [Fact]
+ public void BuildState_ExposesPayloadSumsValidationAndVersion()
+ {
+ var (svc, _, _) = NewService();
+ var s = svc.OpenFromPayload(Payload(), "user1");
+ svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "email", Value = JToken.FromObject("x@y.de") });
+
+ var state = JObject.FromObject(svc.BuildState(svc.Get(s.Token)!));
+
+ Assert.Equal(1, state["version"]!.Value());
+ Assert.Equal(100m, state["sums"]!["total_net"]!.Value());
+ Assert.Equal(119m, state["sums"]!["total_gross"]!.Value());
+ Assert.Equal(19m, state["sums"]!["vat"]!["19"]!.Value());
+ Assert.Single((JArray)state["req"]!);
+ Assert.Equal(1, state["historyCount"]!.Value());
+ Assert.NotNull(state["validation"]);
+ }
+
+ [Fact]
+ public async Task FlushToDbAsync_ExistingInvId_UpdatesInsteadOfCreates()
+ {
+ var (svc, inv, _) = NewService();
+ var payload = Payload();
+ payload["invid"] = "INV7";
+ var s = svc.OpenFromPayload(payload, "user1");
+
+ await svc.FlushToDbAsync(s.Token, "user1", null!);
+
+ Assert.True(inv.LastChange); // prior InvId → update path
+ }
+
+ [Fact]
+ public async Task FlushToDbAsync_MapsInvoiceOptionsFrom13bAndSetmode()
+ {
+ var (svc, inv, _) = NewService();
+ var payload = Payload();
+ payload["admin"]!["p13b"] = true;
+ payload["admin"]!["setmode"] = "setonly";
+ var s = svc.OpenFromPayload(payload, "user1");
+
+ await svc.FlushToDbAsync(s.Token, "user1", null!);
+
+ var options = inv.Registered!.BuildInvoiceParams(change: false, invId: "")
+ .First(p => p.ParameterName == "@InvoiceOptions").Value?.ToString() ?? "";
+ Assert.Contains("§13b", options);
+ Assert.Contains("setmode:setonly", options);
+ }
+
+ [Fact]
+ public void RenderPreview_SynthesizesDraftRegistrationFromSession()
+ {
+ var (svc, inv, _) = NewService();
+ var s = svc.OpenFromPayload(Payload(), "user1");
+
+ var doc = svc.RenderPreview(s.Token);
+
+ Assert.NotNull(doc);
+ Assert.True(inv.PreviewDraft); // always rendered as a draft
+ var reg = inv.PreviewInvoice!.InvoiceRegistration!;
+ Assert.Equal("Rechnung", reg.getString("InvoiceTitle"));
+ Assert.Equal("Weg 1", reg.getString("SendToAddress"));
+ Assert.Equal("a@b.de", reg.getString("SendToEmail"));
+ Assert.Equal("19", reg.getString("InvoiceVAT_1")); // rate synthesised from server sums
+ Assert.True(inv.PreviewInvoice.IsDraft);
+ }
+
+ [Fact]
+ public void RenderPreview_UnknownToken_ReturnsNull()
+ {
+ var (svc, _, _) = NewService();
+ Assert.Null(svc.RenderPreview("ghost"));
+ }
+
+ [Fact]
+ public void Close_RemovesSession_ThenReportsFalse()
+ {
+ var (svc, _, _) = NewService();
+ var s = svc.OpenFromPayload(Payload(), "user1");
+
+ Assert.True(svc.Close(s.Token));
+ Assert.Null(svc.Get(s.Token));
+ Assert.False(svc.Close(s.Token));
+ }
+
+ // ── HTML sanitisation (values must never reach the DB/PDF wrapped in tags) ─
+ [Theory]
+ [InlineData("provisionperiod", "provisionperiod")]
+ [InlineData("title", "invoicetitle")]
+ [InlineData("email", "invoiceemail")]
+ public void ApplyPatch_ScalarField_StripsHtmlWrapper(string target, string newKey)
+ {
+ var (svc, _, _) = NewService();
+ var s = svc.OpenFromPayload(Payload(), "user1");
+
+ var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = target, Value = JToken.FromObject("
18.06.2026
") });
+
+ Assert.Equal("18.06.2026", s2!.New[newKey]!.Value()); // no
tags stored
+ Assert.Equal("18.06.2026", Assert.Single(s2.History).NewValue);
+ }
+
+ [Fact]
+ public void ApplyPatch_Address_MultilineHtml_KeepsLineBreaks()
+ {
+ var (svc, _, _) = NewService();
+ var s = svc.OpenFromPayload(Payload(), "user1");
+
+ var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta
+ {
+ Target = "address",
+ Value = JToken.FromObject("
Firma AG
Weg 1 5080 Laufenburg
")
+ });
+
+ Assert.Equal("Firma AG\nWeg 1\n5080 Laufenburg", s2!.New["invoiceaddress"]!.Value());
+ }
+
+ [Fact]
+ public void ApplyPatch_ScalarField_DecodesEntities()
+ {
+ var (svc, _, _) = NewService();
+ var s = svc.OpenFromPayload(Payload(), "user1");
+
+ var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "title", Value = JToken.FromObject("Tom & Jerry") });
+
+ Assert.Equal("Tom & Jerry", s2!.New["invoicetitle"]!.Value());
+ }
+
+ [Fact]
+ public void ApplyPatch_ProvisionLocation_SanitisesAndMirrorsLoc()
+ {
+ var (svc, _, _) = NewService();
+ var s = svc.OpenFromPayload(Payload(), "user1");
+
+ var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "provisionlocation", Value = JToken.FromObject("
Baustelle 7
") });
+
+ Assert.Equal("Baustelle 7", s2!.New["provisionlocation"]!.Value());
+ Assert.Equal("Baustelle 7", s2.New["loc"]!.Value());
+ }
+
+ // ── Change history records the changed field, not the whole block JSON ─────
+ [Fact]
+ public void ApplyPatch_BlockReplace_HistoryNewValueIsSectionText_NotJson()
+ {
+ var (svc, _, _) = NewService();
+ var s = svc.OpenFromPayload(Payload(), "user1");
+
+ var newBlock = JObject.Parse(@"{'Id':'1','text':'
Neue Überschrift
',
+ 'itm':[{'id':'900','typ':'material','vt':100,'vv':19,'vat':'19%'}],
+ 'items':[{'id':'900','type':'material','total_net':100,'vat':'19%'}]}");
+ var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.replace", Ref = "1", Value = newBlock });
+
+ var h = Assert.Single(s2!.History);
+ Assert.Equal("Neue Überschrift", h.NewValue); // the heading, sanitised — never the block JSON
+ Assert.DoesNotContain("{", h.NewValue);
+ Assert.Equal("Auftrag", h.OldValue);
+ // and the cached block text is stored clean too
+ Assert.Equal("Neue Überschrift", ((JObject)s2.Req[0])["text"]!.Value());
+ }
+
+ // ── Section reorder ───────────────────────────────────────────────────────
+ private static JObject TwoBlockPayload() => JObject.Parse(@"{
+ 'admin':{'p13b':false,'type':'r'},
+ 'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1'},
+ 'req':[
+ {'Id':'1','text':'A','itm':[{'id':'900','typ':'material','vt':100,'vv':19,'vat':'19%'}],'items':[{'id':'900','type':'material','total_net':100,'vat':'19%'}]},
+ {'Id':'2','text':'B','itm':[{'id':'950','typ':'material','vt':30,'vv':5.7,'vat':'19%'}],'items':[{'id':'950','type':'material','total_net':30,'vat':'19%'}]}
+ ]}");
+
+ [Fact]
+ public void ApplyPatch_BlockOrder_ReordersReqAndRenumbersPositions()
+ {
+ var (svc, _, _) = NewService();
+ var s = svc.OpenFromPayload(TwoBlockPayload(), "user1");
+ Assert.Equal(new[] { "1", "2" }, s.Req.Select(b => b["Id"]!.Value()).ToArray());
+
+ var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.order", Value = JArray.Parse("['2','1']") });
+
+ Assert.Equal(new[] { "2", "1" }, s2!.Req.Select(b => b["Id"]!.Value()).ToArray());
+ Assert.Equal("1", ((JObject)((JArray)((JObject)s2.Req[0])["itm"]!)[0])["p"]!.ToString()); // block 2's item now position 1
+ Assert.Equal(130m, s2.Sums.TotalNet); // totals unaffected by reorder
+ var h = Assert.Single(s2.History);
+ Assert.Equal("1,2", h.OldValue);
+ Assert.Equal("2,1", h.NewValue);
+ }
+
+ [Fact]
+ public void ApplyPatch_BlockOrder_UnchangedSequence_IsNoOp()
+ {
+ var (svc, _, _) = NewService();
+ var s = svc.OpenFromPayload(TwoBlockPayload(), "user1");
+
+ var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.order", Value = JArray.Parse("['1','2']") });
+
+ Assert.Equal(0, s2!.Version); // no-op: no version bump, no history
+ Assert.Empty(s2.History);
+ }
+
+ [Fact]
+ public void ApplyPatch_BlockOrder_UnknownIds_KeepMentionedFirstThenRest()
+ {
+ var (svc, _, _) = NewService();
+ var s = svc.OpenFromPayload(TwoBlockPayload(), "user1");
+
+ // Only name block 2; block 1 is unmentioned and must be kept (appended after).
+ var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.order", Value = JArray.Parse("['2','ghost']") });
+
+ Assert.Equal(new[] { "2", "1" }, s2!.Req.Select(b => b["Id"]!.Value()).ToArray());
+ }
+
+ // ── Multiple VAT rates aggregate independently (ADR 0008: server owns every tax sum) ──
+ private static JObject MultiRatePayload() => JObject.Parse(@"{
+ 'admin':{'p13b':false,'type':'r'},
+ 'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1'},
+ 'req':[
+ {'Id':'1','text':'A','itm':[
+ {'id':'900','typ':'material','vt':100,'vv':19,'vat':'19%'},
+ {'id':'901','typ':'material','vt':200,'vv':14,'vat':'7%'},
+ {'id':'902','typ':'material','vt':50,'vv':0,'vat':'0%'}],
+ 'items':[{'id':'900','type':'material','total_net':100,'vat':'19%'},
+ {'id':'901','type':'material','total_net':200,'vat':'7%'},
+ {'id':'902','type':'material','total_net':50,'vat':'0%'}]}
+ ]}");
+
+ [Fact]
+ public void OpenFromPayload_MultipleVatRates_GroupsSumsPerRate()
+ {
+ var (svc, _, _) = NewService();
+ var s = svc.OpenFromPayload(MultiRatePayload(), "user1");
+
+ Assert.Equal(350m, s.Sums.TotalNet);
+ Assert.Equal(33m, s.Sums.TotalVat);
+ Assert.Equal(383m, s.Sums.TotalGross);
+ Assert.Equal(19m, s.Sums.VatByRate["19"]);
+ Assert.Equal(14m, s.Sums.VatByRate["7"]);
+ Assert.False(s.Sums.VatByRate.ContainsKey("0")); // zero-rate contributes no VAT key (mirrors calculator)
+ }
+
+ [Fact]
+ public void ApplyPatch_BlockReplace_MultiRate_RecomputesEachRateIndependently()
+ {
+ var (svc, _, _) = NewService();
+ var s = svc.OpenFromPayload(MultiRatePayload(), "user1");
+
+ // Halve the 7%-rate line's net/VAT via a block replace; the 19% bucket must stay untouched.
+ var newBlock = JObject.Parse(@"{'Id':'1','text':'A','itm':[
+ {'id':'900','typ':'material','vt':100,'vv':19,'vat':'19%'},
+ {'id':'901','typ':'material','vt':100,'vv':7,'vat':'7%'},
+ {'id':'902','typ':'material','vt':50,'vv':0,'vat':'0%'}],
+ 'items':[]}");
+ var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.replace", Ref = "1", Value = newBlock });
+
+ Assert.Equal(19m, s2!.Sums.VatByRate["19"]); // unchanged
+ Assert.Equal(7m, s2.Sums.VatByRate["7"]); // recomputed from new line
+ Assert.Equal(250m, s2.Sums.TotalNet);
+ Assert.Equal(26m, s2.Sums.TotalVat);
+ }
+
+ // ── BuildSetDisplay reflects both set-pricing modes (mirrors the PDF's InvoiceSetPricing) ──
+ // SetPayload()'s header item is still unconverted (own total_net == 0, as delivered by
+ // fds__prepInvoice) — until the set-item switch (ApplyItemSetPrice) actually gives it its own
+ // price, InvoiceSetPricing.Build must not apply the chosen setmode yet: header stays blank and
+ // each member keeps showing its own individual price.
+ [Theory]
+ [InlineData("setprice")]
+ [InlineData("setonly")]
+ public void BuildState_SetDisplay_UnconvertedSet_HeaderBlankMembersIndividuallyPriced(string setmode)
+ {
+ var (svc, _, _) = NewService();
+ var payload = SetPayload();
+ payload["admin"]!["setmode"] = setmode;
+ var s = svc.OpenFromPayload(payload, "user1");
+
+ var state = JObject.FromObject(svc.BuildState(s));
+ var setDisplay = (JObject)state["setDisplay"]!;
+
+ Assert.False(setDisplay["1"]!["p"]!.Value()); // header not priced yet
+ Assert.True(setDisplay["2"]!["p"]!.Value()); // member 2 keeps its own price
+ Assert.True(setDisplay["3"]!["p"]!.Value()); // member 3 keeps its own price
+ }
+
+ // Once converted (ApplyItemSetPrice has given the header its own price), the chosen setmode
+ // takes effect: SetPrice blanks the members (still present in the map); SetOnly drops them
+ // from the map entirely.
+ [Theory]
+ [InlineData("setprice", true, false)] // header priced, member 2 blank
+ [InlineData("setonly", true, false)] // header priced; member 2 dropped entirely (absent from map)
+ public void BuildState_SetDisplay_ConvertedSet_ReflectsSetmodeForHeaderAndMember(string setmode, bool headerShown, bool memberShown)
+ {
+ var (svc, _, _) = NewService();
+ var payload = SetPayload();
+ payload["admin"]!["setmode"] = setmode;
+ var s = svc.OpenFromPayload(payload, "user1");
+ svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "item.setprice", Ref = "1" }); // convert the set first
+
+ var state = JObject.FromObject(svc.BuildState(s));
+ var setDisplay = (JObject)state["setDisplay"]!;
+
+ Assert.Equal(headerShown, setDisplay["1"]!["p"]!.Value());
+ if (setmode == "setonly")
+ Assert.False(setDisplay.ContainsKey("2")); // member removed from the display entirely
+ else
+ Assert.Equal(memberShown, setDisplay["2"]!["p"]!.Value());
+ }
+
+ // ── Full lifecycle recalculation: several edits of different kinds land in one consistent recompute ──
+ [Fact]
+ public void ApplyPatch_FullEditSequence_TextReorderSetPriceAndTaxToggle_EndsConsistent()
+ {
+ var (svc, _, _) = NewService();
+ var s = svc.OpenFromPayload(TwoBlockPayload(), "user1");
+
+ // 1) text change
+ svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "title", Value = JToken.FromObject("Endabrechnung") });
+ // 2) reorder sections
+ svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "block.order", Value = JArray.Parse("['2','1']") });
+ // 3) toggle reverse-charge on, then off again (settings roundtrip)
+ svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "p13b", Value = JToken.FromObject(true) });
+ var final = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "p13b", Value = JToken.FromObject(false) });
+
+ Assert.NotNull(final);
+ Assert.Equal("Endabrechnung", final!.New["invoicetitle"]!.Value());
+ Assert.Equal(new[] { "2", "1" }, final.Req.Select(b => b["Id"]!.Value()).ToArray());
+ Assert.Equal("1", ((JObject)((JArray)((JObject)final.Req[0])["itm"]!)[0])["p"]!.ToString()); // renumbered after reorder
+ Assert.Equal(130m, final.Sums.TotalNet); // totals stable across the whole sequence
+ Assert.Equal(24.7m, final.Sums.TotalVat); // VAT restored after the toggle roundtrip
+ Assert.Equal(4, final.History.Count);
+ Assert.DoesNotContain(final.ValidationMessages, m => m.Severity == "error");
+ }
+}
diff --git a/Fuchs.Tests/InvoiceOptionsTests.cs b/Fuchs.Tests/InvoiceOptionsTests.cs
index d6f91c5..35a68de 100644
--- a/Fuchs.Tests/InvoiceOptionsTests.cs
+++ b/Fuchs.Tests/InvoiceOptionsTests.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Linq;
using Fuchs.intranet;
@@ -31,16 +31,25 @@ public class InvoiceOptionsTests
=> Assert.Equal("", InvoiceOptionsFor(new { type = "r" }));
[Fact]
- public void DefaultSetPrice_OmitsToken()
- => Assert.Equal("", InvoiceOptionsFor(new { type = "r", setmode = "setprice" }));
+ public void ExplicitDefaultSetPrice_EmitsToken()
+ // An explicit choice of the default mode must still be persisted — otherwise it is
+ // indistinguishable from an invoice that was never switched to set-pricing at all
+ // (the editor relies on this to hide the "Set-Preisanzeige" menu entry once chosen).
+ => Assert.Equal("setmode:setprice", InvoiceOptionsFor(new { type = "r", setmode = "setprice" }));
[Theory]
- [InlineData("itemprices", "setmode:itemprices")]
[InlineData("setonly", "setmode:setonly")]
- [InlineData("ITEMPRICES", "setmode:itemprices")] // case-insensitive
+ [InlineData("SETONLY", "setmode:setonly")] // case-insensitive
public void SetMode_EmitsToken(string mode, string expected)
=> Assert.Equal(expected, InvoiceOptionsFor(new { type = "r", setmode = mode }));
+ [Fact]
+ public void RemovedItemPricesMode_OmitsToken()
+ // "itemprices" was a valid mode before the button/mode was removed. Any invoice options
+ // still carrying the stale value (or a client re-posting it) must not be persisted as a
+ // recognized token — it is treated the same as an unknown/garbage mode.
+ => Assert.Equal("", InvoiceOptionsFor(new { type = "r", setmode = "itemprices" }));
+
[Fact]
public void UnknownSetMode_OmitsToken()
=> Assert.Equal("", InvoiceOptionsFor(new { type = "r", setmode = "garbage" }));
@@ -103,9 +112,11 @@ public class InvoiceOptionsTests
public void ModeFromInvoiceOptions_RoundTripsBackendEmission()
{
// The token this side emits must parse back to the same mode on the PDF side.
- Assert.Equal(SetDisplayMode.ItemPrices,
- InvoiceSetPricing.ModeFromInvoiceOptions(InvoiceOptionsFor(new { type = "r", setmode = "itemprices" })));
Assert.Equal(SetDisplayMode.SetOnly,
InvoiceSetPricing.ModeFromInvoiceOptions(InvoiceOptionsFor(new { type = "r", p13b = true, setmode = "setonly" })));
+ // A removed mode never round-trips as itself — it is never persisted, so reading it back
+ // always yields the default SetPrice.
+ Assert.Equal(SetDisplayMode.SetPrice,
+ InvoiceSetPricing.ModeFromInvoiceOptions(InvoiceOptionsFor(new { type = "r", setmode = "itemprices" })));
}
}
diff --git a/Fuchs.Tests/InvoiceSetPricingTests.cs b/Fuchs.Tests/InvoiceSetPricingTests.cs
index bb61669..f8a6e42 100644
--- a/Fuchs.Tests/InvoiceSetPricingTests.cs
+++ b/Fuchs.Tests/InvoiceSetPricingTests.cs
@@ -1,4 +1,4 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
using System.Linq;
using Fuchs.intranet;
using Xunit;
@@ -40,11 +40,11 @@ public class InvoiceSetPricingTests
// ── Mode parsing ────────────────────────────────────────────────────────
[Theory]
- [InlineData("itemprices", SetDisplayMode.ItemPrices)]
- [InlineData("items", SetDisplayMode.ItemPrices)]
[InlineData("setonly", SetDisplayMode.SetOnly)]
+ [InlineData("set_only", SetDisplayMode.SetOnly)]
[InlineData("setprice", SetDisplayMode.SetPrice)]
[InlineData("", SetDisplayMode.SetPrice)]
+ [InlineData("itemprices", SetDisplayMode.SetPrice)] // removed mode — falls back to default, never throws
[InlineData("garbage", SetDisplayMode.SetPrice)]
public void ParseMode_Works(string raw, SetDisplayMode expected)
=> Assert.Equal(expected, InvoiceSetPricing.ParseMode(raw));
@@ -52,10 +52,12 @@ public class InvoiceSetPricingTests
[Fact]
public void ModeFromInvoiceOptions_ReadsToken()
{
- Assert.Equal(SetDisplayMode.ItemPrices, InvoiceSetPricing.ModeFromInvoiceOptions("§13b,setmode:itemprices"));
- Assert.Equal(SetDisplayMode.SetOnly, InvoiceSetPricing.ModeFromInvoiceOptions("setmode:setonly"));
- Assert.Equal(SetDisplayMode.SetPrice, InvoiceSetPricing.ModeFromInvoiceOptions("§13b")); // default
- Assert.Equal(SetDisplayMode.SetPrice, InvoiceSetPricing.ModeFromInvoiceOptions(null));
+ Assert.Equal(SetDisplayMode.SetOnly, InvoiceSetPricing.ModeFromInvoiceOptions("§13b,setmode:setonly"));
+ Assert.Equal(SetDisplayMode.SetPrice, InvoiceSetPricing.ModeFromInvoiceOptions("§13b")); // default
+ Assert.Equal(SetDisplayMode.SetPrice, InvoiceSetPricing.ModeFromInvoiceOptions(null));
+ // A stale/removed "itemprices" token (e.g. from an invoice created before the mode was
+ // dropped) must not throw — it degrades gracefully to the default SetPrice mode.
+ Assert.Equal(SetDisplayMode.SetPrice, InvoiceSetPricing.ModeFromInvoiceOptions("setmode:itemprices"));
}
[Fact]
@@ -85,22 +87,6 @@ public class InvoiceSetPricingTests
Assert.Equal(50.00m, lines[3].TotalNet);
}
- // ── ItemPrices: members priced, set header blank ──────────────────────────
- [Fact]
- public void Build_ItemPrices_MembersPricedHeaderBlank()
- {
- var lines = InvoiceSetPricing.Build(Sample(), SetDisplayMode.ItemPrices);
-
- Assert.Equal(4, lines.Count);
- Assert.True(lines[0].IsSetHeader);
- Assert.False(lines[0].ShowPrice); // set header is just a title now
- Assert.True(lines[1].ShowPrice);
- Assert.Equal(600.00m, lines[1].TotalNet);
- Assert.True(lines[2].ShowPrice);
- Assert.Equal(400.00m, lines[2].TotalNet);
- Assert.True(lines[3].ShowPrice); // standalone
- }
-
// ── SetOnly: members removed ──────────────────────────────────────────────
[Fact]
public void Build_SetOnly_RemovesMembers()
@@ -115,18 +101,45 @@ public class InvoiceSetPricingTests
Assert.Equal("Anfahrt", lines[1].Title);
}
- // ── Set price falls back to sum of members when header total is 0 ─────────
+ // ── Unconverted set (header total still 0): shown blank, members individually priced ──
[Fact]
- public void Build_HeaderTotalZero_UsesSumOfMembers()
+ public void Build_HeaderTotalZero_UnconvertedSet_HeaderBlankMembersPriced()
{
var items = new List>
{
- SetHeader("7", "Set ohne Preis"), // total 0
+ SetHeader("7", "Set ohne Preis"), // total 0 — not yet converted
Member("7", "A", "120.00", "120.00"),
Member("7", "B", "80.00", "80.00")
};
var lines = InvoiceSetPricing.Build(items, SetDisplayMode.SetPrice);
- Assert.Equal(200.00m, lines[0].TotalNet); // 120 + 80
+
+ Assert.Equal(3, lines.Count);
+ Assert.True(lines[0].IsSetHeader);
+ Assert.False(lines[0].ShowPrice); // header not priced yet
+ Assert.Equal(0m, lines[0].TotalNet);
+
+ Assert.True(lines[1].ShowPrice); // members keep their own price
+ Assert.Equal(120.00m, lines[1].TotalNet);
+ Assert.True(lines[2].ShowPrice);
+ Assert.Equal(80.00m, lines[2].TotalNet);
+ }
+
+ // ── SetOnly on an unconverted set also just passes items through unchanged ──
+ [Fact]
+ public void Build_HeaderTotalZero_UnconvertedSet_SetOnlyModeStillPassesThrough()
+ {
+ var items = new List>
+ {
+ SetHeader("7", "Set ohne Preis"),
+ Member("7", "A", "120.00", "120.00"),
+ Member("7", "B", "80.00", "80.00")
+ };
+ var lines = InvoiceSetPricing.Build(items, SetDisplayMode.SetOnly);
+
+ Assert.Equal(3, lines.Count); // members not dropped before conversion
+ Assert.False(lines[0].ShowPrice);
+ Assert.True(lines[1].ShowPrice);
+ Assert.True(lines[2].ShowPrice);
}
// ── No sets: pass-through unchanged ───────────────────────────────────────
@@ -164,7 +177,7 @@ public class InvoiceSetPricingTests
}
[Fact]
- public void Build_TextLine_AsSetMember_NoPriceEvenInItemPrices()
+ public void Build_TextLine_AsSetMember_NoPriceInSetPriceMode()
{
var items = new List>
{
@@ -172,10 +185,10 @@ public class InvoiceSetPricingTests
new() { ["type"] = "title", ["setId"] = "10", ["title"] = "Hinweis", ["total_net"] = "" },
Member("10", "Waschbecken", "600.00", "600.00")
};
- var lines = InvoiceSetPricing.Build(items, SetDisplayMode.ItemPrices);
+ var lines = InvoiceSetPricing.Build(items, SetDisplayMode.SetPrice);
var note = lines.First(l => l.Title == "Hinweis");
- Assert.False(note.ShowPrice); // text member stays blank
- Assert.True(lines.First(l => l.Title == "Waschbecken").ShowPrice);
+ Assert.False(note.ShowPrice); // text member stays blank (already blank in SetPrice mode)
+ Assert.False(lines.First(l => l.Title == "Waschbecken").ShowPrice); // members blank in SetPrice mode too
}
// ── Set price equals sum of member prices across modes (no double counting) ─
diff --git a/Fuchs.Tests/ProcessWebComServiceTests.cs b/Fuchs.Tests/ProcessWebComServiceTests.cs
index ce94512..28d2248 100644
--- a/Fuchs.Tests/ProcessWebComServiceTests.cs
+++ b/Fuchs.Tests/ProcessWebComServiceTests.cs
@@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Diagnostics.Metrics;
using System.IO;
+using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading;
@@ -48,6 +49,12 @@ public class ProcessWebComServiceTests
}
}
+ private sealed class ThrowingHandler : HttpMessageHandler
+ {
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ => throw new HttpRequestException("simulated transport failure");
+ }
+
private sealed class StubHttpClientFactory : IHttpClientFactory
{
private readonly HttpMessageHandler _handler;
@@ -55,6 +62,41 @@ public class ProcessWebComServiceTests
public HttpClient CreateClient(string name) => new(_handler, disposeHandler: false);
}
+ ///
+ /// Captures what would be written to fds__logEmail without hitting a database, by
+ /// overriding the audit-log write. Lets tests assert that every send path is logged and that
+ /// failures carry the service response / exception detail.
+ ///
+ private sealed class CapturingComService : ProcessWebComService
+ {
+ public readonly List<(bool success, List log)> Entries = new();
+
+ public CapturingComService(
+ IOptions settings,
+ IOptions emailSettings,
+ IHttpClientFactory httpClientFactory)
+ : base(NullLogger.Instance, intranet: null!, settings, emailSettings, httpClientFactory)
+ {
+ }
+
+ protected override Task WriteAuditLogAsync(string reference, string guid, string config,
+ DateTime sent, bool success, IEnumerable errors)
+ {
+ Entries.Add((success, errors.ToList()));
+ return Task.CompletedTask;
+ }
+ }
+
+ private static CapturingComService CreateCapturing(HttpMessageHandler handler, bool enabled = true, string? overrideRecipient = null)
+ {
+ var settings = Options.Create(new ProcessWebComSettings
+ {
+ Enabled = enabled, BaseUrl = "https://mailer.test", AccountId = "acct", Token = "tok"
+ });
+ var emailSettings = Options.Create(new FuchsEmailSettings { OverrideRecipient = overrideRecipient });
+ return new CapturingComService(settings, emailSettings, new StubHttpClientFactory(handler));
+ }
+
private static ProcessWebComService CreateService(StubHandler handler, bool enabled = true, string? overrideRecipient = null)
{
var settings = Options.Create(new ProcessWebComSettings
@@ -129,6 +171,102 @@ public class ProcessWebComServiceTests
Assert.Equal(0, handler.CallCount);
}
+ // ── Audit logging: every attempt is logged; failures carry the service response ─
+ [Fact]
+ public async Task SendEmailAsync_Success_LogsSuccessfulAttempt()
+ {
+ var svc = CreateCapturing(new StubHandler(HttpStatusCode.OK));
+
+ await svc.SendEmailAsync("inv_log_ok", "S", "
", "kunde@example.de", "Kunde");
+
+ var entry = Assert.Single(svc.Entries);
+ Assert.False(entry.success);
+ Assert.Contains(entry.log, l => l.ToLowerInvariant().Contains("disabled"));
+ }
+
+ // ── Audit-log parameters honour the NOT NULL columns of fds__emaillog ──────
+ [Fact]
+ public void BuildAuditLogParameters_FailedAttempt_NeverPassesNullForNotNullColumns()
+ {
+ // fds__emaillog.config and .DateSent are NOT NULL; a failed/unset send must not send NULL
+ // (regression: it previously did, so every audit write failed and no email was ever logged).
+ var pl = ProcessWebComService.BuildAuditLogParameters(
+ "admin_test", guid: "", config: "", sent: default, success: false, errors: new[] { "err" });
+
+ object? config = pl.Single(p => p.ParameterName == "@config").Value;
+ object? dateSent = pl.Single(p => p.ParameterName == "@DateSent").Value;
+
+ Assert.Equal("", config);
+ Assert.NotEqual(DBNull.Value, config);
+ Assert.IsType(dateSent);
+ Assert.NotEqual((object)DBNull.Value, dateSent);
+ }
+
+ [Fact]
+ public void BuildAuditLogParameters_SuccessfulSend_KeepsProvidedSentTimestamp()
+ {
+ var when = new DateTime(2026, 7, 16, 12, 0, 0, DateTimeKind.Utc);
+
+ var pl = ProcessWebComService.BuildAuditLogParameters(
+ "inv_1", guid: "g1", config: "", sent: when, success: true, errors: Array.Empty());
+
+ Assert.Equal(when, pl.Single(p => p.ParameterName == "@DateSent").Value);
+ }
+
// ── Dev/test recipient override safety net ─────────────────────────────────
[Fact]
public async Task SendEmailAsync_OverrideRecipientSet_RedirectsToOverrideAddress()
@@ -141,7 +279,7 @@ public class ProcessWebComServiceTests
Assert.True(result);
Assert.Equal(1, handler.CallCount);
var json = JObject.Parse(handler.LastRequestBody!);
- Assert.Equal("dev-inbox@example.test", json["recipient"]!.ToString());
+ Assert.Equal("dev-inbox@example.test", json["communication"]!["to"]!.ToString());
}
[Fact]
@@ -153,7 +291,7 @@ public class ProcessWebComServiceTests
await svc.SendEmailAsync("inv_ov2", "Rechnung 123", "
hi
", "realcustomer@example.de", "Kunde");
var json = JObject.Parse(handler.LastRequestBody!);
- string subject = json["subject"]!.ToString();
+ string subject = json["communication"]!["subject"]!.ToString();
Assert.Contains("realcustomer@example.de", subject);
Assert.Contains("Rechnung 123", subject);
}
@@ -169,7 +307,7 @@ public class ProcessWebComServiceTests
Assert.True(result);
Assert.Equal(1, handler.CallCount);
var json = JObject.Parse(handler.LastRequestBody!);
- Assert.Equal("dev-inbox@example.test", json["recipient"]!.ToString());
+ Assert.Equal("dev-inbox@example.test", json["communication"]!["to"]!.ToString());
}
[Fact]
@@ -181,7 +319,7 @@ public class ProcessWebComServiceTests
await svc.SendEmailAsync("inv_ov4", "Subject", "
hi
", "realcustomer@example.de", "Kunde");
var json = JObject.Parse(handler.LastRequestBody!);
- Assert.Equal("realcustomer@example.de", json["recipient"]!.ToString());
+ Assert.Equal("realcustomer@example.de", json["communication"]!["to"]!.ToString());
}
// ── Override enforcement across every real appsettings*.json environment ───
@@ -194,9 +332,9 @@ public class ProcessWebComServiceTests
// hand-typed literal - so a future edit that silently breaks the override key
// or its value in any appsettings*.json file would fail this test.
//
- // Note: this API has a single "recipient" field - there is no distinct
+ // Note: this API has a single "communication.to" field - there is no distinct
// to/cc/bcc concept anywhere in the codebase (see ProcessWebComService.
- // SendEmailAsync / payload.recipient). "to/cc/bcc cleared" is therefore fully
+ // SendEmailAsync / communication.to). "to/cc/bcc cleared" is therefore fully
// satisfied by asserting that field no longer carries the original recipient
// once an override is configured.
public static IEnumerable