Compare commits
21
Commits
e53d8962ad
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c38ee3d55 | ||
|
|
4b28672d71 | ||
|
|
7a9eb94a13 | ||
|
|
4bb6cce9f3 | ||
|
|
235c9587f0 | ||
|
|
00e72c96d4 | ||
|
|
d94974ce06 | ||
|
|
628802db19 | ||
|
|
5ccd85c38f | ||
|
|
9b2c99f697 | ||
|
|
a45ca014ca | ||
|
|
c812d94d99 | ||
|
|
8a0ebeeb1e | ||
|
|
f6079af0de | ||
|
|
49e3ed2673 | ||
|
|
f724b9b59d | ||
|
|
5f85b75c22 | ||
|
|
5c0fdc6c1d | ||
|
|
83d1c28b29 | ||
|
|
42997c4f49 | ||
|
|
af445c015e |
@@ -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.
|
||||
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
[submodule "OCORE"]
|
||||
path = OCORE
|
||||
url = https://git.processweb.de/Stefan/OCORE.git
|
||||
branch = main
|
||||
[submodule "OCORE_web"]
|
||||
path = OCORE_web
|
||||
url = https://git.processweb.de/Stefan/OCORE_web.git
|
||||
branch = main
|
||||
[submodule "OCORE_web_pdf"]
|
||||
path = OCORE_web_pdf
|
||||
url = https://git.processweb.de/Stefan/OCORE_web_pdf.git
|
||||
branch = main
|
||||
[submodule "OCORE_Charting"]
|
||||
path = OCORE_Charting
|
||||
url = https://git.processweb.de/Stefan/OCORE_Charting.git
|
||||
branch = main
|
||||
[submodule "eRechnungLib"]
|
||||
path = eRechnungLib
|
||||
url = https://git.processweb.de/ProcessWeb_Tools/eRechnungLib.git
|
||||
branch = main
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
Binary file not shown.
@@ -1,131 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<configuration>
|
||||
<configSections>
|
||||
<sectionGroup name="applicationSettings" type="System.Configuration.ApplicationSettingsGroup, System, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
|
||||
<section name="fds.My.MySettings" type="System.Configuration.ClientSettingsSection, System, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089" requirePermission="false" />
|
||||
</sectionGroup>
|
||||
</configSections>
|
||||
|
||||
<connectionStrings>
|
||||
<add name="fuchs_ConnectionString" connectionString="Data Source=MSSQL4.NBG4.DOMAINXYZ.DE,10439;Initial Catalog=site_fuchs;Persist Security Info=False;TrustServerCertificate=true;Encrypt=true;User ID=fuchs_web;password='Bt5pL/cJg9oxb5';Connect Timeout=60;Load Balance Timeout=240;Max Pool Size=500;" providerName="System.Data.SqlClient" />
|
||||
<add name="fuchs_fds_ConnectionString" connectionString="Data Source=MSSQL4.NBG4.DOMAINXYZ.DE,10439;Initial Catalog=site_fuchs;Persist Security Info=False;TrustServerCertificate=true;Encrypt=true;User ID=fuchs_fds;password='!Po@cGZ5bUn37khO';Connect Timeout=60;Load Balance Timeout=240;Max Pool Size=500;" providerName="System.Data.SqlClient" />
|
||||
</connectionStrings>
|
||||
<startup>
|
||||
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.8" />
|
||||
</startup>
|
||||
<applicationSettings>
|
||||
<fds.My.MySettings>
|
||||
<setting name="ExecutionFrequency_Minutes" serializeAs="String">
|
||||
<value>15</value>
|
||||
</setting>
|
||||
<setting name="DebugDetails" serializeAs="String">
|
||||
<value>True</value>
|
||||
</setting>
|
||||
<setting name="MFR_UserName" serializeAs="String">
|
||||
<value>system@sebastian-fuchs---bad-und-heizung-gmbh-und-co-kg.com</value>
|
||||
</setting>
|
||||
<setting name="MFR_Password" serializeAs="String">
|
||||
<value>0oT4G3H2</value>
|
||||
</setting>
|
||||
<setting name="MFR_host" serializeAs="String">
|
||||
<value>portal.mobilefieldreport.com</value>
|
||||
</setting>
|
||||
</fds.My.MySettings>
|
||||
</applicationSettings>
|
||||
<runtime>
|
||||
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="System.Diagnostics.DiagnosticSource" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-7.0.0.2" newVersion="7.0.0.2" />
|
||||
</dependentAssembly>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="System.Runtime.CompilerServices.Unsafe" publicKeyToken="b03f5f7f11d50a3a" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0" />
|
||||
</dependentAssembly>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="Newtonsoft.Json" publicKeyToken="30ad4fe6b2a6aeed" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-13.0.0.0" newVersion="13.0.0.0" />
|
||||
</dependentAssembly>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="System.Net.Http.Formatting" publicKeyToken="31bf3856ad364e35" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-5.2.7.0" newVersion="5.2.7.0" />
|
||||
</dependentAssembly>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="Microsoft.Azure.Services.AppAuthentication" publicKeyToken="31bf3856ad364e35" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-1.6.2.0" newVersion="1.6.2.0" />
|
||||
</dependentAssembly>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="System.IdentityModel.Tokens.Jwt" publicKeyToken="31bf3856ad364e35" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-7.0.2.0" newVersion="7.0.2.0" />
|
||||
</dependentAssembly>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="Microsoft.IdentityModel.Clients.ActiveDirectory" publicKeyToken="31bf3856ad364e35" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-5.3.0.0" newVersion="5.3.0.0" />
|
||||
</dependentAssembly>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="System.Numerics.Vectors" publicKeyToken="b03f5f7f11d50a3a" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-4.1.4.0" newVersion="4.1.4.0" />
|
||||
</dependentAssembly>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="System.Buffers" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-4.0.3.0" newVersion="4.0.3.0" />
|
||||
</dependentAssembly>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="System.ValueTuple" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-4.0.3.0" newVersion="4.0.3.0" />
|
||||
</dependentAssembly>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="Microsoft.IdentityModel.Tokens" publicKeyToken="31bf3856ad364e35" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-7.0.2.0" newVersion="7.0.2.0" />
|
||||
</dependentAssembly>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="Microsoft.IdentityModel.Logging" publicKeyToken="31bf3856ad364e35" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-6.11.0.0" newVersion="6.11.0.0" />
|
||||
</dependentAssembly>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="Microsoft.IdentityModel.JsonWebTokens" publicKeyToken="31bf3856ad364e35" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-6.11.0.0" newVersion="6.11.0.0" />
|
||||
</dependentAssembly>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="Microsoft.IO.RecyclableMemoryStream" publicKeyToken="31bf3856ad364e35" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-2.3.2.0" newVersion="2.3.2.0" />
|
||||
</dependentAssembly>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="BouncyCastle.Crypto" publicKeyToken="0e99375e54769942" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-1.9.0.0" newVersion="1.9.0.0" />
|
||||
</dependentAssembly>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="MimeKit" publicKeyToken="bede1c8a46c66814" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-3.0.0.0" newVersion="3.0.0.0" />
|
||||
</dependentAssembly>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="System.Text.Json" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-7.0.0.3" newVersion="7.0.0.3" />
|
||||
</dependentAssembly>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="Microsoft.Bcl.AsyncInterfaces" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-7.0.0.0" newVersion="7.0.0.0" />
|
||||
</dependentAssembly>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="System.Text.Encoding.CodePages" publicKeyToken="b03f5f7f11d50a3a" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0" />
|
||||
</dependentAssembly>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="Microsoft.Web.Infrastructure" publicKeyToken="31bf3856ad364e35" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-2.0.0.0" newVersion="2.0.0.0" />
|
||||
</dependentAssembly>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="System.Memory" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-4.0.1.2" newVersion="4.0.1.2" />
|
||||
</dependentAssembly>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="System.ComponentModel.Annotations" publicKeyToken="b03f5f7f11d50a3a" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-4.2.1.0" newVersion="4.2.1.0" />
|
||||
</dependentAssembly>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="System.Text.Encodings.Web" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-7.0.0.0" newVersion="7.0.0.0" />
|
||||
</dependentAssembly>
|
||||
</assemblyBinding>
|
||||
</runtime>
|
||||
</configuration>
|
||||
@@ -1,186 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
|
||||
<PropertyGroup>
|
||||
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
|
||||
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
|
||||
<ProjectGuid>{7A56E271-A6BE-4C34-A859-DADEBC4C7A54}</ProjectGuid>
|
||||
<OutputType>Exe</OutputType>
|
||||
<StartupObject>Sub Main</StartupObject>
|
||||
<RootNamespace>fds</RootNamespace>
|
||||
<AssemblyName>Fuchs_DataService</AssemblyName>
|
||||
<FileAlignment>512</FileAlignment>
|
||||
<MyType>Console</MyType>
|
||||
<TargetFrameworkVersion>v4.8</TargetFrameworkVersion>
|
||||
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
|
||||
<Deterministic>true</Deterministic>
|
||||
<IsWebBootstrapper>false</IsWebBootstrapper>
|
||||
<PublishUrl>publish\</PublishUrl>
|
||||
<Install>true</Install>
|
||||
<InstallFrom>Disk</InstallFrom>
|
||||
<UpdateEnabled>false</UpdateEnabled>
|
||||
<UpdateMode>Foreground</UpdateMode>
|
||||
<UpdateInterval>7</UpdateInterval>
|
||||
<UpdateIntervalUnits>Days</UpdateIntervalUnits>
|
||||
<UpdatePeriodically>false</UpdatePeriodically>
|
||||
<UpdateRequired>false</UpdateRequired>
|
||||
<MapFileExtensions>true</MapFileExtensions>
|
||||
<ApplicationRevision>0</ApplicationRevision>
|
||||
<ApplicationVersion>1.0.0.%2a</ApplicationVersion>
|
||||
<UseApplicationTrust>false</UseApplicationTrust>
|
||||
<BootstrapperEnabled>true</BootstrapperEnabled>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
|
||||
<PlatformTarget>x64</PlatformTarget>
|
||||
<DebugSymbols>true</DebugSymbols>
|
||||
<DebugType>full</DebugType>
|
||||
<DefineDebug>true</DefineDebug>
|
||||
<DefineTrace>true</DefineTrace>
|
||||
<OutputPath>bin\Debug\</OutputPath>
|
||||
<DocumentationFile>Fuchs_DataService.xml</DocumentationFile>
|
||||
<NoWarn>42016,41999,42017,42018,42019,42032,42036,42020,42021,42022</NoWarn>
|
||||
<Prefer32Bit>false</Prefer32Bit>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
|
||||
<PlatformTarget>AnyCPU</PlatformTarget>
|
||||
<DebugType>pdbonly</DebugType>
|
||||
<DefineDebug>false</DefineDebug>
|
||||
<DefineTrace>true</DefineTrace>
|
||||
<Optimize>true</Optimize>
|
||||
<OutputPath>bin\Release\</OutputPath>
|
||||
<DocumentationFile>Fuchs_DataService.xml</DocumentationFile>
|
||||
<NoWarn>42016,41999,42017,42018,42019,42032,42036,42020,42021,42022</NoWarn>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<OptionExplicit>On</OptionExplicit>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<OptionCompare>Binary</OptionCompare>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<OptionStrict>Off</OptionStrict>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<OptionInfer>On</OptionInfer>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Reference Include="Microsoft.Web.Infrastructure, Version=2.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35, processorArchitecture=MSIL">
|
||||
<HintPath>..\..\..\NugetPackages\Microsoft.Web.Infrastructure.2.0.0\lib\net40\Microsoft.Web.Infrastructure.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Newtonsoft.Json, Version=13.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL">
|
||||
<HintPath>..\..\..\NugetPackages\Newtonsoft.Json.13.0.3\lib\net45\Newtonsoft.Json.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="SevenZipSharp, Version=1.6.1.23, Culture=neutral, PublicKeyToken=c8ff6ba0184838bb, processorArchitecture=MSIL">
|
||||
<HintPath>..\..\..\NugetPackages\Squid-Box.SevenZipSharp.1.6.1.23\lib\net472\SevenZipSharp.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="System" />
|
||||
<Reference Include="System.Configuration" />
|
||||
<Reference Include="System.Configuration.Install" />
|
||||
<Reference Include="System.Data" />
|
||||
<Reference Include="System.Deployment" />
|
||||
<Reference Include="System.Runtime.InteropServices.RuntimeInformation, Version=4.0.1.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
|
||||
<HintPath>..\..\..\NugetPackages\System.Runtime.InteropServices.RuntimeInformation.4.3.0\lib\net45\System.Runtime.InteropServices.RuntimeInformation.dll</HintPath>
|
||||
<Private>True</Private>
|
||||
<Private>True</Private>
|
||||
</Reference>
|
||||
<Reference Include="System.ServiceProcess" />
|
||||
<Reference Include="System.Web" />
|
||||
<Reference Include="System.Web.Razor, Version=3.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35, processorArchitecture=MSIL">
|
||||
<HintPath>..\..\..\NugetPackages\Microsoft.AspNet.Razor.3.2.9\lib\net45\System.Web.Razor.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="System.Xml" />
|
||||
<Reference Include="System.Core" />
|
||||
<Reference Include="System.Xml.Linq" />
|
||||
<Reference Include="System.Data.DataSetExtensions" />
|
||||
<Reference Include="System.Net.Http" />
|
||||
<Reference Include="Topshelf, Version=4.3.0.0, Culture=neutral, PublicKeyToken=b800c4cfcdeea87b, processorArchitecture=MSIL">
|
||||
<HintPath>..\..\..\NugetPackages\Topshelf.4.3.0\lib\net452\Topshelf.dll</HintPath>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Import Include="Microsoft.VisualBasic" />
|
||||
<Import Include="System" />
|
||||
<Import Include="System.Collections" />
|
||||
<Import Include="System.Collections.Generic" />
|
||||
<Import Include="System.Data" />
|
||||
<Import Include="System.Diagnostics" />
|
||||
<Import Include="System.Linq" />
|
||||
<Import Include="System.Xml.Linq" />
|
||||
<Import Include="System.Threading.Tasks" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="fds_zip.vb" />
|
||||
<Compile Include="fds_debug.vb" />
|
||||
<Compile Include="fds_mfr.vb" />
|
||||
<Compile Include="fds_shared.vb" />
|
||||
<Compile Include="My Project\AssemblyInfo.vb" />
|
||||
<Compile Include="My Project\Application.Designer.vb">
|
||||
<AutoGen>True</AutoGen>
|
||||
<DependentUpon>Application.myapp</DependentUpon>
|
||||
<DesignTime>True</DesignTime>
|
||||
</Compile>
|
||||
<Compile Include="My Project\Resources.Designer.vb">
|
||||
<AutoGen>True</AutoGen>
|
||||
<DesignTime>True</DesignTime>
|
||||
<DependentUpon>Resources.resx</DependentUpon>
|
||||
</Compile>
|
||||
<Compile Include="My Project\Settings.Designer.vb">
|
||||
<AutoGen>True</AutoGen>
|
||||
<DependentUpon>Settings.settings</DependentUpon>
|
||||
<DesignTimeSharedInput>True</DesignTimeSharedInput>
|
||||
</Compile>
|
||||
<Compile Include="fds_main.vb" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="My Project\Resources.resx">
|
||||
<Generator>VbMyResourcesResXFileCodeGenerator</Generator>
|
||||
<LastGenOutput>Resources.Designer.vb</LastGenOutput>
|
||||
<CustomToolNamespace>My.Resources</CustomToolNamespace>
|
||||
<SubType>Designer</SubType>
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Content Include="install.bat" />
|
||||
<None Include="My Project\Application.myapp">
|
||||
<Generator>MyApplicationCodeGenerator</Generator>
|
||||
<LastGenOutput>Application.Designer.vb</LastGenOutput>
|
||||
</None>
|
||||
<None Include="My Project\Settings.settings">
|
||||
<Generator>SettingsSingleFileGenerator</Generator>
|
||||
<CustomToolNamespace>My</CustomToolNamespace>
|
||||
<LastGenOutput>Settings.Designer.vb</LastGenOutput>
|
||||
</None>
|
||||
<None Include="App.config" />
|
||||
<None Include="packages.config" />
|
||||
<Content Include="un-install.bat" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\WebProjectComponents\OCMS\OCMS.vbproj">
|
||||
<Project>{ac8cba60-d786-48fd-a9f0-8b045a7bd505}</Project>
|
||||
<Name>OCMS</Name>
|
||||
</ProjectReference>
|
||||
<ProjectReference Include="..\MFR_RESTClient\MFR_RESTClient.vbproj">
|
||||
<Project>{00c70b53-516d-4d56-ad25-6757094b4335}</Project>
|
||||
<Name>MFR_RESTClient</Name>
|
||||
</ProjectReference>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Content Include="7z.dll">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<BootstrapperPackage Include=".NETFramework,Version=v4.8">
|
||||
<Visible>False</Visible>
|
||||
<ProductName>Microsoft .NET Framework 4.8 %28x86 and x64%29</ProductName>
|
||||
<Install>true</Install>
|
||||
</BootstrapperPackage>
|
||||
<BootstrapperPackage Include="Microsoft.Net.Framework.3.5.SP1">
|
||||
<Visible>False</Visible>
|
||||
<ProductName>.NET Framework 3.5 SP1</ProductName>
|
||||
<Install>false</Install>
|
||||
</BootstrapperPackage>
|
||||
</ItemGroup>
|
||||
<ItemGroup />
|
||||
<Import Project="$(MSBuildToolsPath)\Microsoft.VisualBasic.targets" />
|
||||
</Project>
|
||||
@@ -1,13 +0,0 @@
|
||||
'------------------------------------------------------------------------------
|
||||
' <auto-generated>
|
||||
' 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.
|
||||
' </auto-generated>
|
||||
'------------------------------------------------------------------------------
|
||||
|
||||
Option Strict On
|
||||
Option Explicit On
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<MyApplicationData xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
|
||||
<MySubMain>false</MySubMain>
|
||||
<SingleInstance>false</SingleInstance>
|
||||
<ShutdownMode>0</ShutdownMode>
|
||||
<EnableVisualStyles>true</EnableVisualStyles>
|
||||
<AuthenticationMode>0</AuthenticationMode>
|
||||
<ApplicationType>2</ApplicationType>
|
||||
<SaveMySettingsOnExit>true</SaveMySettingsOnExit>
|
||||
</MyApplicationData>
|
||||
@@ -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
|
||||
|
||||
<Assembly: AssemblyTitle("Fuchs_DataService")>
|
||||
<Assembly: AssemblyDescription("")>
|
||||
<Assembly: AssemblyCompany("")>
|
||||
<Assembly: AssemblyProduct("Fuchs_DataService")>
|
||||
<Assembly: AssemblyCopyright("Copyright © 2021")>
|
||||
<Assembly: AssemblyTrademark("")>
|
||||
|
||||
<Assembly: ComVisible(False)>
|
||||
|
||||
'The following GUID is for the ID of the typelib if this project is exposed to COM
|
||||
<Assembly: Guid("b4650e09-34ae-4c0f-b973-63439b8a22f0")>
|
||||
|
||||
' 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:
|
||||
' <Assembly: AssemblyVersion("1.0.*")>
|
||||
|
||||
<Assembly: AssemblyVersion("1.0.0.0")>
|
||||
<Assembly: AssemblyFileVersion("1.0.0.0")>
|
||||
-63
@@ -1,63 +0,0 @@
|
||||
'------------------------------------------------------------------------------
|
||||
' <auto-generated>
|
||||
' 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.
|
||||
' </auto-generated>
|
||||
'------------------------------------------------------------------------------
|
||||
|
||||
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.
|
||||
'''<summary>
|
||||
''' A strongly-typed resource class, for looking up localized strings, etc.
|
||||
'''</summary>
|
||||
<Global.System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "16.0.0.0"), _
|
||||
Global.System.Diagnostics.DebuggerNonUserCodeAttribute(), _
|
||||
Global.System.Runtime.CompilerServices.CompilerGeneratedAttribute(), _
|
||||
Global.Microsoft.VisualBasic.HideModuleNameAttribute()> _
|
||||
Friend Module Resources
|
||||
|
||||
Private resourceMan As Global.System.Resources.ResourceManager
|
||||
|
||||
Private resourceCulture As Global.System.Globalization.CultureInfo
|
||||
|
||||
'''<summary>
|
||||
''' Returns the cached ResourceManager instance used by this class.
|
||||
'''</summary>
|
||||
<Global.System.ComponentModel.EditorBrowsableAttribute(Global.System.ComponentModel.EditorBrowsableState.Advanced)> _
|
||||
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
|
||||
|
||||
'''<summary>
|
||||
''' Overrides the current thread's CurrentUICulture property for all
|
||||
''' resource lookups using this strongly typed resource class.
|
||||
'''</summary>
|
||||
<Global.System.ComponentModel.EditorBrowsableAttribute(Global.System.ComponentModel.EditorBrowsableState.Advanced)> _
|
||||
Friend Property Culture() As Global.System.Globalization.CultureInfo
|
||||
Get
|
||||
Return resourceCulture
|
||||
End Get
|
||||
Set
|
||||
resourceCulture = value
|
||||
End Set
|
||||
End Property
|
||||
End Module
|
||||
End Namespace
|
||||
@@ -1,117 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<root>
|
||||
<!--
|
||||
Microsoft ResX Schema
|
||||
|
||||
Version 2.0
|
||||
|
||||
The primary goals of this format is to allow a simple XML format
|
||||
that is mostly human readable. The generation and parsing of the
|
||||
various data types are done through the TypeConverter classes
|
||||
associated with the data types.
|
||||
|
||||
Example:
|
||||
|
||||
... ado.net/XML headers & schema ...
|
||||
<resheader name="resmimetype">text/microsoft-resx</resheader>
|
||||
<resheader name="version">2.0</resheader>
|
||||
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
|
||||
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
|
||||
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
|
||||
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
|
||||
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
|
||||
<value>[base64 mime encoded serialized .NET Framework object]</value>
|
||||
</data>
|
||||
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
|
||||
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
|
||||
<comment>This is a comment</comment>
|
||||
</data>
|
||||
|
||||
There are any number of "resheader" rows that contain simple
|
||||
name/value pairs.
|
||||
|
||||
Each data row contains a name, and value. The row also contains a
|
||||
type or mimetype. Type corresponds to a .NET class that support
|
||||
text/value conversion through the TypeConverter architecture.
|
||||
Classes that don't support this are serialized and stored with the
|
||||
mimetype set.
|
||||
|
||||
The mimetype is used for serialized objects, and tells the
|
||||
ResXResourceReader how to depersist the object. This is currently not
|
||||
extensible. For a given mimetype the value must be set accordingly:
|
||||
|
||||
Note - application/x-microsoft.net.object.binary.base64 is the format
|
||||
that the ResXResourceWriter will generate, however the reader can
|
||||
read any of the formats listed below.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.binary.base64
|
||||
value : The object must be serialized with
|
||||
: System.Serialization.Formatters.Binary.BinaryFormatter
|
||||
: and then encoded with base64 encoding.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.soap.base64
|
||||
value : The object must be serialized with
|
||||
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
|
||||
: and then encoded with base64 encoding.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.bytearray.base64
|
||||
value : The object must be serialized into a byte array
|
||||
: using a System.ComponentModel.TypeConverter
|
||||
: and then encoded with base64 encoding.
|
||||
-->
|
||||
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||
<xsd:element name="root" msdata:IsDataSet="true">
|
||||
<xsd:complexType>
|
||||
<xsd:choice maxOccurs="unbounded">
|
||||
<xsd:element name="metadata">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" />
|
||||
<xsd:attribute name="type" type="xsd:string" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="assembly">
|
||||
<xsd:complexType>
|
||||
<xsd:attribute name="alias" type="xsd:string" />
|
||||
<xsd:attribute name="name" type="xsd:string" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="data">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" msdata:Ordinal="1" />
|
||||
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="resheader">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" use="required" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:choice>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:schema>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>2.0</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
</root>
|
||||
-118
@@ -1,118 +0,0 @@
|
||||
'------------------------------------------------------------------------------
|
||||
' <auto-generated>
|
||||
' 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.
|
||||
' </auto-generated>
|
||||
'------------------------------------------------------------------------------
|
||||
|
||||
Option Strict On
|
||||
Option Explicit On
|
||||
|
||||
|
||||
Namespace My
|
||||
|
||||
<Global.System.Runtime.CompilerServices.CompilerGeneratedAttribute(), _
|
||||
Global.System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "16.8.1.0"), _
|
||||
Global.System.ComponentModel.EditorBrowsableAttribute(Global.System.ComponentModel.EditorBrowsableState.Advanced)> _
|
||||
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
|
||||
|
||||
<Global.System.Diagnostics.DebuggerNonUserCodeAttribute(), Global.System.ComponentModel.EditorBrowsableAttribute(Global.System.ComponentModel.EditorBrowsableState.Advanced)> _
|
||||
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
|
||||
|
||||
<Global.System.Configuration.ApplicationScopedSettingAttribute(), _
|
||||
Global.System.Diagnostics.DebuggerNonUserCodeAttribute(), _
|
||||
Global.System.Configuration.DefaultSettingValueAttribute("15")> _
|
||||
Public ReadOnly Property ExecutionFrequency_Minutes() As String
|
||||
Get
|
||||
Return CType(Me("ExecutionFrequency_Minutes"),String)
|
||||
End Get
|
||||
End Property
|
||||
|
||||
<Global.System.Configuration.ApplicationScopedSettingAttribute(), _
|
||||
Global.System.Diagnostics.DebuggerNonUserCodeAttribute(), _
|
||||
Global.System.Configuration.DefaultSettingValueAttribute("True")> _
|
||||
Public ReadOnly Property DebugDetails() As Boolean
|
||||
Get
|
||||
Return CType(Me("DebugDetails"),Boolean)
|
||||
End Get
|
||||
End Property
|
||||
|
||||
<Global.System.Configuration.ApplicationScopedSettingAttribute(), _
|
||||
Global.System.Diagnostics.DebuggerNonUserCodeAttribute(), _
|
||||
Global.System.Configuration.DefaultSettingValueAttribute("system@sebastian-fuchs---bad-und-heizung-gmbh-und-co-kg.com")> _
|
||||
Public ReadOnly Property MFR_UserName() As String
|
||||
Get
|
||||
Return CType(Me("MFR_UserName"),String)
|
||||
End Get
|
||||
End Property
|
||||
|
||||
<Global.System.Configuration.ApplicationScopedSettingAttribute(), _
|
||||
Global.System.Diagnostics.DebuggerNonUserCodeAttribute(), _
|
||||
Global.System.Configuration.DefaultSettingValueAttribute("0oT4G3H2")> _
|
||||
Public ReadOnly Property MFR_Password() As String
|
||||
Get
|
||||
Return CType(Me("MFR_Password"),String)
|
||||
End Get
|
||||
End Property
|
||||
|
||||
<Global.System.Configuration.ApplicationScopedSettingAttribute(), _
|
||||
Global.System.Diagnostics.DebuggerNonUserCodeAttribute(), _
|
||||
Global.System.Configuration.DefaultSettingValueAttribute("portal.mobilefieldreport.com")> _
|
||||
Public ReadOnly Property MFR_host() As String
|
||||
Get
|
||||
Return CType(Me("MFR_host"),String)
|
||||
End Get
|
||||
End Property
|
||||
End Class
|
||||
End Namespace
|
||||
|
||||
Namespace My
|
||||
|
||||
<Global.Microsoft.VisualBasic.HideModuleNameAttribute(), _
|
||||
Global.System.Diagnostics.DebuggerNonUserCodeAttribute(), _
|
||||
Global.System.Runtime.CompilerServices.CompilerGeneratedAttribute()> _
|
||||
Friend Module MySettingsProperty
|
||||
|
||||
<Global.System.ComponentModel.Design.HelpKeywordAttribute("My.Settings")> _
|
||||
Friend ReadOnly Property Settings() As Global.fds.My.MySettings
|
||||
Get
|
||||
Return Global.fds.My.MySettings.Default
|
||||
End Get
|
||||
End Property
|
||||
End Module
|
||||
End Namespace
|
||||
@@ -1,21 +0,0 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<SettingsFile xmlns="http://schemas.microsoft.com/VisualStudio/2004/01/settings" CurrentProfile="(Default)" GeneratedClassNamespace="My" GeneratedClassName="MySettings" UseMySettingsClassName="true">
|
||||
<Profiles />
|
||||
<Settings>
|
||||
<Setting Name="ExecutionFrequency_Minutes" Type="System.String" Scope="Application">
|
||||
<Value Profile="(Default)">15</Value>
|
||||
</Setting>
|
||||
<Setting Name="DebugDetails" Type="System.Boolean" Scope="Application">
|
||||
<Value Profile="(Default)">True</Value>
|
||||
</Setting>
|
||||
<Setting Name="MFR_UserName" Type="System.String" Scope="Application">
|
||||
<Value Profile="(Default)">system@sebastian-fuchs---bad-und-heizung-gmbh-und-co-kg.com</Value>
|
||||
</Setting>
|
||||
<Setting Name="MFR_Password" Type="System.String" Scope="Application">
|
||||
<Value Profile="(Default)">0oT4G3H2</Value>
|
||||
</Setting>
|
||||
<Setting Name="MFR_host" Type="System.String" Scope="Application">
|
||||
<Value Profile="(Default)">portal.mobilefieldreport.com</Value>
|
||||
</Setting>
|
||||
</Settings>
|
||||
</SettingsFile>
|
||||
@@ -1,134 +0,0 @@
|
||||
Option Explicit On
|
||||
|
||||
|
||||
|
||||
|
||||
Partial Friend Module fds_debug
|
||||
|
||||
<Diagnostics.DebuggerStepThrough>
|
||||
Public Function LogFile(FileName As String) As IO.FileInfo
|
||||
Return New IO.FileInfo(AppBaseDirectory().FullName & FileName)
|
||||
End Function
|
||||
|
||||
|
||||
<Diagnostics.DebuggerStepThrough>
|
||||
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
|
||||
|
||||
<Diagnostics.DebuggerStepThrough>
|
||||
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
|
||||
|
||||
<Diagnostics.DebuggerStepThrough>
|
||||
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
|
||||
|
||||
|
||||
<Diagnostics.DebuggerStepThrough>
|
||||
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
|
||||
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
|
||||
'''' <summary>
|
||||
'''' Returns a delimited <see cref="String" /> containing the field values from a <see cref="DataRow" />.
|
||||
'''' </summary>
|
||||
'''' <param name="source">
|
||||
'''' The input <see cref="DataRow" />.
|
||||
'''' </param>
|
||||
'''' <param name="delimiter">
|
||||
'''' The delimiter placed between field values. the default value is a comma.
|
||||
'''' </param>
|
||||
'''' <returns>
|
||||
'''' A <see cref="String"/> containing the field values from the row separated by the specified delimiter.
|
||||
'''' </returns>
|
||||
'<Runtime.CompilerServices.Extension>
|
||||
'Public Function ToCsv(source As DataRow,
|
||||
' Optional delimiter As String = ",") As String
|
||||
' Return String.Join(delimiter, source.ItemArray)
|
||||
'End Function
|
||||
|
||||
''' <summary>
|
||||
''' Returns a delimited <see cref="String" /> containing the field values from a <see cref="DataRow" />.
|
||||
''' </summary>
|
||||
''' <param name="source">
|
||||
''' The input <see cref="DataRow" />.
|
||||
''' </param>
|
||||
''' <param name="quoteStrings">
|
||||
''' <b>True</b> to wrap <see cref="String"/> values in double-quotes; otherwise, <b>False</b>.
|
||||
''' If double-quotes are added, double-quotes within text are escaped with another double-quote.
|
||||
''' </param>
|
||||
''' <param name="delimiter">
|
||||
''' The delimiter placed between field values. the default value is a comma.
|
||||
''' </param>
|
||||
''' <returns>
|
||||
''' A <see cref="String"/> containing the field values from the row separated by the specified delimiter.
|
||||
''' </returns>
|
||||
<Runtime.CompilerServices.Extension>
|
||||
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
|
||||
|
||||
|
||||
|
||||
''' <summary>
|
||||
''' Returns a delimited <see cref="String" /> containing the field values from the rows a <see cref="DataTable" />.
|
||||
''' </summary>
|
||||
''' <param name="source">The input <see cref="DataTable" />.</param>
|
||||
''' <param name="includeHeaders"><b>True</b> to include a row of column headers; otherwise, <b>False</b></param>
|
||||
''' <param name="quoteStrings"><b>True</b> to wrap <see cref="String"/> values in double-quotes; otherwise, <b>False</b>.
|
||||
''' If double-quotes are added, double-quotes within text are escaped with another double-quote.</param>
|
||||
''' <param name="rowDelimiter">The delimiter placed between rows. the default value is a line break comprising a carriage return and a line feed.</param>
|
||||
''' <param name="fieldDelimiter">The delimiter placed between field values. the default value is a comma.</param>
|
||||
''' <param name="cultureinfo">The culture that is used to convert float-point numbers like <see cref="Decimal" /> or <see cref="Double"/> to string. <br/>This falls back to InvariantCulture, if not provided.</param>
|
||||
''' <param name="quoteHeader"><b>True</b> to wrap <see cref="String"/> column header names in double-quotes; otherwise, <b>False</b>.<br />
|
||||
''' If no value is provided, the settings falls back to <b>quoteStrings</b> parameter.</param>
|
||||
''' <returns>A <see cref="String"/> containing the field values from the rows of the table separated by the specified delimiters.</returns>
|
||||
<Runtime.CompilerServices.Extension>
|
||||
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
|
||||
|
||||
''' <summary>
|
||||
''' Returns a text-file containing the string, created by streamwriter.
|
||||
''' </summary>
|
||||
''' <param name="input">The input <see cref="String"/>.</param>
|
||||
''' <param name="encoding">The encoding used with streamwriter for the textfile. This falls back to <see cref="System.Text.Encoding.utf8"/>, if not provided.</param>
|
||||
''' <returns>A file as byte-array.</returns>
|
||||
<Runtime.CompilerServices.Extension>
|
||||
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
|
||||
|
||||
<System.Diagnostics.DebuggerStepThrough()>
|
||||
<Runtime.CompilerServices.Extension()>
|
||||
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
|
||||
|
||||
|
||||
<System.Diagnostics.DebuggerStepThrough()>
|
||||
<Runtime.CompilerServices.Extension()>
|
||||
Public Function MimeType(ByVal FI As System.IO.FileInfo) As String
|
||||
Return System.Web.MimeMapping.GetMimeMapping(FI.Name)
|
||||
End Function
|
||||
End Module
|
||||
@@ -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
|
||||
@@ -1 +0,0 @@
|
||||
Fuchs_Dataservice.exe install --autostart
|
||||
@@ -1,9 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<packages>
|
||||
<package id="Microsoft.AspNet.Razor" version="3.2.9" targetFramework="net48" />
|
||||
<package id="Microsoft.Web.Infrastructure" version="2.0.0" targetFramework="net48" />
|
||||
<package id="Newtonsoft.Json" version="13.0.3" targetFramework="net48" />
|
||||
<package id="Squid-Box.SevenZipSharp" version="1.6.1.23" targetFramework="net48" />
|
||||
<package id="System.Runtime.InteropServices.RuntimeInformation" version="4.3.0" targetFramework="net48" />
|
||||
<package id="Topshelf" version="4.3.0" targetFramework="net48" />
|
||||
</packages>
|
||||
@@ -1 +0,0 @@
|
||||
Fuchs_Dataservice.exe uninstall
|
||||
@@ -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<string, object?> { ["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("")]
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using eRechnungLib.Model.CodeLists;
|
||||
using eRechnungLib.Profiles;
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Services;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
using static OCORE.OCORE_dictionaries;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the FdsInvoiceData → EN 16931 model mapping and the ZUGFeRD (EN 16931) hybrid
|
||||
/// production: structured buyer, lines/totals, effortless B2C, and §13b reverse charge. See
|
||||
/// ADR 0012.
|
||||
/// </summary>
|
||||
public class ERechnungMapperTests
|
||||
{
|
||||
private static FdsInvoiceData BuildInvoice(string sendToAddressJson, string vat = "19",
|
||||
string invoiceOptions = "", bool withItem = true, string provisionPeriod = "")
|
||||
{
|
||||
var items = withItem
|
||||
? "[{'id':'900','type':'material','title':'Reparatur','desc':'Vor Ort','qty':2,'price_net':50,'total_net':100,'vat':'" + vat + "'}]"
|
||||
: "[]";
|
||||
var jobj = JObject.Parse("{'req':[{'Id':'1','text':'Auftrag','items':" + items + "}]}");
|
||||
var inv = new FdsInvoiceData(jobj)
|
||||
{
|
||||
InvoiceRegistration = new GenericObjectDictionary(new Dictionary<string, object>
|
||||
{
|
||||
["Id"] = "42",
|
||||
["InvoiceId"] = "R2026-0007",
|
||||
["InvoiceTitle"] = "Rechnung",
|
||||
["DateCreated"] = "2026-07-17 10:00:00",
|
||||
["InvoiceOptions"] = invoiceOptions,
|
||||
["InvoiceBalance_net"] = "100",
|
||||
["InvoiceVAT_1"] = vat,
|
||||
["ProvisionPeriod"] = provisionPeriod,
|
||||
["SendToAddressJson"] = sendToAddressJson,
|
||||
})
|
||||
};
|
||||
return inv;
|
||||
}
|
||||
|
||||
private const string B2BAddress =
|
||||
"{'name':'Muster GmbH','street':'Hauptstr. 1','postalCode':'40223','city':'Düsseldorf','countryCode':'DE','vatId':'DE123456789'}";
|
||||
|
||||
[Fact]
|
||||
public void BuildEInvoice_MapsSellerBuyerAndLines_WithConsistentTotals()
|
||||
{
|
||||
var model = ERechnungMapper.BuildEInvoice(BuildInvoice(B2BAddress)).Model;
|
||||
|
||||
Assert.Equal("Sebastian Fuchs GmbH & Co. KG", model.Seller.Name);
|
||||
Assert.Equal("DE", model.Seller.Address.Country.Value);
|
||||
Assert.Equal("Muster GmbH", model.Buyer.Name);
|
||||
Assert.Equal("40223", model.Buyer.Address.PostalCode);
|
||||
Assert.Equal("DE123456789", model.Buyer.VatId);
|
||||
Assert.Equal("R2026-0007", model.InvoiceNumber);
|
||||
|
||||
var line = Assert.Single(model.Lines);
|
||||
Assert.Equal(100m, line.NetAmount);
|
||||
Assert.Equal(VatCategoryCode.StandardRate, line.VatCategory);
|
||||
Assert.NotNull(model.Totals);
|
||||
Assert.Equal(100m, model.Totals!.TaxExclusiveAmount);
|
||||
Assert.Equal(19m, model.Totals.TaxTotalAmount);
|
||||
Assert.Equal(119m, model.Totals.TaxInclusiveAmount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToZugferd_EN16931_ProducesHybridPdfWithEmbeddedCii()
|
||||
{
|
||||
var einvoice = ERechnungMapper.BuildEInvoice(BuildInvoice(B2BAddress));
|
||||
var result = einvoice.ToZugferd(ZugferdProfile.EN16931);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.StartsWith("%PDF", Encoding.ASCII.GetString(result.Value!, 0, 4));
|
||||
// The CII XML is embedded and carries the invoice number.
|
||||
string content = Encoding.Latin1.GetString(result.Value!);
|
||||
Assert.Contains("CrossIndustryInvoice", content);
|
||||
// No PDFA-ICC warning: the bundled sRGB profile is present, so the output intent is set.
|
||||
Assert.DoesNotContain(result.Validation.Warnings, m => m.RuleId == "PDFA-ICC");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PrivatePerson_NoVatId_MapsWithoutBuyerTaxRegistration_AndProducesHybrid()
|
||||
{
|
||||
var b2c = "{'name':'Max Mustermann','street':'Weg 2','postalCode':'50667','city':'Köln','countryCode':'DE'}";
|
||||
var einvoice = ERechnungMapper.BuildEInvoice(BuildInvoice(b2c));
|
||||
|
||||
Assert.Null(einvoice.Model.Buyer.VatId);
|
||||
var result = einvoice.ToZugferd(ZugferdProfile.EN16931);
|
||||
Assert.True(result.Success);
|
||||
Assert.StartsWith("%PDF", Encoding.ASCII.GetString(result.Value!, 0, 4));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReverseCharge_13b_SetsCategoryAeAndExemptionReason()
|
||||
{
|
||||
var model = ERechnungMapper.BuildEInvoice(BuildInvoice(B2BAddress, vat: "0", invoiceOptions: "§13b")).Model;
|
||||
|
||||
var line = Assert.Single(model.Lines);
|
||||
Assert.Equal(VatCategoryCode.ReverseCharge, line.VatCategory);
|
||||
Assert.Equal(0m, line.VatRate);
|
||||
Assert.True(model.VatExemptionReasons.ContainsKey(VatCategoryCode.ReverseCharge));
|
||||
Assert.Equal(0m, model.Totals!.TaxTotalAmount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LumpSumInvoice_NoItems_SynthesisesSingleLineFromTotal()
|
||||
{
|
||||
var model = ERechnungMapper.BuildEInvoice(BuildInvoice(B2BAddress, withItem: false)).Model;
|
||||
var line = Assert.Single(model.Lines);
|
||||
Assert.Equal(100m, line.NetAmount);
|
||||
Assert.Equal(119m, model.Totals!.TaxInclusiveAmount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("DE", "DE")]
|
||||
[InlineData("Deutschland", "DE")]
|
||||
[InlineData("Österreich", "AT")]
|
||||
[InlineData("", "DE")]
|
||||
public void NormalizeCountry_MapsNamesAndCodes(string raw, string expected)
|
||||
=> Assert.Equal(expected, ERechnungMapper.NormalizeCountry(raw).Value);
|
||||
|
||||
[Fact]
|
||||
public void ServicePeriod_SingleDate_MapsToDeliveryDate()
|
||||
{
|
||||
var model = ERechnungMapper.BuildEInvoice(BuildInvoice(B2BAddress, provisionPeriod: "18.06.2026")).Model;
|
||||
Assert.NotNull(model.Delivery);
|
||||
Assert.Equal(new System.DateOnly(2026, 6, 18), model.Delivery!.DeliveryDate);
|
||||
Assert.Null(model.InvoicingPeriod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ServicePeriod_DateRange_MapsToInvoicingPeriod()
|
||||
{
|
||||
var model = ERechnungMapper.BuildEInvoice(BuildInvoice(B2BAddress, provisionPeriod: "01.06.2026 - 30.06.2026")).Model;
|
||||
Assert.NotNull(model.InvoicingPeriod);
|
||||
Assert.Equal(new System.DateOnly(2026, 6, 1), model.InvoicingPeriod!.StartDate);
|
||||
Assert.Equal(new System.DateOnly(2026, 6, 30), model.InvoicingPeriod.EndDate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Seller_ComesFromSettings_WhenProvided()
|
||||
{
|
||||
var seller = new ERechnungSellerSettings { Name = "Test Handwerk GmbH", VatId = "DE999999999", Iban = "DE00" };
|
||||
var model = ERechnungMapper.BuildEInvoice(BuildInvoice(B2BAddress), seller).Model;
|
||||
Assert.Equal("Test Handwerk GmbH", model.Seller.Name);
|
||||
Assert.Equal("DE999999999", model.Seller.VatId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void B2G_WithLeitwegId_SetsBuyerReference_AndSellerContact()
|
||||
{
|
||||
var b2g = "{'name':'Stadt Düsseldorf','street':'Marktplatz 2','postalCode':'40213','city':'Düsseldorf','countryCode':'DE','leitwegId':'05111-12345-67'}";
|
||||
var model = ERechnungMapper.BuildEInvoice(BuildInvoice(b2g)).Model;
|
||||
|
||||
Assert.Equal("05111-12345-67", model.BuyerReference); // BT-10 (Leitweg-ID)
|
||||
Assert.NotNull(model.Seller.ElectronicAddress); // BT-34 (XRechnung)
|
||||
Assert.NotNull(model.Seller.Contact); // BG-6 (BR-DE-5/6/7)
|
||||
Assert.Equal("info@sanitaerfuchs.de", model.Seller.Contact!.Email);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void B2B_NoLeitwegId_LeavesBuyerReferenceUnset()
|
||||
{
|
||||
var model = ERechnungMapper.BuildEInvoice(BuildInvoice(B2BAddress)).Model;
|
||||
Assert.True(string.IsNullOrEmpty(model.BuyerReference));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Seller_HasVatIdAndPaymentTerms_ForBrCo25AndBrCo26()
|
||||
{
|
||||
var model = ERechnungMapper.BuildEInvoice(BuildInvoice(B2BAddress)).Model;
|
||||
// BR-CO-26: seller VAT id (BT-31) present, in addition to the Steuernummer (BT-32).
|
||||
Assert.Equal("DE286366012", model.Seller.VatId);
|
||||
// BR-CO-25: payment terms (BT-20) / due date (BT-9) present for a positive amount due.
|
||||
Assert.NotNull(model.PaymentTerms);
|
||||
Assert.False(string.IsNullOrWhiteSpace(model.PaymentTerms!.Description));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void B2G_ToZugferdXRechnung_ProducesHybridWithXRechnungCustomization()
|
||||
{
|
||||
var b2g = "{'name':'Stadt Düsseldorf','street':'Marktplatz 2','postalCode':'40213','city':'Düsseldorf','countryCode':'DE','leitwegId':'05111-12345-67'}";
|
||||
var result = ERechnungMapper.BuildEInvoice(BuildInvoice(b2g)).ToZugferd(ZugferdProfile.XRechnung);
|
||||
|
||||
Assert.True(result.Success);
|
||||
string content = System.Text.Encoding.Latin1.GetString(result.Value!);
|
||||
Assert.Contains("xrechnung_3.0", content); // XRechnung 3.0 CIUS customization id
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Fuchs.Services;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Xunit;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Exercises the external eRechnung validator client against a stubbed HTTP endpoint: response
|
||||
/// parsing, the not-configured short-circuit, and unreachable/error handling (never throws).
|
||||
/// </summary>
|
||||
public class ERechnungValidatorTests
|
||||
{
|
||||
private sealed class StubHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly HttpStatusCode _status;
|
||||
private readonly string _body;
|
||||
public HttpRequestMessage? Last;
|
||||
public StubHandler(HttpStatusCode status, string body) { _status = status; _body = body; }
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
|
||||
{
|
||||
Last = request;
|
||||
return Task.FromResult(new HttpResponseMessage(_status) { Content = new StringContent(_body) });
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubFactory : IHttpClientFactory
|
||||
{
|
||||
private readonly HttpMessageHandler _handler;
|
||||
public StubFactory(HttpMessageHandler handler) => _handler = handler;
|
||||
public HttpClient CreateClient(string name) => new(_handler, disposeHandler: false);
|
||||
}
|
||||
|
||||
private static ProcessWebERechnungValidator Make(HttpMessageHandler handler, bool enabled = true,
|
||||
string url = "https://validator.test/api/eInvoice")
|
||||
{
|
||||
var settings = Options.Create(new ERechnungSettings
|
||||
{
|
||||
Validation = new ERechnungValidationSettings { Enabled = enabled, ServiceUrl = url }
|
||||
});
|
||||
return new ProcessWebERechnungValidator(new StubFactory(handler), settings,
|
||||
NullLogger<ProcessWebERechnungValidator>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidatePdf_BothPass_ReturnsIsValid()
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.OK,
|
||||
"{\"isValid\":true,\"summary\":\"ok\",\"xml\":{\"isValid\":true},\"pdfa\":{\"isCompliant\":true}}");
|
||||
var result = await Make(handler).ValidatePdfAsync(new byte[] { 1, 2, 3 });
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.True(result.XmlValid);
|
||||
Assert.True(result.PdfACompliant);
|
||||
Assert.EndsWith("/validatepdf", handler.Last!.RequestUri!.ToString());
|
||||
Assert.Equal("application/pdf", handler.Last.Content!.Headers.ContentType!.MediaType);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("{\"xml\":{\"isValid\":false,\"errorCount\":3},\"pdfa\":{\"isCompliant\":true}}", false, true)]
|
||||
[InlineData("{\"xml\":{\"isValid\":true},\"pdfa\":{\"isCompliant\":false}}", true, false)]
|
||||
public async Task ValidatePdf_PartialFailure_IsNotValid(string body, bool xml, bool pdfa)
|
||||
{
|
||||
var result = await Make(new StubHandler(HttpStatusCode.OK, body)).ValidatePdfAsync(new byte[] { 1 });
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(xml, result.XmlValid);
|
||||
Assert.Equal(pdfa, result.PdfACompliant);
|
||||
Assert.True(result.HasHardError); // real PDF/A or XML errors are hard failures
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidatePdf_PdfAOk_ButXmlScenarioNotMatched_IsNotHardError()
|
||||
{
|
||||
// EN 16931 ZUGFeRD checked by an XRechnung-only scenario set: PDF/A compliant, XML rejected
|
||||
// with zero errors → not strictly valid, but not a hard error (must not withhold the invoice).
|
||||
var body = "{\"xml\":{\"isValid\":false,\"scenarioMatched\":false,\"errorCount\":0},\"pdfa\":{\"isCompliant\":true}}";
|
||||
var result = await Make(new StubHandler(HttpStatusCode.OK, body)).ValidatePdfAsync(new byte[] { 1 });
|
||||
|
||||
Assert.True(result.PdfACompliant);
|
||||
Assert.False(result.ScenarioMatched);
|
||||
Assert.False(result.IsValid);
|
||||
Assert.False(result.HasHardError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidatePdf_Disabled_ReturnsNotConfigured()
|
||||
{
|
||||
var result = await Make(new StubHandler(HttpStatusCode.OK, "{}"), enabled: false).ValidatePdfAsync(new byte[] { 1 });
|
||||
Assert.False(result.Configured);
|
||||
Assert.False(result.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidatePdf_ServerError_IsReachedFalse_AndDoesNotThrow()
|
||||
{
|
||||
var result = await Make(new StubHandler(HttpStatusCode.BadGateway, "validator down")).ValidatePdfAsync(new byte[] { 1 });
|
||||
Assert.False(result.Reached);
|
||||
Assert.False(result.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidatePdf_Enabled_RequiresNonEmptyUrl()
|
||||
{
|
||||
var v = Make(new StubHandler(HttpStatusCode.OK, "{}"), url: "");
|
||||
Assert.False(v.Enabled);
|
||||
var result = await v.ValidatePdfAsync(new byte[] { 1 });
|
||||
Assert.False(result.Configured);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>DATEV header line formatting — pure string assembly, no I/O.</summary>
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>DATEV CSV + document XML generation on FdsMfr (no DB / no MFR).</summary>
|
||||
public class FdsMfrDatevTests
|
||||
{
|
||||
private static FdsMfr NewMfr() =>
|
||||
new(NullLogger<FdsMfr>.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 <archive> 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<DatevDocument>
|
||||
{
|
||||
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<DatevDocument>
|
||||
{
|
||||
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<DatevDocument>
|
||||
{
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>FdsMfr.UpdateNeed parsing + enum contract.</summary>
|
||||
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<Exception>(() => 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>FdsShared utility helpers — pure formatting + local file I/O.</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="FdsConfig.Initialize"/> call.
|
||||
/// </summary>
|
||||
[CollectionDefinition("FdsConfig")]
|
||||
public class FdsConfigCollection { }
|
||||
|
||||
[Collection("FdsConfig")]
|
||||
public class FdsConfigTests
|
||||
{
|
||||
private static IConfiguration BuildConfig(Dictionary<string, string?> 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<InvalidOperationException>(() => 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<string, string?>
|
||||
{
|
||||
["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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Linq;
|
||||
using Fuchs.intranet;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,8 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
|
||||
<PackageReference Include="xunit.v3" Version="3.2.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public class InvoiceDraftCacheTests
|
||||
{
|
||||
private static IConfiguration Config(int idle = 30, int warn = 5) =>
|
||||
new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["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<InvoiceDraftExpiryService>.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<InvoiceDraftExpiryService>.Instance);
|
||||
cache.Set(new InvoiceDraftSession { Token = "fresh" });
|
||||
|
||||
await svc.SweepAsync(CancellationToken.None);
|
||||
|
||||
Assert.Empty(notifier.Expiring);
|
||||
Assert.Empty(notifier.Closed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
using Fuchs.intranet;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the server-side aggregation of invoice draft totals (the port of the
|
||||
/// former client-side <c>invSumUpdate</c> 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 <c>itm</c> array (the editor's <c>co</c> objects: <c>vt</c>=net,
|
||||
/// <c>vv</c>=VAT, <c>vs</c>=service-net, <c>vsv</c>=service-VAT, <c>vat</c>=rate).
|
||||
/// </summary>
|
||||
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<decimal>());
|
||||
Assert.Equal(19m, l["vv"]!.Value<decimal>());
|
||||
Assert.Equal(0m, l["vs"]!.Value<decimal>());
|
||||
Assert.Equal(0m, l["vsv"]!.Value<decimal>());
|
||||
}
|
||||
|
||||
[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<decimal>());
|
||||
Assert.Equal(5.7m, l["vv"]!.Value<decimal>());
|
||||
Assert.Equal(30m, l["vs"]!.Value<decimal>());
|
||||
Assert.Equal(5.7m, l["vsv"]!.Value<decimal>());
|
||||
}
|
||||
|
||||
[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<decimal>()); // untouched — mirrors quantChange's own guard
|
||||
Assert.Equal(111m, l["vv"]!.Value<decimal>());
|
||||
}
|
||||
|
||||
[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<decimal>());
|
||||
Assert.Equal(0m, l["vv"]!.Value<decimal>());
|
||||
}
|
||||
|
||||
[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<decimal>());
|
||||
Assert.Equal(190m, l["vv"]!.Value<decimal>());
|
||||
}
|
||||
|
||||
[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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies <see cref="DraftNotifier"/> 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.
|
||||
/// </summary>
|
||||
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<string> e) => throw new NotImplementedException();
|
||||
public IClientProxy Client(string c) => throw new NotImplementedException();
|
||||
public IClientProxy Clients(IReadOnlyList<string> c) => throw new NotImplementedException();
|
||||
public IClientProxy Groups(IReadOnlyList<string> g) => throw new NotImplementedException();
|
||||
public IClientProxy GroupExcept(string g, IReadOnlyList<string> e) => throw new NotImplementedException();
|
||||
public IClientProxy User(string u) => throw new NotImplementedException();
|
||||
public IClientProxy Users(IReadOnlyList<string> u) => throw new NotImplementedException();
|
||||
}
|
||||
|
||||
private sealed class StubHubContext : IHubContext<DraftPreviewHub>
|
||||
{
|
||||
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<DraftNotifier>.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<string>());
|
||||
Assert.Equal(7, payload["version"]!.Value<int>());
|
||||
}
|
||||
|
||||
[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<int>());
|
||||
}
|
||||
|
||||
[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<string>());
|
||||
}
|
||||
|
||||
[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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,873 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>itm</c>/<c>items</c> shape.
|
||||
/// </summary>
|
||||
public class InvoiceDraftServiceTests
|
||||
{
|
||||
/// <summary>Captures the invoice handed to registration and returns it with a fake DB id — no SQL.</summary>
|
||||
private sealed class FakeInvoiceService : IInvoiceService
|
||||
{
|
||||
public FdsInvoiceData? Registered;
|
||||
public bool? LastChange;
|
||||
public Task<FdsInvoiceData> RegisterInvoiceAsync(FdsInvoiceData invoice, bool change, string invId, string userAccountId, DatabaseSecurity dbSec)
|
||||
{
|
||||
Registered = invoice;
|
||||
LastChange = change;
|
||||
invoice.InvoiceRegistration = new GenericObjectDictionary(new System.Collections.Generic.Dictionary<string, object> { ["Id"] = "INV42" });
|
||||
return Task.FromResult(invoice);
|
||||
}
|
||||
public FdsInvoiceData? PreviewInvoice;
|
||||
public bool? PreviewDraft;
|
||||
public Task<FdsInvoiceData> 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<byte[]> RenderInvoicePdfBytesAsync(FdsInvoiceData i, bool d) => throw new System.NotSupportedException();
|
||||
public Task<byte[]> StoreInvoiceDocumentFileAsync(FdsInvoiceData i, bool d, string u, DatabaseSecurity s) => throw new System.NotSupportedException();
|
||||
public Task<byte[]?> 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<InvoiceDraftEditService>.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<string>());
|
||||
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_StructuredAddress_ComposesFreeTextAndPersistsJson()
|
||||
{
|
||||
var (svc, _, _) = NewService();
|
||||
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||
|
||||
var addr = JObject.Parse(@"{'name':'Muster GmbH','street':'Weg 1','postalCode':'40223','city':'Düsseldorf','countryCode':'DE','vatId':'DE123456789'}");
|
||||
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "address", Value = addr });
|
||||
|
||||
Assert.NotNull(s2);
|
||||
// Free-text block is composed for the PDF / SendToAddress path.
|
||||
Assert.Equal("Muster GmbH\nWeg 1\n40223 Düsseldorf", s2!.New["invoiceaddress"]!.Value<string>());
|
||||
// Structured JSON rides inside the CustomValues blob (interim persistence).
|
||||
var parsed = InvoiceRecipientAddress.FromCustomValues(s2.New["CustomValues"]!.Value<string>());
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal("DE123456789", parsed!.VatId);
|
||||
Assert.True(parsed.IsEn16931Conformant);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyPatch_Address_PlainString_KeepsLegacyFreeTextBehavior()
|
||||
{
|
||||
var (svc, _, _) = NewService();
|
||||
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||
|
||||
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "address", Value = JToken.FromObject("Weg 9\n50667 Köln") });
|
||||
|
||||
Assert.Equal("Weg 9\n50667 Köln", s2!.New["invoiceaddress"]!.Value<string>());
|
||||
Assert.Null(InvoiceRecipientAddress.FromCustomValues(s2.New["CustomValues"]?.Value<string>()));
|
||||
}
|
||||
|
||||
[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<string>());
|
||||
}
|
||||
|
||||
[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<string>());
|
||||
Assert.Equal("Baustelle 7", s2.New["loc"]!.Value<string>());
|
||||
}
|
||||
|
||||
[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<string>()!);
|
||||
Assert.Equal("Max Mustermann", cv["contactName"]!.Value<string>());
|
||||
Assert.Equal("max@kunde.de", cv["contactEmail"]!.Value<string>());
|
||||
}
|
||||
|
||||
[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<string>());
|
||||
}
|
||||
|
||||
[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<JObject>().Single(l => (string)l["id"]! == "1");
|
||||
var m2 = lines.OfType<JObject>().Single(l => (string)l["id"]! == "2");
|
||||
var m3 = lines.OfType<JObject>().Single(l => (string)l["id"]! == "3");
|
||||
|
||||
Assert.Equal(100m, header["vt"]!.Value<decimal>()); // 60 + 40
|
||||
Assert.Equal(19m, header["vv"]!.Value<decimal>()); // 11.4 + 7.6
|
||||
Assert.Equal(10m, header["vs"]!.Value<decimal>());
|
||||
Assert.Equal(1.9m, header["vsv"]!.Value<decimal>());
|
||||
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<JObject>().Single(l => (string)l["id"]! == "1");
|
||||
|
||||
Assert.Equal(100m, header["vt"]!.Value<decimal>()); // 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<JObject>().Single(l => (string)l["id"]! == "1");
|
||||
var m2 = lines.OfType<JObject>().Single(l => (string)l["id"]! == "2");
|
||||
var m3 = lines.OfType<JObject>().Single(l => (string)l["id"]! == "3");
|
||||
var other = lines.OfType<JObject>().Single(l => (string)l["id"]! == "4");
|
||||
|
||||
Assert.Equal(100m, header["vt"]!.Value<decimal>()); // 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<decimal>()); // untouched — never part of the set
|
||||
Assert.Equal(4.75m, other["vv"]!.Value<decimal>());
|
||||
|
||||
// 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<decimal>()); // block sum
|
||||
Assert.Equal(19m, setA["vv"]!.Value<decimal>());
|
||||
Assert.Equal(40m, setA["vs"]!.Value<decimal>()); // service-net split preserved
|
||||
Assert.Equal(7.6m, setA["vsv"]!.Value<decimal>());
|
||||
var m11 = linesA.OfType<JObject>().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<decimal>());
|
||||
Assert.Equal(JTokenType.Null, itemsA.OfType<JObject>().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<decimal>());
|
||||
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<decimal>());
|
||||
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<int>());
|
||||
Assert.Equal(100m, state["sums"]!["total_net"]!.Value<decimal>());
|
||||
Assert.Equal(119m, state["sums"]!["total_gross"]!.Value<decimal>());
|
||||
Assert.Equal(19m, state["sums"]!["vat"]!["19"]!.Value<decimal>());
|
||||
Assert.Single((JArray)state["req"]!);
|
||||
Assert.Equal(1, state["historyCount"]!.Value<int>());
|
||||
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("<p>18.06.2026</p>") });
|
||||
|
||||
Assert.Equal("18.06.2026", s2!.New[newKey]!.Value<string>()); // no <p> 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("<p>Firma AG</p><p>Weg 1<br>5080 Laufenburg</p>")
|
||||
});
|
||||
|
||||
Assert.Equal("Firma AG\nWeg 1\n5080 Laufenburg", s2!.New["invoiceaddress"]!.Value<string>());
|
||||
}
|
||||
|
||||
[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<string>());
|
||||
}
|
||||
|
||||
[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("<p>Baustelle 7</p>") });
|
||||
|
||||
Assert.Equal("Baustelle 7", s2!.New["provisionlocation"]!.Value<string>());
|
||||
Assert.Equal("Baustelle 7", s2.New["loc"]!.Value<string>());
|
||||
}
|
||||
|
||||
// ── 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':'<p>Neue Überschrift</p>',
|
||||
'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<string>());
|
||||
}
|
||||
|
||||
// ── 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<string>()).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<string>()).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<string>()).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<bool>()); // header not priced yet
|
||||
Assert.True(setDisplay["2"]!["p"]!.Value<bool>()); // member 2 keeps its own price
|
||||
Assert.True(setDisplay["3"]!["p"]!.Value<bool>()); // 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<bool>());
|
||||
if (setmode == "setonly")
|
||||
Assert.False(setDisplay.ContainsKey("2")); // member removed from the display entirely
|
||||
else
|
||||
Assert.Equal(memberShown, setDisplay["2"]!["p"]!.Value<bool>());
|
||||
}
|
||||
|
||||
// ── 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<string>());
|
||||
Assert.Equal(new[] { "2", "1" }, final.Req.Select(b => b["Id"]!.Value<string>()).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");
|
||||
}
|
||||
}
|
||||
@@ -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" })));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
using Fuchs.Services;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pure-logic tests for the structured invoice recipient address: free-text composition for the
|
||||
/// PDF, EN 16931 / DATEV conformity detection, effortless B2C (no VAT id), and JSON round-tripping
|
||||
/// through the <c>CustomValues</c> blob. See ADR 0012.
|
||||
/// </summary>
|
||||
public class InvoiceRecipientAddressTests
|
||||
{
|
||||
private static InvoiceRecipientAddress FullB2B() => new()
|
||||
{
|
||||
Name = "Muster GmbH",
|
||||
Contact = "Frau Schmidt",
|
||||
Street = "Hauptstraße 1",
|
||||
PostalCode = "40223",
|
||||
City = "Düsseldorf",
|
||||
CountryCode = "DE",
|
||||
VatId = "DE123456789",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Compose_DomesticFullAddress_OmitsCountryLineAndVatId()
|
||||
{
|
||||
var text = FullB2B().Compose();
|
||||
Assert.Equal("Muster GmbH\nz.Hd. Frau Schmidt\nHauptstraße 1\n40223 Düsseldorf", text);
|
||||
Assert.DoesNotContain("DE123456789", text); // VAT id never in the postal block
|
||||
Assert.DoesNotContain("\nDE", text); // domestic country code suppressed
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compose_ForeignCountry_AppendsCountryCodeLine()
|
||||
{
|
||||
var addr = FullB2B();
|
||||
addr.CountryCode = "AT";
|
||||
addr.City = "Wien";
|
||||
addr.PostalCode = "1010";
|
||||
Assert.EndsWith("1010 Wien\nAT", addr.Compose());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PrivatePerson_NoVatId_IsB2CAndStillEn16931Conformant()
|
||||
{
|
||||
var addr = new InvoiceRecipientAddress
|
||||
{
|
||||
Name = "Max Mustermann",
|
||||
Street = "Weg 2",
|
||||
PostalCode = "50667",
|
||||
City = "Köln",
|
||||
CountryCode = "DE",
|
||||
// no VatId
|
||||
};
|
||||
Assert.True(addr.IsPrivatePerson);
|
||||
Assert.True(addr.IsEn16931Conformant);
|
||||
Assert.Empty(addr.MissingForEn16931());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("", "DE", "Köln", new[] { "Name" })]
|
||||
[InlineData("Firma", "", "Köln", new[] { "Land" })]
|
||||
[InlineData("Firma", "DE", "", new[] { "Ort/PLZ" })]
|
||||
public void MissingForEn16931_FlagsMandatoryGaps(string name, string country, string city, string[] expected)
|
||||
{
|
||||
var addr = new InvoiceRecipientAddress { Name = name, CountryCode = country, City = city };
|
||||
Assert.Equal(expected, addr.MissingForEn16931());
|
||||
Assert.False(addr.IsEn16931Conformant);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromJson_AcceptsAlternateKeys()
|
||||
{
|
||||
var addr = InvoiceRecipientAddress.FromJson(JObject.Parse(
|
||||
@"{'name':'X','plz':'12345','ort':'Ort','country':'de','ustid':'DE9'}"));
|
||||
Assert.Equal("12345", addr.PostalCode);
|
||||
Assert.Equal("Ort", addr.City);
|
||||
Assert.Equal("DE", addr.CountryCode); // uppercased
|
||||
Assert.Equal("DE9", addr.VatId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromCustomValues_RoundTripsThroughTheBlob()
|
||||
{
|
||||
var cv = new JObject
|
||||
{
|
||||
["contactName"] = "someone",
|
||||
[InvoiceRecipientAddress.CustomValuesKey] = FullB2B().ToJson(),
|
||||
};
|
||||
var parsed = InvoiceRecipientAddress.FromCustomValues(cv.ToString());
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal("Muster GmbH", parsed!.Name);
|
||||
Assert.Equal("DE123456789", parsed.VatId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromCustomValues_ReturnsNull_WhenNoStructuredAddressPresent()
|
||||
{
|
||||
Assert.Null(InvoiceRecipientAddress.FromCustomValues(@"{'contactName':'x'}"));
|
||||
Assert.Null(InvoiceRecipientAddress.FromCustomValues(""));
|
||||
Assert.Null(InvoiceRecipientAddress.FromCustomValues("not json"));
|
||||
}
|
||||
}
|
||||
@@ -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.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<Dictionary<string, object?>>
|
||||
{
|
||||
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<Dictionary<string, object?>>
|
||||
{
|
||||
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<Dictionary<string, object?>>
|
||||
{
|
||||
@@ -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) ─
|
||||
|
||||
@@ -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<HttpResponseMessage> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures what would be written to <c>fds__logEmail</c> 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.
|
||||
/// </summary>
|
||||
private sealed class CapturingComService : ProcessWebComService
|
||||
{
|
||||
public readonly List<(bool success, List<string> log)> Entries = new();
|
||||
|
||||
public CapturingComService(
|
||||
IOptions<ProcessWebComSettings> settings,
|
||||
IOptions<FuchsEmailSettings> emailSettings,
|
||||
IHttpClientFactory httpClientFactory)
|
||||
: base(NullLogger<ProcessWebComService>.Instance, intranet: null!, settings, emailSettings, httpClientFactory)
|
||||
{
|
||||
}
|
||||
|
||||
protected override Task WriteAuditLogAsync(string reference, string guid, string config,
|
||||
DateTime sent, bool success, IEnumerable<string> 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", "<p>x</p>", "kunde@example.de", "Kunde");
|
||||
|
||||
var entry = Assert.Single(svc.Entries);
|
||||
Assert.True(entry.success);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_ApiError_LogsFailureIncludingServiceResponse()
|
||||
{
|
||||
var svc = CreateCapturing(new StubHandler(HttpStatusCode.InternalServerError, "mailer rejected: quota exceeded"));
|
||||
|
||||
bool result = await svc.SendEmailAsync("inv_log_api", "S", "<p>x</p>", "kunde@example.de", "Kunde");
|
||||
|
||||
Assert.False(result);
|
||||
var entry = Assert.Single(svc.Entries);
|
||||
Assert.False(entry.success);
|
||||
Assert.Contains(entry.log, l => l.Contains("mailer rejected: quota exceeded")); // raw service response
|
||||
Assert.Contains(entry.log, l => l.Contains("500")); // HTTP status
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_TransportException_LogsFailureIncludingExceptionDetail()
|
||||
{
|
||||
var svc = CreateCapturing(new ThrowingHandler());
|
||||
|
||||
bool result = await svc.SendEmailAsync("inv_log_ex", "S", "<p>x</p>", "kunde@example.de", "Kunde");
|
||||
|
||||
Assert.False(result);
|
||||
var entry = Assert.Single(svc.Entries);
|
||||
Assert.False(entry.success);
|
||||
Assert.Contains(entry.log, l => l.Contains("Exception while sending"));
|
||||
Assert.Contains(entry.log, l => l.Contains("simulated transport failure"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("not-an-email")]
|
||||
[InlineData("")]
|
||||
public async Task SendEmailAsync_InvalidEmail_StillLogsFailedAttempt(string badEmail)
|
||||
{
|
||||
var svc = CreateCapturing(new StubHandler(HttpStatusCode.OK));
|
||||
|
||||
bool result = await svc.SendEmailAsync("inv_log_bad", "S", "<p>x</p>", badEmail, "Kunde");
|
||||
|
||||
Assert.False(result);
|
||||
var entry = Assert.Single(svc.Entries);
|
||||
Assert.False(entry.success);
|
||||
Assert.Contains(entry.log, l => l.Contains("Invalid recipient email address"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_Disabled_LogsFailedAttempt()
|
||||
{
|
||||
var svc = CreateCapturing(new StubHandler(HttpStatusCode.OK), enabled: false);
|
||||
|
||||
await svc.SendEmailAsync("inv_log_dis", "S", "<p>x</p>", "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<DateTime>(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<string>());
|
||||
|
||||
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", "<p>hi</p>", "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", "<p>hi</p>", "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<object[]> AppSettingsEnvironments()
|
||||
@@ -236,7 +374,7 @@ public class ProcessWebComServiceTests
|
||||
Assert.True(result);
|
||||
Assert.Equal(1, handler.CallCount);
|
||||
var json = JObject.Parse(handler.LastRequestBody!);
|
||||
string sentRecipient = json["recipient"]!.ToString();
|
||||
string sentRecipient = json["communication"]!["to"]!.ToString();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(overrideRecipient))
|
||||
{
|
||||
@@ -266,7 +404,7 @@ public class ProcessWebComServiceTests
|
||||
Assert.True(result);
|
||||
Assert.NotNull(handler.LastRequestBody);
|
||||
var json = JObject.Parse(handler.LastRequestBody!);
|
||||
var att = (JArray)json["attachments"]!;
|
||||
var att = (JArray)json["communication"]!["attachments"]!;
|
||||
Assert.Single(att);
|
||||
Assert.Equal("Rechnung.pdf", att[0]!["filename"]!.ToString());
|
||||
Assert.Equal("application/pdf", att[0]!["mimeType"]!.ToString());
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
using Fuchs.intranet;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Exhaustively exercises the pure reminder-draft aggregation/validation (ADR 0006,
|
||||
/// the reminder mirror of <see cref="InvoiceDraftCalculatorTests"/>). Being static/pure,
|
||||
/// the open-amount math and the plausibility checks are unit-testable without a DB.
|
||||
/// </summary>
|
||||
public class ReminderDraftCalculatorTests
|
||||
{
|
||||
private static ReminderDraftSession Session(string newJson) =>
|
||||
new() { New = JObject.Parse(newJson) };
|
||||
|
||||
[Theory]
|
||||
[InlineData("{'amount':119,'amount_payed':0}", 119, 0, 119)]
|
||||
[InlineData("{'amount':119,'amount_payed':20}", 119, 20, 99)]
|
||||
[InlineData("{'amount':'119,50','amount_payed':'19,50'}", 119.50, 19.50, 100)] // German decimals
|
||||
[InlineData("{'amount':'100.00','amount_payed':'40.00'}", 100, 40, 60)] // invariant decimals
|
||||
[InlineData("{}", 0, 0, 0)] // missing → 0
|
||||
[InlineData("{'amount':50,'amount_payed':80}", 50, 80, -30)] // overpaid → negative
|
||||
public void RecomputeTotals_ComputesOpenAmount(string newJson, double total, double payed, double open)
|
||||
{
|
||||
var s = Session(newJson);
|
||||
ReminderDraftCalculator.RecomputeTotals(s);
|
||||
Assert.Equal((decimal)total, s.Sums.AmountTotal);
|
||||
Assert.Equal((decimal)payed, s.Sums.AmountPayed);
|
||||
Assert.Equal((decimal)open, s.Sums.AmountOpen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_EmptyEmail_Warns()
|
||||
{
|
||||
var s = Session("{'amount':119,'invoiceaddress':'Weg 1','subject':'X'}");
|
||||
ReminderDraftCalculator.RecomputeTotals(s);
|
||||
ReminderDraftCalculator.Validate(s);
|
||||
Assert.Contains(s.ValidationMessages, m => m.Field == "email" && m.Severity == "warning");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("bad")]
|
||||
[InlineData("no-at-sign.de")]
|
||||
[InlineData("trailing@dot.")]
|
||||
public void Validate_InvalidEmail_Errors(string email)
|
||||
{
|
||||
var s = Session($"{{'amount':119,'invoiceemail':'{email}','invoiceaddress':'Weg 1','subject':'X'}}");
|
||||
ReminderDraftCalculator.RecomputeTotals(s);
|
||||
ReminderDraftCalculator.Validate(s);
|
||||
Assert.Contains(s.ValidationMessages, m => m.Field == "email" && m.Severity == "error");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ValidEmail_NoEmailMessage()
|
||||
{
|
||||
var s = Session("{'amount':119,'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','subject':'X'}");
|
||||
ReminderDraftCalculator.RecomputeTotals(s);
|
||||
ReminderDraftCalculator.Validate(s);
|
||||
Assert.DoesNotContain(s.ValidationMessages, m => m.Field == "email");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_EmptyAddressAndSubject_Warn()
|
||||
{
|
||||
var s = Session("{'amount':119,'invoiceemail':'a@b.de'}");
|
||||
ReminderDraftCalculator.RecomputeTotals(s);
|
||||
ReminderDraftCalculator.Validate(s);
|
||||
Assert.Contains(s.ValidationMessages, m => m.Field == "address" && m.Severity == "warning");
|
||||
Assert.Contains(s.ValidationMessages, m => m.Field == "subject" && m.Severity == "warning");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("{'amount':0,'amount_payed':0,'invoiceemail':'a@b.de','invoiceaddress':'W','subject':'X'}")]
|
||||
[InlineData("{'amount':50,'amount_payed':80,'invoiceemail':'a@b.de','invoiceaddress':'W','subject':'X'}")]
|
||||
public void Validate_NonPositiveOpenAmount_Warns(string newJson)
|
||||
{
|
||||
var s = Session(newJson);
|
||||
ReminderDraftCalculator.RecomputeTotals(s);
|
||||
ReminderDraftCalculator.Validate(s);
|
||||
Assert.Contains(s.ValidationMessages, m => m.Field == "amount" && m.Severity == "warning");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_HealthyDraft_HasNoMessages()
|
||||
{
|
||||
var s = Session("{'amount':119,'amount_payed':0,'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','subject':'Zahlungserinnerung'}");
|
||||
ReminderDraftCalculator.RecomputeTotals(s);
|
||||
ReminderDraftCalculator.Validate(s);
|
||||
Assert.Empty(s.ValidationMessages);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
using System;
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Exercises the reminder draft edit orchestrator's pure paths (open/patch/history/flush)
|
||||
/// without a database — the reminder mirror of <see cref="InvoiceDraftServiceTests"/>,
|
||||
/// proving the backend-authoritative model behaves correctly at the service seam (ADR 0006).
|
||||
/// </summary>
|
||||
public class ReminderDraftServiceTests
|
||||
{
|
||||
/// <summary>Captures the reminder handed to registration and returns it with a fake DB id — no SQL.</summary>
|
||||
private sealed class FakeReminderService : IReminderService
|
||||
{
|
||||
public FdsReminderData? Registered;
|
||||
public bool? LastChange;
|
||||
public FdsReminderData? PreviewReminder;
|
||||
public bool? PreviewDraft;
|
||||
|
||||
public Task<FdsReminderData> RegisterReminderAsync(FdsReminderData reminder, bool change, string remId, string userAccountId, DatabaseSecurity dbSec)
|
||||
{
|
||||
Registered = reminder;
|
||||
LastChange = change;
|
||||
reminder.ReminderRegistration = new GenericObjectDictionary(new System.Collections.Generic.Dictionary<string, object> { ["Id"] = "REM42" });
|
||||
return Task.FromResult(reminder);
|
||||
}
|
||||
public Document GenerateReminderPdf(FdsReminderData reminder, bool draft) { PreviewReminder = reminder; PreviewDraft = draft; return new Document(); }
|
||||
public Task<FdsReminderData> LoadReminderAsync(string id, string u, DatabaseSecurity s) => throw new NotSupportedException();
|
||||
public Task<byte[]> RenderReminderPdfBytesAsync(FdsReminderData r, bool d) => throw new NotSupportedException();
|
||||
public Task<byte[]> StoreReminderDocumentFileAsync(FdsReminderData r, bool d, string u, DatabaseSecurity s) => throw new NotSupportedException();
|
||||
public Task<byte[]> GetReminderFileAsync(FdsReminderData r, bool d, fds.IFdsMfr m, string u, DatabaseSecurity s) => throw new NotSupportedException();
|
||||
public Task<(System.IO.FileInfo? file, byte[]? content)> GetStoredFileAsync(string id, string u, DatabaseSecurity s) => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
private static (ReminderDraftEditService svc, FakeReminderService rem) NewService()
|
||||
{
|
||||
var cache = new ReminderDraftCache(new ConfigurationBuilder().Build());
|
||||
var rem = new FakeReminderService();
|
||||
var svc = new ReminderDraftEditService(cache, rem, NullLogger<ReminderDraftEditService>.Instance);
|
||||
return (svc, rem);
|
||||
}
|
||||
|
||||
private static JObject Payload() => JObject.Parse(@"{
|
||||
'rem':{'invid':'INV5','type':'R','invoiceid':'R2026-1','invoicedate':'2026-06-01'},
|
||||
'new':{'invoiceemail':'a@b.de','invoiceaddress':'Weg 1','subject':'Zahlungserinnerung','amount':119,'amount_payed':0}
|
||||
}");
|
||||
|
||||
[Fact]
|
||||
public void OpenFromPayload_SeedsSessionAndComputesOpenAmount()
|
||||
{
|
||||
var (svc, _) = NewService();
|
||||
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||
Assert.False(string.IsNullOrEmpty(s.Token));
|
||||
Assert.Equal(0, s.Version);
|
||||
Assert.Equal(119m, s.Sums.AmountTotal);
|
||||
Assert.Equal(119m, s.Sums.AmountOpen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyPatch_Email_MutatesBumpsVersionAndRecordsHistory()
|
||||
{
|
||||
var (svc, _) = NewService();
|
||||
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||
|
||||
var s2 = svc.ApplyPatch(s.Token, new ReminderDraftDelta { 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<string>());
|
||||
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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyPatch_Amount_RecomputesOpenAmount()
|
||||
{
|
||||
var (svc, _) = NewService();
|
||||
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||
|
||||
var s2 = svc.ApplyPatch(s.Token, new ReminderDraftDelta { Target = "amount", Value = JToken.FromObject(200) });
|
||||
|
||||
Assert.Equal(200m, s2!.Sums.AmountTotal);
|
||||
Assert.Equal(200m, s2.Sums.AmountOpen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyPatch_AmountPayed_RecomputesOpenAmount()
|
||||
{
|
||||
var (svc, _) = NewService();
|
||||
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||
|
||||
var s2 = svc.ApplyPatch(s.Token, new ReminderDraftDelta { Target = "amount_payed", Value = JToken.FromObject(19) });
|
||||
|
||||
Assert.Equal(100m, s2!.Sums.AmountOpen); // 119 - 19
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyPatch_AmountFromGermanString_NormalisesToInvariant()
|
||||
{
|
||||
var (svc, _) = NewService();
|
||||
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||
|
||||
var s2 = svc.ApplyPatch(s.Token, new ReminderDraftDelta { Target = "amount", Value = JToken.FromObject("249,90") });
|
||||
|
||||
Assert.Equal("249.90", s2!.New["amount"]!.Value<string>()); // stored invariant
|
||||
Assert.Equal(249.90m, s2.Sums.AmountTotal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyPatch_UnknownToken_ReturnsNull()
|
||||
{
|
||||
var (svc, _) = NewService();
|
||||
Assert.Null(svc.ApplyPatch("ghost", new ReminderDraftDelta { Target = "email", Value = JToken.FromObject("x@y.de") }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyPatch_UnknownTarget_IsNoOp()
|
||||
{
|
||||
var (svc, _) = NewService();
|
||||
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||
|
||||
var s2 = svc.ApplyPatch(s.Token, new ReminderDraftDelta { Target = "nonsense", Value = JToken.FromObject("x") });
|
||||
|
||||
Assert.Equal(0, s2!.Version);
|
||||
Assert.Empty(s2.History);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("subject", "subject")]
|
||||
[InlineData("address", "invoiceaddress")]
|
||||
[InlineData("text", "text")]
|
||||
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 ReminderDraftDelta { Target = target, Value = JToken.FromObject("X-VALUE") });
|
||||
|
||||
Assert.Equal("X-VALUE", s2!.New[newKey]!.Value<string>());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("subject", "subject")]
|
||||
[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 ReminderDraftDelta { Target = target, Value = JToken.FromObject("<p>clean me</p>") });
|
||||
|
||||
Assert.Equal("clean me", s2!.New[newKey]!.Value<string>());
|
||||
Assert.DoesNotContain("<", s2.New[newKey]!.Value<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyPatch_Address_MultilineHtml_KeepsLineBreaks()
|
||||
{
|
||||
var (svc, _) = NewService();
|
||||
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||
|
||||
var s2 = svc.ApplyPatch(s.Token, new ReminderDraftDelta
|
||||
{
|
||||
Target = "address",
|
||||
Value = JToken.FromObject("<p>Firma AG</p><p>Weg 1<br>40000 Düsseldorf</p>")
|
||||
});
|
||||
|
||||
Assert.Equal("Firma AG\nWeg 1\n40000 Düsseldorf", s2!.New["invoiceaddress"]!.Value<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyPatch_Contact_BuildsCustomValuesJson()
|
||||
{
|
||||
var (svc, _) = NewService();
|
||||
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||
|
||||
var s2 = svc.ApplyPatch(s.Token, new ReminderDraftDelta
|
||||
{
|
||||
Target = "contact",
|
||||
Value = JObject.Parse(@"{'name':'Max Mustermann','email':'max@kunde.de'}")
|
||||
});
|
||||
|
||||
var cv = JObject.Parse(s2!.New["CustomValues"]!.Value<string>()!);
|
||||
Assert.Equal("Max Mustermann", cv["contactName"]!.Value<string>());
|
||||
Assert.Equal("max@kunde.de", cv["contactEmail"]!.Value<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyPatch_MultipleEdits_AccumulateHistoryInOrder()
|
||||
{
|
||||
var (svc, _) = NewService();
|
||||
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||
|
||||
svc.ApplyPatch(s.Token, new ReminderDraftDelta { Target = "email", Value = JToken.FromObject("a1@x.de") });
|
||||
svc.ApplyPatch(s.Token, new ReminderDraftDelta { Target = "subject", Value = JToken.FromObject("Mahnung 2") });
|
||||
var s3 = svc.ApplyPatch(s.Token, new ReminderDraftDelta { Target = "amount", Value = JToken.FromObject(200) });
|
||||
|
||||
Assert.Equal(3, s3!.Version);
|
||||
Assert.Equal(new[] { "email", "subject", "amount" }, 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 ReminderDraftDelta { Target = "amount_payed", Value = JToken.FromObject(19) });
|
||||
|
||||
var state = JObject.FromObject(svc.BuildState(svc.Get(s.Token)!));
|
||||
|
||||
Assert.Equal(1, state["version"]!.Value<int>());
|
||||
Assert.Equal(119m, state["sums"]!["amount_total"]!.Value<decimal>());
|
||||
Assert.Equal(100m, state["sums"]!["amount_open"]!.Value<decimal>());
|
||||
Assert.Equal(1, state["historyCount"]!.Value<int>());
|
||||
Assert.NotNull(state["validation"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FlushToDbAsync_RegistersAndSetsRemId_CreatePath()
|
||||
{
|
||||
var (svc, rem) = NewService();
|
||||
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||
|
||||
var result = await svc.FlushToDbAsync(s.Token, "user1", null!);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("REM42", result!.Id);
|
||||
Assert.False(rem.LastChange); // new draft (no prior RemId) → create
|
||||
Assert.Equal("REM42", svc.Get(s.Token)!.RemId);
|
||||
// the email/subject the editor set must reach registration
|
||||
Assert.Equal("a@b.de", rem.Registered!.RawInvoiceEmail);
|
||||
Assert.Equal("Zahlungserinnerung", rem.Registered!.NewValues!.getString("subject"));
|
||||
Assert.Equal("INV5", rem.Registered!.RawInvId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FlushToDbAsync_ExistingRemId_UpdatePath()
|
||||
{
|
||||
var (svc, rem) = NewService();
|
||||
var payload = Payload();
|
||||
payload["remid"] = "REM7";
|
||||
var s = svc.OpenFromPayload(payload, "user1");
|
||||
|
||||
await svc.FlushToDbAsync(s.Token, "user1", null!);
|
||||
|
||||
Assert.True(rem.LastChange); // prior RemId → update path
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderPreview_SynthesizesDraftRegistrationFromSession()
|
||||
{
|
||||
var (svc, rem) = NewService();
|
||||
var s = svc.OpenFromPayload(Payload(), "user1");
|
||||
|
||||
var doc = svc.RenderPreview(s.Token);
|
||||
|
||||
Assert.NotNull(doc);
|
||||
Assert.True(rem.PreviewDraft);
|
||||
Assert.True(rem.PreviewReminder!.IsDraft);
|
||||
var reg = rem.PreviewReminder!.ReminderRegistration!;
|
||||
Assert.Equal("Zahlungserinnerung", reg.getString("subject"));
|
||||
Assert.Equal("Weg 1", reg.getString("SendToAddress"));
|
||||
Assert.Equal("a@b.de", reg.getString("SendToEmail"));
|
||||
Assert.Equal("R2026-1", reg.getString("InvoiceId"));
|
||||
// the synthesised single-invoice row the reminder table renders
|
||||
Assert.Single(rem.PreviewReminder!.ReminderItems);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderPreview_UnknownToken_ReturnsNull()
|
||||
{
|
||||
var (svc, _) = NewService();
|
||||
Assert.Null(svc.RenderPreview("ghost"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetHistory_UnknownToken_IsEmpty()
|
||||
{
|
||||
var (svc, _) = NewService();
|
||||
Assert.Empty(svc.GetHistory("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));
|
||||
}
|
||||
}
|
||||
@@ -205,6 +205,54 @@ public class StartupSelfTestServiceTests
|
||||
Assert.Equal(1, service.MailerCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunOnce_Disabled_PublishesNotRunReport()
|
||||
{
|
||||
var reporter = new StartupCheckReporter();
|
||||
using var service = new TestableStartupSelfTestService(
|
||||
new ServiceCollection().BuildServiceProvider(),
|
||||
CreateConfiguration(),
|
||||
Options.Create(CreateSettings(enabled: false)),
|
||||
NullLogger<StartupSelfTestService>.Instance,
|
||||
reporter);
|
||||
|
||||
await service.RunForTestAsync(CancellationToken.None);
|
||||
|
||||
Assert.NotNull(reporter.Latest);
|
||||
Assert.False(reporter.Latest!.Ran);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunOnce_Enabled_PublishesReportWithPerCheckResults()
|
||||
{
|
||||
var reporter = new StartupCheckReporter();
|
||||
using var service = new TestableStartupSelfTestService(
|
||||
new ServiceCollection().BuildServiceProvider(),
|
||||
CreateConfiguration(),
|
||||
Options.Create(CreateSettings(enabled: true, checkKeyVault: true, checkDatabase: true, checkMfr: false, sendStartupEmail: true, startupRecipient: "ops@example.test", checkPdfLicense: false)),
|
||||
NullLogger<StartupSelfTestService>.Instance,
|
||||
reporter)
|
||||
{
|
||||
KeyVaultResult = true,
|
||||
DatabaseResult = false,
|
||||
MailerResult = true,
|
||||
};
|
||||
|
||||
await service.RunForTestAsync(CancellationToken.None);
|
||||
|
||||
var report = reporter.Latest;
|
||||
Assert.NotNull(report);
|
||||
Assert.True(report!.Ran);
|
||||
Assert.NotNull(report.CompletedUtc);
|
||||
var kv = Assert.Single(report.Items, i => i.Name == "KeyVault");
|
||||
Assert.True(kv.Enabled && kv.Ok);
|
||||
var db = Assert.Single(report.Items, i => i.Name == "Database");
|
||||
Assert.True(db.Enabled);
|
||||
Assert.False(db.Ok); // intentionally-failing path
|
||||
var mfr = Assert.Single(report.Items, i => i.Name == "MFR");
|
||||
Assert.False(mfr.Enabled); // disabled check reported as not-enabled
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_ProbeThrows_ServiceDoesNotThrowAndContinuesRemainingChecks()
|
||||
{
|
||||
@@ -250,8 +298,9 @@ public class StartupSelfTestServiceTests
|
||||
IServiceProvider serviceProvider,
|
||||
IConfiguration configuration,
|
||||
IOptions<StartupSelfTestSettings> settings,
|
||||
Microsoft.Extensions.Logging.ILogger<StartupSelfTestService> logger)
|
||||
: base(serviceProvider, configuration, settings, logger)
|
||||
Microsoft.Extensions.Logging.ILogger<StartupSelfTestService> logger,
|
||||
StartupCheckReporter? reporter = null)
|
||||
: base(serviceProvider, configuration, settings, logger, reporter)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Fuchs.Services;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the Admin module's read-only diagnostics service. Covers the passive config
|
||||
/// snapshot (SQL server parsing, email override reflection, presence flags), each probe's
|
||||
/// disabled/unconfigured/ok/error outcomes where deterministically reachable without live
|
||||
/// infrastructure, the test-email pipeline (success/failure/validation + HTML-encoding +
|
||||
/// override reporting), and the emitted probe telemetry.
|
||||
/// </summary>
|
||||
public class SystemStatusServiceTests
|
||||
{
|
||||
private const string FuchsMeterName = "Fuchs.Intranet";
|
||||
|
||||
private static IConfiguration Config(Dictionary<string, string?>? overrides = null)
|
||||
{
|
||||
var dict = new Dictionary<string, string?>
|
||||
{
|
||||
["ConnectionStrings:fuchs_fds_ConnectionString"] =
|
||||
"Data Source=SQLHOST,1433;Initial Catalog=site_fuchs_test;User ID=fuchs_app;password='secret';",
|
||||
["ConnectionStrings:AzureBlobStorage_ConnectionString"] = "DefaultEndpointsProtocol=https;AccountName=acct;AccountKey=key==;",
|
||||
["Fuchs:IsTestDeployment"] = "true",
|
||||
["Fds:MFR_host"] = "portal.mobilefieldreport.com",
|
||||
["Fds:MFR_UserName"] = "mfruser",
|
||||
["Fds:SyncEnabled"] = "true",
|
||||
["SecretManagement:VaultUri"] = "https://vault.example/",
|
||||
["SecretManagement:AppName"] = "fuchs",
|
||||
["SecretManagement:ManagedSecretKeys:0"] = "Fuchs--Mailer--Token",
|
||||
["SecretManagement:ManagedSecretKeys:1"] = "ConnectionStrings--fuchs-fds-password",
|
||||
};
|
||||
if (overrides != null)
|
||||
foreach (var kv in overrides) dict[kv.Key] = kv.Value;
|
||||
return new ConfigurationBuilder().AddInMemoryCollection(dict).Build();
|
||||
}
|
||||
|
||||
private static SystemStatusService Build(
|
||||
IConfiguration? config = null,
|
||||
Mock<IBlobStorageService>? blob = null,
|
||||
Mock<IMfrClientFactory>? mfr = null,
|
||||
Mock<IComService>? com = null,
|
||||
ProcessWebComSettings? mailer = null,
|
||||
FuchsEmailSettings? email = null,
|
||||
AzureBlobStorageSettings? blobSettings = null,
|
||||
IServiceProvider? provider = null,
|
||||
StartupCheckReporter? startupChecks = null)
|
||||
{
|
||||
var env = new Mock<IHostEnvironment>();
|
||||
env.SetupGet(e => e.EnvironmentName).Returns("Development");
|
||||
|
||||
return new SystemStatusService(
|
||||
config ?? Config(),
|
||||
env.Object,
|
||||
provider ?? new ServiceCollection().BuildServiceProvider(),
|
||||
(blob ?? new Mock<IBlobStorageService>()).Object,
|
||||
(mfr ?? new Mock<IMfrClientFactory>()).Object,
|
||||
(com ?? new Mock<IComService>()).Object,
|
||||
Options.Create(mailer ?? new ProcessWebComSettings { Enabled = true, Token = "tok", BaseUrl = "https://api.example", AccountId = "acc", ServerId = "srv" }),
|
||||
Options.Create(email ?? new FuchsEmailSettings()),
|
||||
Options.Create(blobSettings ?? new AzureBlobStorageSettings { Enabled = true }),
|
||||
startupChecks ?? new StartupCheckReporter(),
|
||||
NullLogger<SystemStatusService>.Instance);
|
||||
}
|
||||
|
||||
// ── GetInfo (passive snapshot) ─────────────────────────────────────────────
|
||||
[Fact]
|
||||
public void GetInfo_ParsesConnectionStringAndReflectsConfiguration()
|
||||
{
|
||||
var svc = Build();
|
||||
|
||||
SystemInfoSnapshot info = svc.GetInfo();
|
||||
|
||||
Assert.Equal("Development", info.Environment);
|
||||
Assert.True(info.IsTestDeployment);
|
||||
Assert.True(info.Database.Configured);
|
||||
Assert.Equal("SQLHOST,1433", info.Database.Server);
|
||||
Assert.Equal("site_fuchs_test", info.Database.Catalog);
|
||||
Assert.Equal("fuchs_app", info.Database.UserId); // login only, never the password
|
||||
Assert.True(info.Mfr.CredentialsConfigured);
|
||||
Assert.Equal("portal.mobilefieldreport.com", info.Mfr.Host);
|
||||
Assert.True(info.Mfr.SyncEnabled);
|
||||
Assert.Equal("fuchs", info.KeyVault.AppName);
|
||||
Assert.Equal(2, info.KeyVault.ManagedSecretCount);
|
||||
Assert.False(info.KeyVault.ClientRegistered); // empty provider
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetInfo_NeverLeaksDatabasePassword()
|
||||
{
|
||||
var svc = Build();
|
||||
|
||||
SystemInfoSnapshot info = svc.GetInfo();
|
||||
|
||||
Assert.DoesNotContain("secret", info.Database.UserId ?? "");
|
||||
Assert.DoesNotContain("secret", info.Database.Server ?? "");
|
||||
Assert.DoesNotContain("secret", info.Database.Catalog ?? "");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public void GetInfo_EmailOverride_ReflectedOnlyWhenConfigured(bool overrideSet)
|
||||
{
|
||||
var email = new FuchsEmailSettings { OverrideRecipient = overrideSet ? "safety@example.test" : "" };
|
||||
var svc = Build(email: email);
|
||||
|
||||
SystemInfoSnapshot info = svc.GetInfo();
|
||||
|
||||
Assert.Equal(overrideSet, info.Email.OverrideActive);
|
||||
Assert.Equal(overrideSet ? "safety@example.test" : null, info.Email.OverrideRecipient);
|
||||
Assert.True(info.Email.MailerEnabled); // Build() default enables the mailer
|
||||
Assert.True(info.Email.TokenConfigured);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetInfo_StartupChecks_NullWhenServiceHasNotRun()
|
||||
{
|
||||
var svc = Build(startupChecks: new StartupCheckReporter()); // nothing set yet
|
||||
|
||||
Assert.Null(svc.GetInfo().StartupChecks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetInfo_StartupChecks_ReflectsReporterContents()
|
||||
{
|
||||
var reporter = new StartupCheckReporter();
|
||||
reporter.Set(new StartupCheckReport
|
||||
{
|
||||
Ran = true,
|
||||
CompletedUtc = DateTimeOffset.UtcNow,
|
||||
MachineName = "BUILD-HOST",
|
||||
Items =
|
||||
[
|
||||
new StartupCheckItem { Name = "Database", Enabled = true, Ok = true },
|
||||
new StartupCheckItem { Name = "Mailer", Enabled = false, Ok = false },
|
||||
],
|
||||
});
|
||||
var svc = Build(startupChecks: reporter);
|
||||
|
||||
var report = svc.GetInfo().StartupChecks;
|
||||
|
||||
Assert.NotNull(report);
|
||||
Assert.True(report!.Ran);
|
||||
Assert.Equal("BUILD-HOST", report.MachineName);
|
||||
Assert.Equal(2, report.Items.Count);
|
||||
Assert.Contains(report.Items, i => i.Name == "Database" && i.Enabled && i.Ok);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetInfo_DatabaseUnconfigured_WhenConnectionStringMissing()
|
||||
{
|
||||
var config = Config(new() { ["ConnectionStrings:fuchs_fds_ConnectionString"] = "" });
|
||||
var svc = Build(config);
|
||||
|
||||
Assert.False(svc.GetInfo().Database.Configured);
|
||||
}
|
||||
|
||||
// ── Blob probe ─────────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public async Task ProbeBlob_Disabled_ReturnsDisabled()
|
||||
{
|
||||
var blob = new Mock<IBlobStorageService>();
|
||||
blob.Setup(b => b.CheckConnectivityAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new BlobConnectivity { Enabled = false });
|
||||
var svc = Build(blob: blob);
|
||||
|
||||
var r = await svc.ProbeAsync("blob");
|
||||
|
||||
Assert.Equal("blob", r.Component);
|
||||
Assert.Equal("disabled", r.Status);
|
||||
Assert.False(r.Ok);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProbeBlob_EnabledButUnconfigured_ReturnsUnconfigured()
|
||||
{
|
||||
var blob = new Mock<IBlobStorageService>();
|
||||
blob.Setup(b => b.CheckConnectivityAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new BlobConnectivity { Enabled = true, Configured = false, Detail = "keine Verbindungszeichenfolge" });
|
||||
var svc = Build(blob: blob);
|
||||
|
||||
var r = await svc.ProbeAsync("blob");
|
||||
|
||||
Assert.Equal("unconfigured", r.Status);
|
||||
Assert.False(r.Ok);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProbeBlob_Reachable_ReturnsOkWithAccountDetail()
|
||||
{
|
||||
var blob = new Mock<IBlobStorageService>();
|
||||
blob.Setup(b => b.CheckConnectivityAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new BlobConnectivity { Enabled = true, Configured = true, Reachable = true, AccountName = "acct", Detail = "StorageV2 / Standard_LRS" });
|
||||
var svc = Build(blob: blob);
|
||||
|
||||
var r = await svc.ProbeAsync("blob");
|
||||
|
||||
Assert.Equal("ok", r.Status);
|
||||
Assert.True(r.Ok);
|
||||
Assert.Contains("acct", r.Detail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProbeBlob_Reachable_ReportsFileCountsPerContainerAsMetrics()
|
||||
{
|
||||
var blob = new Mock<IBlobStorageService>();
|
||||
blob.Setup(b => b.CheckConnectivityAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new BlobConnectivity
|
||||
{
|
||||
Enabled = true, Configured = true, Reachable = true, AccountName = "acct",
|
||||
Detail = "StorageV2 / Standard_LRS",
|
||||
Containers = new List<BlobContainerCount>
|
||||
{
|
||||
new() { Name = "dev-fuchs-invoices", Exists = true, FileCount = 42 },
|
||||
new() { Name = "dev-fuchs-reminders", Exists = false, FileCount = 0 },
|
||||
},
|
||||
});
|
||||
var svc = Build(blob: blob);
|
||||
|
||||
var r = await svc.ProbeAsync("blob");
|
||||
|
||||
Assert.Equal("ok", r.Status);
|
||||
Assert.NotNull(r.Metrics);
|
||||
Assert.Equal(2, r.Metrics!.Count);
|
||||
var inv = Assert.Single(r.Metrics, m => m.Label == "dev-fuchs-invoices");
|
||||
Assert.Equal("42 Dateien", inv.Value);
|
||||
var rem = Assert.Single(r.Metrics, m => m.Label == "dev-fuchs-reminders");
|
||||
Assert.Equal("nicht vorhanden", rem.Value);
|
||||
Assert.Contains("42 Dateien gesamt", r.Message); // total across containers
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProbeBlob_FileCountUnavailable_ReportsMetricWithoutFailing()
|
||||
{
|
||||
var blob = new Mock<IBlobStorageService>();
|
||||
blob.Setup(b => b.CheckConnectivityAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new BlobConnectivity
|
||||
{
|
||||
Enabled = true, Configured = true, Reachable = true, AccountName = "acct",
|
||||
Containers = new List<BlobContainerCount>
|
||||
{
|
||||
new() { Name = "dev-fuchs-invoices", Exists = true, FileCount = -1 },
|
||||
},
|
||||
});
|
||||
var svc = Build(blob: blob);
|
||||
|
||||
var r = await svc.ProbeAsync("blob");
|
||||
|
||||
Assert.Equal("ok", r.Status);
|
||||
var inv = Assert.Single(r.Metrics!);
|
||||
Assert.Equal("Anzahl nicht ermittelbar", inv.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProbeBlob_ConfiguredButUnreachable_ReturnsError()
|
||||
{
|
||||
var blob = new Mock<IBlobStorageService>();
|
||||
blob.Setup(b => b.CheckConnectivityAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new BlobConnectivity { Enabled = true, Configured = true, Reachable = false, AccountName = "acct", Detail = "403 Forbidden" });
|
||||
var svc = Build(blob: blob);
|
||||
|
||||
var r = await svc.ProbeAsync("blob");
|
||||
|
||||
Assert.Equal("error", r.Status);
|
||||
Assert.False(r.Ok);
|
||||
}
|
||||
|
||||
// ── Key Vault probe ────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public async Task ProbeKeyVault_ClientNotRegistered_ReturnsUnconfigured()
|
||||
{
|
||||
var svc = Build(); // empty service provider → no SecretClient
|
||||
|
||||
var r = await svc.ProbeAsync("keyvault");
|
||||
|
||||
Assert.Equal("keyvault", r.Component);
|
||||
Assert.Equal("unconfigured", r.Status);
|
||||
Assert.False(r.Ok);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProbeKeyVault_NoManagedKeys_ReturnsUnconfigured()
|
||||
{
|
||||
var config = Config(new()
|
||||
{
|
||||
["SecretManagement:ManagedSecretKeys:0"] = null,
|
||||
["SecretManagement:ManagedSecretKeys:1"] = null,
|
||||
});
|
||||
var svc = Build(config);
|
||||
|
||||
var r = await svc.ProbeAsync("keyvault");
|
||||
|
||||
Assert.Equal("unconfigured", r.Status);
|
||||
}
|
||||
|
||||
// ── MFR probe ──────────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public async Task ProbeMfr_FactoryThrows_ReturnsError()
|
||||
{
|
||||
var mfr = new Mock<IMfrClientFactory>();
|
||||
mfr.Setup(f => f.Create()).Throws(new InvalidOperationException("no credentials"));
|
||||
var svc = Build(mfr: mfr);
|
||||
|
||||
var r = await svc.ProbeAsync("mfr");
|
||||
|
||||
Assert.Equal("mfr", r.Component);
|
||||
Assert.Equal("error", r.Status);
|
||||
Assert.False(r.Ok);
|
||||
Assert.Equal("no credentials", r.Detail);
|
||||
}
|
||||
|
||||
// ── Database probe (unconfigured is deterministic without a live server) ────
|
||||
[Fact]
|
||||
public async Task ProbeDatabase_NoConnectionString_ReturnsUnconfigured()
|
||||
{
|
||||
var config = Config(new() { ["ConnectionStrings:fuchs_fds_ConnectionString"] = "" });
|
||||
var svc = Build(config);
|
||||
|
||||
var r = await svc.ProbeAsync("database");
|
||||
|
||||
Assert.Equal("database", r.Component);
|
||||
Assert.Equal("unconfigured", r.Status);
|
||||
Assert.False(r.Ok);
|
||||
}
|
||||
|
||||
// ── Unknown component ──────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public async Task ProbeAsync_UnknownComponent_ReturnsError()
|
||||
{
|
||||
var svc = Build();
|
||||
|
||||
var r = await svc.ProbeAsync("does-not-exist");
|
||||
|
||||
Assert.Equal("error", r.Status);
|
||||
Assert.False(r.Ok);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProbeAllAsync_ReturnsOneResultPerComponent()
|
||||
{
|
||||
var blob = new Mock<IBlobStorageService>();
|
||||
blob.Setup(b => b.CheckConnectivityAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new BlobConnectivity { Enabled = false });
|
||||
var mfr = new Mock<IMfrClientFactory>();
|
||||
mfr.Setup(f => f.Create()).Throws(new InvalidOperationException("x"));
|
||||
var config = Config(new() { ["ConnectionStrings:fuchs_fds_ConnectionString"] = "" });
|
||||
var svc = Build(config, blob: blob, mfr: mfr);
|
||||
|
||||
var results = await svc.ProbeAllAsync();
|
||||
|
||||
Assert.Equal(svc.ProbeComponents.Count, results.Count);
|
||||
foreach (var comp in svc.ProbeComponents)
|
||||
Assert.Contains(results, r => r.Component == comp);
|
||||
Assert.All(results, r => Assert.True(r.DurationMs >= 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProbeAsync_EmitsProbeTelemetry()
|
||||
{
|
||||
var blob = new Mock<IBlobStorageService>();
|
||||
blob.Setup(b => b.CheckConnectivityAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new BlobConnectivity { Enabled = false });
|
||||
var svc = Build(blob: blob);
|
||||
|
||||
long delta = 0;
|
||||
using var listener = new MeterListener
|
||||
{
|
||||
InstrumentPublished = (inst, l) =>
|
||||
{
|
||||
if (inst.Meter.Name == FuchsMeterName && inst.Name == "fuchs.systemstatus.probes")
|
||||
l.EnableMeasurementEvents(inst);
|
||||
}
|
||||
};
|
||||
listener.SetMeasurementEventCallback<long>((_, value, _, _) => Interlocked.Add(ref delta, value));
|
||||
listener.Start();
|
||||
|
||||
await svc.ProbeAsync("blob");
|
||||
|
||||
Assert.True(delta >= 1, "fuchs.systemstatus.probes counter should increment per probe.");
|
||||
}
|
||||
|
||||
// ── Test email ─────────────────────────────────────────────────────────────
|
||||
[Theory]
|
||||
[InlineData("", "subj", "body")]
|
||||
[InlineData("to@example.test", "", "body")]
|
||||
public async Task SendTestEmailAsync_MissingRequiredFields_DoesNotSend(string to, string subject, string body)
|
||||
{
|
||||
var com = new Mock<IComService>();
|
||||
var svc = Build(com: com);
|
||||
|
||||
var r = await svc.SendTestEmailAsync(to, subject, body);
|
||||
|
||||
Assert.False(r.Sent);
|
||||
com.Verify(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendTestEmailAsync_Success_SendsHtmlEncodedBodyToRecipient()
|
||||
{
|
||||
string? capturedHtml = null, capturedTo = null, capturedSubject = null;
|
||||
var com = new Mock<IComService>();
|
||||
com.Setup(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>?>()))
|
||||
.Callback<string, string, string, string, string, Dictionary<string, byte[]>?>(
|
||||
(_, subj, html, to, _, _) => { capturedSubject = subj; capturedHtml = html; capturedTo = to; })
|
||||
.ReturnsAsync(true);
|
||||
var svc = Build(com: com);
|
||||
|
||||
var r = await svc.SendTestEmailAsync("dest@example.test", "Betreff", "Zeile1<script>\nZeile2");
|
||||
|
||||
Assert.True(r.Sent);
|
||||
Assert.Equal("dest@example.test", capturedTo);
|
||||
Assert.Equal("Betreff", capturedSubject);
|
||||
Assert.NotNull(capturedHtml);
|
||||
Assert.Contains("<script>", capturedHtml); // HTML-encoded, not injected
|
||||
Assert.DoesNotContain("<script>", capturedHtml);
|
||||
Assert.Contains("<br/>", capturedHtml); // newline → <br/>
|
||||
Assert.Null(r.OverrideRecipient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendTestEmailAsync_OverrideActive_ReportsOverrideRecipient()
|
||||
{
|
||||
var com = new Mock<IComService>();
|
||||
com.Setup(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>?>()))
|
||||
.ReturnsAsync(true);
|
||||
var svc = Build(com: com, email: new FuchsEmailSettings { OverrideRecipient = "safety@example.test" });
|
||||
|
||||
var r = await svc.SendTestEmailAsync("dest@example.test", "Betreff", "Body");
|
||||
|
||||
Assert.True(r.Sent);
|
||||
Assert.Equal("dest@example.test", r.RequestedRecipient);
|
||||
Assert.Equal("safety@example.test", r.OverrideRecipient);
|
||||
Assert.Contains("safety@example.test", r.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendTestEmailAsync_MailerRejects_ReturnsNotSent()
|
||||
{
|
||||
var com = new Mock<IComService>();
|
||||
com.Setup(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>?>()))
|
||||
.ReturnsAsync(false);
|
||||
var svc = Build(com: com);
|
||||
|
||||
var r = await svc.SendTestEmailAsync("dest@example.test", "Betreff", "Body");
|
||||
|
||||
Assert.False(r.Sent);
|
||||
Assert.Equal("dest@example.test", r.RequestedRecipient);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
using Fuchs.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Serialization;
|
||||
using OCORE.SQL;
|
||||
using static OCORE.SQL.sql;
|
||||
using static OCORE.web.mvc_helper_async;
|
||||
|
||||
namespace Fuchs.Controllers;
|
||||
|
||||
// Partial class: Admin / system-status module.
|
||||
//
|
||||
// Access is restricted to users whose "fds_sys" module authorization is greater than 4.
|
||||
// The menu button is only shown, and the module script only loaded, for such users (frontend),
|
||||
// but every data endpoint here ALSO enforces the level server-side (defense in depth) — the
|
||||
// passive/probe data is diagnostic and must never be reachable by a lower-privileged session.
|
||||
public partial class IntranetController
|
||||
{
|
||||
// fds_sys authorization must exceed this to use the Admin module.
|
||||
private const int AdminMinAuthExclusive = 4;
|
||||
|
||||
private async Task<IActionResult> Do_Process_Admin(string fn, string id, string code)
|
||||
{
|
||||
_logger.LogDebug("Do_Process_Admin action={Action} code={Code} user={User}", id, code, UserAccountID);
|
||||
|
||||
// The auth probe is the one endpoint that answers for BOTH authorized and unauthorized
|
||||
// users (the frontend uses manage>0 to decide whether to render the module at all).
|
||||
int authLevel = await GetSystemAdminAuthAsync(fn, id, code);
|
||||
bool authorized = authLevel > AdminMinAuthExclusive;
|
||||
|
||||
if (id.Equals("auth", StringComparison.OrdinalIgnoreCase))
|
||||
return await JSONAsync(new { manage = authorized ? 1 : 0, level = authLevel });
|
||||
|
||||
if (!authorized)
|
||||
{
|
||||
_logger.LogWarning("Admin access denied for user={User} (fds_sys={Level}) action={Action}",
|
||||
UserAccountID, authLevel, id);
|
||||
return Unauthorized401();
|
||||
}
|
||||
|
||||
var status = _systemStatus;
|
||||
switch (id.ToLowerInvariant())
|
||||
{
|
||||
case "status":
|
||||
{
|
||||
var info = status.GetInfo();
|
||||
var probes = await status.ProbeAllAsync(HttpContext.RequestAborted);
|
||||
return AdminJson(new { info, probes });
|
||||
}
|
||||
|
||||
case "info":
|
||||
return AdminJson(new { info = status.GetInfo() });
|
||||
|
||||
case "probe":
|
||||
{
|
||||
// code carries the component id, e.g. /do/admin/probe/database
|
||||
string component = string.IsNullOrWhiteSpace(code) ? Form("component") : code;
|
||||
if (string.IsNullOrWhiteSpace(component))
|
||||
return BadRequest400();
|
||||
var probe = await status.ProbeAsync(component, HttpContext.RequestAborted);
|
||||
return AdminJson(new { probe });
|
||||
}
|
||||
|
||||
case "testmail":
|
||||
{
|
||||
if (!HasForm("to", "subject"))
|
||||
return BadRequest400();
|
||||
var result = await status.SendTestEmailAsync(
|
||||
Form("to"), Form("subject"), Form("body"), HttpContext.RequestAborted);
|
||||
_logger.LogInformation("Admin test email requested by user={User} to={To} sent={Sent}",
|
||||
UserAccountID, result.RequestedRecipient, result.Sent);
|
||||
return AdminJson(new { result });
|
||||
}
|
||||
|
||||
default:
|
||||
_logger.LogWarning("Admin: no handler for action={Action}, user={User}", id, UserAccountID);
|
||||
return BadRequest400();
|
||||
}
|
||||
}
|
||||
|
||||
// The status DTOs (SystemInfoSnapshot / SystemProbeResult) are PascalCase; serialize them
|
||||
// camelCase so the Admin frontend contract matches the lowercase convention used elsewhere.
|
||||
private static readonly JsonSerializerSettings CamelCaseJson = new()
|
||||
{
|
||||
ContractResolver = new CamelCasePropertyNamesContractResolver(),
|
||||
};
|
||||
|
||||
// Mirror OCORE's getJSONResult exactly (application/json; charset=utf-8) so the response
|
||||
// parses identically to every other endpoint the frontend's postXT talks to.
|
||||
private ContentResult AdminJson(object payload) =>
|
||||
Content(JsonConvert.SerializeObject(payload, CamelCaseJson), "application/json; charset=utf-8");
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the calling user's <c>fds_sys</c> module authorization level via the
|
||||
/// <c>fis_getModuleAuth</c> SQL function (same mechanism as <see cref="HandleAuth"/>).
|
||||
/// Returns -3 when it cannot be determined (fail-closed).
|
||||
/// </summary>
|
||||
private async Task<int> GetSystemAdminAuthAsync(string fn, string id, string code)
|
||||
{
|
||||
var val = await getSQLValue_async<int>(
|
||||
"SELECT [dbo].[fis_getModuleAuth](@module, @authuser);",
|
||||
_intranet.Intranet__SQLConnectionString, -3,
|
||||
StdParamlist(SQL_VarChar("@module", "fds_sys")),
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
return val.Result;
|
||||
}
|
||||
}
|
||||
@@ -222,6 +222,52 @@ public partial class IntranetController
|
||||
: StatusCode(500, new { error = "not successful" });
|
||||
}
|
||||
|
||||
case "man":
|
||||
{
|
||||
var pl = StdParamlist(
|
||||
SQL_VarChar("@taID", Form("taID"), dbNull_IfEmpty: true),
|
||||
SQL_Date("@ValueDate", Form("ValueDate")),
|
||||
SQL_Decimal("@Amount", Form("Amount"), precision: 9, scale: 2),
|
||||
SQL_NVarChar("@NameOfPayer", Form("NameOfPayer"), dbNull_IfEmpty: true),
|
||||
SQL_VarChar("@SepaRemittanceInformation", Form("SepaRemittanceInformation"), dbNull_IfEmpty: true));
|
||||
await getSQLDatatable_async(
|
||||
"EXECUTE [dbo].[fds__setBankingtransaction_manual] @taID, @ValueDate, @Amount, @NameOfPayer, @SepaRemittanceInformation, @authuser;",
|
||||
_intranet.Intranet__SQLConnectionString, pl,
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
_logger.LogInformation(
|
||||
"Manual banking transaction upserted taID={TaID} user={User}", Form("taID"), UserAccountID);
|
||||
return await JSONAsync(new { ok = true });
|
||||
}
|
||||
|
||||
case "mget":
|
||||
{
|
||||
if (!HasForm("taid")) return BadRequest400();
|
||||
var pl = StdParamlist(SQL_VarChar("@taID", Form("taid"), dbNull_IfEmpty: true));
|
||||
var res = await getSQLDatatable_async(
|
||||
"EXECUTE [dbo].[fds__getBankingtransaction_manual] @taID, @authuser;",
|
||||
_intranet.Intranet__SQLConnectionString, pl,
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
return await JSONAsync(res.FirstRow.toObjectDictionary());
|
||||
}
|
||||
|
||||
case "mdel":
|
||||
{
|
||||
if (!HasForm("taid")) return BadRequest400();
|
||||
var pl = StdParamlist(
|
||||
SQL_VarChar("@taID", Form("taid"), dbNull_IfEmpty: true),
|
||||
SQL_Date("@ValueDate", (string?)null),
|
||||
SQL_Decimal("@Amount", (string?)null, precision: 9, scale: 2),
|
||||
SQL_NVarChar("@NameOfPayer", null, dbNull_IfEmpty: true),
|
||||
SQL_VarChar("@SepaRemittanceInformation", null, dbNull_IfEmpty: true));
|
||||
await getSQLDatatable_async(
|
||||
"EXECUTE [dbo].[fds__setBankingtransaction_manual] @taID, @ValueDate, @Amount, @NameOfPayer, @SepaRemittanceInformation, @authuser;",
|
||||
_intranet.Intranet__SQLConnectionString, pl,
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
_logger.LogInformation(
|
||||
"Manual banking transaction deleted taID={TaID} user={User}", Form("taid"), UserAccountID);
|
||||
return await JSONAsync(new { ok = true });
|
||||
}
|
||||
|
||||
case "vfi":
|
||||
{
|
||||
if (!HasForm("invid")) return BadRequest400();
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using static OCORE.web.mvc_helper_async;
|
||||
|
||||
namespace Fuchs.Controllers;
|
||||
|
||||
// Partial class: live, backend-authoritative invoice draft editing (ADR 0006).
|
||||
// The browser posts single edits here; the server mutates the in-memory session
|
||||
// (the source of truth), recomputes/validates, and pings the editing browser over
|
||||
// SignalR (draftReady) to re-fetch. Commands are ordinary POSTs — the hub carries
|
||||
// only signals (ADR 0007).
|
||||
public partial class IntranetController
|
||||
{
|
||||
/// <summary>Standard 410 when a session token is unknown/expired — the client re-opens the draft.</summary>
|
||||
private IActionResult DraftGone() => StatusCode(410, new { error = "expired" });
|
||||
|
||||
// POST inv/dopen — { payload } → { token, version }
|
||||
// The editor assembles the initial draft (from a service request or a reloaded DB draft
|
||||
// via the existing render paths) and seeds the authoritative session here. Reload/discard
|
||||
// is the client re-fetching + re-seeding, so there is no server-side DB reshaping.
|
||||
private async Task<IActionResult> HandleDraftOpen(string fn, string id, string code)
|
||||
{
|
||||
if (!HasForm("payload"))
|
||||
{
|
||||
_logger.LogWarning("Draft dopen: 'payload' missing user={User}", UserAccountID);
|
||||
return BadRequest400();
|
||||
}
|
||||
JObject payload;
|
||||
try { payload = JObject.Parse(Form("payload")); }
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Draft dopen: invalid payload JSON user={User}", UserAccountID);
|
||||
return BadRequest400();
|
||||
}
|
||||
var session = _invoiceDrafts.OpenFromPayload(payload, UserAccountID);
|
||||
_logger.LogInformation("Draft dopen: session {Token} (invId={InvId}) user={User}", session.Token, session.InvId, UserAccountID);
|
||||
// The browser holds the token from this response and fetches dstate directly; there is
|
||||
// no server 'draftReady' on open (it would race the client's group-join). Signals drive
|
||||
// only subsequent server-side changes.
|
||||
return await JSONAsync(new { token = session.Token, version = session.Version });
|
||||
}
|
||||
|
||||
// POST inv/dstate — { token } → full view state
|
||||
private async Task<IActionResult> HandleDraftState(string fn, string id, string code)
|
||||
{
|
||||
if (!HasForm("token")) return BadRequest400();
|
||||
var session = _invoiceDrafts.Get(Form("token"));
|
||||
if (session == null) return DraftGone();
|
||||
return await JSONAsync(_invoiceDrafts.BuildState(session));
|
||||
}
|
||||
|
||||
// POST inv/dpatch — { token, delta } → { ok, version }; signals draftReady
|
||||
private async Task<IActionResult> HandleDraftPatch(string fn, string id, string code)
|
||||
{
|
||||
if (!HasForm("token", "delta")) return BadRequest400();
|
||||
InvoiceDraftDelta? delta;
|
||||
try { delta = JsonConvert.DeserializeObject<InvoiceDraftDelta>(Form("delta")); }
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Draft dpatch: invalid delta JSON user={User}", UserAccountID);
|
||||
return BadRequest400();
|
||||
}
|
||||
if (delta == null || string.IsNullOrEmpty(delta.Target)) return BadRequest400();
|
||||
|
||||
var session = _invoiceDrafts.ApplyPatch(Form("token"), delta);
|
||||
if (session == null) return DraftGone();
|
||||
await _draftNotifier.SignalDraftReadyAsync(session.Token, session.Version);
|
||||
return await JSONAsync(new { ok = true, version = session.Version });
|
||||
}
|
||||
|
||||
// POST inv/dpreview — { token } → { img[], total } (rendered straight from the cache)
|
||||
private async Task<IActionResult> HandleDraftPreview(string fn, string id, string code)
|
||||
{
|
||||
if (!HasForm("token")) return BadRequest400();
|
||||
var doc = _invoiceDrafts.RenderPreview(Form("token"));
|
||||
if (doc == null) return DraftGone();
|
||||
var imgcol = await _pdf.DocToImageCollectionAsync(doc);
|
||||
return await JSONAsync(new { img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
||||
}
|
||||
|
||||
// POST inv/dsave — { token } → { ok, invid }; flush cache→DB + business event + draftReady
|
||||
private async Task<IActionResult> HandleDraftSave(string fn, string id, string code)
|
||||
{
|
||||
if (!HasForm("token")) return BadRequest400();
|
||||
string token = Form("token");
|
||||
var before = _invoiceDrafts.Get(token);
|
||||
if (before == null) return DraftGone();
|
||||
bool existed = !string.IsNullOrEmpty(before.InvId);
|
||||
|
||||
var fdInv = await _invoiceDrafts.FlushToDbAsync(token, UserAccountID, DbSec);
|
||||
if (fdInv == null) return DraftGone();
|
||||
if (string.IsNullOrEmpty(fdInv.Id))
|
||||
return await InvoiceIssueResult("Der Zwischenstand konnte aufgrund eines Fehlers nicht gespeichert werden.");
|
||||
|
||||
await _events.InvoiceDraftRegisteredAsync(fdInv, existed, UserAccountID);
|
||||
var after = _invoiceDrafts.Get(token);
|
||||
if (after != null) await _draftNotifier.SignalDraftReadyAsync(after.Token, after.Version);
|
||||
return await JSONAsync(new { ok = true, invid = fdInv.Id });
|
||||
}
|
||||
|
||||
// POST inv/dhistory — { token } → { history[] }
|
||||
private async Task<IActionResult> HandleDraftHistory(string fn, string id, string code)
|
||||
{
|
||||
if (!HasForm("token")) return BadRequest400();
|
||||
if (_invoiceDrafts.Get(Form("token")) == null) return DraftGone();
|
||||
var history = _invoiceDrafts.GetHistory(Form("token"))
|
||||
.Select(h => new
|
||||
{
|
||||
timestamp = h.TimestampUtc,
|
||||
target = h.Target,
|
||||
@ref = h.Ref,
|
||||
oldValue = h.OldValue,
|
||||
newValue = h.NewValue,
|
||||
version = h.Version
|
||||
});
|
||||
return await JSONAsync(new { history });
|
||||
}
|
||||
|
||||
// POST inv/dclose — { token } → { ok }
|
||||
private async Task<IActionResult> HandleDraftClose(string fn, string id, string code)
|
||||
{
|
||||
if (!HasForm("token")) return BadRequest400();
|
||||
bool ok = _invoiceDrafts.Close(Form("token"));
|
||||
_logger.LogDebug("Draft dclose token={Token} removed={Removed} user={User}", Form("token"), ok, UserAccountID);
|
||||
return await JSONAsync(new { ok });
|
||||
}
|
||||
}
|
||||
@@ -158,6 +158,15 @@ public partial class IntranetController
|
||||
fds.FdsMfr.UpdateNeed.Reset, new[] { relId });
|
||||
return await JSONAsync(new { ok = true });
|
||||
|
||||
// ── Live backend-authoritative draft editing (ADR 0006) ───────────
|
||||
case "dopen": return await HandleDraftOpen(fn, id, code);
|
||||
case "dstate": return await HandleDraftState(fn, id, code);
|
||||
case "dpatch": return await HandleDraftPatch(fn, id, code);
|
||||
case "dpreview": return await HandleDraftPreview(fn, id, code);
|
||||
case "dsave": return await HandleDraftSave(fn, id, code);
|
||||
case "dhistory": return await HandleDraftHistory(fn, id, code);
|
||||
case "dclose": return await HandleDraftClose(fn, id, code);
|
||||
|
||||
default:
|
||||
_logger.LogWarning("Do_Process_Invoices: unhandled action id={Id}, user={User}", id, UserAccountID);
|
||||
return await JSONAsync(new { ok = true });
|
||||
|
||||
@@ -78,9 +78,17 @@ public partial class IntranetController
|
||||
var ldic = BuildInvoiceRequestList(sqldset);
|
||||
var adminDic = sqldset.Table("admin").FirstRow.toObjectDictionary();
|
||||
var invDic = sqldset.Table("inv").FirstRow.toObjectDictionary();
|
||||
bool has13b = invDic.nz("InvoiceOptions", "").Split(',').Contains("§13b");
|
||||
string invoiceOptions = invDic.nz("InvoiceOptions", "");
|
||||
bool has13b = invoiceOptions.Split(',').Contains("§13b");
|
||||
if (has13b)
|
||||
adminDic["p13b"] = true;
|
||||
// Carry the persisted set-pricing mode forward (see InvoiceSetPricing.cs / FdsInvoiceData.BuildInvoiceOptions):
|
||||
// needed so the editor knows the invoice has already been explicitly switched to a set-display
|
||||
// mode (including the default "setprice") and hides the "Set-Preisanzeige" menu entry accordingly.
|
||||
string? setmodeToken = invoiceOptions.Split(',')
|
||||
.FirstOrDefault(t => t.StartsWith("setmode:", StringComparison.OrdinalIgnoreCase));
|
||||
if (setmodeToken != null)
|
||||
adminDic["setmode"] = setmodeToken["setmode:".Length..].Trim().ToLowerInvariant();
|
||||
_logger.LogDebug("HandleInvoiceGet invoiceId={InvoiceId} requestCount={ReqCount} has13b={Has13b} user={User}",
|
||||
invoiceId, ldic.Count, has13b, UserAccountID);
|
||||
return await JSONAsync(new { admin = adminDic, inv = invDic, req = ldic });
|
||||
|
||||
@@ -97,6 +97,15 @@ public partial class IntranetController
|
||||
case "idoc": return await HandleReminderIdoc(fn, id, code);
|
||||
case "resend": return await HandleReminderResend(fn, id, code);
|
||||
|
||||
// ── Live backend-authoritative draft editing (ADR 0006) ───────────
|
||||
case "dopen": return await HandleReminderDraftOpen(fn, id, code);
|
||||
case "dstate": return await HandleReminderDraftState(fn, id, code);
|
||||
case "dpatch": return await HandleReminderDraftPatch(fn, id, code);
|
||||
case "dpreview": return await HandleReminderDraftPreview(fn, id, code);
|
||||
case "dsave": return await HandleReminderDraftSave(fn, id, code);
|
||||
case "dhistory": return await HandleReminderDraftHistory(fn, id, code);
|
||||
case "dclose": return await HandleReminderDraftClose(fn, id, code);
|
||||
|
||||
case "lrem":
|
||||
{
|
||||
if (!HasForm("id")) { _logger.LogWarning("Reminder lrem: missing form field 'id', user={User}", UserAccountID); return BadRequest400(); }
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
using Fuchs.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using static OCORE.web.mvc_helper_async;
|
||||
|
||||
namespace Fuchs.Controllers;
|
||||
|
||||
// Partial class: live, backend-authoritative reminder draft editing (ADR 0006) — the
|
||||
// reminder mirror of IntranetController.InvoiceDraft.cs. The browser posts single edits
|
||||
// here; the server mutates the in-memory session (the source of truth), recomputes the
|
||||
// open amount / validates, and pings the editing browser over the shared DraftPreviewHub
|
||||
// (draftReady) to re-fetch. Commands are ordinary POSTs — the hub carries only signals.
|
||||
public partial class IntranetController
|
||||
{
|
||||
// POST rem/dopen — { payload } → { token, version }
|
||||
private async Task<IActionResult> HandleReminderDraftOpen(string fn, string id, string code)
|
||||
{
|
||||
if (!HasForm("payload"))
|
||||
{
|
||||
_logger.LogWarning("Reminder draft dopen: 'payload' missing user={User}", UserAccountID);
|
||||
return BadRequest400();
|
||||
}
|
||||
JObject payload;
|
||||
try { payload = JObject.Parse(Form("payload")); }
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Reminder draft dopen: invalid payload JSON user={User}", UserAccountID);
|
||||
return BadRequest400();
|
||||
}
|
||||
var session = _reminderDrafts.OpenFromPayload(payload, UserAccountID);
|
||||
_logger.LogInformation("Reminder draft dopen: session {Token} (remId={RemId}) user={User}", session.Token, session.RemId, UserAccountID);
|
||||
// The browser holds the token from this response and fetches dstate directly; there is
|
||||
// no server 'draftReady' on open (it would race the client's group-join).
|
||||
return await JSONAsync(new { token = session.Token, version = session.Version });
|
||||
}
|
||||
|
||||
// POST rem/dstate — { token } → full view state
|
||||
private async Task<IActionResult> HandleReminderDraftState(string fn, string id, string code)
|
||||
{
|
||||
if (!HasForm("token")) return BadRequest400();
|
||||
var session = _reminderDrafts.Get(Form("token"));
|
||||
if (session == null) return DraftGone();
|
||||
return await JSONAsync(_reminderDrafts.BuildState(session));
|
||||
}
|
||||
|
||||
// POST rem/dpatch — { token, delta } → { ok, version }; signals draftReady
|
||||
private async Task<IActionResult> HandleReminderDraftPatch(string fn, string id, string code)
|
||||
{
|
||||
if (!HasForm("token", "delta")) return BadRequest400();
|
||||
ReminderDraftDelta? delta;
|
||||
try { delta = JsonConvert.DeserializeObject<ReminderDraftDelta>(Form("delta")); }
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Reminder draft dpatch: invalid delta JSON user={User}", UserAccountID);
|
||||
return BadRequest400();
|
||||
}
|
||||
if (delta == null || string.IsNullOrEmpty(delta.Target)) return BadRequest400();
|
||||
|
||||
var session = _reminderDrafts.ApplyPatch(Form("token"), delta);
|
||||
if (session == null) return DraftGone();
|
||||
await _draftNotifier.SignalDraftReadyAsync(session.Token, session.Version);
|
||||
return await JSONAsync(new { ok = true, version = session.Version });
|
||||
}
|
||||
|
||||
// POST rem/dpreview — { token } → { img[], total } (rendered straight from the cache)
|
||||
private async Task<IActionResult> HandleReminderDraftPreview(string fn, string id, string code)
|
||||
{
|
||||
if (!HasForm("token")) return BadRequest400();
|
||||
var doc = _reminderDrafts.RenderPreview(Form("token"));
|
||||
if (doc == null) return DraftGone();
|
||||
var imgcol = await _pdf.DocToImageCollectionAsync(doc);
|
||||
return await JSONAsync(new { img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
||||
}
|
||||
|
||||
// POST rem/dsave — { token } → { ok, remid }; flush cache→DB + business event + draftReady
|
||||
private async Task<IActionResult> HandleReminderDraftSave(string fn, string id, string code)
|
||||
{
|
||||
if (!HasForm("token")) return BadRequest400();
|
||||
string token = Form("token");
|
||||
var before = _reminderDrafts.Get(token);
|
||||
if (before == null) return DraftGone();
|
||||
bool existed = !string.IsNullOrEmpty(before.RemId);
|
||||
|
||||
var fdRem = await _reminderDrafts.FlushToDbAsync(token, UserAccountID, DbSec);
|
||||
if (fdRem == null) return DraftGone();
|
||||
if (string.IsNullOrEmpty(fdRem.Id))
|
||||
return await ReminderIssueResult("Der Zwischenstand konnte aufgrund eines Fehlers nicht gespeichert werden.");
|
||||
|
||||
await _events.ReminderDraftRegisteredAsync(fdRem, existed, UserAccountID);
|
||||
var after = _reminderDrafts.Get(token);
|
||||
if (after != null) await _draftNotifier.SignalDraftReadyAsync(after.Token, after.Version);
|
||||
return await JSONAsync(new { ok = true, remid = fdRem.Id });
|
||||
}
|
||||
|
||||
// POST rem/dhistory — { token } → { history[] }
|
||||
private async Task<IActionResult> HandleReminderDraftHistory(string fn, string id, string code)
|
||||
{
|
||||
if (!HasForm("token")) return BadRequest400();
|
||||
if (_reminderDrafts.Get(Form("token")) == null) return DraftGone();
|
||||
var history = _reminderDrafts.GetHistory(Form("token"))
|
||||
.Select(h => new
|
||||
{
|
||||
timestamp = h.TimestampUtc,
|
||||
target = h.Target,
|
||||
@ref = h.Ref,
|
||||
oldValue = h.OldValue,
|
||||
newValue = h.NewValue,
|
||||
version = h.Version
|
||||
});
|
||||
return await JSONAsync(new { history });
|
||||
}
|
||||
|
||||
// POST rem/dclose — { token } → { ok }
|
||||
private async Task<IActionResult> HandleReminderDraftClose(string fn, string id, string code)
|
||||
{
|
||||
if (!HasForm("token")) return BadRequest400();
|
||||
bool ok = _reminderDrafts.Close(Form("token"));
|
||||
_logger.LogDebug("Reminder draft dclose token={Token} removed={Removed} user={User}", Form("token"), ok, UserAccountID);
|
||||
return await JSONAsync(new { ok });
|
||||
}
|
||||
}
|
||||
@@ -369,6 +369,21 @@ public partial class IntranetController
|
||||
return await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht erstellt werden.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serves the PDF inline (browser shows it) while advertising the real download filename —
|
||||
/// both a quoted ASCII form and RFC 5987 <c>filename*</c> for spaces/non-ASCII. Works around
|
||||
/// the OCORE FileContentResult helper, whose classic-MVC <c>ExecuteResult(ControllerContext)</c>
|
||||
/// never runs under ASP.NET Core, so the filename was dropped and downloads used the "idoc"
|
||||
/// endpoint segment.
|
||||
/// </summary>
|
||||
private void SetInlinePdfFilename(string filename)
|
||||
{
|
||||
string safe = (filename ?? "").Replace("\"", "").Replace("\r", " ").Replace("\n", " ").Trim();
|
||||
if (safe.Length == 0) return;
|
||||
Response.Headers["Content-Disposition"] =
|
||||
$"inline; filename=\"{safe}\"; filename*=UTF-8''{Uri.EscapeDataString(safe)}";
|
||||
}
|
||||
|
||||
private async Task<IActionResult> HandleRequestIdoc(string fn, string id, string code)
|
||||
{
|
||||
if (!HasForm("id") || string.IsNullOrEmpty(Form("id"))) { _logger.LogWarning("HandleRequestIdoc: missing/empty form field 'id', user={User}", UserAccountID); return StatusCode(404); }
|
||||
@@ -381,9 +396,13 @@ public partial class IntranetController
|
||||
byte[]? ct = Form("create", "0") != "1"
|
||||
? await _invoices.GetInvoiceFileAsync(fdInv, fdInv.IsDraft, _mfr) is { Length: > 0 } f1 ? f1 : await _invoices.StoreInvoiceDocumentFileAsync(fdInv, fdInv.IsDraft, UserAccountID, DbSec)
|
||||
: _pdf.DocToPdfBytes(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft));
|
||||
return ct != null
|
||||
? await FileContentResultAsync(ct, "application/pdf", filename, inline: true)
|
||||
: await InvoiceIssueResult("Die Rechnungs-PDF konnte aufgrund eines Fehlers nicht erstellt werden.", fdInv.Id);
|
||||
if (ct == null)
|
||||
return await InvoiceIssueResult("Die Rechnungs-PDF konnte aufgrund eines Fehlers nicht erstellt werden.", fdInv.Id);
|
||||
// Serve inline for the in-browser viewer, but carry the real DocumentName so the browser's
|
||||
// "download" uses "Rechnung R2026-0121.pdf" instead of the "idoc" endpoint segment. (The
|
||||
// OCORE FileContentResult helper drops the filename under ASP.NET Core, so set it here.)
|
||||
SetInlinePdfFilename(filename);
|
||||
return File(ct, "application/pdf");
|
||||
}
|
||||
var imgcol = await _pdf.DocToImageCollectionAsync(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft));
|
||||
return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
||||
|
||||
@@ -35,6 +35,10 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
private readonly IInvoiceService _invoices;
|
||||
private readonly IReminderService _reminders;
|
||||
private readonly IEventService _events;
|
||||
private readonly IInvoiceDraftService _invoiceDrafts;
|
||||
private readonly IReminderDraftService _reminderDrafts;
|
||||
private readonly IDraftNotifier _draftNotifier;
|
||||
private readonly ISystemStatusService _systemStatus;
|
||||
private readonly List<string> _allowedNonAuth = new() { "spwc", "spw" };
|
||||
private readonly List<string> _allowedGet = new()
|
||||
{
|
||||
@@ -62,7 +66,11 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
IReportService reports,
|
||||
IInvoiceService invoices,
|
||||
IReminderService reminders,
|
||||
IEventService events)
|
||||
IEventService events,
|
||||
IInvoiceDraftService invoiceDrafts,
|
||||
IReminderDraftService reminderDrafts,
|
||||
IDraftNotifier draftNotifier,
|
||||
ISystemStatusService systemStatus)
|
||||
{
|
||||
_intranet = intranet;
|
||||
_mfr = mfr;
|
||||
@@ -76,6 +84,10 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
_invoices = invoices;
|
||||
_reminders = reminders;
|
||||
_events = events;
|
||||
_invoiceDrafts = invoiceDrafts;
|
||||
_reminderDrafts = reminderDrafts;
|
||||
_draftNotifier = draftNotifier;
|
||||
_systemStatus = systemStatus;
|
||||
}
|
||||
|
||||
/// <summary>Merged query-string + form parameters (form wins) for report processing.</summary>
|
||||
@@ -154,6 +166,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
"rem" => await Do_Process_Reminder(fn, id, code),
|
||||
"rep" => await Do_Process_Reports(fn, id, code),
|
||||
"bam" => await Do_Process_Bankings(fn, id, code),
|
||||
"admin" => await Do_Process_Admin(fn, id, code),
|
||||
"auth" => await HandleAuth(fn, id, code),
|
||||
"spwc" => await HandleSendPasswordCode(fn, id, code),
|
||||
"spw" => await HandleSendPassword(fn, id, code),
|
||||
|
||||
+17
-10
@@ -12,7 +12,7 @@ The **Fuchs Intranet** solution is a line-of-business web application for **Seba
|
||||
| Project | Type | Purpose |
|
||||
|---|---|---|
|
||||
| **Fuchs** | ASP.NET Core Web (MVC) | Main web application — the intranet |
|
||||
| **Fuchs_DataService** | Console / Windows Service (Topshelf) | Background data sync service (MFR ERP polling) |
|
||||
| **Fuchs_DataService** | Class Library | MFR ERP sync logic (entity polling, invoice/DATEV files). Hosted in-process by **Fuchs** as a `PeriodicHostedService` (see ADR 0010) — no longer a standalone process. |
|
||||
| **MFR_RESTClient** | Class Library | REST/OData client for the MFR ERP system. The REST/OData contract is documented in `MFR_RESTClient/Docs/mfr_interface_description.md`. |
|
||||
| **Fuchs_Database** | SSDT (SQL project) | Source of truth for the `fuchs_fds` SQL schema (tables, table types, functions, stored procedures the backend calls). |
|
||||
| **OCORE** | Class Library (shared) | Core utilities: SQL, crypto, email, IO, logging |
|
||||
@@ -96,17 +96,18 @@ The **Fuchs Intranet** solution is a line-of-business web application for **Seba
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ Fuchs_DataService (Windows Service / Console) │
|
||||
│ Fuchs_DataService (Class Library, hosted in-process) │
|
||||
│ │
|
||||
│ FdsMain.cs — Topshelf host, job definitions │
|
||||
│ PeriodicHostedService — BackgroundService with PeriodicTimer │
|
||||
│ PeriodicHostedService — in Fuchs/Services; BackgroundService + │
|
||||
│ PeriodicTimer, registered in Program.cs │
|
||||
│ when Fds:SyncEnabled (ADR 0010) │
|
||||
│ FdsMfr.cs (IFdsMfr) — MFR sync orchestration │
|
||||
│ FdsMfrClient.cs — MFR REST client wrapper │
|
||||
│ FdsShared.cs — FdsConfig (appsettings.json reader) │
|
||||
│ FdsZip.cs — 7-Zip archive handling (DATEV export) │
|
||||
│ FdsShared.cs — FdsConfig (reads the host's IConfiguration) │
|
||||
│ (DATEV zip) — OCORE.zip (System.IO.Compression), no 7-Zip │
|
||||
│ FdsDebug.cs — Debug/file logging │
|
||||
│ │
|
||||
│ Jobs: MfrSync (every N min) │
|
||||
│ Jobs: MfrSync (every Fds:ExecutionFrequency_Minutes) │
|
||||
│ → UpdateIfNecessary_async (entity table sync) │
|
||||
│ → UpdateRequested_async (on-demand entity refresh) │
|
||||
│ → GetInvoiceFiles_async (invoice PDF download) │
|
||||
@@ -167,8 +168,14 @@ OCORE_Charting (standalone — referenced by solution but no direct project ref
|
||||
|
||||
### 4.3 Service Layer (Dependency Injection)
|
||||
Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces, injected into `IntranetController`:
|
||||
`IComService`, `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`.
|
||||
`IComService`, `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`, `ISystemStatusService`, `IERechnungService`.
|
||||
|
||||
`IERechnungService` (singleton) maps a finalized invoice to the EN 16931 model and embeds the
|
||||
CII XML into the render-only visual PDF to produce a ZUGFeRD/Factur-X **PDF/A-3** hybrid via the
|
||||
`eRechnungLib` submodule (gated by `Fuchs:ERechnung:Enabled`; falls back to the plain PDF/A on
|
||||
disable/failure). See [`Concepts/erechnung-output.md`](Concepts/erechnung-output.md) and ADR 0012.
|
||||
Stateless services (`IPdfService`, `IBankingService`, `IMfrClientFactory`) are singletons; DB/request-scoped services are scoped (see `Program.cs`).
|
||||
The **Admin** module (`Do_Process_Admin`, `ISystemStatusService`) surfaces a live system-status/diagnostics page (host, SQL/Key Vault/blob/MFR connectivity, email config, test-email) restricted to `fds_sys` > 4 — see ADR [0011](Decisions/0011-admin-module-system-status.md) and the [concept doc](Concepts/admin-system-status.md).
|
||||
`FdsInvoiceData` / `FdsReminderData` are now **pure data holders** (parse + properties); loading, persistence and PDF generation live in the services (fully async — no `Task.Run(...).Wait()`).
|
||||
`FuchsPdf` / `FuchsVisualization` remain as static rendering libraries used *by* the services. The earlier static, controller-coupled helpers (`FuchsWidgets`, `FuchsReports`, `Banking`, `FuchsFdsEmail`) have been removed.
|
||||
|
||||
@@ -176,7 +183,7 @@ Stateless services (`IPdfService`, `IBankingService`, `IMfrClientFactory`) are s
|
||||
There is no ORM (no EF Core). All data access uses **ADO.NET via OCORE SQL helpers** (`getSQLDatatable_async`, `getSQLDataSet_async`, `setSQLValue_async`) calling stored procedures and inline SQL. `DataTable`/`DataRow` is the primary data transfer mechanism.
|
||||
|
||||
### 4.5 Background Service
|
||||
`Fuchs_DataService` runs as a Windows Service (Topshelf) with a `PeriodicHostedService` that polls the MFR ERP system on a timer, syncing entities and downloading invoice files.
|
||||
The MFR ERP sync runs **in-process inside the web app** as a `PeriodicHostedService` (`Fuchs/Services/PeriodicHostedService.cs`), registered in `Program.cs` when `Fds:SyncEnabled` is true. It polls the MFR ERP on a timer (`Fds:ExecutionFrequency_Minutes`), syncing entities and downloading invoice files via the sync logic in the `Fuchs_DataService` library. See ADR [0010](Decisions/0010-mfr-sync-hosted-in-web-app.md).
|
||||
|
||||
### 4.6 Authentication
|
||||
Cookie-based authentication (`CookieAuthenticationDefaults`) with custom claims (`FuchsUserIdentity`). SQL-based user/password verification.
|
||||
@@ -409,7 +416,7 @@ public class MfrClientFactory : IMfrClientFactory, IDisposable
|
||||
2. ✅ **Resolved** — `FdsInvoiceData`/`FdsReminderData` are now pure data holders; DB + PDF logic moved to `IInvoiceService`/`IReminderService`.
|
||||
3. ✅ **Resolved** — `FdsMfrClient` is created via `IMfrClientFactory` (no `new` in controllers).
|
||||
4. ✅ **Resolved** — `OCORE_Charting` is now used (transitively, via `OCORE_web`'s chart engine) by the report renderer (`FuchsVisualization`).
|
||||
5. ⏳ **Open** — **Topshelf** in `Fuchs_DataService` could be replaced with native `dotnet` Worker Service hosting for .NET 10 alignment.
|
||||
5. ✅ **Resolved** — **Topshelf** removed; `Fuchs_DataService` is now a class library and the MFR sync is hosted in-process by the web app as a `PeriodicHostedService` (ADR 0010).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
---
|
||||
status: Active
|
||||
lastUpdated: 2026-07-16
|
||||
applyTo:
|
||||
- "Fuchs/Controllers/IntranetController.Admin.cs"
|
||||
- "Fuchs/Services/SystemStatusService.cs"
|
||||
- "Fuchs/Services/ISystemStatusService.cs"
|
||||
- "Fuchs/Services/SystemStatusModels.cs"
|
||||
- "Fuchs/js/intranet/modules/fis.admin*.js"
|
||||
- "Fuchs/js/intranet/modules/fis.admin.scss"
|
||||
- "Fuchs/js/intranet/fis_main_menu.js"
|
||||
relatedDecisions:
|
||||
- "0011-admin-module-system-status.md"
|
||||
---
|
||||
|
||||
# Admin / System-Status module
|
||||
|
||||
## Summary
|
||||
The **Admin** module gives a privileged operator a live, in-app view of the running
|
||||
deployment's configuration and health, plus a test-email tool. It answers "which host am I
|
||||
on, can this instance reach SQL Server / Key Vault / Blob Storage / the MFR ERP, how is
|
||||
email wired up (including the OverrideRecipient redirect), and does sending actually work".
|
||||
Access is restricted to users whose `fds_sys` module authorization is greater than 4.
|
||||
|
||||
## How it works
|
||||
|
||||
### Authorization (two layers)
|
||||
1. **Menu visibility** — at page load `fis_main_menu.js#addAdminMenuIfAuthorized` calls
|
||||
`$fis.getAuth('fds_sys')`; only when the level is > 4 does it push the `init:admin`
|
||||
button into `$ocms.ocmsmenu` and re-render `#mainmenu`. Users below the threshold never
|
||||
see the button and never fetch the module script.
|
||||
2. **Server-side gate** — every `/do/admin/*` endpoint resolves
|
||||
`fis_getModuleAuth('fds_sys', authuser)` and returns 401 unless it is > 4. The only
|
||||
exception is `admin/auth`, which returns `{ manage: 0 }` for unauthorized users so the
|
||||
frontend can cleanly decline to render. This is defense in depth: hiding the button is
|
||||
not a security control on its own.
|
||||
|
||||
### Request flow
|
||||
```
|
||||
click "Administration" → $ocms.init('admin')
|
||||
POST /do/admin/auth → { manage, level } (manage>0 ⇒ authorized)
|
||||
load /web/fis.admin.de.js + /web/fis.admin.css
|
||||
$ocms.admin.init2() → init3() renders the page
|
||||
POST /do/admin/status → { info, probes } full snapshot + all probes
|
||||
per-card "Aktualisieren" → POST /do/admin/probe/<component> → { probe }
|
||||
"Test-E-Mail senden" → POST /do/admin/testmail (to/subject/body) → { result }
|
||||
```
|
||||
|
||||
### Backend (`ISystemStatusService` / `SystemStatusService`, scoped)
|
||||
- `GetInfo()` — passive snapshot, no network calls: host (machine/OS/framework/environment/
|
||||
test-deployment/uptime), database (server/catalog/login parsed from the connection string —
|
||||
**never the password**), email (mailer enabled/base-url/account/server-id/token-present +
|
||||
OverrideRecipient), blob (enabled/configured/containers), MFR (host/creds-present/sync),
|
||||
Key Vault (vault-uri/app-prefix/managed-secret-count/client-registered).
|
||||
- Probes (`database`, `keyvault`, `blob`, `mfr`) each return a `SystemProbeResult`
|
||||
(`status` = `ok`/`error`/`disabled`/`unconfigured`, `ok`, `message`, `detail`, `durationMs`,
|
||||
optional `metrics`). They never throw — failures are captured in the result. `database` runs
|
||||
`SELECT @@SERVERNAME, DB_NAME(), SUSER_SNAME()`; `keyvault` reads the first managed secret via
|
||||
the DI `SecretClient`; `blob` calls `IBlobStorageService.CheckConnectivityAsync` (account-info
|
||||
request **plus a per-container blob count** for the invoice/reminder containers, surfaced as
|
||||
`metrics` and totalled in the message); `mfr` calls `IMfrClientFactory.Create().GetEntities()`.
|
||||
The generic `metrics` (label/value pairs) is how a probe reports extra facts for display — the
|
||||
blob probe uses it for the file counts.
|
||||
- `SendTestEmailAsync(to, subject, body)` HTML-encodes the body and sends via `IComService`, so
|
||||
the `Fuchs:Email:OverrideRecipient` redirect applies exactly as for any other mail; the result
|
||||
reports the requested recipient and, when active, the override target.
|
||||
- Every probe emits the `fuchs.systemstatus.probes` counter tagged by component + status and an
|
||||
`systemstatus.probe` activity span.
|
||||
|
||||
### Startup-checks widget (non-refreshable)
|
||||
`StartupSelfTestService` runs the boot self-test once (Key Vault / Database / MFR / PDF-license /
|
||||
mailer, gated by `Fuchs:StartupChecks:*`). It now also writes its outcome to the singleton
|
||||
`StartupCheckReporter`, which `GetInfo()` returns as `StartupChecks`. The Admin page renders it as a
|
||||
single **non-refreshable** card (there is no per-check retry — the live connectivity probes above
|
||||
cover on-demand re-testing; the startup card is a historical record of the boot run). When the
|
||||
self-test is disabled (`Enabled=false`, the appsettings default) or has not completed, the card shows
|
||||
a "nicht ausgeführt" note. Each item shows OK / Fehler / übersprungen (a disabled check reports
|
||||
`enabled=false`).
|
||||
|
||||
### Frontend (`fis.admin.js` + `fis.admin_txt_de.js` + `fis.admin.scss`)
|
||||
Standard lazy-loaded module (same contract as `inv`/`rep`/`bam`). Renders a system card plus a
|
||||
responsive grid of status cards; connectivity cards carry a colored status pill and a per-card
|
||||
refresh button, the email card carries the test-email dialog. The topbar has an "Alle prüfen"
|
||||
button that reloads the whole snapshot. Admin responses are serialized **camelCase** (the DTOs are
|
||||
PascalCase) so the JS reads `probe.status`, `info.database.server`, etc.
|
||||
|
||||
## Key files
|
||||
- `Fuchs/Controllers/IntranetController.Admin.cs` — `Do_Process_Admin` dispatch + auth gate + camelCase JSON helper.
|
||||
- `Fuchs/Services/ISystemStatusService.cs`, `SystemStatusService.cs`, `SystemStatusModels.cs` — diagnostics service + DTOs.
|
||||
- `Fuchs/Services/StartupCheckReport.cs` (`StartupCheckReporter` singleton) + `StartupSelfTestService.cs` — boot self-test result captured for the non-refreshable widget.
|
||||
- `Fuchs/Services/IBlobStorageService.cs` / `AzureBlobStorageService.cs` — `CheckConnectivityAsync` + `BlobConnectivity`.
|
||||
- `Fuchs/js/intranet/modules/fis.admin*.js`, `fis.admin.scss` — the module, texts, styles (bundled via `bdlconfig.json`).
|
||||
- `Fuchs/js/intranet/fis_main_menu.js` (`addAdminMenuIfAuthorized`) + `fis_main_go.js` — conditional menu button.
|
||||
- `Fuchs.Tests/SystemStatusServiceTests.cs` — service tests.
|
||||
|
||||
## Related decisions
|
||||
- [0011 — Admin module gated on `fds_sys` > 4](../Decisions/0011-admin-module-system-status.md)
|
||||
@@ -0,0 +1,84 @@
|
||||
---
|
||||
status: Active
|
||||
lastUpdated: 2026-07-18
|
||||
applyTo:
|
||||
- "Fuchs/Services/ERechnungMapper.cs"
|
||||
- "Fuchs/Services/ERechnungService.cs"
|
||||
- "Fuchs/Services/ERechnungSettings.cs"
|
||||
- "Fuchs/Services/InvoiceRecipientAddress.cs"
|
||||
- "Fuchs/Services/InvoiceService.cs"
|
||||
- "Fuchs/code/FuchsPdf.cs"
|
||||
- "eRechnungLib/**"
|
||||
relatedDecisions:
|
||||
- "0005-pdf-generation-and-erechnung.md"
|
||||
- "0012-erechnung-single-pdfa-engine-pipeline.md"
|
||||
---
|
||||
|
||||
# eRechnung output (ZUGFeRD/Factur-X)
|
||||
|
||||
## Summary
|
||||
Finalized invoices are emitted as a **ZUGFeRD 2.4 / Factur-X** hybrid: the FuchsPdf visual PDF
|
||||
with the EN 16931 **CII XML** embedded, in a formally conformant **PDF/A-3**. This makes invoices
|
||||
DATEV-ingestible and satisfies the B2B/B2G e-invoicing mandate. The library doing the structured
|
||||
XML + PDF/A-3 work is the `eRechnungLib` submodule; Fuchs supplies the invoice data and the visual
|
||||
PDF.
|
||||
|
||||
## How it works
|
||||
|
||||
```
|
||||
Editor (structured recipient dialog)
|
||||
→ InvoiceDraftEditService (address delta = JSON object, stored in CustomValues.sendToAddress)
|
||||
→ fds__setInvoice/… (persisted; dedicated SendToAddressJson column + composed SendToAddress)
|
||||
→ InvoiceService.RenderInvoicePdfBytesAsync(final)
|
||||
├─ FuchsPdf.DocToPdfBytesRaw(doc) → visual PDF (no Spire PDF/A)
|
||||
└─ IERechnungService.TryBuildHybridPdf
|
||||
├─ ERechnungMapper.BuildEInvoice FdsInvoiceData → eRechnungLib.Model.Invoice
|
||||
└─ EInvoice.ToZugferd(EN16931, raw) → PDF/A-3 + Factur-X hybrid (bundled sRGB ICC)
|
||||
```
|
||||
|
||||
- **Single PDF/A engine (ADR 0012).** eRechnungLib owns the one PDF/A-3 layer. For the eRechnung
|
||||
path the Spire PDF/A step is skipped (`DocToPdfBytesRaw`); Spire stays only for on-screen preview
|
||||
rasterisation. This avoids a conflicting second output intent / `pdfaid` marker.
|
||||
- **Structured recipient address.** `InvoiceRecipientAddress` holds the EN 16931 buyer fields
|
||||
(name, street, post code, city, country BT-55, optional VAT id BT-48). It is edited via a
|
||||
dialog form (`$inv.eAddress` in `fis.inv_shared.js`), prefilled from `fds__prepInvoice`'s
|
||||
`invoiceaddressData`, and carried as a JSON object through the draft cache. A private person
|
||||
(no VAT id) is fully valid — B2C stays effortless. The free-text `SendToAddress` is composed
|
||||
from it so the PDF layout is unchanged.
|
||||
- **Mapping.** `ERechnungMapper` maps the Fuchs invoice to the EN 16931 model: seller = Fuchs
|
||||
(from `Fuchs:ERechnung:Seller` config — name, address, Steuernummer BT-32, **USt-IdNr BT-31**,
|
||||
IBAN/BIC, contact; defaults mirror the FuchsPdf letterhead), buyer = the structured recipient,
|
||||
lines from the invoice items, §13b → reverse charge (category AE + exemption reason), payment
|
||||
terms BT-20/BT-9, and the service date/period (BT-72 or BG-14) parsed from the structured
|
||||
`ProvisionPeriod`. VAT breakdown and totals are recomputed by the library.
|
||||
- **Profile selection (B2B/B2C vs B2G).** Default is ZUGFeRD **EN 16931** (DATEV). When the
|
||||
recipient carries a **Leitweg-ID** (`InvoiceRecipientAddress.LeitwegId` → `BuyerReference` BT-10),
|
||||
the invoice is B2G and emitted as **XRechnung** (`ZugferdProfile.XRechnung`, embedded
|
||||
`xrechnung.xml`); the mapper then also fills the seller electronic address/contact (BT-34/BG-6)
|
||||
and buyer electronic address (BT-49) that XRechnung requires.
|
||||
- **Feature flag & fallback.** Emission is gated by `Fuchs:ERechnung:Enabled` (off until fully
|
||||
validated). `IERechnungService` returns `null` on disable **or any failure**, so
|
||||
`RenderInvoicePdfBytesAsync` falls back to the plain Spire PDF/A — invoicing never breaks.
|
||||
- **Formal verification.** `Fuchs:ERechnung:Validation` calls the ProcessWeb eInvoice service
|
||||
(`POST {ServiceUrl}/validatepdf`, raw `application/pdf`) which checks the EN 16931 XML **and**
|
||||
PDF/A-3 (veraPDF) in one call. Both the EN 16931-ZUGFeRD and the XRechnung 3.0 output are
|
||||
externally **ACCEPTED** (0 errors) and **PDF/A-3B COMPLIANT**. Getting there required fixing two
|
||||
eRechnungLib defects (CII root children must be `rsm:` not `ram:`; embedded-file `/Subtype` MIME
|
||||
encoding) and completing the mapper (seller VAT id BT-31, payment terms BT-20/BT-9). The
|
||||
validator's target feature scope is documented in
|
||||
[`../eRechnung-Validator-Requirements.md`](../eRechnung-Validator-Requirements.md).
|
||||
|
||||
## Key files
|
||||
- `Fuchs/Services/InvoiceRecipientAddress.cs` — structured buyer address, composition, conformity.
|
||||
- `Fuchs/Services/ERechnungMapper.cs` — `FdsInvoiceData` → `eRechnungLib.Model.Invoice`.
|
||||
- `Fuchs/Services/ERechnungService.cs` / `ERechnungSettings.cs` — hybrid production + config.
|
||||
- `Fuchs/Services/InvoiceService.cs` — wiring in `RenderInvoicePdfBytesAsync`.
|
||||
- `Fuchs/code/FuchsPdf.cs` — `DocToPdfBytesRaw` (render-only visual PDF).
|
||||
- `Fuchs/js/intranet/modules/fis.inv_shared.js` — `$inv.eAddress` structured dialog.
|
||||
- `Fuchs_Database` — `fds__invoices.SendToAddressJson`, `fds__getCompanyAddressJson`,
|
||||
`fds__prepInvoice.invoiceaddressData`, `fds__createInvoice`/`setInvoice`/`getInvoice`.
|
||||
- `eRechnungLib/**` — CII/UBL serialization, EN 16931 validation, `FacturXPdfBuilder` (PDF/A-3).
|
||||
|
||||
## Related decisions
|
||||
- [`0005-pdf-generation-and-erechnung.md`](../Decisions/0005-pdf-generation-and-erechnung.md)
|
||||
- [`0012-erechnung-single-pdfa-engine-pipeline.md`](../Decisions/0012-erechnung-single-pdfa-engine-pipeline.md)
|
||||
@@ -0,0 +1,153 @@
|
||||
---
|
||||
status: Active
|
||||
lastUpdated: 2026-07-10
|
||||
applyTo:
|
||||
- "Fuchs/Services/InvoiceDraft*"
|
||||
- "Fuchs/Services/IInvoiceDraft*"
|
||||
- "Fuchs/Services/ReminderDraft*"
|
||||
- "Fuchs/Services/IReminderDraft*"
|
||||
- "Fuchs/code/InvoiceDraftSession.cs"
|
||||
- "Fuchs/code/InvoiceDraftCalculator.cs"
|
||||
- "Fuchs/code/ReminderDraftSession.cs"
|
||||
- "Fuchs/code/ReminderDraftCalculator.cs"
|
||||
- "Fuchs/Notifications/DraftPreviewHub.cs"
|
||||
- "Fuchs/Notifications/*DraftNotifier*"
|
||||
- "Fuchs/Controllers/IntranetController.InvoiceDraft.cs"
|
||||
- "Fuchs/Controllers/IntranetController.ReminderDraft.cs"
|
||||
- "Fuchs/js/intranet/**"
|
||||
relatedDecisions:
|
||||
- "0006-backend-authoritative-draft-editing.md"
|
||||
- "0007-targeted-draft-signalr-groups.md"
|
||||
---
|
||||
|
||||
# Live draft editing (backend-authoritative invoice previews)
|
||||
|
||||
## Summary
|
||||
While a back-office user edits an invoice draft, the authoritative state is held in
|
||||
server memory, not in the browser. The browser posts single edits, the server mutates
|
||||
the cached record, recomputes totals/VAT and re-validates, then pushes a "state changed"
|
||||
signal so the browser re-fetches and re-renders. This makes the backend the single source
|
||||
of truth (server-computed sums, consistency checks, in-place PDF preview, change history,
|
||||
explicit discard), reversing the earlier stateless editor. Invoices were the pilot;
|
||||
reminders now mirror the same design (see "Reminders" below).
|
||||
|
||||
## How it works
|
||||
|
||||
```
|
||||
Open: Browser --POST inv/dopen {id | payload}--> server builds InvoiceDraftSession, caches it
|
||||
Browser --SignalR JoinDraft(token)--> joins the draft's group; spinner while loading
|
||||
Browser --POST inv/dstate {token}--> renders admin/new/req + server sums + validation
|
||||
|
||||
Edit: Browser --POST inv/dpatch {token, delta}--> mutate + recompute + validate + version++
|
||||
Server --SignalR draftReady{token,version}--> Browser re-fetches inv/dstate, re-renders
|
||||
|
||||
Preview: Browser --POST inv/dpreview {token}--> PDF rendered straight from the cache (no upload)
|
||||
Save: Browser --POST inv/dsave {token}--> flush cache->DB (RegisterInvoiceAsync) + EventService toast
|
||||
History: Browser --POST inv/dhistory {token}--> change list -> "Änderungshistorie" dialog
|
||||
Discard: Browser --POST inv/ddiscard {token}--> reload session from DB draft -> draftReady
|
||||
Close: Browser --POST inv/dclose {token}--> session removed (+ LeaveDraft)
|
||||
|
||||
Expiry: Server (timer) --SignalR draftExpiring{token,secondsLeft}--> warn "bitte zwischenspeichern"
|
||||
Server (evict) --SignalR draftClosed{token,reason}--> close the editor with a reason
|
||||
```
|
||||
|
||||
- **Session** (`InvoiceDraftSession`) is a pure data holder: the editable payload as the
|
||||
exact editor JSON (`admin` / `new` / `req` blocks with `items`), plus server-computed
|
||||
`Sums`, `ValidationMessages`, `History`, `Version`, `Token`, `InvId`, `LastAccessUtc`.
|
||||
- **Calculation** (`InvoiceDraftCalculator`, static/pure) ports the former client math:
|
||||
`RecomputeLineValues` (net/VAT/service-net/service-VAT per line from raw quantity ×
|
||||
price × VAT rate, the `quantChange`/`setVat` port), `RecomputeTotals`
|
||||
(the `invSumUpdate`/`csms` aggregation + §13b reverse-charge), `RecomputePositions`
|
||||
(numbers every line except heading/free-text lines continuously across the whole invoice —
|
||||
mirroring the editor's `invSumUpdate`, so the editor and the PDF show identical `Pos.` numbers,
|
||||
including after a reorder),
|
||||
and `Validate` (email/address/items/VAT-rate/negative-total checks). Being pure, it is
|
||||
exhaustively unit-tested. **The online editor performs no arithmetic of any kind** — not
|
||||
header, footer, sums, totals, taxes, nor a single line's own net/VAT value: `$inv.quantChange`
|
||||
and `$inv.setVat` only post the raw field the user changed (qty/price/vat rate), and
|
||||
`$inv.invSumUpdate` only reassembles the row-contract array needed to post `req` — it computes
|
||||
no totals, no VAT breakdown, and no service-refund note figures. All of those are rendered
|
||||
exclusively from `dstate.sums` via `$inv.d.footer`.
|
||||
- **Sanitisation & reorder.** Scalar text deltas (`title`/`email`/`address`/`provisionperiod`/
|
||||
`provisionlocation`) and the section heading (`block.replace`) are stripped of the editor's
|
||||
TinyMCE HTML (`<p>…</p>`, `<br>`) to plain text in `ApplyDelta` (`HtmlToPlain`) — the backend
|
||||
is the single source of truth, so no HTML reaches the DB, the PDF or a reloaded draft. Section
|
||||
drags post a `block.order` delta (`["id",…]`) that reorders `Req`; positions are then
|
||||
renumbered and pushed back via the view state (`applyState`/`applyPositions`). The change
|
||||
history records the **changed field** (e.g. the new heading text), never the whole block JSON.
|
||||
The PDF (`FuchsPdf`) renders a heading row per block (`FdsInvoiceData.InvoiceBlocks`) and shows
|
||||
every position's price (set members are priced like standalone lines; only `setonly` collapses
|
||||
them), so the PDF preview mirrors the online editor.
|
||||
- **Orchestration** (`InvoiceDraftEditService`, scoped) opens sessions (from a fresh
|
||||
payload or by reloading a DB draft via `fds__getInvoice`, reshaped like
|
||||
`BuildInvoiceRequestList`), applies deltas (`ApplyDelta`), builds the view-state DTO,
|
||||
flushes to the DB by reusing `IInvoiceService.RegisterInvoiceAsync` (no new persistence
|
||||
path), renders previews from a synthesised registration, and discards by reloading.
|
||||
- **Cache** (`InvoiceDraftCache`, singleton) stores sessions by token with an idle sliding
|
||||
TTL; `InvoiceDraftExpiryService` (a `BackgroundService`) warns before, and evicts after,
|
||||
the TTL. TTL/warn-lead are configurable under `Fuchs:DraftEditing`.
|
||||
- **Signals** (`DraftPreviewHub` at `/draftpreview` + `IDraftNotifier`) are targeted at the
|
||||
editing browser via a group named after the session token: `draftReady`, `draftExpiring`,
|
||||
`draftClosed`. Business success/failure still flows through `IEventService`/`NotificationHub`.
|
||||
- **Frontend** (`$fis.draft` in `fis_main.js`, editor in `fis.inv_shared.js`) opens/joins,
|
||||
posts one delta per change, shows a loading state whenever awaiting a signal, and offers
|
||||
"Änderungen verwerfen" and "Änderungshistorie" menu actions. It computes nothing — no
|
||||
header, footer, sums, totals, taxes, or per-line values.
|
||||
|
||||
## Key files
|
||||
- `Fuchs/code/InvoiceDraftSession.cs` — session + `ChangeHistoryEntry` + `InvoiceDraftSums`.
|
||||
- `Fuchs/code/InvoiceDraftCalculator.cs` — pure recompute + validate.
|
||||
- `Fuchs/Services/InvoiceDraftCache.cs` / `IInvoiceDraftCache.cs` — in-memory store + TTL.
|
||||
- `Fuchs/Services/InvoiceDraftEditService.cs` / `IInvoiceDraftService.cs` — orchestration + delta contract.
|
||||
- `Fuchs/Services/InvoiceDraftExpiryService.cs` — idle warn/evict monitor.
|
||||
- `Fuchs/Notifications/DraftPreviewHub.cs`, `DraftNotifier.cs`, `IDraftNotifier.cs` — targeted signals.
|
||||
- `Fuchs/Controllers/IntranetController.InvoiceDraft.cs` — `inv/d*` endpoints.
|
||||
- `Fuchs/js/intranet/fis_main.js`, `Fuchs/js/intranet/modules/fis.inv_shared.js` — client.
|
||||
|
||||
## Reminders (Zahlungserinnerung)
|
||||
|
||||
Reminders mirror the same backend-authoritative model with a reminder-shaped session. A
|
||||
reminder chases a single invoiced amount, so the machinery is simpler than an invoice's:
|
||||
there are no line-item blocks, VAT grouping or reordering — just recipient fields and the
|
||||
amount pair.
|
||||
|
||||
- **Endpoints** are `rem/d*` (`dopen`/`dstate`/`dpatch`/`dpreview`/`dsave`/`dhistory`/`dclose`),
|
||||
dispatched from `Do_Process_Reminder`. Finalise + email still runs through the existing
|
||||
`rem/conf` (`HandleReminderConf`), exactly as invoices finalise through `req/sconf`.
|
||||
- **Session** (`ReminderDraftSession`) holds the editor's `new` (subject / invoiceaddress /
|
||||
invoiceemail / text / amount / amount_payed / CustomValues) and `rem` (invid / type /
|
||||
invoiceid / invoicedate) blocks, plus server-computed `Sums` (`AmountTotal`, `AmountPayed`,
|
||||
`AmountOpen`). It reuses the shared `ChangeHistoryEntry`; validation uses
|
||||
`ReminderDraftValidationMessage`.
|
||||
- **Calculation** (`ReminderDraftCalculator`, static/pure): `AmountOpen = AmountTotal − AmountPayed`,
|
||||
plus email/address/subject/open-amount plausibility checks. Exhaustively unit-tested.
|
||||
- **Deltas** (`ReminderDraftDelta`): scalar `email`/`address`/`subject`/`text` (HTML-sanitised via
|
||||
the shared `InvoiceDraftEditService.HtmlToPlain`), the numeric `amount`/`amount_payed`
|
||||
(normalised to an invariant decimal string), and `contact` (→ `CustomValues`).
|
||||
- **Orchestration** (`ReminderDraftEditService`, scoped) flushes to the DB by reusing
|
||||
`IReminderService.RegisterReminderAsync`, and renders previews from a synthesised
|
||||
`ReminderRegistration` (including the single-invoice `invoices` row the reminder PDF table
|
||||
renders) so a preview needs no DB round-trip. **Note:** `RegisterReminderAsync` is create-only
|
||||
(there is no `fds__setReminder` update proc), so a re-saved reminder draft does not update the
|
||||
prior DB row — the primary flow (preview → confirm) flushes once immediately before finalising.
|
||||
- **Cache/expiry** (`ReminderDraftCache` singleton + `ReminderDraftExpiryService`) mirror the
|
||||
invoice ones and share the same `Fuchs:DraftEditing` TTL config.
|
||||
- **Signals** reuse the shared `DraftPreviewHub` + `IDraftNotifier` unchanged — the token-keyed
|
||||
groups serve invoice and reminder drafts alike.
|
||||
- **Frontend** (`$inv.rd` in `fis.inv_shared.js`) opens/joins on `rem/dopen`, posts one delta per
|
||||
inline edit and per item-row amount change, renders the open-amount footer + validation from the
|
||||
server state, and previews/finalises through `rem/dpreview` → `rem/dsave` → `rem/conf`. It shares
|
||||
the invoice editor DOM; `$inv.d` and `$inv.rd` each key off their own token, so the shared inline
|
||||
editor safely no-ops for whichever mode is inactive.
|
||||
|
||||
### Reminder key files
|
||||
- `Fuchs/code/ReminderDraftSession.cs` — session + `ReminderDraftSums` + `ReminderDraftValidationMessage`.
|
||||
- `Fuchs/code/ReminderDraftCalculator.cs` — pure open-amount recompute + validate.
|
||||
- `Fuchs/Services/ReminderDraftCache.cs` / `IReminderDraftCache.cs` — in-memory store + TTL.
|
||||
- `Fuchs/Services/ReminderDraftEditService.cs` / `IReminderDraftService.cs` — orchestration + delta contract.
|
||||
- `Fuchs/Services/ReminderDraftExpiryService.cs` — idle warn/evict monitor.
|
||||
- `Fuchs/Controllers/IntranetController.ReminderDraft.cs` — `rem/d*` endpoints.
|
||||
|
||||
## Related decisions
|
||||
- [0006 — Backend-authoritative draft editing](../Decisions/0006-backend-authoritative-draft-editing.md)
|
||||
- [0007 — Targeted draft SignalR groups](../Decisions/0007-targeted-draft-signalr-groups.md)
|
||||
@@ -0,0 +1,90 @@
|
||||
---
|
||||
status: Accepted
|
||||
date: 2026-07-10
|
||||
applyTo:
|
||||
- "Fuchs/Services/InvoiceDraft*"
|
||||
- "Fuchs/Services/IInvoiceDraft*"
|
||||
- "Fuchs/code/InvoiceDraftSession.cs"
|
||||
- "Fuchs/code/InvoiceDraftCalculator.cs"
|
||||
- "Fuchs/Notifications/DraftPreviewHub.cs"
|
||||
- "Fuchs/Notifications/*DraftNotifier*"
|
||||
- "Fuchs/Controllers/IntranetController.InvoiceDraft.cs"
|
||||
- "Fuchs/js/intranet/**"
|
||||
supersededBy: ""
|
||||
---
|
||||
|
||||
# 0006 — Invoice draft editing is backend-authoritative over an in-memory cache
|
||||
|
||||
## Context
|
||||
The invoice editor was deliberately **stateless**: the browser held the working
|
||||
model, computed totals/VAT client-side (`invSumUpdate` in `fis.inv_shared.js`) and
|
||||
re-posted the whole `invc` JSON on every preview/save. `EVAL_live_invoice_editing.md`
|
||||
(2026) recommended keeping it that way and **against** a server-cached, SignalR-driven
|
||||
model, because the real-time/co-editing benefits were weak for a single back-office
|
||||
editor.
|
||||
|
||||
The product owner has since decided the trade-off differently and prioritised a
|
||||
**single source of truth in the backend** with server-computed sums, server-side
|
||||
plausibility/consistency checks, in-place PDF preview without re-upload, an automatic
|
||||
change history, and an explicit discard. This decision records that reversal and the
|
||||
architecture chosen to implement it.
|
||||
|
||||
## Decision
|
||||
While a user edits an invoice draft, the authoritative state lives **server-side** in
|
||||
an in-memory `InvoiceDraftSession` (`Fuchs/code/InvoiceDraftSession.cs`), held by the
|
||||
singleton `IInvoiceDraftCache` and orchestrated by the scoped `IInvoiceDraftService`
|
||||
(`InvoiceDraftEditService`). The browser is a pure view/input layer.
|
||||
|
||||
- **Truth & calculation on the server.** `InvoiceDraftCalculator` is the pure,
|
||||
unit-tested port of the former client-side math (`quantChange` + `setVat` +
|
||||
`invSumUpdate`), including per-line net/VAT/service-value multiplication
|
||||
(`RecomputeLineValues`), the §13b reverse-charge rule and VAT-per-rate grouping. The
|
||||
browser performs **no arithmetic whatsoever** — not even a single line's
|
||||
`net = qty × price` — it only renders the server's `req`/`sums`.
|
||||
- **Commands are ordinary POSTs; signals are SignalR.** The editor posts single edits
|
||||
to `inv/dpatch` (and `dopen`/`dstate`/`dpreview`/`dsave`/`dhistory`/`ddiscard`/`dclose`).
|
||||
The server mutates the session, recomputes, validates, bumps a version, and pings the
|
||||
editing browser (`draftReady`) to re-fetch `inv/dstate`. See
|
||||
[0007](0007-targeted-draft-signalr-groups.md) for the targeted-signal transport.
|
||||
- **Cache-only until Zwischenspeichern/Finalise.** Opening builds the session (from a
|
||||
brand-new payload or by reloading a DB draft); edits touch only the cache. `dsave`
|
||||
flushes the session to the DB by reusing the existing
|
||||
`IInvoiceService.RegisterInvoiceAsync` — **no new persistence path** — and reports
|
||||
success/failure through the existing `IEventService` (ADR 0001). Finalise continues
|
||||
through `req/sconf`.
|
||||
- **Preview from cache.** `inv/dpreview` renders the draft PDF straight from the session
|
||||
(synthesised registration), with no client upload.
|
||||
- **Automatic change history.** Every applied patch appends a `ChangeHistoryEntry`
|
||||
(cache-only, never persisted); `inv/dhistory` exposes it for the "Änderungshistorie"
|
||||
dialog.
|
||||
- **Idle lifecycle with user warning.** `InvoiceDraftExpiryService` warns the editing
|
||||
browser before a session's idle TTL lapses (`draftExpiring`) and, on eviction, tells
|
||||
it to close the editor with a reason (`draftClosed`). TTL and warning lead are under
|
||||
`Fuchs:DraftEditing`.
|
||||
|
||||
## Consequences
|
||||
- The server is now **stateful for in-progress drafts**. This is acceptable for a
|
||||
single-instance deployment; **scale-out requires sticky sessions or a distributed
|
||||
cache/SignalR backplane** — none exist today, so this is a documented limitation, not
|
||||
a silent assumption.
|
||||
- New editor interactions must be modelled as a **delta** applied server-side (add a
|
||||
case in `InvoiceDraftEditService.ApplyDelta` + calculator handling), never as a new
|
||||
client-side calculation. Do not reintroduce client-side totals.
|
||||
- `FdsInvoiceData` stays a pure data holder; `InvoiceDraftSession` is likewise a data
|
||||
holder, with all logic in the service/calculator (mirrors the existing service split).
|
||||
- Reminders (Mahnungen) are intended to follow the identical pattern as a second phase;
|
||||
this decision covers invoices first (the pilot) and applies to the reminder mirror
|
||||
when built.
|
||||
- `EVAL_live_invoice_editing.md` and `INVOICE_LIFECYCLE.md` §4/§10 (the "stateless
|
||||
editor" invariant) are superseded by this decision for the draft-editing flow and have
|
||||
been annotated accordingly.
|
||||
|
||||
## Alternatives considered
|
||||
- **Keep the stateless editor** (the prior recommendation): rejected by the product
|
||||
owner in favour of a backend single source of truth.
|
||||
- **Full bidirectional SignalR hub for commands too**: rejected — edits as POSTs reuse
|
||||
the existing controller/auth pattern and avoid a command reconnect/replay protocol; the
|
||||
hub carries only coordination signals.
|
||||
- **Write-through to the DB on every edit**: rejected — conflicts with the
|
||||
"Zwischenspeichern = persist the cache" semantics and adds DB load; the cache is the
|
||||
truth until an explicit save/finalise.
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
status: Accepted
|
||||
date: 2026-07-10
|
||||
applyTo:
|
||||
- "Fuchs/Notifications/DraftPreviewHub.cs"
|
||||
- "Fuchs/Notifications/IDraftNotifier.cs"
|
||||
- "Fuchs/Notifications/DraftNotifier.cs"
|
||||
- "Fuchs/Program.cs"
|
||||
- "Fuchs/js/intranet/**"
|
||||
supersededBy: ""
|
||||
---
|
||||
|
||||
# 0007 — Draft-editing signals are targeted via a dedicated hub with per-draft groups
|
||||
|
||||
## Context
|
||||
Backend-authoritative draft editing (ADR 0006) needs to notify **exactly the one
|
||||
browser** editing a given draft that its cached state changed, is about to expire, or
|
||||
was closed. The existing `NotificationHub` (ADR 0002) deliberately **broadcasts** every
|
||||
business toast to all logged-in sessions and explicitly deferred per-user/targeted
|
||||
delivery as "a new decision". Draft coordination pings are high-frequency, per-editor,
|
||||
and must not spray to every session.
|
||||
|
||||
## Decision
|
||||
Draft signals use a **dedicated** SignalR hub, `DraftPreviewHub`, mapped at
|
||||
`/draftpreview` (separate from `NotificationHub` at `/notifications`). Targeting is by
|
||||
**SignalR group named after the draft's session token**:
|
||||
|
||||
- The client calls the hub methods `JoinDraft(token)` / `LeaveDraft(token)` to
|
||||
subscribe/unsubscribe its connection to a draft's group. The hub carries **no
|
||||
commands** — only group membership (edits are POSTs; see ADR 0006).
|
||||
- The server sends via `IDraftNotifier` (`DraftNotifier`) to `Clients.Group(token)`:
|
||||
`draftReady{token,version}` (re-fetch), `draftExpiring{token,secondsLeft}` (idle
|
||||
warning), `draftClosed{token,reason}` (session evicted/discarded → close the editor).
|
||||
- Like `EventService`, delivery failures are logged and swallowed — a missed
|
||||
coordination ping must never fail the underlying operation; the client also re-syncs on
|
||||
reconnect and on its next POST.
|
||||
|
||||
Business success/failure messages for draft operations (e.g. "Zwischenstand
|
||||
gespeichert") continue to flow through `IEventService`/`NotificationHub`, **not** this
|
||||
hub — the two channels stay separate.
|
||||
|
||||
## Consequences
|
||||
- The session **token doubles as the group name**; it is an opaque GUID and must not
|
||||
encode sensitive data. Any browser that knows a token can join its group, so tokens
|
||||
must be treated as capabilities and only handed to the authenticated editor that opened
|
||||
the draft.
|
||||
- Adding a new draft signal means adding a method to `IDraftNotifier` + `DraftNotifier`
|
||||
and a client handler in `$fis.draft` — not overloading the business notification path.
|
||||
- ADR 0002 is unchanged: `NotificationHub` stays broadcast-only for toasts. This hub is
|
||||
the answer to its "if per-user targeting becomes necessary, that is a new decision".
|
||||
- Multi-instance scale-out needs a SignalR backplane for group delivery — same limitation
|
||||
as ADR 0006.
|
||||
|
||||
## Alternatives considered
|
||||
- **Reuse `NotificationHub` with groups**: rejected — it would entangle broadcast toasts
|
||||
with targeted, high-frequency editing pings and force ADR 0002's broadcast contract to
|
||||
change. A separate hub keeps the concerns and their decisions independent.
|
||||
- **Per-user groups (by account id)**: rejected — a user may open two drafts/tabs;
|
||||
per-draft-token groups target the precise editor and naturally support that.
|
||||
@@ -0,0 +1,114 @@
|
||||
---
|
||||
status: Accepted
|
||||
date: 2026-07-15
|
||||
applyTo:
|
||||
- "Fuchs/Services/InvoiceDraft*"
|
||||
- "Fuchs/Services/IInvoiceDraft*"
|
||||
- "Fuchs/Services/ReminderDraft*"
|
||||
- "Fuchs/Services/IReminderDraft*"
|
||||
- "Fuchs/code/InvoiceDraftSession.cs"
|
||||
- "Fuchs/code/InvoiceDraftCalculator.cs"
|
||||
- "Fuchs/code/InvoiceSetPricing.cs"
|
||||
- "Fuchs/code/ReminderDraftSession.cs"
|
||||
- "Fuchs/code/ReminderDraftCalculator.cs"
|
||||
- "Fuchs/code/FuchsPdf.cs"
|
||||
- "Fuchs/js/intranet/**"
|
||||
supersededBy: ""
|
||||
---
|
||||
|
||||
# 0008 — Invoices and reminders (all kinds) are fully backend-authoritative; PDF and online editor must render identical content
|
||||
|
||||
## Context
|
||||
ADR [0006](0006-backend-authoritative-draft-editing.md) established the backend-authoritative
|
||||
draft-editing model for invoices and noted reminders were "intended to follow the identical
|
||||
pattern as a second phase". Both are now implemented (`InvoiceDraftEditService` /
|
||||
`ReminderDraftEditService`). In practice, ambiguity kept resurfacing about *which* invoice/
|
||||
reminder kinds this covers and *which* kinds of change qualify as "must be computed server-side":
|
||||
e.g. whether a purely presentational client-side re-render (set-price display toggle, item
|
||||
reordering, position renumbering) was allowed to keep any client-side math, and whether this
|
||||
applies uniformly to every invoice type (regular `r`, partial/Abschlag `i`, final `f`, storno
|
||||
`c`) and every reminder stage, not just the pilot "regular invoice" flow. This decision closes
|
||||
that ambiguity explicitly.
|
||||
|
||||
## Decision
|
||||
**Every invoice (all `InvoiceType` kinds: regular, partial/Abschlagsrechnung, final/
|
||||
Schlussrechnung, Storno/credit) and every reminder (all reminder stages/Mahnstufen) is
|
||||
backend-authoritative while being drafted or previewed.** This generalises and makes explicit
|
||||
what ADR 0006 already implied for the pilot flow:
|
||||
|
||||
- **Any calculation** (net/VAT/gross totals, per-rate VAT grouping, service-refund figures,
|
||||
§13b reverse-charge suppression, set-price sums, open-amount for reminders, position/line
|
||||
numbering) is performed exclusively by the server (`InvoiceDraftCalculator`,
|
||||
`ReminderDraftCalculator`, `InvoiceSetPricing`). The browser never sums, subtracts, or
|
||||
otherwise derives a monetary or positional value — it only displays server-computed values.
|
||||
This includes the single-line arithmetic that used to run in `quantChange`/`setVat`
|
||||
(`net_val = qty × price`, `vat_val = net_val × rate`, service-net/-VAT splits): those
|
||||
handlers now only post the raw, unmultiplied field the user typed (`qn`/`v`/`vat`) and the
|
||||
server (`InvoiceDraftCalculator.RecomputeLineValues`) computes every derived line value.
|
||||
Likewise the invoice footer (net/VAT-by-rate/gross), the per-block "isum" cell, and the
|
||||
service-refund note figures are rendered exclusively from `dstate.sums` (`$inv.d.footer`);
|
||||
`$inv.invSumUpdate` no longer accumulates any of these — it only reassembles the row
|
||||
contract array needed to post `req` to the server and (on first load) seeds the session.
|
||||
- **Any setting** (§13b flag, set-pricing display mode, payment terms, contact, custom values,
|
||||
…) is applied server-side via a named `InvoiceDraftDelta`/`ReminderDraftDelta` target and
|
||||
reflected back through `dstate`. The client never mutates its local model as the source of
|
||||
truth for a setting; it optimistically reflects the *request* but always re-renders from the
|
||||
next `dstate`/`draftReady` refresh.
|
||||
- **Any text change** (recipient email/address, invoice title, provision location/period,
|
||||
section headings, item name/description/notes) is sanitised and stored server-side
|
||||
(`InvoiceDraftEditService.HtmlToPlain` et al.); the server's stored value is the one that
|
||||
reaches the PDF and any reloaded draft.
|
||||
- **Any reordering** (drag-reorder of service-request blocks/sections, drag-reorder of item
|
||||
rows within a block) is committed as a `block.order` (or equivalent) delta; the server
|
||||
performs the actual reorder and renumbers positions (`InvoiceDraftCalculator.RecomputePositions`).
|
||||
The client's drag interaction is input only — the rendered order after a refresh is the
|
||||
server's order, not whatever the browser left in the DOM mid-drag.
|
||||
- **Irreversible one-way conversions** (e.g. "Auf Setpreis umstellen" — switching a set's
|
||||
member items from individual prices to a single set price) are likewise backend-only
|
||||
operations (`item.setprice` delta / `InvoiceDraftEditService.ApplyItemSetPrice`), never
|
||||
computed or applied in the browser.
|
||||
- **The PDF must render 100% the same information and content as the online editor at any
|
||||
given moment.** Both consume the identical authoritative session data:
|
||||
- The online editor renders `dstate`'s `req`/`sums`/`setDisplay`/`notes` — all server-computed.
|
||||
- The PDF preview (`inv/dpreview`, `rem/dpreview`) renders straight from the same cached
|
||||
session via a synthesised registration (`InvoiceDraftEditService.RenderPreview` /
|
||||
`ReminderDraftEditService`'s reminder equivalent) — **not** from a separate client upload
|
||||
or a re-derived model.
|
||||
- `FuchsPdf.BuildInvoiceNotes` (notice paragraphs) is called identically for both the
|
||||
editor's `notes` array and the PDF body, so intro/closing texts can never drift between
|
||||
the two renderings.
|
||||
- Any new editor-visible fact (a new total, a new flag, a new note) must be added to the
|
||||
shared session/service layer once, not duplicated as separate editor-only and PDF-only
|
||||
logic.
|
||||
- This applies for the full lifecycle while a document is a draft (open → edit → preview →
|
||||
Zwischenspeichern) up to finalise; a finalised, persisted invoice/reminder is immutable
|
||||
and is rendered straight from its stored DB data (no draft session involved) — that path
|
||||
already has no client-side math to begin with.
|
||||
|
||||
## Consequences
|
||||
- New invoice/reminder editor features must be modelled as a server-side delta + calculator
|
||||
change, exactly as ADR 0006 already requires; this decision removes any residual excuse to
|
||||
special-case a "just this one is presentational, do it in JS" shortcut for reordering,
|
||||
display-mode toggles, or one-way conversions.
|
||||
- Any PDF-only or editor-only special-casing found in review is a bug against this decision —
|
||||
the shared session/service must be extended so both renderers read the same value/flag.
|
||||
- Reminder "Mahnstufen" and every invoice type share this obligation; there is no partial/
|
||||
Abschlagsrechnung, Schlussrechnung, or Storno exemption while such a document is still a
|
||||
draft going through the same `dopen`/`dpatch`/`dpreview`/`dsave` flow.
|
||||
- Test coverage for the cache/session layer (`InvoiceDraftEditService`, `ReminderDraftEditService`,
|
||||
`InvoiceDraftCalculator`, `InvoiceSetPricing`) must exercise every mutating operation
|
||||
(text edits, reordering, all three set-pricing display modes, the set-price conversion,
|
||||
multi-rate VAT sums, full recompute) against mock datasets, since this is now the single
|
||||
place all of these behaviours are guaranteed correct — see `Fuchs.Tests/InvoiceDraftServiceTests.cs`,
|
||||
`Fuchs.Tests/ReminderDraftServiceTests.cs`, `Fuchs.Tests/InvoiceDraftCalculatorTests.cs`,
|
||||
`Fuchs.Tests/InvoiceSetPricingTests.cs`.
|
||||
|
||||
## Alternatives considered
|
||||
- **Scope this only to the invoice pilot flow** (leave reminders/other invoice kinds
|
||||
ambiguous): rejected — the ambiguity itself was the problem being fixed; the underlying
|
||||
session/service code already treats all kinds uniformly, so documenting anything narrower
|
||||
would misrepresent the code.
|
||||
- **Allow "purely cosmetic" client-side math for reordering/display toggles**: rejected —
|
||||
history showed exactly this exception is where drift crept in (e.g. the set-price toggle
|
||||
originally computed sums in the browser before being moved server-side); no exception is
|
||||
granted.
|
||||
@@ -0,0 +1,122 @@
|
||||
---
|
||||
status: Accepted
|
||||
date: 2026-07-14
|
||||
applyTo:
|
||||
- "Fuchs/code/InvoiceSetPricing.cs"
|
||||
- "Fuchs/Services/InvoiceDraft*"
|
||||
- "Fuchs/Services/IInvoiceDraft*"
|
||||
- "Fuchs/code/InvoiceDraftSession.cs"
|
||||
- "Fuchs/code/InvoiceDraftCalculator.cs"
|
||||
- "Fuchs/code/FuchsPdf.cs"
|
||||
- "Fuchs/js/intranet/**"
|
||||
- "Fuchs/Docs/INVOICE_SET_PRICING.md"
|
||||
supersededBy: ""
|
||||
---
|
||||
|
||||
# 0009 — The two menu set-price modes are per-service-request-block, irreversible cache mutations that insert a dedicated set row
|
||||
|
||||
## Context
|
||||
ADRs [0006](0006-backend-authoritative-draft-editing.md) and
|
||||
[0008](0008-invoices-and-reminders-fully-backend-authoritative.md) made draft editing
|
||||
backend-authoritative. Under that model the set-price feature had **three** functions, of
|
||||
which the two menu-driven ones ("Set mit Preis" / "Nur Set mit Preis") were framed as
|
||||
whole-invoice **display modes** (`SetDisplayMode.SetPrice`/`SetOnly`): a non-mutating,
|
||||
render-time transform (`InvoiceSetPricing.Build`) over explicit `type == "set"` header items
|
||||
and their `SetItmId` members, persisted only as an `admin.setmode` flag. ADR 0008 calls them
|
||||
"display-mode toggles" and treats them as presentational.
|
||||
|
||||
The product owner has redefined those two menu functions. They are **not** display toggles and
|
||||
they are **not** keyed on set-item membership:
|
||||
|
||||
- Their grouping is the **service request** (`ServiceRequestId` = the editor's tbody block),
|
||||
never `SetItmId`. Every block is treated as one set, whether or not it contains any
|
||||
`type == "set"` item.
|
||||
- Applying a mode is an **irreversible data change** written hard into the cached draft
|
||||
dataset — not a reversible view flag. There is no toggle back; the user adjusts the result
|
||||
by hand afterwards.
|
||||
|
||||
This decision records that redefinition. It **refines** ADR 0008's characterisation of these
|
||||
two operations (from "non-mutating display toggle" to "mutating, one-way conversion"); ADR
|
||||
0008's broader rule — every calculation server-side, and the PDF renders 100% the same content
|
||||
as the editor — remains fully in force and is not superseded.
|
||||
|
||||
## Decision
|
||||
There are three distinct set-price operations, kept clearly separated:
|
||||
|
||||
1. **Inline set-item switch — unchanged.** The row context button on a single `type == "set"`
|
||||
item (`$inv.toSetPrice` → `item.setprice` delta → `InvoiceDraftEditService.ApplyItemSetPrice`).
|
||||
It is `SetItmId`-based, sums the header's members onto the header, sets the members' prices to
|
||||
`null` (empty cell, excluded from the sum — consistent with modes 2 & 3, not `0`), and is
|
||||
one-way. This is the **only** set-price operation that reads `SetItmId`. The button is shown —
|
||||
and the operation available — **only** for items that are `type == "set"` **and** carry a
|
||||
`SetItmId` (and are still unconverted, i.e. own price `0`); an item missing either condition
|
||||
never offers it.
|
||||
|
||||
2. **"Set mit Preis" (menu) — per-block, irreversible mutation.** Applied server-side to the
|
||||
cached `InvoiceDraftSession`, grouped by `ServiceRequestId`. For **every** service-request
|
||||
block:
|
||||
- Insert one dedicated, emphasised **set row** at the top of the block, carrying the block's
|
||||
aggregated value (net + VAT + service-net/-VAT splits) as its price. This row is a real,
|
||||
editable line item with its own id, so the user can manually change the set value afterwards
|
||||
as an ordinary item edit.
|
||||
- **Null out** the price of every existing item row in the block (set the price fields to
|
||||
`null`, **not** `0`) so the row renders with an **empty** price/total cell. `null` and `0`
|
||||
are semantically distinct here: `null` means "no price — render an empty cell and exclude
|
||||
from the block sum", whereas `0` would legitimately print `0,00 €`. The rows themselves are
|
||||
retained.
|
||||
|
||||
3. **"Nur Set mit Preis" (menu) — per-block, irreversible mutation.** As above, grouped by
|
||||
`ServiceRequestId`. For every block:
|
||||
- Insert the same dedicated, emphasised set row carrying the block's aggregated value.
|
||||
- **Remove** every existing item row in the block from the dataset entirely (the lines are
|
||||
gone, not merely hidden).
|
||||
|
||||
Properties common to the two menu modes (2 and 3):
|
||||
|
||||
- **Mutation, not display.** The change is written into `InvoiceDraftSession.Req` (the "cache
|
||||
dataset") as a mutating `InvoiceDraftDelta`, computed on the server (never in the browser).
|
||||
There is no render-time `admin.setmode` grouping flag driving how lines are shown, and no
|
||||
reversible toggle.
|
||||
- **Irreversible.** There is no patch to undo it. The only ways back are discarding the draft
|
||||
(reloads the DB state) or hand-editing the resulting rows.
|
||||
- **`SetItmId` is irrelevant.** Membership is the block, full stop.
|
||||
- **Total unchanged.** The inserted set row's value equals the sum of the block's original items,
|
||||
which are then excluded from the sum — either because their price is `null` (mode 2, `null`
|
||||
counts as no contribution) or because they are gone (mode 3). So `InvoiceBalance`/
|
||||
`InvoiceBalance_net` are unaffected.
|
||||
- **Editor and PDF render identically** (ADR 0008): the dedicated set row is emphasised in both,
|
||||
and both read the same mutated session.
|
||||
|
||||
## Consequences
|
||||
- For the two menu modes, `InvoiceSetPricing` stops being a non-mutating render transform over
|
||||
`type == "set"` groups; the grouping/insert/blank/remove is a real mutation in
|
||||
`InvoiceDraftEditService`, keyed on the block. The inline `item.setprice` switch (operation 1)
|
||||
remains the sole `SetItmId`-based, set-item-scoped operation.
|
||||
- The previous `admin.setmode` display-flag model for these two modes — persisted `setmode:`
|
||||
`InvoiceOptions` token, "Set-Preisanzeige menu entry disappears while unset", `Build(...)`
|
||||
choosing `ShowPrice` per member at render time — is retired. Because the operation is a
|
||||
one-shot irreversible mutation, there is no persisted display state to toggle. Any residual
|
||||
`setmode:` token must degrade safely (ignored) and is no longer (re-)persisted.
|
||||
- New/changed behaviour must be modelled as a server-side delta + calculator/service change and
|
||||
covered by tests (`InvoiceDraftServiceTests`, `InvoiceSetPricingTests`): for each menu mode,
|
||||
assert the inserted set row's value equals the block sum, the total is unchanged, mode 2
|
||||
nulls-but-keeps member rows while mode 3 removes them, and an empty block is a no-op.
|
||||
- Reminders follow the identical pattern when/if the same feature is offered there (ADR 0006/0008
|
||||
reminder mirror).
|
||||
|
||||
## Alternatives considered
|
||||
Each of the following was raised and **explicitly decided against** as part of accepting this
|
||||
decision — they are rejected choices, not open options to revisit without a superseding ADR:
|
||||
|
||||
- **Keep them as non-mutating display toggles** (the prior design): **explicitly rejected** by the
|
||||
product owner — the set price must be a real, hand-editable value baked into the document, and
|
||||
"Nur Set mit Preis" must actually drop the member lines, not just hide them.
|
||||
- **Zero the blanked members' prices instead of nulling them**: **explicitly rejected** — `0` is
|
||||
ambiguous (it prints `0,00 €`), so the frontend could not tell an empty cell from a genuine
|
||||
zero price. Blanked members are set to `null` precisely to make "no price" unambiguous.
|
||||
- **Carry the set price on the existing block heading row** instead of a dedicated row:
|
||||
**explicitly rejected** — a separate, individually-editable set row keeps the section-heading
|
||||
semantics intact and gives the user a concrete line to adjust afterwards.
|
||||
- **Group by `SetItmId`/`type == "set"` headers like the inline switch:** **explicitly rejected**
|
||||
— the menu modes present each *service request* as one set, independent of any mfr set-item;
|
||||
conflating the two groupings is exactly the ambiguity this decision removes.
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
status: Accepted
|
||||
date: 2026-07-15
|
||||
applyTo:
|
||||
- "Fuchs_DataService/**"
|
||||
- "Fuchs/Services/PeriodicHostedService.cs"
|
||||
- "Fuchs/Program.cs"
|
||||
supersededBy: ""
|
||||
---
|
||||
|
||||
# 0010 — MFR ERP sync runs in-process in the web app; Fuchs_DataService is a library
|
||||
|
||||
## Context
|
||||
`Fuchs_DataService` was a standalone console/Windows Service hosted by **Topshelf**.
|
||||
It carried its own `appsettings.json`, its own file-based configuration bootstrap
|
||||
(`FdsConfig.Initialize()` reading the file), its own logging provider
|
||||
(`FdsLoggerProvider`/`AddFdsLogging`), and a machine-name guard in `Main()` that
|
||||
disabled the service on developer PCs. In practice the web app (`Fuchs`) already
|
||||
referenced the project, already called `fds.FdsConfig.Initialize(builder.Configuration)`,
|
||||
already registered `IFdsMfr`, and already created `FdsMfrClient` via
|
||||
`IMfrClientFactory` — so the sync logic and the web app were sharing the same code
|
||||
and the same connection strings while the service kept a second, parallel copy of
|
||||
configuration/logging/hosting.
|
||||
|
||||
Maintaining a separate process, a second `appsettings.json` (duplicating connection
|
||||
strings + MFR credentials), Topshelf, and a machine-name guard added drift risk and
|
||||
operational overhead for no benefit the web host couldn't provide.
|
||||
|
||||
## Decision
|
||||
- **`Fuchs_DataService` is now a class library** (no `OutputType Exe`, no Topshelf,
|
||||
no own `appsettings.json`, no `install.bat`/`un-install.bat`, no
|
||||
`System.Configuration.ConfigurationManager`). It contains only the MFR sync
|
||||
logic (`FdsMfr`/`IFdsMfr`, `FdsMfrClient`), the DATEV/zip helpers, `FdsShared`,
|
||||
`FdsDebug`, and `FdsConfig`.
|
||||
- **The host owns configuration.** `FdsConfig` keeps only
|
||||
`Initialize(IConfiguration)` (the file-based overload is gone). The Fuchs web app
|
||||
injects its `IConfiguration`; connection strings (`fuchs_fds_ConnectionString`)
|
||||
and MFR credentials (`Fds:MFR_*`, Key Vault-managed) come from Fuchs.
|
||||
- **The host owns logging.** `FdsLoggerProvider`/`AddFdsLogging` were removed; the
|
||||
library uses only `ILogger`/`ILoggerFactory` injected from Fuchs's logging
|
||||
(`AddFuchsLogging`). The library depends only on
|
||||
`Microsoft.Extensions.Logging.Abstractions` + `Microsoft.Extensions.Configuration.Binder`.
|
||||
- **The sync runs in-process.** `PeriodicHostedService` (the generic
|
||||
multi-job `BackgroundService`) moved to `Fuchs/Services/` and is registered in
|
||||
`Program.cs` as a hosted service. The single `MfrSync` job calls
|
||||
`UpdateIfNecessary_async` → `UpdateRequested_async` → `GetInvoiceFiles_async`.
|
||||
- **A config flag replaces the machine-name guard.** Registration is gated by
|
||||
`Fds:SyncEnabled` (default `false` when unset): `true` in production
|
||||
`appsettings.json`, `false` in `appsettings.Development.json`, so developer
|
||||
machines never poll the ERP. Interval (`Fds:ExecutionFrequency_Minutes`, default
|
||||
15) and debug verbosity (`Fds:DebugDetails`) also come from the `Fds` section.
|
||||
|
||||
## Consequences
|
||||
- One process, one configuration surface, one logging pipeline. The sync inherits
|
||||
the web app's OpenTelemetry, DI, and lifetime automatically.
|
||||
- **Instance fan-out is a consideration:** the sync now runs in *every* web instance
|
||||
where `Fds:SyncEnabled` is true. The intranet is deployed single-instance, so this
|
||||
is acceptable; if Fuchs is ever scaled out, gate the sync to a single instance
|
||||
(leader election / dedicated instance flag) to avoid concurrent MFR polling.
|
||||
- Enabling/disabling the sync per environment is now a config change, not a
|
||||
redeploy of a separate service.
|
||||
- `Fuchs_DataService` is intentionally kept as a separate project (not folded into
|
||||
`Fuchs`) so the sync logic stays isolated and unit-testable; `Fuchs.Tests` covers
|
||||
it via `InternalsVisibleTo`.
|
||||
- **The `Squid-Box.SevenZipSharp` native dependency (and the bundled `7z.dll`) was
|
||||
removed** from both `Fuchs_DataService` and `Fuchs`. The only live archive use — the
|
||||
DATEV export — is a plain, unencrypted zip, now produced via the native
|
||||
`OCORE.zip.filesToZipArchive` (`System.IO.Compression`). The 7-Zip-only paths
|
||||
(`.7z`/LZMA2, AES-encrypted archives, extraction, `FastAppend`) had no callers.
|
||||
Trade-off accepted: `System.IO.Compression` cannot produce `.7z` or password/AES
|
||||
archives; if that is ever required, a compression library must be reintroduced.
|
||||
|
||||
## Alternatives considered
|
||||
- **Native `dotnet` Worker Service (separate process).** Would modernize off
|
||||
Topshelf but keep the duplicate-config/duplicate-logging/second-process problem.
|
||||
Rejected because the web app already hosts everything the sync needs.
|
||||
- **Fold the code directly into `Fuchs`.** Rejected to preserve a clean, separately
|
||||
testable sync library and avoid enlarging the web project.
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
status: Accepted
|
||||
date: 2026-07-16
|
||||
applyTo:
|
||||
- "Fuchs/Controllers/IntranetController.Admin.cs"
|
||||
- "Fuchs/Services/SystemStatusService.cs"
|
||||
- "Fuchs/Services/ISystemStatusService.cs"
|
||||
- "Fuchs/Services/SystemStatusModels.cs"
|
||||
- "Fuchs/js/intranet/modules/fis.admin*.js"
|
||||
- "Fuchs/js/intranet/modules/fis.admin.scss"
|
||||
supersededBy: ""
|
||||
---
|
||||
|
||||
# 0011 — Admin module gated on `fds_sys` > 4
|
||||
|
||||
## Context
|
||||
Operators needed an in-app view of the running deployment's health: which host it
|
||||
runs on, whether SQL Server / Azure Key Vault / Azure Blob Storage / the MFR ERP are
|
||||
reachable, how the email service is configured (including the dev/test
|
||||
`Fuchs:Email:OverrideRecipient` redirect), and a way to send a test email. The
|
||||
`StartupSelfTestService` already probes most of these once at startup and writes the
|
||||
result to the log — but that is invisible to a logged-in operator and cannot be re-run
|
||||
on demand. This is privileged, infrastructure-revealing information (server names,
|
||||
account names, connectivity state) that must not be exposed to ordinary users.
|
||||
|
||||
## Decision
|
||||
- Add an **Admin** module (`admin`) alongside the existing invoice/reminder/report/banking
|
||||
modules, following the same frontend module contract (`init:admin` → `admin/auth`
|
||||
returns `{ manage }` → lazy-load `/web/fis.admin.de.js` + `/web/fis.admin.css` →
|
||||
`init2()`).
|
||||
- Access is gated on the caller's **`fds_sys` module authorization being strictly greater
|
||||
than 4** (`fis_getModuleAuth('fds_sys', authuser) > 4`). The menu button is only rendered,
|
||||
and the module script only fetched, for such users; **every** Admin data endpoint
|
||||
additionally re-checks the level server-side (defense in depth) and returns 401 otherwise.
|
||||
`admin/auth` is the sole endpoint that answers for unauthorized users too (it returns
|
||||
`manage: 0`), so the frontend can decide whether to render the module at all.
|
||||
- Backend diagnostics live in a new DI-registered `ISystemStatusService` (scoped). It exposes
|
||||
a passive `GetInfo()` snapshot and live, never-throwing connectivity probes
|
||||
(`database`, `keyvault`, `blob`, `mfr`) plus `SendTestEmailAsync`. The test email goes
|
||||
through the normal `IComService` pipeline, so the `OverrideRecipient` safety net applies
|
||||
exactly as for any other outbound mail.
|
||||
- The service never returns secrets — only presence flags and non-sensitive values
|
||||
(server/catalog/login name, storage account name, error text). Responses are serialized
|
||||
camelCase to match the frontend's lowercase convention.
|
||||
|
||||
## Consequences
|
||||
- `fds_sys` is now a security-relevant authorization key: granting it a value > 4 exposes
|
||||
infrastructure status and the ability to send test emails. Provision it deliberately.
|
||||
- `SystemStatusService` is intentionally **separate** from `StartupSelfTestService` rather
|
||||
than a shared refactor: the startup service's probe methods are `protected virtual` and its
|
||||
tests override them, so folding both onto one probe surface would have broken that contract.
|
||||
The two therefore duplicate a little probe logic (Key Vault secret read, MFR `GetEntities`,
|
||||
SQL `SELECT`); keep them behaviourally aligned when either changes.
|
||||
- New probes (or new status facts) belong in `SystemStatusService` behind the same
|
||||
`SystemProbeResult` / `SystemInfoSnapshot` shapes; add the component id to
|
||||
`SystemStatusService.Components` and a card in `fis.admin.js`.
|
||||
- Blob connectivity is checked via a new `IBlobStorageService.CheckConnectivityAsync`
|
||||
(account-info request); it too never throws.
|
||||
|
||||
## Alternatives considered
|
||||
- **Reuse `StartupSelfTestService` directly.** Rejected — see Consequences; its virtual/overridden
|
||||
probe surface is owned by its tests, and it is a one-shot `BackgroundService`, not a
|
||||
request-scoped query service.
|
||||
- **Gate only in the frontend (hide the menu button).** Rejected — the endpoints would still be
|
||||
reachable by crafting the POST. Server-side enforcement on every endpoint is required.
|
||||
- **A separate `fds_admin`/new module-auth key.** Rejected — `fds_sys` already models
|
||||
system-level privilege; reusing it avoids a parallel permission to provision.
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
status: Accepted
|
||||
date: 2026-07-17
|
||||
applyTo:
|
||||
- "Fuchs/code/FuchsPdf.cs"
|
||||
- "Fuchs/Services/FuchsPdfService.cs"
|
||||
- "Fuchs/Services/InvoiceService.cs"
|
||||
- "Fuchs/Services/ERechnungSettings.cs"
|
||||
- "eRechnungLib/**"
|
||||
supersededBy: ""
|
||||
---
|
||||
|
||||
# 0012 — eRechnung uses a single PDF/A engine (eRechnungLib owns PDF/A-3)
|
||||
|
||||
## Context
|
||||
ADR 0005 established that invoices are emitted as eRechnung by embedding the CII
|
||||
XML into the FuchsPdf-rendered visual PDF via `eRechnungLib.ToZugferd(...)`.
|
||||
|
||||
Two hard requirements then surfaced: the emitted invoice must (a) satisfy the
|
||||
**ZUGFeRD 2.4 / Factur-X** standard for **DATEV** ingestion, and (b) be a
|
||||
**formally verifiable PDF/A-3** (veraPDF-clean).
|
||||
|
||||
A conflict became apparent in the rendering pipeline. `FuchsPdf.DocToPdfBytes`
|
||||
post-processes its MigraDoc/PdfSharp output to **PDF/A via Spire**
|
||||
(`OCORE…pdfAFileContent`). `eRechnungLib`'s `FacturXPdfBuilder` **also** produces
|
||||
a PDF/A layer (raises to PDF 1.7, writes `pdfaid` XMP, adds an sRGB output
|
||||
intent, embeds `factur-x.xml` in `/AF`). Feeding a Spire-made PDF/A into
|
||||
eRechnungLib stacks **two** PDF/A conversions → duplicate/*conflicting* output
|
||||
intents and `pdfaid` markers, which veraPDF rejects. Spire's output is also
|
||||
PDF/A-1/2 and does **not** carry the `/AF` associated-file structure ZUGFeRD
|
||||
requires (PDF/A-3).
|
||||
|
||||
Separately, `eRechnungLib` shipped **no** sRGB ICC profile, so its output intent
|
||||
was silently omitted (`PDFA-ICC` warning) — never formally PDF/A-3 conformant.
|
||||
|
||||
## Decision
|
||||
- **eRechnungLib is the single PDF/A engine for eRechnung output.** The invoice
|
||||
visual PDF is rendered by `FuchsPdf` **without** the Spire PDF/A step and handed
|
||||
to `eRechnungLib.ToZugferd(ZugferdProfile.EN16931, rawPdfBytes)`, which owns the
|
||||
one PDF/A-3 conversion and embeds the CII XML. The render-only path is
|
||||
`FuchsPdf.DocToPdfBytesRaw` / `IPdfService.DocToPdfBytesRaw` (fonts still
|
||||
embedded via `OCOREFontResolver`, no PDF/A post-processing).
|
||||
- **Spire stays only for on-screen preview rasterisation** (`DocToImageCollection`
|
||||
/ `BytesToImageCollection` for `sprep`/`sedit`). It is **not** part of the
|
||||
eRechnung file's PDF/A path. `DocToPdfBytes` (render + Spire PDF/A) is unchanged
|
||||
and remains the path for non-eRechnung documents (e.g. reminders).
|
||||
- **A bundled sRGB ICC profile is required.** `eRechnungLib` ships
|
||||
`Resources/Color/sRGB.icc` (sRGB IEC61966-2.1) so the PDF/A output intent is
|
||||
always attached. A caller may override it per conversion via
|
||||
`ConversionOptions.IccProfile`.
|
||||
- **Profile is EN 16931.** MINIMUM / BASIC WL are not offered for real invoices —
|
||||
DATEV needs at least EN 16931 (COMFORT) for full booking.
|
||||
- **Formal conformance is verified by an external online service** (veraPDF for
|
||||
PDF/A-3 + a ZUGFeRD/EN 16931 validator), behind the configurable
|
||||
`Fuchs:ERechnung:Validation:ServiceUrl` seam. Until the URL is provisioned,
|
||||
verification reports "not configured / skipped".
|
||||
|
||||
## Consequences
|
||||
- The eRechnung invoice PDF and a plain Spire PDF/A must never both be produced
|
||||
for the same document — pick the render-only path when emitting eRechnung.
|
||||
- The incoming visual PDF must itself be PDF/A-friendly (fonts embedded —
|
||||
handled; letterhead images must be **RGB, not CMYK**; transparency is allowed
|
||||
because we target PDF/A-**3**).
|
||||
- `Fuchs:ERechnung:Enabled` gates emission and stays `false` until the
|
||||
`FdsInvoiceData` → `eRechnungLib.Model.Invoice` mapping is wired (the ADR 0005
|
||||
follow-up). Open item for that mapping: the buyer address is currently a
|
||||
free-text block (`SendToAddress`); EN 16931 needs **structured** buyer
|
||||
fields (name/postcode/city/country, VAT id), so structured customer master
|
||||
data must feed the mapping. Seller data (currently hard-coded in `FuchsPdf`:
|
||||
name, address, tax number, IBAN/BIC) must be lifted into the seller model.
|
||||
- `Fuchs.csproj` must add a project reference to `eRechnungLib` when the flow is
|
||||
wired (not present yet).
|
||||
|
||||
## Alternatives considered
|
||||
- **Keep Spire PDF/A and have eRechnungLib only embed the XML:** rejected — Spire
|
||||
produces the wrong PDF/A part (1/2, no `/AF`) and a second conversion collides
|
||||
with eRechnungLib's own output intent/XMP, failing veraPDF.
|
||||
- **Drop Spire entirely:** rejected — Spire is still needed to rasterise PDFs to
|
||||
the on-screen invoice/reminder preview images; PdfSharp/eRechnungLib cannot.
|
||||
- **Ship no ICC and rely on callers:** rejected — formal PDF/A-3 requires an
|
||||
output intent; bundling a profile makes conformance the default.
|
||||
@@ -1,5 +1,17 @@
|
||||
# Evaluation — Backend-cached invoice editing over SignalR
|
||||
|
||||
> **⚠️ Superseded (2026-07-10).** This note's recommendation (keep the editor
|
||||
> stateless; do **not** build the SignalR/server-cached model) was reversed by the
|
||||
> product owner. Invoice draft editing is now backend-authoritative over an in-memory
|
||||
> cache — see **ADR
|
||||
> [`Decisions/0006-backend-authoritative-draft-editing.md`](Decisions/0006-backend-authoritative-draft-editing.md)**,
|
||||
> [`Decisions/0007-targeted-draft-signalr-groups.md`](Decisions/0007-targeted-draft-signalr-groups.md)
|
||||
> and the concept doc [`Concepts/live-draft-editing.md`](Concepts/live-draft-editing.md).
|
||||
> The analysis below is retained for the historical rationale and the risks it flagged
|
||||
> (server-held state, scaling/backplane, reconnect) — which the new design addresses or
|
||||
> accepts explicitly as documented limitations.
|
||||
|
||||
|
||||
**Idea (as proposed):** hold invoices that users are editing in a **server-side
|
||||
cache**, keep a **SignalR / WebSocket** connection open, apply each front-end
|
||||
change **in the backend**, and **push the recomputed state back** to the browser.
|
||||
|
||||
@@ -90,12 +90,20 @@ payload; see `EVAL_live_invoice_editing.md` for the rationale.
|
||||
### 4.1 What the user can change
|
||||
- **Line items** — quantities, prices, notes, combine into one sum
|
||||
(`$inv.rendersrq`, `$inv.quantChange`).
|
||||
- **Recipient fields** — invoice title, address, email, provision
|
||||
location/period (inline edit fields, `fm(...)` helper in `fis.inv_shared.js`).
|
||||
- **Recipient fields** — invoice title, email, provision location/period (inline
|
||||
edit fields, `fm(...)` helper in `fis.inv_shared.js`). The **recipient address**
|
||||
is edited via a **structured dialog** (`$inv.eAddress`: name, street, PLZ, city,
|
||||
country, optional VAT id) prefilled from `fds__prepInvoice`'s `invoiceaddressData`;
|
||||
it drives the EN 16931 eRechnung and composes the free-text `SendToAddress` for the
|
||||
PDF (see [`Concepts/erechnung-output.md`](Concepts/erechnung-output.md)). The **service
|
||||
date/period** (Leistungsdatum/-zeitraum) is likewise a structured German-date dialog
|
||||
(`$inv.eProvisionPeriod`) — a single date or a from/to range — mapped to BT-72 / BG-14.
|
||||
- **§13b reverse-charge** toggle (`$inv.sp13b`) — suppresses VAT lines/columns.
|
||||
- **Set-pricing display mode** (`$inv.ssetmode` / `setSetmode`) — `SetPrice`
|
||||
(default) / `ItemPrices` / `SetOnly`; see `INVOICE_SET_PRICING.md`. Purely
|
||||
presentational — totals never change.
|
||||
(default) / `SetOnly`; see `INVOICE_SET_PRICING.md`. Purely presentational
|
||||
while the set header itself has no own price; once a set is converted via
|
||||
the item switch (`item.setprice`), the conversion is one-way and totals
|
||||
recompute from that point on.
|
||||
- **Contact person** for the invoice (`$inv.sctp`, stored in `CustomValues`).
|
||||
|
||||
All of this recalculates client-side totals live via the `fds.inv` event
|
||||
@@ -122,7 +130,7 @@ On the server, `RegisterInvoiceAsync` (in `InvoiceService`) turns the posted
|
||||
JSON into SQL parameters and calls, in one batch:
|
||||
- **New invoice**: `fds__createInvoice` (allocates the `Id`, returns a fresh
|
||||
row) → `fds__createInvoice_Details` (service net/VAT + `InvoiceOptions`,
|
||||
e.g. `setmode:itemprices`, `§13b`).
|
||||
e.g. `setmode:setonly`, `§13b`).
|
||||
- **Existing draft**: `fds__setInvoice` (same parameter set, updates in place)
|
||||
→ `fds__createInvoice_Details` again.
|
||||
|
||||
@@ -337,9 +345,18 @@ flowchart TD
|
||||
|
||||
## 10. Key invariants worth remembering
|
||||
|
||||
- **Stateless editor**: every preview/save/finalise call re-posts the full
|
||||
`invc` JSON; the server never holds a partial invoice in memory or session
|
||||
between requests (see `EVAL_live_invoice_editing.md`).
|
||||
> **⚠️ Updated (2026-07-10):** the "stateless editor" invariant below describes the
|
||||
> **legacy** draft-editing flow. Invoice draft editing is being moved to a
|
||||
> **backend-authoritative** model where the server holds the draft in an in-memory
|
||||
> cache (the single source of truth), the browser posts single edits and re-fetches on
|
||||
> a SignalR signal, and totals are computed server-side. See ADR
|
||||
> [`Decisions/0006-backend-authoritative-draft-editing.md`](Decisions/0006-backend-authoritative-draft-editing.md)
|
||||
> and [`Concepts/live-draft-editing.md`](Concepts/live-draft-editing.md). Finalise/email
|
||||
> (§5–§6) are unchanged. The remaining invariants below still hold.
|
||||
|
||||
- **Stateless editor** *(legacy — see the note above; superseded by ADR 0006)*: every
|
||||
preview/save/finalise call re-posts the full `invc` JSON; the server never holds a
|
||||
partial invoice in memory or session between requests (see `EVAL_live_invoice_editing.md`).
|
||||
- **Totals come from the registration, not the rendered lines**: `sms.ttn`
|
||||
/`sms.ttb` (posted) become `InvoiceBalance`/`InvoiceBalance_net`; display
|
||||
mode (set pricing) never changes what the customer owes.
|
||||
@@ -349,6 +366,10 @@ flowchart TD
|
||||
- **Draft vs. final changes the rendered PDF**: draft = watermark overlay, no
|
||||
GiroCode; final = no watermark, GiroCode payment QR added when there's a
|
||||
positive balance.
|
||||
- **Final invoices can be emitted as eRechnung**: when `Fuchs:ERechnung:Enabled`,
|
||||
the final PDF is a ZUGFeRD/Factur-X **PDF/A-3 hybrid** (eRechnungLib embeds the
|
||||
CII XML into the render-only visual PDF; any failure falls back to the plain
|
||||
PDF/A). Off by default. See [`Concepts/erechnung-output.md`](Concepts/erechnung-output.md).
|
||||
- **Email is best-effort and tracked**: `IsSent` is only set `true`
|
||||
automatically after a *successful* send; a failed send still leaves a
|
||||
correctly finalised, stored invoice that staff can resend or mark sent
|
||||
|
||||
@@ -1,24 +1,54 @@
|
||||
# Invoice "Set" Pricing — Design & Front-/Back-end Contract
|
||||
# Invoice "Set" Pricing — Design & Front-/Back-end Contract
|
||||
|
||||
Customer requirement: items declared as a **set** in `[dbo].[mfr__items]`
|
||||
(`[Type] = 'set'`) should normally be shown as a single **set price** on the
|
||||
invoice instead of being broken up into their member items and summed.
|
||||
> Governed by ADR [0009](Decisions/0009-block-setprice-modes-are-irreversible-mutations.md)
|
||||
> (the two menu modes) and ADR [0008](Decisions/0008-invoices-and-reminders-fully-backend-authoritative.md)
|
||||
> (everything server-side, PDF == editor). ADR 0009 **redefined** the two menu modes from the
|
||||
> reversible, non-mutating display toggles this document previously described into irreversible,
|
||||
> per-service-request-block mutations — the text below reflects the redefinition.
|
||||
|
||||
Three display modes (switchable in the invoice editor):
|
||||
There are **three** separate set-price operations. They fall into two families that must not be
|
||||
confused, because they group items by different keys and differ in whether they mutate the data:
|
||||
|
||||
| Mode | Set line | Member items | Use as |
|
||||
|---|---|---|---|
|
||||
| **SetPrice** (default) | shown **with price** | shown **without price** | the new default |
|
||||
| **ItemPrices** | shown as a heading **without price** | shown **with price** | the previous behaviour |
|
||||
| **SetOnly** | shown **with price** | **removed** | compact |
|
||||
| # | Operation | Trigger | Grouped by | Effect |
|
||||
|---|---|---|---|---|
|
||||
| **1** | **Set-item switch** | row context button on a single `type == "set"` item (`$inv.toSetPrice`), shown only when it has a `SetItmId` | `SetItmId` (the mfr set-item and its members) | sums members onto the set header, sets the members' prices to `null` — **one-way mutation** |
|
||||
| **2** | **"Set mit Preis"** | editor menu ("Set-Preisanzeige") | **`ServiceRequestId`** (the whole block) | inserts a dedicated **set row** per block (block sum as its price) and sets every item's price to **`null`** (items shown **without price**) — **irreversible mutation** |
|
||||
| **3** | **"Nur Set mit Preis"** | editor menu ("Set-Preisanzeige") | **`ServiceRequestId`** (the whole block) | inserts the dedicated **set row** per block and **removes** every item line from the block — **irreversible mutation** |
|
||||
|
||||
> **Totals are unaffected.** The invoice total is taken from the registration
|
||||
> balance (`InvoiceBalance` / `InvoiceBalance_net`), not by summing the rendered
|
||||
> lines, so switching modes is purely presentational. The set price always
|
||||
> equals the sum of its members (computed as a fallback when the set header
|
||||
> carries no own price).
|
||||
Only operation **1** reads `SetItmId`. Operations **2** and **3** ignore it entirely; their only
|
||||
grouping key is the service request (the editor tbody block). See "All set-price functions" below
|
||||
for the full breakdown.
|
||||
|
||||
## Back-end (implemented + unit-tested)
|
||||
> **The two menu modes are irreversible data changes, not display toggles.** Choosing "Set mit
|
||||
> Preis" or "Nur Set mit Preis" rewrites the block's items in the authoritative, server-cached
|
||||
> draft session (`InvoiceDraftSession.Req` — the "cache dataset"): a dedicated set row is inserted
|
||||
> and members have their price set to `null` (mode 2) or are deleted (mode 3). There is **no**
|
||||
> reversible toggle and **no** persisted `admin.setmode` render flag driving grouping. The only
|
||||
> ways back are discarding the draft (reloads the DB state) or hand-editing the resulting rows —
|
||||
> the set row is a real, editable line item precisely so the user can adjust the set value
|
||||
> afterwards.
|
||||
|
||||
> **`ItemPrices` / `admin.setmode` display-flag model was removed.** The earlier design persisted
|
||||
> a `setmode:<mode>` token in `InvoiceOptions` and re-rendered set-item groups per that flag at
|
||||
> render time (`InvoiceSetPricing.Build`). Under ADR 0009 the two menu modes are one-shot
|
||||
> mutations, so there is no display state to persist or toggle. A stale `setmode:`/`itemprices`
|
||||
> token degrades safely (ignored) and is never (re-)persisted.
|
||||
|
||||
> **Totals are unaffected.** The invoice total is taken from the registration balance
|
||||
> (`InvoiceBalance` / `InvoiceBalance_net`), not by summing the rendered lines. Each operation
|
||||
> conserves the total: the inserted set row's value equals the sum of the items it blanks (mode 2)
|
||||
> or removes (mode 3), and the set-item switch (mode 1) writes exactly the members' sum onto the
|
||||
> header.
|
||||
|
||||
## Back-end
|
||||
|
||||
> **Migration status (ADR 0009).** The code below still reflects the previous
|
||||
> `admin.setmode` + `InvoiceSetPricing.Build` **display-mode** implementation for
|
||||
> functions 2 & 3. Under ADR 0009 those two functions become per-block mutations in
|
||||
> `InvoiceDraftEditService` (insert set row + blank/remove members); the render-time
|
||||
> `Build`/`ModeFromInvoiceOptions`/`setmode` display path for them is being retired.
|
||||
> Function 1 (`ApplyItemSetPrice`) is unaffected. Update this section as the
|
||||
> migration lands so it stays a faithful description of the code.
|
||||
|
||||
- `Fuchs/code/InvoiceSetPricing.cs` — the authoritative transformation:
|
||||
`SetDisplayMode` + `Build(items, mode)` → ordered `InvoiceSetLine`s, each with
|
||||
@@ -34,15 +64,15 @@ Three display modes (switchable in the invoice editor):
|
||||
Wired in `Fuchs/js/intranet/modules/fis.inv_shared.js` (bundled to
|
||||
`wwwroot/web/fis.inv.de.js` via gulp `min:js`):
|
||||
|
||||
1. **Mode** — a 3-way switch (`$inv.ssetmode`, menu entry `setm`, label
|
||||
`$ict.setm`) writes the choice onto `admin.setmode`
|
||||
(`setprice` | `itemprices` | `setonly`). The back-end
|
||||
`FdsInvoiceData.BuildInvoiceOptions` turns that into the
|
||||
`setmode:<mode>` token inside `@InvoiceOptions` (default `setprice` omitted),
|
||||
persisted by `fds__createInvoice_Details` and read back by
|
||||
`InvoiceSetPricing.ModeFromInvoiceOptions`. This rides the **same `admin`
|
||||
channel as `§13b`** (the posted payload is `{admin, req, sms, new}` — `inv`
|
||||
is not sent).
|
||||
1. **Menu modes** — the "Set-Preisanzeige" menu (`$inv.ssetmode` → `$inv.setSetmode`,
|
||||
menu entry `setm`, label `$ict.setm`) offers "Set mit Preis" and "Nur Set mit
|
||||
Preis". Selecting one posts a **mutating delta** (grouped by service-request
|
||||
block) to `inv/dpatch`; the server rewrites the block's items in the cached
|
||||
session (inserts the set row, blanks or removes members) and pushes the new
|
||||
state back via `draftReady`/`dstate`. The browser performs **no** grouping or
|
||||
pricing math (ADR 0008/0009) — it only posts the chosen mode and re-renders the
|
||||
server's `req`/`sums`. There is no persisted `admin.setmode` display flag for
|
||||
these two modes.
|
||||
|
||||
2. **Item shape** — `$inv.invSumUpdate` now posts each request block's
|
||||
`items[]` in the back-end contract shape via `$inv.itemToContract`:
|
||||
@@ -51,12 +81,20 @@ Wired in `Fuchs/js/intranet/modules/fis.inv_shared.js` (bundled to
|
||||
`FdsInvoiceData.InvoiceItems` does not read — so line items never reached the
|
||||
C# PDF. This change closes that gap for **all** invoices, not just sets.)
|
||||
|
||||
3. **Set flags** — `invSumUpdate` tags items as it builds `items[]`: an item with
|
||||
`type === 'set'` is a header (`id` = its set id); the **following items in the
|
||||
same block become its members** (`setId` = the header's id) until the next set
|
||||
header. `mfr__items` has a `Type='set'` header but **no explicit member link**,
|
||||
so this "header claims the following items in its block" rule is the convention
|
||||
— adjust in `invSumUpdate` if mfr later exposes a real grouping.
|
||||
3. **Set flags (function 1 only)** — `invSumUpdate` tags items as it builds `items[]`:
|
||||
an item with `type === 'set'` is a header (`id` = its set id); a member item's
|
||||
`setId` is taken directly from the server-computed `SetItmId` field on the row
|
||||
(`rrx.SetItmId`, populated by `fds__prepInvoice`'s `[SetItmID]` window function,
|
||||
anchored on the still-unconverted, zero-priced `'set'` header that owns it) —
|
||||
**not** re-derived from row order in the browser. `mfr__items` itself still has no
|
||||
explicit member link; `fds__prepInvoice` computes `SetItmId` per request from the
|
||||
item list, so only items the server actually attributes to a set are tagged, and
|
||||
unrelated items following a set in the list are never swept in. The header row's
|
||||
own `SetItmId` self-references its own id (rather than being `null`); it is
|
||||
explicitly excluded from being its own member both here (`sid !== citem.id`) and
|
||||
in `InvoiceDraftEditService.ApplyItemSetPrice`. These flags feed **only** the
|
||||
set-item switch (function 1); the two menu modes (functions 2 & 3) ignore
|
||||
`SetItmId` and group by service-request block.
|
||||
|
||||
### Editor → backend field normalization (`$inv.invcPayload`)
|
||||
The editor's internal model keeps the long-standing key names, but the migrated C#
|
||||
@@ -80,20 +118,95 @@ no per-rate `vat_*` keys.
|
||||
`sms.vat`, so non-19 % rates are stored correctly. Single-rate procs still store only
|
||||
the highest rate.
|
||||
|
||||
The editor's running **total stays the member sum in every mode**, matching the
|
||||
registration balance — switching modes is purely presentational.
|
||||
The editor's running **total is unaffected by any set-price operation**, matching
|
||||
the registration balance — each operation conserves the total (the set row's value
|
||||
equals the members it blanks/removes; the set-item switch writes exactly the
|
||||
members' sum onto the header).
|
||||
|
||||
### Why the switch lives in the editor
|
||||
Set grouping is only known where the request/item tree is rendered (front-end).
|
||||
The back-end intentionally stays the single, tested authority for *how* a chosen
|
||||
mode maps to printed lines, so the editor only needs to pick the mode and tag the
|
||||
items — it does not re-implement the pricing rules.
|
||||
### Why the trigger lives in the editor
|
||||
The choice of *when* to apply a set-price operation is only known where the invoice
|
||||
is being composed (front-end), but the operation itself is executed **server-side**
|
||||
against the cached draft session — the editor merely names the target (a set-item
|
||||
`Ref` for function 1, or the chosen menu mode for functions 2 & 3) and re-renders
|
||||
the server's result. The back-end stays the single, tested authority for how each
|
||||
operation rewrites the lines; the editor never re-implements the grouping, the
|
||||
per-block aggregation, or the pricing rules (ADR 0008/0009).
|
||||
|
||||
## All set-price functions: before/after comparison
|
||||
|
||||
There are **three** distinct functions, and all three are **mutations** of the
|
||||
authoritative cached draft session (`InvoiceDraftSession.Req`) — none is a
|
||||
transient, freely-reversible view flag. They differ in what they group by and
|
||||
what they touch:
|
||||
|
||||
- **Function 1 — the set-item switch** (`item.setprice`) groups by `SetItmId`
|
||||
(one mfr set-item and its members) and is triggered per set row.
|
||||
- **Functions 2 & 3 — the two menu modes** ("Set mit Preis" / "Nur Set mit
|
||||
Preis") group by `ServiceRequestId` (the whole block), ignore `SetItmId`
|
||||
entirely, and are triggered once from the "Set-Preisanzeige" menu.
|
||||
|
||||
Only function 1 reads `SetItmId`. All three are one-way; the only escape hatch
|
||||
is discarding the draft or hand-editing the resulting rows.
|
||||
|
||||
### 1. The set-item switch (`item.setprice` patch, single set, mutating)
|
||||
|
||||
Triggered from the invoice editor's row context menu (`$inv.toSetPrice`), applied
|
||||
server-side by `InvoiceDraftEditService.ApplyItemSetPrice`. The context button is
|
||||
shown — and the operation available — **only** on a row that is `type == "set"`
|
||||
**and** carries a `SetItmId` (and is still unconverted, own price `0`); a row
|
||||
missing either condition never offers it. It gives a set-item its "own price": the
|
||||
members' values are summed onto the header and the members' prices are set to
|
||||
`null` (empty cell, excluded from the sum — not `0`). This
|
||||
conversion is **one-way** — there is no patch to move a converted set back to
|
||||
separately-priced members; the user would re-edit the individual line prices by
|
||||
hand. It is the **only** function keyed on `SetItmId`.
|
||||
|
||||
| Aspect | Before the switch | After the switch |
|
||||
|---|---|---|
|
||||
| Set header item (`type == "set"`, `id == Ref`) price | `0` (zero-priced, as delivered by `fds__prepInvoice`) | Price fields (`total_net`/`v`/`vt` + VAT amounts `vv`/`vs`/`vsv`) replaced by the sum of all its members' corresponding values |
|
||||
| Member items (`SetItmId == Ref`, excluding the header itself) | Each shows its own individual `total_net` / VAT amounts | Each price field is set to **`null`** (`v`/`vt`/`vv`/`vs`/`vsv` all `null`, not `0`) → renders an **empty** price/total cell and is excluded from the sum; the row itself stays in the list |
|
||||
| Membership determination | N/A — membership already fixed by the server (`fds__prepInvoice`'s `[SetItmID]` window function) | **Unchanged** — the switch only sums/nulls the items the server already tagged; it never re-derives or reassigns `SetItmId` |
|
||||
| Items **not** tagged with this header's `SetItmId` (e.g. unrelated items following the set in the same block) | Untouched | **Still untouched** — never swept in, regardless of row order/position |
|
||||
| Draft version / history | — | Version bumped by one; an `item.setprice` history entry recorded with old/new header value |
|
||||
| Invoice total (`Sums.TotalNet`/`TotalGross`) | Sum of all individual item prices (header 0 + each member's own price) | **Unchanged** — same total, because the header received exactly the sum of its members |
|
||||
| Idempotency / no-ops | `Ref` unknown, or `Ref` does not point at a `type == 'set'` header → **no-op**: no version bump, no history entry | Same guard still applies after conversion — re-issuing the patch against a non-header `Ref` remains a no-op |
|
||||
|
||||
### 2 & 3. The two menu modes (per service-request block, mutating)
|
||||
|
||||
Triggered once from the editor's "Set-Preisanzeige" menu (`$inv.ssetmode` →
|
||||
`$inv.setSetmode`) and applied server-side per **service-request block**
|
||||
(`ServiceRequestId`), independent of any `type == "set"` item or `SetItmId`.
|
||||
Both are **irreversible** and rewrite the block's items in the cached session.
|
||||
|
||||
For each block, a dedicated, emphasised **set row** is inserted (see "The
|
||||
dedicated set row" below) carrying the block's aggregated value as its price;
|
||||
then, depending on the mode, the block's original items are either blanked or
|
||||
removed:
|
||||
|
||||
| Aspect | **"Set mit Preis"** (mode 2) | **"Nur Set mit Preis"** (mode 3) |
|
||||
|---|---|---|
|
||||
| Grouping key | `ServiceRequestId` (block) | `ServiceRequestId` (block) |
|
||||
| Inserted set row | one per block, price = block's aggregated net (+ VAT/service splits) | one per block, same value |
|
||||
| Original item rows | **kept**, but each price field (`v`/`vt`/`vv`/`vs`/`vsv`) is set to **`null`** (not `0`) → renders an **empty** price/total cell and is excluded from the block sum | **removed** from the block entirely |
|
||||
| `SetItmId` | ignored | ignored |
|
||||
| Reversibility | irreversible (discard draft or hand-edit) | irreversible (discard draft or hand-edit) |
|
||||
| Invoice total | **unchanged** — the set row's value equals the sum of the block's members it blanks | **unchanged** — the set row's value equals the sum of the removed lines |
|
||||
| Empty block | no-op | no-op |
|
||||
|
||||
### The dedicated set row
|
||||
|
||||
Both menu modes insert a **real, editable line item** (its own id, rendered
|
||||
emphasised in the editor and the PDF), not a reused block-heading row and not a
|
||||
render-only overlay. Because it is a genuine row in the cached dataset, the user
|
||||
can adjust the set value afterwards with an ordinary item edit — that hand-edit
|
||||
is the intended and only "undo" for the conversion (ADR
|
||||
[0009](Decisions/0009-block-setprice-modes-are-irreversible-mutations.md)).
|
||||
|
||||
## Persistence note
|
||||
Draft/preview PDFs render straight from the posted `invc` JSON, so the contract
|
||||
works end-to-end for previews and creation. `setmode` persists via
|
||||
`InvoiceOptions`; the finalised document is rendered once and stored as a file, so
|
||||
re-rendering from line items is not needed for correctness. Persisting the
|
||||
per-item `type`/`setId` flags (an SSDT + `fds__createInvoice_Details` change) is
|
||||
only required if a finalised invoice must be **re-generated** from stored items in
|
||||
a different mode later — not done here.
|
||||
Draft/preview PDFs render straight from the cached draft session, so the contract
|
||||
works end-to-end for previews and creation. The two menu modes bake their result
|
||||
directly into the session's items (a set row plus blanked/removed members), so no
|
||||
`setmode:` display token is needed or persisted; the finalised document is
|
||||
rendered once and stored as a file. Persisting the per-item `type`/`setId` flags
|
||||
(an SSDT + `fds__createInvoice_Details` change) is only required if a finalised
|
||||
invoice must be **re-generated** from stored items later — not done here.
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Fuchs Intranet — Das ist neu</title>
|
||||
<style>
|
||||
:root{
|
||||
--blue:#1b4379; /* $fuchs_blau */
|
||||
--blue-2:#2a5da3;
|
||||
--accent:#56a532; /* $fuchs_akzent */
|
||||
--accent-2:#74c14a;
|
||||
--ink:#12243f;
|
||||
--ink-2:#1a2f52;
|
||||
--paper:#f4f6fa;
|
||||
--card:#ffffff;
|
||||
--text:#1c2430;
|
||||
--muted:#586172;
|
||||
--line:#e3e8f0; /* near $fuchs_lightgray */
|
||||
--green:#56a532;
|
||||
--shadow:0 18px 50px -20px rgba(18,36,63,.35);
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html{scroll-behavior:smooth}
|
||||
body{
|
||||
margin:0;
|
||||
font-family:"Segoe UI",system-ui,-apple-system,Roboto,Helvetica,Arial,sans-serif;
|
||||
color:var(--text);
|
||||
background:var(--paper);
|
||||
line-height:1.6;
|
||||
-webkit-font-smoothing:antialiased;
|
||||
}
|
||||
.wrap{max-width:1080px;margin:0 auto;padding:0 24px}
|
||||
|
||||
/* ---------- HERO ---------- */
|
||||
.hero{
|
||||
position:relative;
|
||||
color:#fff;
|
||||
background:
|
||||
radial-gradient(1200px 500px at 80% -10%, rgba(86,165,50,.38), transparent 60%),
|
||||
radial-gradient(900px 500px at 0% 10%, rgba(42,93,163,.50), transparent 55%),
|
||||
linear-gradient(160deg,#1b3a63 0%, #12243f 60%, #0b1727 100%);
|
||||
overflow:hidden;
|
||||
border-bottom:1px solid rgba(255,255,255,.06);
|
||||
}
|
||||
.hero::after{
|
||||
content:"";position:absolute;inset:0;
|
||||
background:linear-gradient(180deg,transparent 60%,rgba(0,0,0,.25));
|
||||
pointer-events:none;
|
||||
}
|
||||
.hero .wrap{position:relative;z-index:2;padding:78px 24px 92px}
|
||||
.eyebrow{
|
||||
display:inline-flex;align-items:center;gap:9px;
|
||||
font-size:.8rem;font-weight:600;letter-spacing:.14em;text-transform:uppercase;
|
||||
color:var(--accent-2);
|
||||
background:rgba(86,165,50,.12);
|
||||
border:1px solid rgba(86,165,50,.28);
|
||||
padding:7px 15px;border-radius:100px;
|
||||
}
|
||||
.eyebrow .dot{width:8px;height:8px;border-radius:50%;background:var(--accent);box-shadow:0 0 14px var(--accent)}
|
||||
h1{
|
||||
font-size:clamp(2.1rem,5vw,3.6rem);
|
||||
line-height:1.08;margin:22px 0 16px;font-weight:800;letter-spacing:-.02em;
|
||||
}
|
||||
h1 .grad{
|
||||
background:linear-gradient(92deg,var(--accent-2),#fff 70%);
|
||||
-webkit-background-clip:text;background-clip:text;color:transparent;
|
||||
}
|
||||
.lede{font-size:clamp(1.05rem,2.2vw,1.28rem);color:#c7cdda;max-width:640px;margin:0}
|
||||
.hero-meta{
|
||||
display:flex;flex-wrap:wrap;gap:26px;margin-top:38px;
|
||||
padding-top:26px;border-top:1px solid rgba(255,255,255,.1);
|
||||
}
|
||||
.hero-meta div{min-width:120px}
|
||||
.hero-meta b{display:block;font-size:1.7rem;font-weight:800;color:#fff}
|
||||
.hero-meta span{font-size:.86rem;color:#98a1b3}
|
||||
|
||||
/* ---------- SECTIONS ---------- */
|
||||
section{padding:64px 0}
|
||||
.section-head{max-width:680px;margin-bottom:40px}
|
||||
.section-head .kicker{color:var(--accent);font-weight:700;font-size:.82rem;letter-spacing:.12em;text-transform:uppercase}
|
||||
h2{font-size:clamp(1.6rem,3.4vw,2.3rem);margin:10px 0 12px;font-weight:800;letter-spacing:-.02em}
|
||||
.section-head p{color:var(--muted);font-size:1.06rem;margin:0}
|
||||
|
||||
/* ---------- FEATURE CARDS ---------- */
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:22px}
|
||||
.card{
|
||||
background:var(--card);
|
||||
border:1px solid var(--line);
|
||||
border-radius:18px;
|
||||
padding:28px 26px;
|
||||
box-shadow:var(--shadow);
|
||||
position:relative;
|
||||
transition:transform .25s ease, box-shadow .25s ease;
|
||||
overflow:hidden;
|
||||
}
|
||||
.card::before{
|
||||
content:"";position:absolute;top:0;left:0;right:0;height:3px;
|
||||
background:linear-gradient(90deg,var(--accent),var(--accent-2));
|
||||
opacity:.9;
|
||||
}
|
||||
.card:hover{transform:translateY(-5px);box-shadow:0 26px 60px -24px rgba(20,24,33,.45)}
|
||||
.card .ico{
|
||||
width:48px;height:48px;border-radius:13px;display:grid;place-items:center;
|
||||
background:linear-gradient(150deg,rgba(86,165,50,.16),rgba(116,193,74,.06));
|
||||
border:1px solid rgba(86,165,50,.22);
|
||||
font-size:1.5rem;margin-bottom:16px;
|
||||
}
|
||||
.card h3{margin:0 0 8px;font-size:1.18rem;font-weight:700}
|
||||
.card p{margin:0;color:var(--muted);font-size:.97rem}
|
||||
.card .tag{
|
||||
display:inline-block;margin-top:16px;font-size:.75rem;font-weight:600;
|
||||
color:var(--accent);background:rgba(86,165,50,.09);
|
||||
border:1px solid rgba(86,165,50,.2);padding:4px 11px;border-radius:100px;
|
||||
}
|
||||
|
||||
/* ---------- BEFORE / AFTER ---------- */
|
||||
.compare{background:linear-gradient(180deg,#fff,#f2f4f9);border-top:1px solid var(--line);border-bottom:1px solid var(--line)}
|
||||
.table-scroll{overflow-x:auto;border-radius:16px;box-shadow:var(--shadow);border:1px solid var(--line)}
|
||||
table{border-collapse:collapse;width:100%;min-width:640px;background:#fff}
|
||||
th,td{text-align:left;padding:16px 20px;border-bottom:1px solid var(--line);vertical-align:top}
|
||||
thead th{background:var(--ink);color:#fff;font-weight:600;font-size:.92rem;letter-spacing:.01em}
|
||||
thead th:first-child{border-top-left-radius:16px}
|
||||
thead th:last-child{border-top-right-radius:16px}
|
||||
tbody tr:last-child td{border-bottom:none}
|
||||
td.feat{font-weight:700;color:var(--ink);width:24%}
|
||||
td.old{color:var(--muted)}
|
||||
td.old::before{content:"✕ ";color:#c4453b;font-weight:700}
|
||||
td.new{color:#1c2733}
|
||||
td.new::before{content:"✓ ";color:var(--green);font-weight:700}
|
||||
tbody tr:nth-child(even){background:#fafbfe}
|
||||
|
||||
/* ---------- SPOTLIGHT ---------- */
|
||||
.spot{display:grid;grid-template-columns:1.05fr .95fr;gap:38px;align-items:center}
|
||||
.spot-card{
|
||||
background:linear-gradient(160deg,var(--ink),var(--ink-2));
|
||||
color:#fff;border-radius:22px;padding:34px;box-shadow:var(--shadow);
|
||||
border:1px solid rgba(255,255,255,.07);
|
||||
}
|
||||
.spot-card h3{margin:0 0 14px;font-size:1.35rem}
|
||||
.spot-card ul{margin:0;padding:0;list-style:none}
|
||||
.spot-card li{position:relative;padding:9px 0 9px 30px;color:#cdd3df;border-bottom:1px dashed rgba(255,255,255,.09)}
|
||||
.spot-card li:last-child{border-bottom:none}
|
||||
.spot-card li::before{content:"→";position:absolute;left:0;color:var(--accent-2);font-weight:800}
|
||||
.spot-text h2{margin-top:0}
|
||||
.spot-text p{color:var(--muted)}
|
||||
.chip{display:inline-block;font-size:.78rem;font-weight:600;color:var(--accent);background:rgba(86,165,50,.1);border:1px solid rgba(86,165,50,.22);padding:5px 12px;border-radius:100px;margin-bottom:14px}
|
||||
|
||||
/* ---------- KEY USER BOX ---------- */
|
||||
.keyuser{background:var(--ink);color:#fff}
|
||||
.keyuser .section-head p{color:#a7afbe}
|
||||
.ku-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:20px}
|
||||
.ku{
|
||||
background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.09);
|
||||
border-radius:16px;padding:24px;
|
||||
}
|
||||
.ku h3{margin:0 0 8px;font-size:1.05rem;color:#fff}
|
||||
.ku h3 span{color:var(--accent-2)}
|
||||
.ku p{margin:0;color:#a7afbe;font-size:.93rem}
|
||||
|
||||
/* ---------- EDITOR STEPS ---------- */
|
||||
.steps{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:18px;margin-top:8px}
|
||||
.step{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:22px 20px;box-shadow:0 10px 30px -20px rgba(18,36,63,.3)}
|
||||
.step .n{font-size:.78rem;font-weight:800;color:var(--accent);letter-spacing:.08em}
|
||||
.step h4{margin:6px 0 6px;font-size:1.04rem}
|
||||
.step p{margin:0;color:var(--muted);font-size:.93rem}
|
||||
|
||||
/* ---------- SET-PRICE PANEL ---------- */
|
||||
.setpanel{
|
||||
margin-top:34px;border:1px solid var(--line);border-radius:22px;
|
||||
background:linear-gradient(160deg,#ffffff,#eef4ea);
|
||||
padding:34px 32px;box-shadow:var(--shadow);position:relative;overflow:hidden;
|
||||
}
|
||||
.setpanel::before{content:"";position:absolute;top:0;left:0;right:0;height:4px;background:linear-gradient(90deg,var(--blue),var(--accent))}
|
||||
.setpanel > .ttl{display:flex;align-items:center;gap:12px;margin-bottom:6px}
|
||||
.setpanel > .ttl .badge{font-size:1.4rem}
|
||||
.setpanel h3{margin:0;font-size:1.35rem;font-weight:800;color:var(--blue)}
|
||||
.setpanel > p{margin:8px 0 0;color:var(--muted);max-width:720px}
|
||||
.setgrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:20px;margin-top:26px}
|
||||
.setvar{background:#fff;border:1px solid var(--line);border-radius:16px;padding:24px 22px;box-shadow:0 12px 32px -22px rgba(18,36,63,.4);display:flex;flex-direction:column}
|
||||
.setvar .num{
|
||||
width:36px;height:36px;border-radius:11px;display:grid;place-items:center;
|
||||
background:linear-gradient(150deg,var(--blue),var(--blue-2));color:#fff;font-weight:800;
|
||||
font-size:1.05rem;margin-bottom:14px;
|
||||
}
|
||||
.setvar h4{margin:0 0 8px;font-size:1.06rem;line-height:1.3}
|
||||
.setvar h4 small{display:block;font-size:.76rem;font-weight:600;color:var(--accent);letter-spacing:.04em;margin-top:3px}
|
||||
.setvar p{margin:0 0 12px;color:var(--muted);font-size:.93rem}
|
||||
.setvar .kv{margin-top:auto;font-size:.82rem;color:var(--blue);font-weight:600}
|
||||
.pill{display:inline-block;font-size:.72rem;font-weight:700;padding:3px 10px;border-radius:100px;margin-top:10px}
|
||||
.pill.rev{color:#8a6d3b;background:rgba(210,150,40,.14);border:1px solid rgba(210,150,40,.35)}
|
||||
.pill.one{color:#a03d2e;background:rgba(196,69,59,.12);border:1px solid rgba(196,69,59,.3)}
|
||||
.setnote{
|
||||
margin-top:24px;padding:16px 20px;border-radius:14px;
|
||||
background:rgba(27,67,121,.06);border:1px solid rgba(27,67,121,.16);
|
||||
color:#2a3b52;font-size:.92rem;
|
||||
}
|
||||
.setnote b{color:var(--blue)}
|
||||
|
||||
/* ---------- FOOTER ---------- */
|
||||
footer{padding:44px 0;text-align:center;color:var(--muted);font-size:.9rem;border-top:1px solid var(--line)}
|
||||
footer b{color:var(--ink)}
|
||||
|
||||
@media(max-width:760px){
|
||||
.spot{grid-template-columns:1fr}
|
||||
section{padding:48px 0}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="hero">
|
||||
<div class="wrap">
|
||||
<span class="eyebrow"><span class="dot"></span>Release-Übersicht · 2026</span>
|
||||
<h1>Ihr Intranet wird<br><span class="grad">schneller, sicherer, transparenter.</span></h1>
|
||||
<p class="lede">Die neue Generation des Fuchs Intranets bringt Live-Vorschau bei der Rechnungserstellung, Echtzeit-Rückmeldungen, extern validierte E-Rechnung (ZUGFeRD/DATEV und XRechnung) und eine durchgängig geprüfte Datenverarbeitung — ohne dass sich Ihr gewohnter Arbeitsablauf verändert.</p>
|
||||
<div class="hero-meta">
|
||||
<div><b>E-Rechnung</b><span>ZUGFeRD / XRechnung · extern validiert</span></div>
|
||||
<div><b>Echtzeit</b><span>Live-Vorschau & Benachrichtigungen</span></div>
|
||||
<div><b>.NET 10</b><span>Moderne, geprüfte Plattform</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- WAS NEU IST -->
|
||||
<section>
|
||||
<div class="wrap">
|
||||
<div class="section-head">
|
||||
<span class="kicker">Das Wichtigste auf einen Blick</span>
|
||||
<h2>Die neuen Funktionen für Ihren Alltag</h2>
|
||||
<p>Alle Neuerungen zielen auf dasselbe Ziel: weniger Fehler, mehr Überblick und Rechnungen, die auf Anhieb korrekt sind.</p>
|
||||
</div>
|
||||
<div class="grid">
|
||||
|
||||
<div class="card">
|
||||
<div class="ico">👁️</div>
|
||||
<h3>Live-Vorschau beim Bearbeiten</h3>
|
||||
<p>Während Sie eine Rechnung bearbeiten, sehen Sie das fertige PDF sofort in Echtzeit — genau so, wie es der Kunde erhält. Kein Zwischenspeichern, kein Raten mehr.</p>
|
||||
<span class="tag">Rechnungen & Zahlungserinnerungen</span>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="ico">🧮</div>
|
||||
<h3>Alle Beträge serverseitig berechnet</h3>
|
||||
<p>Summen, Mehrwertsteuer, §13b-Umkehr und offene Beträge rechnet ab sofort der Server — geprüft und einheitlich. Der Bildschirm zeigt immer denselben Stand wie das PDF.</p>
|
||||
<span class="tag">Keine Rechenfehler mehr</span>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="ico">🔔</div>
|
||||
<h3>Benachrichtigungen in Echtzeit</h3>
|
||||
<p>„Rechnung R2026-0001 wurde an den Kunden versandt." — Erfolg <em>und</em> Fehler erscheinen sofort als deutlich lesbare Meldung. Kein Nachschauen in Listen mehr.</p>
|
||||
<span class="tag">Sofortiges Feedback</span>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="ico">🧾</div>
|
||||
<h3>Extern geprüfte E-Rechnung</h3>
|
||||
<p>Rechnungen werden automatisch als ZUGFeRD 2.4 / Factur-X (DATEV) ausgegeben — oder, sobald eine Leitweg-ID hinterlegt ist, als XRechnung 3.0 für den Behördenversand. Beide Formate sind bei einem externen Prüfdienst als fehlerfrei (0 Fehler) und PDF/A-3-konform bestätigt.</p>
|
||||
<span class="tag">ZUGFeRD 2.4 · XRechnung 3.0 · Extern validiert</span>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="ico">🕓</div>
|
||||
<h3>Änderungshistorie & Verwerfen</h3>
|
||||
<p>Jede Änderung an einem Entwurf wird protokolliert. Über „Änderungshistorie" sehen Sie, was passiert ist, und mit „Änderungen verwerfen" kehren Sie jederzeit zum gespeicherten Stand zurück.</p>
|
||||
<span class="tag">Volle Nachvollziehbarkeit</span>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="ico">🏦</div>
|
||||
<h3>Mehr Bankformate</h3>
|
||||
<p>Kontoauszüge werden jetzt auch im modernen ISO-20022-Format (CAMT) automatisch erkannt und eingelesen — zusätzlich zum bewährten MT940. Das Format wird selbstständig erkannt.</p>
|
||||
<span class="tag">CAMT + MT940</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- SPOTLIGHT: Live-Editor -->
|
||||
<section style="padding-top:12px">
|
||||
<div class="wrap">
|
||||
<div class="spot">
|
||||
<div class="spot-text">
|
||||
<span class="chip">Highlight</span>
|
||||
<h2>Der Rechnungs-Editor, der mitdenkt</h2>
|
||||
<p>Früher rechnete der Browser — heute ist der Server die einzige verbindliche Quelle. Das klingt technisch, bedeutet für Sie aber vor allem: Was Sie sehen, stimmt. Immer.</p>
|
||||
<p>Gilt für <strong>alle Rechnungsarten</strong> (Regel-, Abschlags-, Schluss- und Stornorechnung) und <strong>alle Mahnstufen</strong> — das Online-Bild und das PDF sind garantiert identisch, bis hin zur Positionsnummerierung.</p>
|
||||
</div>
|
||||
<div class="spot-card">
|
||||
<h3>Was der Editor jetzt automatisch tut</h3>
|
||||
<ul>
|
||||
<li>Rechnet Netto, MwSt. und Brutto sofort korrekt neu</li>
|
||||
<li>Prüft E-Mail, Adresse, Positionen und Steuersätze live</li>
|
||||
<li>Erzeugt die PDF-Vorschau direkt aus dem aktuellen Stand</li>
|
||||
<li>Nummeriert Positionen auch nach Umsortieren korrekt durch</li>
|
||||
<li>Warnt rechtzeitig, bevor ein Entwurf abläuft</li>
|
||||
<li>Speichert erst final, wenn Sie es bestätigen</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ONLINE-EDITOR IM DETAIL -->
|
||||
<section>
|
||||
<div class="wrap">
|
||||
<div class="section-head">
|
||||
<span class="kicker">Der Online-Editor im Detail</span>
|
||||
<h2>Rechnungen direkt im Browser erstellen</h2>
|
||||
<p>Der neue Rechnungs-Editor führt Sie Schritt für Schritt zur fertigen Rechnung — komfortabel zu bedienen und dabei jederzeit rechnerisch abgesichert. Er gilt für alle Rechnungsarten (Regel-, Abschlags-, Schluss- und Stornorechnung).</p>
|
||||
</div>
|
||||
|
||||
<div class="steps">
|
||||
<div class="step"><div class="n">BEARBEITEN</div><h4>Direkt im Feld</h4><p>Texte und Positionen bearbeiten Sie direkt an Ort und Stelle — ein Klick genügt.</p></div>
|
||||
<div class="step"><div class="n">EMPFÄNGER</div><h4>Adresse als Formular</h4><p>Die Rechnungsadresse erfassen Sie strukturiert (Name, Straße, PLZ, Ort, Land, optional USt-IdNr., bei Behörden die Leitweg-ID) — vorausgefüllt aus den Kundendaten, ergänzt um Leistungsdatum bzw. Leistungszeitraum. Ein Hinweis zeigt, ob alles für die DATEV-/E-Rechnung passt; ist eine Leitweg-ID gesetzt, wird automatisch als XRechnung statt ZUGFeRD ausgegeben. Für Privatpersonen bleibt die USt-IdNr. einfach leer.</p></div>
|
||||
<div class="step"><div class="n">ORDNEN</div><h4>Blöcke & Reihenfolge</h4><p>Positionen sind je Auftrag in Abschnitten gebündelt und lassen sich per Ziehen neu sortieren; die Nummerierung passt sich automatisch an.</p></div>
|
||||
<div class="step"><div class="n">RECHNEN</div><h4>Summen & Steuer live</h4><p>Netto, Mehrwertsteuer, Brutto und die §13b-Umkehr werden bei jeder Änderung sofort und geprüft neu berechnet.</p></div>
|
||||
<div class="step"><div class="n">PRÜFEN</div><h4>Vorschau auf Knopfdruck</h4><p>Die PDF-Vorschau entsteht direkt aus dem aktuellen Stand — was Sie sehen, ist exakt das, was der Kunde erhält.</p></div>
|
||||
</div>
|
||||
|
||||
<!-- SET-PREIS-VARIANTEN -->
|
||||
<div class="setpanel">
|
||||
<div class="ttl"><span class="badge">📦</span><h3>Set-Preis: drei Wege, Positionen zusammenzufassen</h3></div>
|
||||
<p>Oft sollen mehrere Einzelpositionen zu <em>einem</em> Set-Preis zusammengefasst werden — etwa als Pauschale pro Auftrag. Dafür gibt es drei klar getrennte Funktionen. Bei allen bleibt die <strong>Rechnungssumme unverändert</strong>; die Set-Zeile trägt genau den Wert der zusammengefassten Positionen.</p>
|
||||
|
||||
<div class="setgrid">
|
||||
|
||||
<div class="setvar">
|
||||
<div class="num">1</div>
|
||||
<h4>Einzelne Set-Position zusammenfassen<small>Zeilen-Schaltfläche · direkt an der Position</small></h4>
|
||||
<p>Für eine einzelne Set-Position: Der Set-Kopf übernimmt die Summe seiner zugehörigen Teilpositionen, deren Einzelpreise werden dann leer dargestellt (kein Preis, nicht 0,00 €).</p>
|
||||
<span class="kv">Wirkt auf: eine markierte Set-Position</span>
|
||||
<span class="pill one">Einmalig — nicht per Klick umkehrbar</span>
|
||||
</div>
|
||||
|
||||
<div class="setvar">
|
||||
<div class="num">2</div>
|
||||
<h4>„Set mit Preis"<small>Menü · ganzer Auftragsblock</small></h4>
|
||||
<p>Für jeden Auftragsblock wird oben eine hervorgehobene Set-Zeile mit dem Gesamtwert des Blocks eingefügt. Die bisherigen Einzelpositionen <strong>bleiben sichtbar</strong>, jedoch ohne Einzelpreis (leeres Preisfeld). Die eingefügte Set-Zeile ist eine echte, nachträglich editierbare Position.</p>
|
||||
<span class="kv">Wirkt auf: jeden Auftragsblock · Positionen bleiben erhalten</span>
|
||||
<span class="pill rev">Einmalige Umwandlung</span>
|
||||
</div>
|
||||
|
||||
<div class="setvar">
|
||||
<div class="num">3</div>
|
||||
<h4>„Nur Set mit Preis"<small>Menü · ganzer Auftragsblock</small></h4>
|
||||
<p>Wie „Set mit Preis" — aber die Einzelpositionen werden <strong>vollständig entfernt</strong>. Es bleibt allein die eine Set-Zeile mit dem Gesamtpreis des Blocks stehen. Ideal für eine schlanke Pauschal-Darstellung.</p>
|
||||
<span class="kv">Wirkt auf: jeden Auftragsblock · Positionen werden entfernt</span>
|
||||
<span class="pill rev">Einmalige Umwandlung</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="setnote">
|
||||
<b>Gut zu wissen:</b> Die beiden Menü-Varianten (2 & 3) sind bewusste, <b>einmalige Umwandlungen</b> — es gibt keinen Umschalter zurück. Möchten Sie den Ausgangszustand wiederherstellen, verwerfen Sie einfach den Entwurf („Änderungen verwerfen"), oder passen Sie die entstandene Set-Zeile von Hand an. In jedem Fall gilt: die <b>Gesamtsumme ändert sich nicht</b>, und die PDF-Ausgabe zeigt exakt dasselbe wie der Online-Editor.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- BEFORE / AFTER -->
|
||||
<section class="compare">
|
||||
<div class="wrap">
|
||||
<div class="section-head">
|
||||
<span class="kicker">Alt gegen Neu</span>
|
||||
<h2>Was sich konkret verbessert hat</h2>
|
||||
<p>Ein direkter Vergleich der bisherigen Lösung mit der neuen Implementierung.</p>
|
||||
</div>
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Bereich</th><th>Bisher (Legacy)</th><th>Neu</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="feat">Rechnungsvorschau</td>
|
||||
<td class="old">Kein Live-PDF — Ergebnis erst nach dem Speichern sichtbar</td>
|
||||
<td class="new">Echtzeit-PDF-Vorschau schon während der Bearbeitung</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="feat">Berechnung</td>
|
||||
<td class="old">Beträge im Browser gerechnet — Abweichungen möglich</td>
|
||||
<td class="new">Alle Werte serverseitig geprüft & einheitlich berechnet</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="feat">Rückmeldungen</td>
|
||||
<td class="old">Keine aktive Meldung — Status nur durch Nachschauen</td>
|
||||
<td class="new">Sofortige Erfolgs- und Fehlermeldungen in Echtzeit</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="feat">Fehler im Hintergrund</td>
|
||||
<td class="old">Nur im Protokoll — für den Nutzer unsichtbar</td>
|
||||
<td class="new">Werden dem Nutzer verständlich angezeigt</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="feat">Rechnungsformat</td>
|
||||
<td class="old">Reines PDF</td>
|
||||
<td class="new">Zusätzlich extern validierte E-Rechnung (ZUGFeRD/DATEV oder XRechnung für Behörden) — 0 Fehler, PDF/A-3 bestätigt</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="feat">Änderungsverlauf</td>
|
||||
<td class="old">Nicht vorhanden</td>
|
||||
<td class="new">Vollständige Historie & gezieltes Verwerfen je Entwurf</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="feat">Kontoauszüge</td>
|
||||
<td class="old">Nur MT940</td>
|
||||
<td class="new">MT940 <em>und</em> CAMT (ISO 20022) mit Auto-Erkennung</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="feat">System-Überblick</td>
|
||||
<td class="old">Kein Einblick in den Systemzustand</td>
|
||||
<td class="new">Admin-/Status-Modul mit Live-Prüfungen (für berechtigte Nutzer)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="feat">Plattform</td>
|
||||
<td class="old">Ältere VB-Codebasis</td>
|
||||
<td class="new">Modernes .NET 10 — schneller, gepflegt, umfangreich getestet</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- KEY USER -->
|
||||
<section class="keyuser">
|
||||
<div class="wrap">
|
||||
<div class="section-head">
|
||||
<span class="kicker" style="color:var(--accent-2)">Für den Key-User</span>
|
||||
<h2>Mehr Kontrolle hinter den Kulissen</h2>
|
||||
<p>Diese Punkte betreffen vor allem Sie als Key-User — sie sorgen dafür, dass der Betrieb stabil, nachvollziehbar und überprüfbar bleibt.</p>
|
||||
</div>
|
||||
<div class="ku-grid">
|
||||
<div class="ku">
|
||||
<h3><span>◆</span> Admin- & Status-Modul</h3>
|
||||
<p>Ein eigenes Modul zeigt den Zustand des Systems: Server, Datenbank, Schlüsseltresor, Speicher und die ERP-Anbindung werden live geprüft. Inklusive Test-E-Mail-Funktion — sichtbar nur für berechtigte Nutzer.</p>
|
||||
</div>
|
||||
<div class="ku">
|
||||
<h3><span>◆</span> Durchgängige Nachvollziehbarkeit</h3>
|
||||
<p>Jeder wichtige Geschäftsvorfall — erstellt, versandt, importiert, fehlgeschlagen — wird als Ereignis erfasst und in verständliche Meldungen übersetzt.</p>
|
||||
</div>
|
||||
<div class="ku">
|
||||
<h3><span>◆</span> Überwachung & Diagnose</h3>
|
||||
<p>Moderne Telemetrie (OpenTelemetry) misst Abläufe, Laufzeiten und Fehler. Probleme lassen sich damit früher erkennen und schneller eingrenzen.</p>
|
||||
</div>
|
||||
<div class="ku">
|
||||
<h3><span>◆</span> Automatischer ERP-Abgleich</h3>
|
||||
<p>Der Abgleich mit dem ERP-System (mfr) läuft zuverlässig im Hintergrund direkt in der Anwendung — mit automatischer Wiederholung bei kurzzeitigen Störungen.</p>
|
||||
</div>
|
||||
<div class="ku">
|
||||
<h3><span>◆</span> Sichere Konfiguration</h3>
|
||||
<p>Zugangsdaten liegen im zentralen Azure Key Vault. Eine Test-Schutzfunktion verhindert, dass in Test-Umgebungen versehentlich echte Kunden angeschrieben werden.</p>
|
||||
</div>
|
||||
<div class="ku">
|
||||
<h3><span>◆</span> Umfassend getestet</h3>
|
||||
<p>Die Kernlogik ist durch eine breite, automatisierte Testabdeckung abgesichert — erfolgreiche wie fehlerhafte Abläufe werden geprüft, bevor Änderungen live gehen.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- UNVERÄNDERT / VERTRAUT -->
|
||||
<section>
|
||||
<div class="wrap">
|
||||
<div class="section-head">
|
||||
<span class="kicker">Vertraut geblieben</span>
|
||||
<h2>Was sich für Sie <em>nicht</em> ändert</h2>
|
||||
<p>Modernisiert wurde die Technik — nicht Ihre Arbeitsweise.</p>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<div class="card"><div class="ico">🗂️</div><h3>Gewohnte Module</h3><p>Rechnungen, Zahlungserinnerungen, Anfragen, Banking und Berichte finden Sie an denselben Stellen wie bisher.</p></div>
|
||||
<div class="card"><div class="ico">📄</div><h3>Vertrautes Layout</h3><p>Briefkopf, Adressfenster und Rechnungslayout wurden 1:1 übernommen — Ihre Dokumente sehen aus wie gewohnt.</p></div>
|
||||
<div class="card"><div class="ico">🔐</div><h3>Gleiche Anmeldung</h3><p>Login und Berechtigungen bleiben unverändert. Neue Funktionen erscheinen nur dort, wo Sie dafür berechtigt sind.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<div class="wrap">
|
||||
<p><b>Fuchs Intranet</b> — Neue Implementierung · Stand Juli 2026<br>
|
||||
Sebastian Fuchs Bad und Heizung GmbH & Co. KG · Bereitgestellt von ProcessWeb</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
+7
-10
@@ -22,9 +22,9 @@
|
||||
<ProjectReference Include="..\OCORE_web\OCORE_web\OCORE_web.csproj" />
|
||||
<ProjectReference Include="..\OCORE_web_pdf\OCORE_web_pdf.csproj" />
|
||||
<ProjectReference Include="..\CAMTParser\CAMTParser.csproj" />
|
||||
<ProjectReference Include="..\eRechnungLib\src\eRechnungLib\eRechnungLib.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Content Include="code\7z.dll" CopyToOutputDirectory="PreserveNewest" />
|
||||
<Content Include="Data\**" CopyToOutputDirectory="PreserveNewest" />
|
||||
<Content Include="favicon.ico" />
|
||||
</ItemGroup>
|
||||
@@ -34,18 +34,17 @@
|
||||
<PackageReference Include="HtmlAgilityPack" Version="1.12.4" />
|
||||
<PackageReference Include="MailKit" Version="4.17.0" />
|
||||
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.16.0" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.16.0" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.17.0" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.17.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.16.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.16.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.16.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.SqlClient" Version="1.16.0" />
|
||||
<PackageReference Include="Portable.BouncyCastle" Version="1.9.0" />
|
||||
<PackageReference Include="QRCoder" Version="1.8.0" />
|
||||
<PackageReference Include="PDFsharp" Version="6.2.4" />
|
||||
<PackageReference Include="PDFsharp-MigraDoc" Version="6.2.4" />
|
||||
<PackageReference Include="Spire.PDF" Version="[8.10.5,8.10.5]" allowedVersions="[8.10.5,8.10.5]" />
|
||||
<PackageReference Include="Squid-Box.SevenZipSharp" Version="1.6.2.24" />
|
||||
<!-- Updated packages -->
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||
<PackageReference Include="MimeKit" Version="4.17.0" />
|
||||
@@ -53,8 +52,8 @@
|
||||
<!-- New packages (needed for .NET 10) -->
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.2" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="4.0.0" />
|
||||
<PackageReference Include="System.Configuration.ConfigurationManager" Version="10.0.9" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="10.0.9" />
|
||||
<PackageReference Include="System.Configuration.ConfigurationManager" Version="10.0.10" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="10.0.10" />
|
||||
<PackageReference Include="Azure.Storage.Blobs" Version="12.29.1" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
@@ -66,14 +65,12 @@
|
||||
|
||||
|
||||
<!-- UsingTask für plattformneutrales Zippen nach Publish -->
|
||||
<UsingTask TaskName="ZipDir" TaskFactory="CodeTaskFactory" AssemblyName="Microsoft.Build.Tasks.Core">
|
||||
<UsingTask TaskName="ZipDir" TaskFactory="RoslynCodeTaskFactory" AssemblyName="Microsoft.Build.Tasks.Core">
|
||||
<ParameterGroup>
|
||||
<Source ParameterType="System.String" Required="true" />
|
||||
<Destination ParameterType="System.String" Required="true" />
|
||||
</ParameterGroup>
|
||||
<Task>
|
||||
<Reference Include="System.IO.Compression" />
|
||||
<Reference Include="System.IO.Compression.FileSystem" />
|
||||
<Code Type="Fragment" Language="cs">
|
||||
<![CDATA[
|
||||
if (System.IO.File.Exists(Destination)) {
|
||||
|
||||
@@ -12,6 +12,7 @@ public enum DomainEventType
|
||||
InvoiceFileCreationFailed,
|
||||
InvoiceSendFailed,
|
||||
ReminderDraftCreated,
|
||||
ReminderDraftUpdated,
|
||||
ReminderFileCreated,
|
||||
ReminderSentToCustomer,
|
||||
ReminderResentToCustomer,
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Fuchs.Notifications;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IDraftNotifier"/> over the <see cref="DraftPreviewHub"/>. Sends to the
|
||||
/// SignalR group named after the draft token so only the editing browser is notified.
|
||||
/// Like <see cref="EventService.PublishAsync"/>, delivery failures are logged and
|
||||
/// swallowed — a missed coordination ping must never fail the underlying operation
|
||||
/// (the client also re-syncs on reconnect and on its next POST).
|
||||
/// </summary>
|
||||
public sealed class DraftNotifier : IDraftNotifier
|
||||
{
|
||||
private readonly IHubContext<DraftPreviewHub> _hub;
|
||||
private readonly ILogger<DraftNotifier> _logger;
|
||||
|
||||
public DraftNotifier(IHubContext<DraftPreviewHub> hub, ILogger<DraftNotifier> logger)
|
||||
{
|
||||
_hub = hub;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task SignalDraftReadyAsync(string token, int version, CancellationToken cancellationToken = default) =>
|
||||
SendAsync(token, "draftReady", new { token, version }, cancellationToken);
|
||||
|
||||
public Task SignalExpiringAsync(string token, int secondsLeft, CancellationToken cancellationToken = default) =>
|
||||
SendAsync(token, "draftExpiring", new { token, secondsLeft }, cancellationToken);
|
||||
|
||||
public Task SignalClosedAsync(string token, string reason, CancellationToken cancellationToken = default) =>
|
||||
SendAsync(token, "draftClosed", new { token, reason }, cancellationToken);
|
||||
|
||||
private async Task SendAsync(string token, string method, object payload, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrEmpty(token)) return;
|
||||
try
|
||||
{
|
||||
await _hub.Clients.Group(token).SendAsync(method, payload, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Draft signal {Method} failed for token {Token}", method, token);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace Fuchs.Notifications;
|
||||
|
||||
/// <summary>
|
||||
/// SignalR hub for live invoice/reminder draft editing (see ADR 0006 / 0007).
|
||||
///
|
||||
/// Deliberately separate from <see cref="NotificationHub"/>: that hub broadcasts
|
||||
/// business toasts to <b>all</b> logged-in sessions (ADR 0002), whereas draft
|
||||
/// signals must be <b>targeted</b> at the one browser editing a given draft.
|
||||
/// Targeting is done with a SignalR group named after the draft's session token —
|
||||
/// each editor calls <see cref="JoinDraft"/> after opening a draft.
|
||||
///
|
||||
/// The hub carries no commands: edits, saves and discards travel as ordinary POSTs
|
||||
/// (see ADR 0006). The hub only manages group membership and delivers the server's
|
||||
/// <c>draftReady</c> / <c>draftExpiring</c> / <c>draftClosed</c> signals.
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
public sealed class DraftPreviewHub : Hub
|
||||
{
|
||||
/// <summary>Subscribes this connection to a draft's signal group.</summary>
|
||||
public Task JoinDraft(string token) =>
|
||||
string.IsNullOrEmpty(token) ? Task.CompletedTask
|
||||
: Groups.AddToGroupAsync(Context.ConnectionId, token);
|
||||
|
||||
/// <summary>Unsubscribes this connection from a draft's signal group.</summary>
|
||||
public Task LeaveDraft(string token) =>
|
||||
string.IsNullOrEmpty(token) ? Task.CompletedTask
|
||||
: Groups.RemoveFromGroupAsync(Context.ConnectionId, token);
|
||||
}
|
||||
@@ -76,6 +76,12 @@ public sealed class EventService : IEventService
|
||||
public Task ReminderDraftCreatedAsync(FdsReminderData reminder, string userAccountId)
|
||||
=> PublishAsync(new DomainEvent(DomainEventType.ReminderDraftCreated, userAccountId, "Mahnentwurf", ReminderContext(reminder)));
|
||||
|
||||
public Task ReminderDraftRegisteredAsync(FdsReminderData reminder, bool changed, string userAccountId)
|
||||
{
|
||||
var type = changed ? DomainEventType.ReminderDraftUpdated : DomainEventType.ReminderDraftCreated;
|
||||
return PublishAsync(new DomainEvent(type, userAccountId, "Mahnentwurf", ReminderContext(reminder)));
|
||||
}
|
||||
|
||||
public Task ReminderFileCreatedAsync(FdsReminderData reminder, string fileName, string userAccountId)
|
||||
{
|
||||
var ctx = ReminderContext(reminder);
|
||||
@@ -176,6 +182,8 @@ public sealed class EventService : IEventService
|
||||
Ctx(domainEvent, "message"),
|
||||
DomainEventType.ReminderDraftCreated =>
|
||||
$"Mahnentwurf {Ctx(domainEvent, "title")} wurde erstellt.",
|
||||
DomainEventType.ReminderDraftUpdated =>
|
||||
$"Mahnentwurf {Ctx(domainEvent, "title")} wurde aktualisiert.",
|
||||
DomainEventType.ReminderFileCreated =>
|
||||
$"Mahndatei {Ctx(domainEvent, "fileName")} wurde erstellt.",
|
||||
DomainEventType.ReminderSentToCustomer =>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace Fuchs.Notifications;
|
||||
|
||||
/// <summary>
|
||||
/// Sends <b>system-internal</b> draft-editing signals to the one browser editing a
|
||||
/// given draft, over the <see cref="DraftPreviewHub"/> group keyed by session token
|
||||
/// (see ADR 0006 / 0007). These are coordination pings, not business notifications:
|
||||
/// user-facing success/failure messages (e.g. "Zwischenstand gespeichert") still go
|
||||
/// through <see cref="IEventService"/> / <see cref="NotificationHub"/>.
|
||||
/// </summary>
|
||||
public interface IDraftNotifier
|
||||
{
|
||||
/// <summary>The cached draft reached a new <paramref name="version"/> — the client should re-fetch its state.</summary>
|
||||
Task SignalDraftReadyAsync(string token, int version, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>The draft is about to expire in <paramref name="secondsLeft"/>s unless saved — warn the user.</summary>
|
||||
Task SignalExpiringAsync(string token, int secondsLeft, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>The draft session was removed (evicted/expired/discarded) — the client must close the editor and show why.</summary>
|
||||
Task SignalClosedAsync(string token, string reason, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ public interface IEventService
|
||||
Task InvoiceIssueAsync(string message, string userAccountId, string invoiceId = "");
|
||||
|
||||
Task ReminderDraftCreatedAsync(FdsReminderData reminder, string userAccountId);
|
||||
Task ReminderDraftRegisteredAsync(FdsReminderData reminder, bool changed, string userAccountId);
|
||||
Task ReminderFileCreatedAsync(FdsReminderData reminder, string fileName, string userAccountId);
|
||||
Task ReminderSentToCustomerAsync(FdsReminderData reminder, string email, string userAccountId, bool resent = false);
|
||||
Task ReminderMarkedSentAsync(string reminderId, string reminderTitle, string userAccountId);
|
||||
|
||||
@@ -51,6 +51,9 @@ public static class FuchsTelemetry
|
||||
Meter.CreateCounter<long>("fuchs.blobstorage.uploads", "{upload}", "Number of documents successfully archived to Azure Blob Storage.");
|
||||
public static readonly Counter<long> BlobUploadsFailed =
|
||||
Meter.CreateCounter<long>("fuchs.blobstorage.uploads.failed", "{upload}", "Number of documents that failed to archive to Azure Blob Storage.");
|
||||
public static readonly Counter<long> SystemProbes =
|
||||
Meter.CreateCounter<long>("fuchs.systemstatus.probes", "{probe}",
|
||||
"Admin system-status connectivity probes executed, tagged by component and outcome.");
|
||||
|
||||
// ── Performance histograms (durations in milliseconds) ───────────────────
|
||||
public static readonly Histogram<double> PdfRenderDuration =
|
||||
|
||||
@@ -59,6 +59,31 @@ public class Program
|
||||
// FDS MFR singleton — ILogger<FdsMfr> and ILoggerFactory are supplied by the ASP.NET Core DI container
|
||||
builder.Services.AddSingleton<fds.IFdsMfr, fds.FdsMfr>();
|
||||
|
||||
// In-process MFR ERP sync — formerly the standalone Fuchs_DataService Windows Service
|
||||
// (Topshelf), now hosted here as a BackgroundService. Gated by Fds:SyncEnabled so
|
||||
// developer machines (appsettings.Development.json sets it false) never poll the ERP;
|
||||
// interval and debug verbosity come from the Fds config section.
|
||||
if (builder.Configuration.GetValue("Fds:SyncEnabled", false))
|
||||
{
|
||||
builder.Services.AddHostedService(sp =>
|
||||
{
|
||||
var mfr = sp.GetRequiredService<fds.IFdsMfr>();
|
||||
var interval = TimeSpan.FromMinutes(
|
||||
builder.Configuration.GetValue("Fds:ExecutionFrequency_Minutes", 15d));
|
||||
bool debug = builder.Configuration.GetValue("Fds:DebugDetails", false);
|
||||
var jobs = new[]
|
||||
{
|
||||
new PeriodicJobDefinition("MfrSync", interval, async ct =>
|
||||
{
|
||||
await mfr.UpdateIfNecessary_async(debug, ct);
|
||||
await mfr.UpdateRequested_async(debug, ct);
|
||||
await mfr.GetInvoiceFiles_async(debug, ct);
|
||||
})
|
||||
};
|
||||
return new PeriodicHostedService(jobs, sp.GetRequiredService<ILogger<PeriodicHostedService>>());
|
||||
});
|
||||
}
|
||||
|
||||
// MVC with Razor view support
|
||||
builder.Services.AddControllersWithViews();
|
||||
builder.Services.AddSignalR();
|
||||
@@ -96,8 +121,17 @@ public class Program
|
||||
// (see appsettings.Development.json) so real tenant-owners/end-customers are never emailed.
|
||||
builder.Services.Configure<FuchsEmailSettings>(builder.Configuration.GetSection("Fuchs:Email"));
|
||||
builder.Services.Configure<StartupSelfTestSettings>(builder.Configuration.GetSection("Fuchs:StartupChecks"));
|
||||
// eRechnung (ZUGFeRD/Factur-X) output + external formal-conformance validation seam.
|
||||
builder.Services.Configure<ERechnungSettings>(builder.Configuration.GetSection("Fuchs:ERechnung"));
|
||||
builder.Services.AddSingleton<IERechnungService, ERechnungService>(); // stateless: maps + embeds ZUGFeRD
|
||||
builder.Services.AddSingleton<IERechnungValidator, ProcessWebERechnungValidator>(); // external EN16931 + PDF/A-3 check
|
||||
builder.Services.AddHttpClient(ProcessWebERechnungValidator.HttpClientName,
|
||||
c => c.Timeout = TimeSpan.FromSeconds(90));
|
||||
builder.Services.AddHttpClient("ProcessWebMailer");
|
||||
builder.Services.AddScoped<IComService, ProcessWebComService>();
|
||||
// Holds the one-shot startup self-test result for the lifetime of the process so the Admin
|
||||
// module can display it as a non-refreshable widget (must be registered before the service).
|
||||
builder.Services.AddSingleton<StartupCheckReporter>();
|
||||
builder.Services.AddHostedService<StartupSelfTestService>();
|
||||
|
||||
// Business services (DI migration — replaces the static helper / Active-Record pattern)
|
||||
@@ -110,6 +144,24 @@ public class Program
|
||||
builder.Services.AddScoped<IReminderService, ReminderService>();
|
||||
builder.Services.AddScoped<IEventService, EventService>();
|
||||
|
||||
// Read-only system diagnostics for the Admin module (config snapshot + connectivity probes
|
||||
// + test-email). Restricted to fds_sys > 4 in IntranetController.Admin; see the concept doc.
|
||||
builder.Services.AddScoped<ISystemStatusService, SystemStatusService>();
|
||||
|
||||
// Live, backend-authoritative invoice draft editing (ADR 0006): an in-memory
|
||||
// draft cache (singleton), the scoped edit orchestrator, a targeted SignalR
|
||||
// notifier over the dedicated DraftPreviewHub, and the idle-expiry monitor.
|
||||
builder.Services.AddSingleton<IInvoiceDraftCache, InvoiceDraftCache>();
|
||||
builder.Services.AddSingleton<IDraftNotifier, DraftNotifier>();
|
||||
builder.Services.AddScoped<IInvoiceDraftService, InvoiceDraftEditService>();
|
||||
builder.Services.AddHostedService<InvoiceDraftExpiryService>();
|
||||
|
||||
// Live, backend-authoritative reminder draft editing (ADR 0006) — the reminder
|
||||
// mirror of the invoice draft services above, sharing the DraftPreviewHub/notifier.
|
||||
builder.Services.AddSingleton<IReminderDraftCache, ReminderDraftCache>();
|
||||
builder.Services.AddScoped<IReminderDraftService, ReminderDraftEditService>();
|
||||
builder.Services.AddHostedService<ReminderDraftExpiryService>();
|
||||
|
||||
// Secondary archive: invoice/reminder PDFs additionally stored in Azure Blob Storage.
|
||||
// Disabled by default (Fuchs:AzureStorage:Enabled) — see AzureBlobStorageService.
|
||||
builder.Services.Configure<AzureBlobStorageSettings>(builder.Configuration.GetSection("Fuchs:AzureStorage"));
|
||||
@@ -184,6 +236,7 @@ public class Program
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.MapHub<NotificationHub>("/notifications");
|
||||
app.MapHub<DraftPreviewHub>("/draftpreview");
|
||||
|
||||
// Intranet routes (root-level — this IS the website)
|
||||
app.MapControllerRoute(
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- https://go.microsoft.com/fwlink/?LinkID=208121. -->
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<DeleteExistingFiles>true</DeleteExistingFiles>
|
||||
<ExcludeApp_Data>false</ExcludeApp_Data>
|
||||
<LaunchSiteAfterPublish>true</LaunchSiteAfterPublish>
|
||||
<LastUsedBuildConfiguration>Release</LastUsedBuildConfiguration>
|
||||
<LastUsedPlatform>x64</LastUsedPlatform>
|
||||
<PublishProvider>FileSystem</PublishProvider>
|
||||
<PublishUrl>Q:\PWProjects\Fuchs_Intranet</PublishUrl>
|
||||
<WebPublishMethod>FileSystem</WebPublishMethod>
|
||||
<_TargetId>Folder</_TargetId>
|
||||
<SiteUrlToLaunchAfterPublish />
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<ProjectGuid>2856176d-cda6-1be2-0ce0-d72c26fb4f35</ProjectGuid>
|
||||
<SelfContained>false</SelfContained>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -89,6 +89,87 @@ public class AzureBlobStorageService : IBlobStorageService
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<BlobConnectivity> CheckConnectivityAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_settings.Enabled)
|
||||
return new BlobConnectivity { Enabled = false, Configured = _client != null };
|
||||
|
||||
if (_client == null)
|
||||
return new BlobConnectivity
|
||||
{
|
||||
Enabled = true,
|
||||
Configured = false,
|
||||
Detail = "ConnectionStrings:AzureBlobStorage_ConnectionString ist nicht konfiguriert.",
|
||||
};
|
||||
|
||||
using var act = FuchsTelemetry.StartActivity("blobstorage.connectivity");
|
||||
try
|
||||
{
|
||||
AccountInfo info = await _client.GetAccountInfoAsync(cancellationToken);
|
||||
var containers = await CountContainersAsync(cancellationToken);
|
||||
return new BlobConnectivity
|
||||
{
|
||||
Enabled = true,
|
||||
Configured = true,
|
||||
Reachable = true,
|
||||
AccountName = _client.AccountName,
|
||||
Detail = $"{info.AccountKind} / {info.SkuName}",
|
||||
Containers = containers,
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
act?.SetStatus(ActivityStatusCode.Error, ex.Message);
|
||||
_logger.LogWarning(ex, "Blob connectivity check failed for account {Account}.", _client.AccountName);
|
||||
return new BlobConnectivity
|
||||
{
|
||||
Enabled = true,
|
||||
Configured = true,
|
||||
Reachable = false,
|
||||
AccountName = _client.AccountName,
|
||||
Detail = ex.Message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Counts the blobs in each distinct configured container (invoice + reminder). A container
|
||||
/// that does not exist yet reports <see cref="BlobContainerCount.Exists"/> = false / count 0;
|
||||
/// a container whose listing throws reports count -1. Never throws.
|
||||
/// </summary>
|
||||
private async Task<List<BlobContainerCount>> CountContainersAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var result = new List<BlobContainerCount>();
|
||||
if (_client == null) return result;
|
||||
|
||||
var names = new[] { _settings.InvoiceContainer, _settings.ReminderContainer }
|
||||
.Where(n => !string.IsNullOrWhiteSpace(n))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var name in names)
|
||||
{
|
||||
try
|
||||
{
|
||||
var container = _client.GetBlobContainerClient(name);
|
||||
bool exists = await container.ExistsAsync(cancellationToken);
|
||||
int count = 0;
|
||||
if (exists)
|
||||
{
|
||||
await foreach (var _ in container.GetBlobsAsync(cancellationToken: cancellationToken))
|
||||
count++;
|
||||
}
|
||||
result.Add(new BlobContainerCount { Name = name, Exists = exists, FileCount = count });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Blob file count failed for container {Container}.", name);
|
||||
result.Add(new BlobContainerCount { Name = name, Exists = false, FileCount = -1 });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string BuildBlobName(string documentId, string fileName) =>
|
||||
$"{documentId}/{(string.IsNullOrWhiteSpace(fileName) ? $"{documentId}.pdf" : fileName)}";
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ public static class DocumentMetadataBuilder
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(field)) continue;
|
||||
if (!TryGetValue(row, field, out object? value)) continue;
|
||||
metadata[field] = Stringify(value);
|
||||
metadata[field] = ToAsciiHeaderValue(Stringify(value));
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
@@ -55,4 +55,24 @@ public static class DocumentMetadataBuilder
|
||||
DateTime dt => dt.ToString("O"),
|
||||
_ => value.ToString() ?? ""
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Azure Blob metadata is transported in HTTP headers, whose values must contain ASCII
|
||||
/// characters only. Preserve readable, printable ASCII and percent-encode every other
|
||||
/// UTF-8 byte so titles and document names containing umlauts cannot break the upload.
|
||||
/// </summary>
|
||||
private static string ToAsciiHeaderValue(string value)
|
||||
{
|
||||
if (value.All(c => c is >= ' ' and <= '~')) return value;
|
||||
|
||||
var result = new System.Text.StringBuilder(value.Length);
|
||||
foreach (byte b in System.Text.Encoding.UTF8.GetBytes(value))
|
||||
{
|
||||
if (b is >= 0x20 and <= 0x7E)
|
||||
result.Append((char)b);
|
||||
else
|
||||
result.Append('%').Append(b.ToString("X2"));
|
||||
}
|
||||
return result.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
using System.Globalization;
|
||||
using eRechnungLib;
|
||||
using eRechnungLib.Model;
|
||||
using eRechnungLib.Model.CodeLists;
|
||||
using Fuchs.intranet;
|
||||
using static OCORE.OCORE_dictionaries;
|
||||
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Maps a Fuchs <see cref="FdsInvoiceData"/> onto the strongly-typed EN 16931 invoice model of
|
||||
/// <c>eRechnungLib</c>, so it can be emitted as a ZUGFeRD/Factur-X hybrid (see ADR 0005/0012).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The seller (<c>BG-4</c>) is Fuchs itself; its master data comes from
|
||||
/// <see cref="ERechnungSellerSettings"/> (<c>Fuchs:ERechnung:Seller</c> in appsettings, defaults
|
||||
/// mirroring the FuchsPdf letterhead). The buyer (<c>BG-7</c>) comes from the structured
|
||||
/// <see cref="FdsInvoiceData.RecipientAddress"/>; a private person (no VAT id) maps cleanly with
|
||||
/// no buyer tax registration. Amounts/VAT are recomputed by the library from the mapped lines.
|
||||
/// </remarks>
|
||||
public static class ERechnungMapper
|
||||
{
|
||||
/// <summary>Builds an <see cref="EInvoice"/> (EN 16931 model) from the Fuchs invoice data.</summary>
|
||||
/// <param name="invoice">The Fuchs invoice data.</param>
|
||||
/// <param name="seller">Seller master data; defaults to the built-in Fuchs values when omitted.</param>
|
||||
public static EInvoice BuildEInvoice(FdsInvoiceData invoice, ERechnungSellerSettings? seller = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(invoice);
|
||||
seller ??= new ERechnungSellerSettings();
|
||||
var reg = invoice.InvoiceRegistration;
|
||||
bool reverseCharge = (reg?.getString("InvoiceOptions") ?? "").Contains("§13b", StringComparison.Ordinal);
|
||||
|
||||
var model = new Invoice
|
||||
{
|
||||
InvoiceNumber = NonEmpty(reg?.getString("InvoiceId"), invoice.Id, "ENTWURF"),
|
||||
IssueDate = IssueDate(reg),
|
||||
CurrencyCode = CurrencyCode.Eur,
|
||||
Seller = BuildSeller(seller),
|
||||
Buyer = BuildBuyer(invoice),
|
||||
Payment = BuildPayment(invoice, seller),
|
||||
};
|
||||
|
||||
// B2G: a Leitweg-ID makes this an XRechnung (buyer reference BT-10 is then mandatory).
|
||||
var addr = invoice.RecipientAddress;
|
||||
if (addr is { IsPublicAuthority: true })
|
||||
model.BuyerReference = addr.LeitwegId.Trim();
|
||||
|
||||
string title = reg?.getString("InvoiceTitle") ?? "";
|
||||
if (!string.IsNullOrWhiteSpace(title))
|
||||
model.Notes.Add(new InvoiceNote(title));
|
||||
|
||||
AddLines(model, invoice, reverseCharge);
|
||||
|
||||
if (reverseCharge)
|
||||
model.VatExemptionReasons[VatCategoryCode.ReverseCharge] =
|
||||
new VatExemptionReason("Steuerschuldnerschaft des Leistungsempfängers (§ 13b UStG)");
|
||||
|
||||
// Payment terms (BT-20) + due date (BT-9) — required by BR-CO-25 for a positive amount due.
|
||||
model.PaymentTerms = BuildPaymentTerms(invoice, model.IssueDate);
|
||||
|
||||
// Service date / period (BT-72 or BG-14) from the free-text ProvisionPeriod — clears the
|
||||
// XRechnung recommendation BR-DE-TMP-32 when a date/range can be parsed.
|
||||
ApplyServicePeriod(model, reg?.getString("ProvisionPeriod"));
|
||||
|
||||
return EInvoice.CreateInvoice(model).Recalculate();
|
||||
}
|
||||
|
||||
private static TradeParty BuildSeller(ERechnungSellerSettings s)
|
||||
{
|
||||
var seller = new TradeParty
|
||||
{
|
||||
Name = s.Name,
|
||||
Address = new PostalAddress
|
||||
{
|
||||
Line1 = NullIfEmpty(s.Street),
|
||||
PostalCode = NullIfEmpty(s.PostalCode),
|
||||
City = NullIfEmpty(s.City),
|
||||
Country = NormalizeCountry(s.CountryCode),
|
||||
},
|
||||
// Seller electronic address (BT-34) + contact (BG-6) — mandatory for XRechnung (BR-DE-5/6/7).
|
||||
ElectronicAddress = new Identifier(s.Email, "EM"),
|
||||
Contact = new TradeContact { Name = s.Name, Telephone = NullIfEmpty(s.Phone), Email = NullIfEmpty(s.Email) },
|
||||
};
|
||||
if (!string.IsNullOrWhiteSpace(s.LegalRegistrationId))
|
||||
seller.LegalRegistrationId = new Identifier(s.LegalRegistrationId.Trim()); // BT-30
|
||||
if (!string.IsNullOrWhiteSpace(s.TaxNumber))
|
||||
seller.TaxRegistrations.Add(new TaxRegistration(s.TaxNumber, TaxRegistrationScheme.LocalTaxNumber)); // BT-32
|
||||
if (!string.IsNullOrWhiteSpace(s.VatId))
|
||||
seller.TaxRegistrations.Add(new TaxRegistration(s.VatId, TaxRegistrationScheme.Vat)); // BT-31
|
||||
return seller;
|
||||
}
|
||||
|
||||
private static TradeParty BuildBuyer(FdsInvoiceData invoice)
|
||||
{
|
||||
var addr = invoice.RecipientAddress;
|
||||
if (addr is null)
|
||||
{
|
||||
// No structured address yet (older draft): best-effort from the free-text block so a
|
||||
// model can still be produced. The country defaults to DE; refine once structured
|
||||
// capture is in place. EN 16931 validation will flag any remaining gaps.
|
||||
var lines = (invoice.InvoiceRegistration?.getString("SendToAddress") ?? "")
|
||||
.Replace("\r\n", "\n").Split('\n').Select(l => l.Trim()).Where(l => l.Length > 0).ToArray();
|
||||
return new TradeParty
|
||||
{
|
||||
Name = lines.Length > 0 ? lines[0] : "Rechnungsempfänger",
|
||||
Address = new PostalAddress { Country = CountryCode.Germany },
|
||||
};
|
||||
}
|
||||
|
||||
var buyer = new TradeParty
|
||||
{
|
||||
Name = NonEmpty(addr.Name, "Rechnungsempfänger"),
|
||||
Address = new PostalAddress
|
||||
{
|
||||
Line1 = NullIfEmpty(addr.Street),
|
||||
Line2 = NullIfEmpty(addr.AddressLine2),
|
||||
PostalCode = NullIfEmpty(addr.PostalCode),
|
||||
City = NullIfEmpty(addr.City),
|
||||
Country = NormalizeCountry(addr.CountryCode),
|
||||
},
|
||||
};
|
||||
if (!string.IsNullOrWhiteSpace(addr.VatId))
|
||||
buyer.TaxRegistrations.Add(new TaxRegistration(addr.VatId.Trim(), TaxRegistrationScheme.Vat));
|
||||
// Buyer electronic address (BT-49) — mandatory for XRechnung; use the recipient email.
|
||||
string email = NonEmpty(invoice.InvoiceRegistration?.getString("SendToEmail"), invoice.RawInvoiceEmail);
|
||||
if (!string.IsNullOrWhiteSpace(email))
|
||||
buyer.ElectronicAddress = new Identifier(email.Trim(), "EM");
|
||||
return buyer;
|
||||
}
|
||||
|
||||
private static PaymentInstructions BuildPayment(FdsInvoiceData invoice, ERechnungSellerSettings s)
|
||||
{
|
||||
var payment = new PaymentInstructions
|
||||
{
|
||||
MeansCode = PaymentMeansCode.SepaCreditTransfer,
|
||||
RemittanceInformation = invoice.InvoiceRegistration?.getString("InvoiceId") is { Length: > 0 } inv ? inv : null,
|
||||
};
|
||||
if (!string.IsNullOrWhiteSpace(s.Iban))
|
||||
payment.CreditTransfers.Add(new CreditTransferAccount
|
||||
{
|
||||
AccountId = s.Iban.Replace(" ", ""),
|
||||
AccountName = NullIfEmpty(s.Name),
|
||||
BankId = NullIfEmpty(s.Bic),
|
||||
});
|
||||
return payment;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the free-text service period (e.g. <c>"01.06.2026 - 30.06.2026"</c> or a single
|
||||
/// <c>"18.06.2026"</c>) into the invoicing period (BG-14) or, for a single date, the actual
|
||||
/// delivery date (BT-72). Unparseable text is ignored (the field is only a recommendation).
|
||||
/// </summary>
|
||||
private static void ApplyServicePeriod(Invoice model, string? provisionPeriod)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(provisionPeriod)) return;
|
||||
var dates = System.Text.RegularExpressions.Regex
|
||||
.Matches(provisionPeriod, @"(\d{1,2})\.(\d{1,2})\.(\d{4})|(\d{4})-(\d{2})-(\d{2})")
|
||||
.Select(m => ParseDate(m)).Where(d => d is not null).Select(d => d!.Value).OrderBy(d => d).ToList();
|
||||
if (dates.Count == 0) return;
|
||||
|
||||
if (dates.Count == 1)
|
||||
model.Delivery = new DeliveryInformation { DeliveryDate = dates[0] }; // BT-72
|
||||
else
|
||||
model.InvoicingPeriod = new Period(dates[0], dates[^1]); // BG-14
|
||||
}
|
||||
|
||||
private static DateOnly? ParseDate(System.Text.RegularExpressions.Match m)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (m.Groups[1].Success)
|
||||
return new DateOnly(int.Parse(m.Groups[3].Value), int.Parse(m.Groups[2].Value), int.Parse(m.Groups[1].Value));
|
||||
return new DateOnly(int.Parse(m.Groups[4].Value), int.Parse(m.Groups[5].Value), int.Parse(m.Groups[6].Value));
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private static void AddLines(Invoice model, FdsInvoiceData invoice, bool reverseCharge)
|
||||
{
|
||||
int id = 0;
|
||||
foreach (var item in invoice.InvoiceItems)
|
||||
{
|
||||
string name = FirstNonEmpty(Str(item, "title"), Str(item, "nme"), Str(item, "text"), Str(item, "t"));
|
||||
string desc = Str(item, "desc");
|
||||
if (string.IsNullOrWhiteSpace(name) && string.IsNullOrWhiteSpace(desc)) name = "Position";
|
||||
|
||||
decimal qty = Dec(item, "qty", "q", "Quantity") ?? 1m;
|
||||
if (qty == 0m) qty = 1m;
|
||||
decimal net = Dec(item, "total_net", "value_total", "vt") ?? 0m;
|
||||
decimal? unit = Dec(item, "price_net", "value", "v");
|
||||
decimal rate = Dec(item, "vat") ?? 0m;
|
||||
|
||||
id++;
|
||||
var line = new InvoiceLine
|
||||
{
|
||||
Id = id.ToString(CultureInfo.InvariantCulture),
|
||||
Quantity = qty,
|
||||
UnitCode = UnitCode.One,
|
||||
NetPrice = unit ?? (qty != 0 ? decimal.Round(net / qty, 2, MidpointRounding.AwayFromZero) : net),
|
||||
NetAmount = net,
|
||||
VatCategory = reverseCharge ? VatCategoryCode.ReverseCharge
|
||||
: rate > 0 ? VatCategoryCode.StandardRate : VatCategoryCode.ZeroRatedGoods,
|
||||
VatRate = reverseCharge ? 0m : rate,
|
||||
Item = new TradeItem { Name = Truncate(name, 500), Description = NullIfEmpty(desc) },
|
||||
};
|
||||
model.Lines.Add(line);
|
||||
}
|
||||
|
||||
// EN 16931 requires at least one line (BR-16); synthesise one from the invoice total when
|
||||
// the item list is empty (e.g. a lump-sum invoice).
|
||||
if (model.Lines.Count == 0)
|
||||
{
|
||||
decimal net = ParseInvariant(invoice.InvoiceRegistration?.getString("InvoiceBalance_net")) ?? 0m;
|
||||
decimal rate = ParseInvariant(invoice.InvoiceRegistration?.getString("InvoiceVAT_1")) ?? 0m;
|
||||
model.Lines.Add(new InvoiceLine
|
||||
{
|
||||
Id = "1",
|
||||
Quantity = 1m,
|
||||
UnitCode = UnitCode.One,
|
||||
NetPrice = net,
|
||||
NetAmount = net,
|
||||
VatCategory = reverseCharge ? VatCategoryCode.ReverseCharge
|
||||
: rate > 0 ? VatCategoryCode.StandardRate : VatCategoryCode.ZeroRatedGoods,
|
||||
VatRate = reverseCharge ? 0m : rate,
|
||||
Item = new TradeItem { Name = NonEmpty(invoice.InvoiceRegistration?.getString("InvoiceTitle"), "Leistung") },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds payment terms (BT-20 description + BT-9 due date) from the Fuchs payment term token
|
||||
/// (e.g. <c>"10wd"</c> = 10 Werktage, <c>"14d"</c> = 14 Tage). Always returns a term so BR-CO-25
|
||||
/// holds when the amount due is positive.
|
||||
/// </summary>
|
||||
private static PaymentTerms BuildPaymentTerms(FdsInvoiceData invoice, DateOnly issueDate)
|
||||
{
|
||||
string term = NonEmpty(invoice.InvoiceRegistration?.getString("PaymentTerm"), invoice.PaymentTerms).Trim().ToLowerInvariant();
|
||||
var m = System.Text.RegularExpressions.Regex.Match(term, @"^(\d+)\s*(wd|d)?$");
|
||||
if (!m.Success || !int.TryParse(m.Groups[1].Value, out int n) || n <= 0)
|
||||
return new PaymentTerms { Description = "Zahlbar sofort ohne Abzug", DueDate = issueDate };
|
||||
|
||||
bool workingDays = m.Groups[2].Value == "wd";
|
||||
DateOnly due = issueDate;
|
||||
if (workingDays)
|
||||
{
|
||||
int added = 0;
|
||||
while (added < n)
|
||||
{
|
||||
due = due.AddDays(1);
|
||||
if (due.DayOfWeek is not DayOfWeek.Saturday and not DayOfWeek.Sunday) added++;
|
||||
}
|
||||
}
|
||||
else due = issueDate.AddDays(n);
|
||||
|
||||
string desc = workingDays
|
||||
? $"Zahlbar innerhalb von {n} Werktagen ohne Abzug (bis {due:dd.MM.yyyy})"
|
||||
: $"Zahlbar innerhalb von {n} Tagen ohne Abzug (bis {due:dd.MM.yyyy})";
|
||||
return new PaymentTerms { Description = desc, DueDate = due };
|
||||
}
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────
|
||||
private static DateOnly IssueDate(GenericObjectDictionary? reg)
|
||||
{
|
||||
foreach (var key in new[] { "DateFinalized", "DateCreated" })
|
||||
if (reg?.getString(key) is { Length: > 0 } s && DateTime.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dt))
|
||||
return DateOnly.FromDateTime(dt);
|
||||
return DateOnly.FromDateTime(DateTime.Today);
|
||||
}
|
||||
|
||||
/// <summary>Normalises a raw country value (ISO alpha-2, or a German/English name) to a country code.</summary>
|
||||
internal static CountryCode NormalizeCountry(string? raw)
|
||||
{
|
||||
string s = (raw ?? "").Trim();
|
||||
if (s.Length == 2 && s.All(char.IsLetter)) return new CountryCode(s);
|
||||
return s.ToLowerInvariant() switch
|
||||
{
|
||||
"deutschland" or "germany" => CountryCode.Germany,
|
||||
"österreich" or "oesterreich" or "austria" => CountryCode.Austria,
|
||||
"schweiz" or "switzerland" or "suisse" => CountryCode.Switzerland,
|
||||
"" => CountryCode.Germany,
|
||||
_ => CountryCode.Germany,
|
||||
};
|
||||
}
|
||||
|
||||
private static string Str(IDictionary<string, object?> d, string key)
|
||||
=> d.TryGetValue(key, out var v) && v != null ? Convert.ToString(v, CultureInfo.InvariantCulture)?.Trim() ?? "" : "";
|
||||
|
||||
private static decimal? Dec(IDictionary<string, object?> d, params string[] keys)
|
||||
{
|
||||
foreach (var k in keys)
|
||||
if (d.TryGetValue(k, out var v) && v != null)
|
||||
{
|
||||
string s = Convert.ToString(v, CultureInfo.InvariantCulture)?.Replace("%", "").Trim() ?? "";
|
||||
if (decimal.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var dec)) return dec;
|
||||
if (decimal.TryParse(s.Replace(',', '.'), NumberStyles.Any, CultureInfo.InvariantCulture, out dec)) return dec;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static decimal? ParseInvariant(string? s)
|
||||
=> decimal.TryParse((s ?? "").Replace(',', '.'), NumberStyles.Any, CultureInfo.InvariantCulture, out var d) ? d : null;
|
||||
|
||||
private static string FirstNonEmpty(params string[] values) => values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v)) ?? "";
|
||||
private static string NonEmpty(params string?[] values) => values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v)) ?? "";
|
||||
private static string? NullIfEmpty(string? s) => string.IsNullOrWhiteSpace(s) ? null : s.Trim();
|
||||
private static string Truncate(string s, int max) => s.Length <= max ? s : s[..max];
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using System.Diagnostics;
|
||||
using eRechnungLib;
|
||||
using eRechnungLib.Profiles;
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Observability;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Produces the ZUGFeRD/Factur-X hybrid PDF for a finalized invoice by mapping
|
||||
/// <see cref="FdsInvoiceData"/> to the EN 16931 model and embedding the CII XML into the
|
||||
/// FuchsPdf-rendered <b>visual</b> PDF (ADR 0012 — eRechnungLib owns the single PDF/A-3 layer).
|
||||
/// </summary>
|
||||
public interface IERechnungService
|
||||
{
|
||||
/// <summary>Whether eRechnung emission is switched on (<c>Fuchs:ERechnung:Enabled</c>).</summary>
|
||||
bool Enabled { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Builds the hybrid PDF from the raw (non-PDF/A) visual PDF, or returns <see langword="null"/>
|
||||
/// when disabled or on any failure — the caller then falls back to the plain PDF/A so
|
||||
/// invoicing never breaks because of eRechnung production. When external validation is enabled
|
||||
/// and reports a failure with <c>FailOnError</c>, the hybrid is withheld (null → fallback) too.
|
||||
/// </summary>
|
||||
Task<byte[]?> TryBuildHybridPdfAsync(FdsInvoiceData invoice, byte[] rawVisualPdf);
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="IERechnungService"/>
|
||||
public sealed class ERechnungService : IERechnungService
|
||||
{
|
||||
private readonly ERechnungSettings _settings;
|
||||
private readonly IERechnungValidator _validator;
|
||||
private readonly ILogger<ERechnungService> _logger;
|
||||
|
||||
public ERechnungService(IOptions<ERechnungSettings> settings, IERechnungValidator validator,
|
||||
ILogger<ERechnungService> logger)
|
||||
{
|
||||
_settings = settings.Value;
|
||||
_validator = validator;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public bool Enabled => _settings.Enabled;
|
||||
|
||||
public async Task<byte[]?> TryBuildHybridPdfAsync(FdsInvoiceData invoice, byte[] rawVisualPdf)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(invoice);
|
||||
if (!_settings.Enabled) return null;
|
||||
if (rawVisualPdf is null || rawVisualPdf.Length == 0) return null;
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
using var act = FuchsTelemetry.StartActivity("invoice.erechnung");
|
||||
act?.SetTag("fuchs.invoice.id", invoice.Id);
|
||||
try
|
||||
{
|
||||
var einvoice = ERechnungMapper.BuildEInvoice(invoice, _settings.Seller);
|
||||
// A Leitweg-ID (buyer reference) marks a B2G invoice → emit the XRechnung profile
|
||||
// (the mandatory form for public authorities); otherwise the configured default (EN 16931).
|
||||
bool isB2G = !string.IsNullOrWhiteSpace(einvoice.Model.BuyerReference);
|
||||
var profile = isB2G ? ZugferdProfile.XRechnung : ParseProfile(_settings.Profile);
|
||||
act?.SetTag("fuchs.erechnung.profile", profile.ToString());
|
||||
var result = einvoice.ToZugferd(profile, rawVisualPdf);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
_logger.LogError("eRechnung: conversion produced no output for invoice {Id}. Findings: {Findings}",
|
||||
invoice.Id, result.Validation);
|
||||
return null;
|
||||
}
|
||||
if (!result.Validation.IsValid)
|
||||
_logger.LogWarning("eRechnung: invoice {Id} produced with library validation findings: {Findings}",
|
||||
invoice.Id, result.Validation);
|
||||
|
||||
byte[] hybrid = result.Value!;
|
||||
act?.SetTag("fuchs.erechnung.bytes", hybrid.Length);
|
||||
_logger.LogInformation("eRechnung: hybrid PDF/A-3 built for invoice {Id} ({Profile}, {Bytes} bytes, {Ms} ms)",
|
||||
invoice.Id, profile, hybrid.Length, sw.ElapsedMilliseconds);
|
||||
|
||||
// External formal verification (EN 16931 XML + PDF/A-3 via the ProcessWeb eInvoice service).
|
||||
if (_validator.Enabled)
|
||||
{
|
||||
var v = await _validator.ValidatePdfAsync(hybrid);
|
||||
act?.SetTag("fuchs.erechnung.valid", v.IsValid);
|
||||
act?.SetTag("fuchs.erechnung.pdfa", v.PdfACompliant);
|
||||
if (v.IsValid)
|
||||
_logger.LogInformation("eRechnung: invoice {Id} externally validated OK ({Summary})", invoice.Id, v.Summary);
|
||||
else if (v.HasHardError)
|
||||
{
|
||||
_logger.LogWarning("eRechnung: invoice {Id} has validation errors (pdfA={PdfA}, xmlErrors={Errors}): {Summary}",
|
||||
invoice.Id, v.PdfACompliant, v.XmlErrorCount, v.Summary);
|
||||
if (_settings.Validation.FailOnError)
|
||||
{
|
||||
_logger.LogError("eRechnung: withholding hybrid for invoice {Id} (FailOnError); falling back to plain PDF/A", invoice.Id);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
else
|
||||
// Reached but not strictly valid without hard errors — e.g. the validator's XML
|
||||
// scenario set does not cover our EN 16931 ZUGFeRD. Informational only.
|
||||
_logger.LogInformation("eRechnung: invoice {Id} — PDF/A ok; XML scenario not matched by validator (no errors). {Summary}",
|
||||
invoice.Id, v.Summary);
|
||||
}
|
||||
return hybrid;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
act?.SetStatus(ActivityStatusCode.Error, ex.Message);
|
||||
_logger.LogError(ex, "eRechnung: hybrid build failed for invoice {Id}; falling back to plain PDF/A", invoice.Id);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static ZugferdProfile ParseProfile(string? profile) => (profile ?? "").Trim().ToUpperInvariant() switch
|
||||
{
|
||||
"EXTENDED" => ZugferdProfile.Extended,
|
||||
"BASIC" => ZugferdProfile.Basic,
|
||||
"XRECHNUNG" => ZugferdProfile.XRechnung,
|
||||
_ => ZugferdProfile.EN16931,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// eRechnung (ZUGFeRD/Factur-X) output settings, bound from appsettings.json → "Fuchs:ERechnung".
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per ADR 0005 the invoice visual PDF is produced by <c>FuchsPdf</c> (render-only, no Spire
|
||||
/// PDF/A) and handed to <c>eRechnungLib.ToZugferd(...)</c>, which owns the single PDF/A-3 layer
|
||||
/// and embeds the CII XML. This is the gate that turns that emission on and configures it.
|
||||
/// </remarks>
|
||||
public sealed class ERechnungSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// When <see langword="true"/>, finalized invoices are emitted as a ZUGFeRD/Factur-X hybrid
|
||||
/// PDF/A-3. Kept <see langword="false"/> until the <c>FdsInvoiceData</c> → model mapping is
|
||||
/// wired, so the app keeps producing the plain PDF/A until then.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>The ZUGFeRD profile to emit. Defaults to <c>EN16931</c> (the minimum DATEV accepts
|
||||
/// for full booking; MINIMUM/BASIC WL are intentionally not offered).</summary>
|
||||
public string Profile { get; set; } = "EN16931";
|
||||
|
||||
/// <summary>Seller (Fuchs) master data mapped into every eRechnung (BG-4).</summary>
|
||||
public ERechnungSellerSettings Seller { get; set; } = new();
|
||||
|
||||
/// <summary>Formal-conformance validation settings (PDF/A-3 via veraPDF, ZUGFeRD rules).</summary>
|
||||
public ERechnungValidationSettings Validation { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The seller (Fuchs) master data written into every eRechnung, bound from
|
||||
/// <c>Fuchs:ERechnung:Seller</c>. Defaults mirror the FuchsPdf letterhead so the mapping keeps
|
||||
/// working if the section is absent; override in <c>appsettings.json</c>.
|
||||
/// </summary>
|
||||
public sealed class ERechnungSellerSettings
|
||||
{
|
||||
/// <summary>Legal / trading name (BT-27).</summary>
|
||||
public string Name { get; set; } = "Sebastian Fuchs GmbH & Co. KG";
|
||||
|
||||
/// <summary>Street and house number (BT-35).</summary>
|
||||
public string Street { get; set; } = "Germaniastraße 15";
|
||||
|
||||
/// <summary>Post code (BT-38).</summary>
|
||||
public string PostalCode { get; set; } = "40223";
|
||||
|
||||
/// <summary>City (BT-37).</summary>
|
||||
public string City { get; set; } = "Düsseldorf";
|
||||
|
||||
/// <summary>ISO 3166-1 alpha-2 country code (BT-40).</summary>
|
||||
public string CountryCode { get; set; } = "DE";
|
||||
|
||||
/// <summary>Steuernummer / local tax number (BT-32).</summary>
|
||||
public string TaxNumber { get; set; } = "106/5849/2962";
|
||||
|
||||
/// <summary>USt-IdNr / VAT identifier (BT-31) — required by BR-CO-26.</summary>
|
||||
public string VatId { get; set; } = "DE286366012";
|
||||
|
||||
/// <summary>Legal registration id / Handelsregisternummer (BT-30). Optional.</summary>
|
||||
public string LegalRegistrationId { get; set; } = "";
|
||||
|
||||
/// <summary>Payment IBAN (BT-84).</summary>
|
||||
public string Iban { get; set; } = "DE76300501100045014800";
|
||||
|
||||
/// <summary>Payment BIC (BT-86).</summary>
|
||||
public string Bic { get; set; } = "DUSSDEDDXXX";
|
||||
|
||||
/// <summary>Contact / electronic-address e-mail (BT-34 / BT-43).</summary>
|
||||
public string Email { get; set; } = "info@sanitaerfuchs.de";
|
||||
|
||||
/// <summary>Contact telephone (BT-42).</summary>
|
||||
public string Phone { get; set; } = "0211 - 31 07 222";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Settings for the external eRechnung validation service (veraPDF for PDF/A-3 and a
|
||||
/// ZUGFeRD/EN 16931 validator), bound from "Fuchs:ERechnung:Validation".
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The concrete service URL/contract is provided by an online service that is not yet available;
|
||||
/// this is the configurable seam so only the URL has to be supplied later. While
|
||||
/// <see cref="ServiceUrl"/> is empty, verification is reported as "not configured / skipped".
|
||||
/// </remarks>
|
||||
public sealed class ERechnungValidationSettings
|
||||
{
|
||||
/// <summary>Whether to call the external validation service after producing a hybrid PDF.</summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>Base URL of the online veraPDF/ZUGFeRD validation service (empty until provisioned).</summary>
|
||||
public string ServiceUrl { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// When <see langword="true"/>, a validation error withholds the invoice output rather than
|
||||
/// shipping it with findings. Defaults to <see langword="false"/> (report-only).
|
||||
/// </summary>
|
||||
public bool FailOnError { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>The outcome of an external eRechnung validation (ZUGFeRD/EN 16931 XML + PDF/A-3).</summary>
|
||||
/// <param name="Configured">False when no validation service URL is set (validation skipped).</param>
|
||||
/// <param name="Reached">False when the service could not be contacted / returned an error.</param>
|
||||
/// <param name="XmlValid">Whether the embedded XML matched a scenario and passed its rules.</param>
|
||||
/// <param name="PdfACompliant">Whether the PDF/A-3 layer passed (veraPDF).</param>
|
||||
/// <param name="XmlErrorCount">Number of hard XML rule errors reported.</param>
|
||||
/// <param name="ScenarioMatched">Whether the validator had a scenario for this document type.</param>
|
||||
/// <param name="Summary">Short human-readable summary from the service.</param>
|
||||
public sealed record ERechnungValidationResult(
|
||||
bool Configured, bool Reached, bool XmlValid, bool PdfACompliant,
|
||||
int XmlErrorCount, bool ScenarioMatched, string Summary)
|
||||
{
|
||||
/// <summary>True only when the service was reached and both the XML and the PDF/A-3 passed.</summary>
|
||||
public bool IsValid => Reached && XmlValid && PdfACompliant;
|
||||
|
||||
/// <summary>
|
||||
/// A hard defect: an unreachable service is <b>not</b> one, but a non-compliant PDF/A-3 or an
|
||||
/// XML with actual rule errors is. A pure <see cref="ScenarioMatched"/>=false with zero errors
|
||||
/// (e.g. an EN 16931 ZUGFeRD checked by an XRechnung-only scenario set) is <b>not</b> a hard
|
||||
/// error — it is a validator scope limitation, so it never withholds the invoice.
|
||||
/// </summary>
|
||||
public bool HasHardError => Reached && (!PdfACompliant || XmlErrorCount > 0);
|
||||
|
||||
public static ERechnungValidationResult NotConfigured { get; } =
|
||||
new(false, false, false, false, 0, false, "Validierungsdienst nicht konfiguriert (übersprungen).");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a produced ZUGFeRD/Factur-X hybrid against the external ProcessWeb eInvoice service
|
||||
/// (<c>POST {ServiceUrl}/validatepdf</c>, raw <c>application/pdf</c> body — checks the embedded
|
||||
/// EN 16931 XML <b>and</b> PDF/A-3 in one call). Configured via <c>Fuchs:ERechnung:Validation</c>.
|
||||
/// </summary>
|
||||
public interface IERechnungValidator
|
||||
{
|
||||
/// <summary>Whether external validation is switched on and a service URL is configured.</summary>
|
||||
bool Enabled { get; }
|
||||
|
||||
/// <summary>Validates the hybrid PDF; never throws (failures are reported in the result).</summary>
|
||||
Task<ERechnungValidationResult> ValidatePdfAsync(byte[] hybridPdf, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="IERechnungValidator"/>
|
||||
public sealed class ProcessWebERechnungValidator : IERechnungValidator
|
||||
{
|
||||
/// <summary>Named <see cref="HttpClient"/> registered in <c>Program.cs</c>.</summary>
|
||||
public const string HttpClientName = "eInvoiceValidator";
|
||||
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
private readonly ERechnungValidationSettings _settings;
|
||||
private readonly ILogger<ProcessWebERechnungValidator> _logger;
|
||||
|
||||
public ProcessWebERechnungValidator(IHttpClientFactory httpFactory,
|
||||
IOptions<ERechnungSettings> settings, ILogger<ProcessWebERechnungValidator> logger)
|
||||
{
|
||||
_httpFactory = httpFactory;
|
||||
_settings = settings.Value.Validation;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public bool Enabled => _settings.Enabled && !string.IsNullOrWhiteSpace(_settings.ServiceUrl);
|
||||
|
||||
public async Task<ERechnungValidationResult> ValidatePdfAsync(byte[] hybridPdf, CancellationToken ct = default)
|
||||
{
|
||||
if (!Enabled) return ERechnungValidationResult.NotConfigured;
|
||||
if (hybridPdf is null || hybridPdf.Length == 0)
|
||||
return new ERechnungValidationResult(true, false, false, false, 0, false, "Leeres PDF – nicht validiert.");
|
||||
|
||||
var url = _settings.ServiceUrl.TrimEnd('/') + "/validatepdf";
|
||||
try
|
||||
{
|
||||
using var content = new ByteArrayContent(hybridPdf);
|
||||
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/pdf");
|
||||
using var client = _httpFactory.CreateClient(HttpClientName);
|
||||
|
||||
using var response = await client.PostAsync(url, content, ct);
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("eRechnung validation: {Url} returned {Status}: {Body}", url, (int)response.StatusCode, Truncate(body));
|
||||
return new ERechnungValidationResult(true, false, false, false, 0, false, $"HTTP {(int)response.StatusCode} vom Validierungsdienst.");
|
||||
}
|
||||
return Parse(body);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "eRechnung validation: could not reach {Url}", url);
|
||||
return new ERechnungValidationResult(true, false, false, false, 0, false, "Validierungsdienst nicht erreichbar.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Parses the <c>validatepdf</c> response (<c>{ isValid, summary, xml:{…}, pdfa:{ isCompliant } }</c>).</summary>
|
||||
private static ERechnungValidationResult Parse(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
root.TryGetProperty("xml", out var xml);
|
||||
bool xmlValid = xml.ValueKind == JsonValueKind.Object && GetBool(xml, "isValid");
|
||||
bool scenarioMatched = xml.ValueKind == JsonValueKind.Object && GetBool(xml, "scenarioMatched");
|
||||
int errorCount = xml.ValueKind == JsonValueKind.Object && xml.TryGetProperty("errorCount", out var ec)
|
||||
&& ec.ValueKind == JsonValueKind.Number ? ec.GetInt32() : 0;
|
||||
bool pdfa = root.TryGetProperty("pdfa", out var p) && GetBool(p, "isCompliant");
|
||||
string summary = root.TryGetProperty("summary", out var s) && s.ValueKind == JsonValueKind.String
|
||||
? s.GetString() ?? "" : "";
|
||||
return new ERechnungValidationResult(true, true, xmlValid, pdfa, errorCount, scenarioMatched, summary);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new ERechnungValidationResult(true, false, false, false, 0, false, "Antwort des Validierungsdienstes nicht lesbar.");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool GetBool(JsonElement obj, string name)
|
||||
=> obj.TryGetProperty(name, out var v) && (v.ValueKind == JsonValueKind.True
|
||||
|| (v.ValueKind == JsonValueKind.String && bool.TryParse(v.GetString(), out var b) && b));
|
||||
|
||||
private static string Truncate(string s) => s.Length <= 300 ? s : s[..300];
|
||||
}
|
||||
@@ -143,6 +143,29 @@ public class FuchsPdfService : IPdfService
|
||||
}
|
||||
}
|
||||
|
||||
public byte[] DocToPdfBytesRaw(Document doc)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
using var act = FuchsTelemetry.StartActivity("pdf.render.raw");
|
||||
try
|
||||
{
|
||||
byte[] bytes = FuchsPdf.DocToPdfBytesRaw(doc);
|
||||
sw.Stop();
|
||||
FuchsTelemetry.PdfRenderDuration.Record(sw.Elapsed.TotalMilliseconds,
|
||||
new KeyValuePair<string, object?>("operation", "pdf-raw"));
|
||||
act?.SetTag("fuchs.pdf.bytes", bytes.Length);
|
||||
_logger.LogDebug("DocToPdfBytesRaw rendered {Bytes} bytes in {Ms} ms", bytes.Length, sw.ElapsedMilliseconds);
|
||||
return bytes;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
act?.SetStatus(ActivityStatusCode.Error, ex.Message);
|
||||
_logger.LogError(ex, "DocToPdfBytesRaw failed after {Ms} ms", sw.ElapsedMilliseconds);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<OCORE.pdf._pdf.ImageCollection> DocToImageCollectionAsync(Document doc)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
@@ -44,4 +44,49 @@ public interface IBlobStorageService
|
||||
/// </summary>
|
||||
Task<bool> ExistsAsync(string containerName, string documentId, string fileName,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies connectivity and access to the configured storage account by requesting its
|
||||
/// account information. Read-only, never mutates, and never throws — any failure is reported
|
||||
/// via <see cref="BlobConnectivity.Reachable"/>/<see cref="BlobConnectivity.Detail"/>. Used by
|
||||
/// the Admin module's system-status view.
|
||||
/// </summary>
|
||||
Task<BlobConnectivity> CheckConnectivityAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>Result of <see cref="IBlobStorageService.CheckConnectivityAsync"/> — no secrets.</summary>
|
||||
public sealed class BlobConnectivity
|
||||
{
|
||||
/// <summary><c>Fuchs:AzureStorage:Enabled</c>.</summary>
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
/// <summary>A storage client could be constructed (connection string present + valid form).</summary>
|
||||
public bool Configured { get; init; }
|
||||
|
||||
/// <summary>The account responded to an authenticated request.</summary>
|
||||
public bool Reachable { get; init; }
|
||||
|
||||
/// <summary>Storage account name (never the key/connection string).</summary>
|
||||
public string? AccountName { get; init; }
|
||||
|
||||
/// <summary>Non-sensitive detail: account kind/SKU on success, error summary on failure.</summary>
|
||||
public string? Detail { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Per-configured-container blob (file) counts, populated when the account was reachable.
|
||||
/// <c>null</c> when connectivity failed.
|
||||
/// </summary>
|
||||
public IReadOnlyList<BlobContainerCount>? Containers { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Blob (file) count for one configured container.</summary>
|
||||
public sealed class BlobContainerCount
|
||||
{
|
||||
public string Name { get; init; } = "";
|
||||
|
||||
/// <summary><c>false</c> when the container does not exist yet (created lazily on first upload).</summary>
|
||||
public bool Exists { get; init; }
|
||||
|
||||
/// <summary>Number of blobs in the container; <c>-1</c> when the count could not be determined.</summary>
|
||||
public int FileCount { get; init; }
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction for outbound communication: email and SMS.
|
||||
/// Backed by the ProcessWeb Mailer API (POST /api/mailer?fn=push_com).
|
||||
/// Backed by the ProcessWeb Mailer API (POST {BaseUrl}/mailer/push_com).
|
||||
/// </summary>
|
||||
public interface IComService
|
||||
{
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using Fuchs.intranet;
|
||||
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory store of live invoice draft editing sessions (see ADR 0006).
|
||||
/// Singleton, single-instance only — scale-out would need a distributed cache /
|
||||
/// sticky sessions (documented limitation). Keyed by the session token.
|
||||
/// </summary>
|
||||
public interface IInvoiceDraftCache
|
||||
{
|
||||
/// <summary>Stores (or replaces) a session under its token.</summary>
|
||||
void Set(InvoiceDraftSession session);
|
||||
|
||||
/// <summary>Returns the session for the token, or null if absent/evicted. Touches <c>LastAccessUtc</c> on hit.</summary>
|
||||
InvoiceDraftSession? Get(string token);
|
||||
|
||||
/// <summary>Removes the session (explicit close/discard/finalise). Returns the removed session, if any.</summary>
|
||||
InvoiceDraftSession? Remove(string token);
|
||||
|
||||
/// <summary>Snapshot of all live sessions — used by the expiry monitor. Does not touch access time.</summary>
|
||||
IReadOnlyList<InvoiceDraftSession> Snapshot();
|
||||
|
||||
/// <summary>The configured idle time-to-live before a session is eligible for eviction.</summary>
|
||||
TimeSpan IdleTtl { get; }
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using Fuchs.intranet;
|
||||
using MigraDoc.DocumentObjectModel;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using OCORE.security;
|
||||
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrates a live, backend-authoritative invoice draft editing session
|
||||
/// (ADR 0006). Owns the lifecycle around an <see cref="InvoiceDraftSession"/>:
|
||||
/// open (seed the cache), apply single edits, build the view state, render a PDF
|
||||
/// preview from the cache, flush to the DB ("Zwischenspeichern") and expose the
|
||||
/// change history. All totals/VAT are aggregated by <see cref="InvoiceDraftCalculator"/>
|
||||
/// — the browser never sums.
|
||||
///
|
||||
/// Reload/discard is handled by the client (re-fetch the DB draft via the existing
|
||||
/// <c>inv/get</c> render path and re-seed), so there is no server-side DB reshaping here.
|
||||
/// </summary>
|
||||
public interface IInvoiceDraftService
|
||||
{
|
||||
/// <summary>
|
||||
/// Seeds a new cache session from the editor's assembled payload
|
||||
/// (<c>admin</c> / <c>new</c> / <c>req</c> blocks, each block carrying the editor's
|
||||
/// <c>itm</c>/<c>items</c> line arrays). Computes totals + validation and returns the
|
||||
/// session (with its fresh token/version). An <c>invid</c> in the payload marks it as
|
||||
/// an update of an existing DB draft.
|
||||
/// </summary>
|
||||
InvoiceDraftSession OpenFromPayload(JObject payload, string userAccountId);
|
||||
|
||||
/// <summary>Returns the cached session for the token (touching its TTL), or null if absent/expired.</summary>
|
||||
InvoiceDraftSession? Get(string token);
|
||||
|
||||
/// <summary>
|
||||
/// Applies one editor change to the cached session: mutates the payload, re-aggregates
|
||||
/// totals, re-validates, appends a history entry and bumps the version. Returns the
|
||||
/// mutated session, or null if the token is unknown.
|
||||
/// </summary>
|
||||
InvoiceDraftSession? ApplyPatch(string token, InvoiceDraftDelta delta);
|
||||
|
||||
/// <summary>Builds the JSON view-state DTO the frontend renders (payload + server sums + validation + version).</summary>
|
||||
object BuildState(InvoiceDraftSession session);
|
||||
|
||||
/// <summary>The draft's change history for the "Änderungshistorie" dialog (empty if the token is unknown).</summary>
|
||||
IReadOnlyList<ChangeHistoryEntry> GetHistory(string token);
|
||||
|
||||
/// <summary>
|
||||
/// Persists the cached session to the DB via the existing invoice registration path
|
||||
/// ("Zwischenspeichern"). Sets <see cref="InvoiceDraftSession.InvId"/> on success.
|
||||
/// Returns the registered invoice data (for the success event), or null if the token is unknown.
|
||||
/// </summary>
|
||||
Task<FdsInvoiceData?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec);
|
||||
|
||||
/// <summary>Renders a draft PDF straight from the cached session (no client upload). Null if token unknown.</summary>
|
||||
Document? RenderPreview(string token);
|
||||
|
||||
/// <summary>Removes the session from the cache (explicit close/discard/finalise). Returns true if one was present.</summary>
|
||||
bool Close(string token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single editor change posted to <c>inv/dpatch</c>. <see cref="Target"/> names the
|
||||
/// field/operation (e.g. "email", "p13b", "block.replace"); <see cref="Ref"/> is the block
|
||||
/// id it applies to (when relevant); <see cref="Value"/> is the new value (a scalar for
|
||||
/// fields, or a full block object for <c>block.replace</c>).
|
||||
/// </summary>
|
||||
public sealed class InvoiceDraftDelta
|
||||
{
|
||||
public string Target { get; set; } = "";
|
||||
public string Ref { get; set; } = "";
|
||||
public JToken? Value { get; set; }
|
||||
|
||||
/// <summary>The new value as a string (empty when null), for history and simple field assignments.</summary>
|
||||
public string ValueString =>
|
||||
Value == null || Value.Type == JTokenType.Null ? "" : Value.Type == JTokenType.String ? Value.Value<string>() ?? "" : Value.ToString();
|
||||
}
|
||||
@@ -20,6 +20,12 @@ public interface IPdfService
|
||||
/// <summary>Renders a MigraDoc Document to a PDF/A byte array.</summary>
|
||||
byte[] DocToPdfBytes(Document doc);
|
||||
|
||||
/// <summary>
|
||||
/// Renders a MigraDoc Document to a plain (non-PDF/A) PDF byte array — the visual PDF used as
|
||||
/// input to eRechnung (ZUGFeRD) conversion, which owns the PDF/A-3 layer itself.
|
||||
/// </summary>
|
||||
byte[] DocToPdfBytesRaw(Document doc);
|
||||
|
||||
/// <summary>Renders a MigraDoc Document to an image collection for preview.</summary>
|
||||
Task<OCORE.pdf._pdf.ImageCollection> DocToImageCollectionAsync(Document doc);
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
using Fuchs.intranet;
|
||||
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory store of live reminder draft editing sessions (see ADR 0006, mirroring
|
||||
/// <see cref="IInvoiceDraftCache"/>). Singleton, single-instance only — scale-out would
|
||||
/// need a distributed cache / sticky sessions (documented limitation). Keyed by the
|
||||
/// session token.
|
||||
/// </summary>
|
||||
public interface IReminderDraftCache
|
||||
{
|
||||
/// <summary>Stores (or replaces) a session under its token.</summary>
|
||||
void Set(ReminderDraftSession session);
|
||||
|
||||
/// <summary>Returns the session for the token, or null if absent/evicted. Touches <c>LastAccessUtc</c> on hit.</summary>
|
||||
ReminderDraftSession? Get(string token);
|
||||
|
||||
/// <summary>Removes the session (explicit close/discard/finalise). Returns the removed session, if any.</summary>
|
||||
ReminderDraftSession? Remove(string token);
|
||||
|
||||
/// <summary>Snapshot of all live sessions — used by the expiry monitor. Does not touch access time.</summary>
|
||||
IReadOnlyList<ReminderDraftSession> Snapshot();
|
||||
|
||||
/// <summary>The configured idle time-to-live before a session is eligible for eviction.</summary>
|
||||
TimeSpan IdleTtl { get; }
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using Fuchs.intranet;
|
||||
using MigraDoc.DocumentObjectModel;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using OCORE.security;
|
||||
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrates a live, backend-authoritative reminder draft editing session (ADR 0006,
|
||||
/// mirroring <see cref="IInvoiceDraftService"/>). Owns the lifecycle around a
|
||||
/// <see cref="ReminderDraftSession"/>: open (seed the cache), apply single edits, build the
|
||||
/// view state, render a PDF preview from the cache, flush to the DB ("Zwischenspeichern")
|
||||
/// and expose the change history. The open amount is aggregated by
|
||||
/// <see cref="ReminderDraftCalculator"/> — the browser never sums.
|
||||
///
|
||||
/// Reload/discard is handled by the client (re-fetch the DB draft / prep data via the
|
||||
/// existing <c>rem/get</c> path and re-seed), so there is no server-side DB reshaping here.
|
||||
/// </summary>
|
||||
public interface IReminderDraftService
|
||||
{
|
||||
/// <summary>
|
||||
/// Seeds a new cache session from the editor's assembled payload (<c>new</c> / <c>rem</c>
|
||||
/// blocks). Computes the open amount + validation and returns the session (with its fresh
|
||||
/// token/version). A <c>remid</c> in the payload marks it as an update of an existing DB draft.
|
||||
/// </summary>
|
||||
ReminderDraftSession OpenFromPayload(JObject payload, string userAccountId);
|
||||
|
||||
/// <summary>Returns the cached session for the token (touching its TTL), or null if absent/expired.</summary>
|
||||
ReminderDraftSession? Get(string token);
|
||||
|
||||
/// <summary>
|
||||
/// Applies one editor change to the cached session: mutates the payload, re-aggregates the
|
||||
/// open amount, re-validates, appends a history entry and bumps the version. Returns the
|
||||
/// mutated session, or null if the token is unknown.
|
||||
/// </summary>
|
||||
ReminderDraftSession? ApplyPatch(string token, ReminderDraftDelta delta);
|
||||
|
||||
/// <summary>Builds the JSON view-state DTO the frontend renders (payload + server sums + validation + version).</summary>
|
||||
object BuildState(ReminderDraftSession session);
|
||||
|
||||
/// <summary>The draft's change history for the "Änderungshistorie" dialog (empty if the token is unknown).</summary>
|
||||
IReadOnlyList<ChangeHistoryEntry> GetHistory(string token);
|
||||
|
||||
/// <summary>
|
||||
/// Persists the cached session to the DB via the existing reminder registration path
|
||||
/// ("Zwischenspeichern"). Sets <see cref="ReminderDraftSession.RemId"/> on success.
|
||||
/// Returns the registered reminder data (for the success event), or null if the token is unknown.
|
||||
/// </summary>
|
||||
Task<FdsReminderData?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec);
|
||||
|
||||
/// <summary>Renders a draft PDF straight from the cached session (no client upload). Null if token unknown.</summary>
|
||||
Document? RenderPreview(string token);
|
||||
|
||||
/// <summary>Removes the session from the cache (explicit close/discard/finalise). Returns true if one was present.</summary>
|
||||
bool Close(string token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single editor change posted to <c>rem/dpatch</c>. <see cref="Target"/> names the
|
||||
/// field/operation (e.g. "email", "subject", "amount"); <see cref="Ref"/> is reserved for
|
||||
/// future per-item edits; <see cref="Value"/> is the new value (a scalar for fields, or a
|
||||
/// small object for <c>contact</c>).
|
||||
/// </summary>
|
||||
public sealed class ReminderDraftDelta
|
||||
{
|
||||
public string Target { get; set; } = "";
|
||||
public string Ref { get; set; } = "";
|
||||
public JToken? Value { get; set; }
|
||||
|
||||
/// <summary>The new value as a string (empty when null), for history and simple field assignments.</summary>
|
||||
public string ValueString =>
|
||||
Value == null || Value.Type == JTokenType.Null ? "" : Value.Type == JTokenType.String ? Value.Value<string>() ?? "" : Value.ToString();
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Gathers system configuration/environment facts and runs live connectivity/access probes
|
||||
/// for the Admin module. Read-only diagnostics: probes never mutate state and never throw
|
||||
/// (failures are captured in the returned <see cref="SystemProbeResult"/>).
|
||||
/// </summary>
|
||||
public interface ISystemStatusService
|
||||
{
|
||||
/// <summary>Passive snapshot of hosting, database, email, blob, MFR and Key Vault configuration.</summary>
|
||||
SystemInfoSnapshot GetInfo();
|
||||
|
||||
/// <summary>The set of probe component ids this service knows how to run.</summary>
|
||||
IReadOnlyList<string> ProbeComponents { get; }
|
||||
|
||||
/// <summary>Runs a single probe by component id (<c>database</c>/<c>keyvault</c>/<c>blob</c>/<c>mfr</c>).</summary>
|
||||
Task<SystemProbeResult> ProbeAsync(string component, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Runs every known probe (in parallel) and returns the results.</summary>
|
||||
Task<IReadOnlyList<SystemProbeResult>> ProbeAllAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Sends a test email through the normal <see cref="IComService"/> pipeline, so the
|
||||
/// dev/test recipient override (<see cref="FuchsEmailSettings.OverrideRecipient"/>) applies
|
||||
/// exactly as for any other outbound email.
|
||||
/// </summary>
|
||||
Task<EmailTestResult> SendTestEmailAsync(string to, string subject, string body, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Fuchs.intranet;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Single-instance, in-memory implementation of <see cref="IInvoiceDraftCache"/>
|
||||
/// backed by a <see cref="ConcurrentDictionary{TKey,TValue}"/> keyed by session
|
||||
/// token. A plain dictionary (rather than <c>IMemoryCache</c>) is used on purpose:
|
||||
/// the <see cref="InvoiceDraftExpiryService"/> needs to enumerate sessions and warn
|
||||
/// the user <b>before</b> eviction, which opaque cache-entry expiry does not allow.
|
||||
///
|
||||
/// Idle TTL and the pre-expiry warning lead time are configurable under
|
||||
/// <c>Fuchs:DraftEditing</c> (<c>IdleMinutes</c> / <c>ExpiryWarnMinutes</c>).
|
||||
/// </summary>
|
||||
public sealed class InvoiceDraftCache : IInvoiceDraftCache
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, InvoiceDraftSession> _sessions = new(StringComparer.Ordinal);
|
||||
|
||||
public TimeSpan IdleTtl { get; }
|
||||
/// <summary>How long before the idle TTL a warning is emitted to the user.</summary>
|
||||
public TimeSpan ExpiryWarnLead { get; }
|
||||
|
||||
public InvoiceDraftCache(IConfiguration configuration)
|
||||
{
|
||||
int idleMinutes = configuration.GetValue("Fuchs:DraftEditing:IdleMinutes", 30);
|
||||
int warnMinutes = configuration.GetValue("Fuchs:DraftEditing:ExpiryWarnMinutes", 5);
|
||||
IdleTtl = TimeSpan.FromMinutes(Math.Max(1, idleMinutes));
|
||||
ExpiryWarnLead = TimeSpan.FromMinutes(Math.Clamp(warnMinutes, 1, Math.Max(1, idleMinutes - 1)));
|
||||
}
|
||||
|
||||
public void Set(InvoiceDraftSession session)
|
||||
{
|
||||
if (string.IsNullOrEmpty(session.Token)) throw new ArgumentException("Session has no token.", nameof(session));
|
||||
session.Touch();
|
||||
_sessions[session.Token] = session;
|
||||
}
|
||||
|
||||
public InvoiceDraftSession? Get(string token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(token)) return null;
|
||||
if (_sessions.TryGetValue(token, out var s))
|
||||
{
|
||||
s.Touch();
|
||||
// A touch resets the idle window, so a fresh warning is due next time it lapses.
|
||||
s.ExpiryWarningSent = false;
|
||||
return s;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public InvoiceDraftSession? Remove(string token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(token)) return null;
|
||||
return _sessions.TryRemove(token, out var s) ? s : null;
|
||||
}
|
||||
|
||||
public IReadOnlyList<InvoiceDraftSession> Snapshot() => _sessions.Values.ToList();
|
||||
}
|
||||
@@ -0,0 +1,694 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using Fuchs.intranet;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MigraDoc.DocumentObjectModel;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using OCORE.security;
|
||||
using static OCORE.commons;
|
||||
using static OCORE.OCORE_dictionaries;
|
||||
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Backend-authoritative invoice draft editing (ADR 0006). Holds the truth in an
|
||||
/// <see cref="InvoiceDraftSession"/> (via <see cref="IInvoiceDraftCache"/>), applies
|
||||
/// single edits, aggregates totals with <see cref="InvoiceDraftCalculator"/>, renders
|
||||
/// previews and flushes to the DB by reusing the existing <see cref="IInvoiceService"/>
|
||||
/// registration path — no new persistence. The session stores the editor's own block
|
||||
/// shape (<c>itm</c>/<c>items</c> line arrays), which the PDF/persistence already consume,
|
||||
/// so nothing is re-shaped server-side.
|
||||
/// </summary>
|
||||
public sealed class InvoiceDraftEditService : IInvoiceDraftService
|
||||
{
|
||||
private readonly IInvoiceDraftCache _cache;
|
||||
private readonly IInvoiceService _invoices;
|
||||
private readonly ILogger<InvoiceDraftEditService> _logger;
|
||||
|
||||
public InvoiceDraftEditService(IInvoiceDraftCache cache, IInvoiceService invoices,
|
||||
ILogger<InvoiceDraftEditService> logger)
|
||||
{
|
||||
_cache = cache;
|
||||
_invoices = invoices;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// ── Open ─────────────────────────────────────────────────────────────────
|
||||
public InvoiceDraftSession OpenFromPayload(JObject payload, string userAccountId)
|
||||
{
|
||||
var session = new InvoiceDraftSession
|
||||
{
|
||||
Token = NewToken(),
|
||||
UserAccountId = userAccountId,
|
||||
InvId = payload["invid"]?.Value<string>() ?? payload["id"]?.Value<string>() ?? ""
|
||||
};
|
||||
session.Admin = payload["admin"] as JObject ?? new JObject();
|
||||
session.New = payload["new"] as JObject ?? new JObject();
|
||||
session.Req = payload["req"] as JArray ?? new JArray();
|
||||
Refresh(session);
|
||||
_cache.Set(session);
|
||||
_logger.LogInformation("Draft session {Token} opened from payload (invId={InvId}, user={User})",
|
||||
session.Token, session.InvId, userAccountId);
|
||||
return session;
|
||||
}
|
||||
|
||||
public InvoiceDraftSession? Get(string token) => _cache.Get(token);
|
||||
|
||||
// ── Patch ──────────────────────────────────────────────────────────────────
|
||||
public InvoiceDraftSession? ApplyPatch(string token, InvoiceDraftDelta delta)
|
||||
{
|
||||
var session = _cache.Get(token);
|
||||
if (session == null) return null;
|
||||
|
||||
string oldValue = "", newValue = "";
|
||||
bool mutated = ApplyDelta(session, delta, ref oldValue, ref newValue);
|
||||
if (!mutated)
|
||||
{
|
||||
_logger.LogDebug("Draft {Token}: no-op patch target={Target} ref={Ref}", token, delta.Target, delta.Ref);
|
||||
return session;
|
||||
}
|
||||
|
||||
Refresh(session);
|
||||
session.Version++;
|
||||
session.History.Add(new ChangeHistoryEntry
|
||||
{
|
||||
UserAccountId = session.UserAccountId,
|
||||
Target = delta.Target,
|
||||
Ref = delta.Ref,
|
||||
OldValue = oldValue,
|
||||
NewValue = newValue,
|
||||
Version = session.Version
|
||||
});
|
||||
_cache.Set(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies one delta to the payload; returns whether anything changed and captures the prior
|
||||
/// and new value for the change history. Scalar text fields and the section heading are
|
||||
/// sanitised from the editor's HTML (TinyMCE wraps inline edits in <c><p>…</p></c>)
|
||||
/// to plain text here — the backend is the single source of truth (ADR 0006), so no HTML ever
|
||||
/// reaches the DB, the PDF or a reloaded draft, regardless of which UI path produced it.
|
||||
/// </summary>
|
||||
private static bool ApplyDelta(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
|
||||
{
|
||||
switch (d.Target)
|
||||
{
|
||||
case "email": return SetNewText(s, "invoiceemail", d, ref oldValue, ref newValue);
|
||||
case "address":
|
||||
// Structured dialog form posts a JSON object; a plain string keeps the legacy
|
||||
// free-text path working (backward compatibility).
|
||||
return d.Value is JObject
|
||||
? SetSendToAddress(s, d, ref oldValue, ref newValue)
|
||||
: SetNewText(s, "invoiceaddress", d, ref oldValue, ref newValue);
|
||||
case "title": return SetNewText(s, "invoicetitle", d, ref oldValue, ref newValue);
|
||||
case "provisionperiod": return SetNewText(s, "provisionperiod", d, ref oldValue, ref newValue);
|
||||
case "provisionlocation":
|
||||
oldValue = Str(s.New["provisionlocation"]);
|
||||
newValue = HtmlToPlain(d.ValueString);
|
||||
s.New["provisionlocation"] = newValue;
|
||||
s.New["loc"] = newValue; // editor mirrors both
|
||||
return true;
|
||||
case "contact": return SetContact(s, d, ref oldValue, ref newValue);
|
||||
case "setmode": return SetAdmin(s, "setmode", d, ref oldValue, ref newValue);
|
||||
case "p13b":
|
||||
oldValue = Str(s.Admin["p13b"]);
|
||||
bool p13b = d.Value != null && d.Value.Type != JTokenType.Null
|
||||
? AsBool(d.Value) : !AsBool(s.Admin["p13b"]); // toggle when no explicit value
|
||||
s.Admin["p13b"] = p13b;
|
||||
newValue = p13b ? "§13b" : "";
|
||||
return true;
|
||||
case "block.replace": return ReplaceBlock(s, d, ref oldValue, ref newValue);
|
||||
case "block.remove": return RemoveBlock(s, d, ref oldValue, ref newValue);
|
||||
case "block.order": return ReorderBlocks(s, d, ref oldValue, ref newValue);
|
||||
case "item.setprice": return ApplyItemSetPrice(s, d, ref oldValue, ref newValue);
|
||||
case "block.setprice": return ApplyBlockSetPricing(s, removeMembers: false, ref oldValue, ref newValue);
|
||||
case "block.setonly": return ApplyBlockSetPricing(s, removeMembers: true, ref oldValue, ref newValue);
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool SetNewText(InvoiceDraftSession s, string key, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
|
||||
{
|
||||
oldValue = Str(s.New[key]);
|
||||
newValue = HtmlToPlain(d.ValueString);
|
||||
s.New[key] = newValue;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool SetAdmin(InvoiceDraftSession s, string key, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
|
||||
{
|
||||
oldValue = Str(s.Admin[key]);
|
||||
newValue = d.ValueString;
|
||||
s.Admin[key] = newValue;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool SetContact(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
|
||||
{
|
||||
JObject prev = TryParseObject(Str(s.New["CustomValues"]));
|
||||
oldValue = ContactLabel(Str(prev["contactName"]), Str(prev["contactEmail"]));
|
||||
JObject cvo = (JObject)prev.DeepClone();
|
||||
if (d.Value is JObject vo)
|
||||
{
|
||||
cvo["contactName"] = vo["name"] ?? vo["contactName"] ?? "";
|
||||
cvo["contactEmail"] = vo["email"] ?? vo["contactEmail"] ?? "";
|
||||
}
|
||||
s.New["CustomValues"] = cvo.ToString(Newtonsoft.Json.Formatting.None);
|
||||
newValue = ContactLabel(Str(cvo["contactName"]), Str(cvo["contactEmail"]));
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string ContactLabel(string name, string email) =>
|
||||
string.IsNullOrEmpty(name) ? email : string.IsNullOrEmpty(email) ? name : $"{name} <{email}>";
|
||||
|
||||
/// <summary>
|
||||
/// Applies a structured recipient address: stores it as JSON inside the <c>CustomValues</c>
|
||||
/// blob (interim persistence, no schema change) and composes the multi-line free-text block
|
||||
/// into <c>invoiceaddress</c> so the PDF and the <c>SendToAddress</c> column are unchanged.
|
||||
/// </summary>
|
||||
private static bool SetSendToAddress(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
|
||||
{
|
||||
if (d.Value is not JObject vo) return false;
|
||||
var addr = InvoiceRecipientAddress.FromJson(vo);
|
||||
|
||||
oldValue = Str(s.New["invoiceaddress"]);
|
||||
JObject cvo = (JObject)TryParseObject(Str(s.New["CustomValues"])).DeepClone();
|
||||
cvo[InvoiceRecipientAddress.CustomValuesKey] = addr.ToJson();
|
||||
s.New["CustomValues"] = cvo.ToString(Newtonsoft.Json.Formatting.None);
|
||||
|
||||
newValue = addr.Compose();
|
||||
s.New["invoiceaddress"] = newValue;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Replaces (or inserts) a whole block — the editor re-emits an edited block's line arrays as one delta.</summary>
|
||||
private static bool ReplaceBlock(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
|
||||
{
|
||||
if (d.Value is not JObject nb) return false;
|
||||
SanitizeBlockText(nb);
|
||||
string bid = !string.IsNullOrEmpty(d.Ref) ? d.Ref : Str(nb["Id"]);
|
||||
var existing = FindBlock(s, bid);
|
||||
if (existing != null)
|
||||
{
|
||||
oldValue = Str(existing["text"]);
|
||||
existing.Replace(nb);
|
||||
}
|
||||
else
|
||||
{
|
||||
oldValue = "";
|
||||
s.Req.Add(nb);
|
||||
}
|
||||
newValue = Str(nb["text"]); // the section heading — never the whole block JSON
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool RemoveBlock(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
|
||||
{
|
||||
var block = FindBlock(s, d.Ref);
|
||||
if (block == null) return false;
|
||||
oldValue = Str(block["text"]);
|
||||
newValue = "";
|
||||
block.Remove();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// "Auf Setpreis umstellen" (backend-authoritative, ADR 0006): irreversibly converts a Set
|
||||
/// header item (<c>Ref</c> = the header's item id) from individually-priced members to a
|
||||
/// single set price. Sums the <c>net</c>/<c>net_val</c>/<c>vat_val</c>/<c>svcnet_val</c>/
|
||||
/// <c>svcvat_val</c> of every member line in the same block whose <c>SetItmId</c> equals the
|
||||
/// header id (the grouping <c>fds__prepInvoice</c> computes, anchored on the still-unconverted,
|
||||
/// zero-priced Set header — see the <c>[SetItmID]</c> window function there), writes that sum
|
||||
/// onto the header line and zeroes each member's price fields — matching the visual
|
||||
/// <c>SetPrice</c> display mode (<see cref="InvoiceSetPricing"/>), but as an actual, irreversible
|
||||
/// data change rather than a display toggle. The user can still change the resulting header price
|
||||
/// manually afterwards (ordinary item edit), which is why this is one-way.
|
||||
/// <para>
|
||||
/// The header row's own <c>SetItmId</c> now self-references its own id (rather than being
|
||||
/// <c>null</c>) once <c>fds__prepInvoice</c> stopped special-casing it, so membership is
|
||||
/// determined by <c>id == Ref</c> first (captured as <paramref name="header"/> below and
|
||||
/// <c>continue</c>d past) — a header is never mistaken for its own member.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private static bool ApplyItemSetPrice(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
|
||||
{
|
||||
if (string.IsNullOrEmpty(d.Ref)) return false;
|
||||
foreach (var blockTok in s.Req)
|
||||
{
|
||||
if (blockTok is not JObject block || block["itm"] is not JArray lines) continue;
|
||||
JObject? header = null;
|
||||
var members = new List<JObject>();
|
||||
foreach (var lineTok in lines)
|
||||
{
|
||||
if (lineTok is not JObject co) continue;
|
||||
if (Str(co["id"]) == d.Ref) { header = co; continue; } // the header itself is never a member, even if its SetItmId self-references
|
||||
if (Str(co["SetItmId"]) == d.Ref) members.Add(co);
|
||||
}
|
||||
if (header == null) continue;
|
||||
if (!string.Equals(Str(header["typ"]), "set", StringComparison.OrdinalIgnoreCase)) return false;
|
||||
|
||||
oldValue = InvoiceDraftCalculator.Dec(header["vt"]).ToString(CultureInfo.InvariantCulture);
|
||||
decimal sumNet = 0, sumVat = 0, sumSvcNet = 0, sumSvcVat = 0;
|
||||
foreach (var m in members)
|
||||
{
|
||||
sumNet += InvoiceDraftCalculator.Dec(m["vt"]);
|
||||
sumVat += InvoiceDraftCalculator.Dec(m["vv"]);
|
||||
sumSvcNet += InvoiceDraftCalculator.Dec(m["vs"]);
|
||||
sumSvcVat += InvoiceDraftCalculator.Dec(m["vsv"]);
|
||||
NullLinePrice(m); // ADR 0009: null (empty cell), not 0 — excluded from the sum, distinct from a real 0,00 €
|
||||
}
|
||||
header["v"] = sumNet; header["vt"] = sumNet; header["vv"] = sumVat; header["vs"] = sumSvcNet; header["vsv"] = sumSvcVat;
|
||||
newValue = sumNet.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
// Mirror the conversion onto the "items" contract shape (InvoiceSetPricing/BuildSetDisplay
|
||||
// read total_net from here, not from "itm") so the online editor's set-display immediately
|
||||
// reflects that the set is now converted, instead of waiting for a reload.
|
||||
if (block["items"] is JArray items)
|
||||
{
|
||||
foreach (var itemTok in items)
|
||||
{
|
||||
if (itemTok is not JObject io) continue;
|
||||
string id = Str(io["id"]);
|
||||
if (id == d.Ref) { io["total_net"] = sumNet; }
|
||||
else if (members.Any(m => Str(m["id"]) == id)) { NullItemPrice(io); }
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The two menu set-price modes (ADR 0009), grouped by service-request <b>block</b>
|
||||
/// (not <c>SetItmId</c>) and applied server-side as an <b>irreversible mutation</b> of the
|
||||
/// cached session — never a display flag. For every block that carries any priced line:
|
||||
/// a dedicated, emphasised set row (<c>typ == "set"</c>) is inserted at the top carrying the
|
||||
/// block's aggregated value (net + VAT + service-net/-VAT splits); then the block's original
|
||||
/// items are either price-<b>nulled</b> (<paramref name="removeMembers"/> = false → "Set mit
|
||||
/// Preis": empty price cells, kept) or <b>removed</b> (<paramref name="removeMembers"/> = true →
|
||||
/// "Nur Set mit Preis"). <c>null</c> (not <c>0</c>) marks "no price" — an empty cell excluded
|
||||
/// from the sum, distinct from a genuine 0,00 €. Any pre-existing set membership is neutralised
|
||||
/// (existing set rows demoted, <c>setId</c>/<c>SetItmId</c> cleared) so the block becomes one
|
||||
/// flat set and the two groupings (this vs. the <c>SetItmId</c>-based item switch) never collide.
|
||||
/// The invoice total is conserved: the set row's value equals the sum of the members it blanks
|
||||
/// or removes.
|
||||
/// </summary>
|
||||
private static bool ApplyBlockSetPricing(InvoiceDraftSession s, bool removeMembers, ref string oldValue, ref string newValue)
|
||||
{
|
||||
bool mutated = false;
|
||||
int converted = 0;
|
||||
foreach (var blockTok in s.Req)
|
||||
{
|
||||
if (blockTok is not JObject block || block["itm"] is not JArray lines || lines.Count == 0) continue;
|
||||
|
||||
// Aggregate the block from its current line values (nulled/absent = 0 contribution).
|
||||
decimal net = 0, vat = 0, svcNet = 0, svcVat = 0;
|
||||
string rate = "";
|
||||
foreach (var lineTok in lines)
|
||||
{
|
||||
if (lineTok is not JObject co) continue;
|
||||
net += InvoiceDraftCalculator.Dec(co["vt"]);
|
||||
vat += InvoiceDraftCalculator.Dec(co["vv"]);
|
||||
svcNet += InvoiceDraftCalculator.Dec(co["vs"]);
|
||||
svcVat += InvoiceDraftCalculator.Dec(co["vsv"]);
|
||||
string r = InvoiceDraftCalculator.NormalizeRate(Str(co["vat"]));
|
||||
if (r.Length > 0) rate = r; // collapsed set line carries the block's (highest/last) rate
|
||||
}
|
||||
if (net == 0 && vat == 0 && svcNet == 0 && svcVat == 0) continue; // nothing priced -> skip block
|
||||
|
||||
string blockId = Str(block["Id"]);
|
||||
string setId = "bset_" + (blockId.Length > 0 ? blockId : Guid.NewGuid().ToString("N")[..8]);
|
||||
const string title = "Gesamtumfang pauschal"; // neutral label for the block set row (ADR 0009) — not the service-request title
|
||||
|
||||
// The dedicated, emphasised set row, in both line shapes (itm = editor co; items = contract).
|
||||
var setItm = new JObject
|
||||
{
|
||||
["id"] = setId, ["typ"] = "set", ["p"] = "", ["q"] = "", ["t"] = title, ["tt"] = "",
|
||||
["v"] = JValue.CreateNull(), ["vt"] = net, ["vv"] = vat, ["vs"] = svcNet, ["vsv"] = svcVat,
|
||||
["vat"] = rate, ["det"] = false
|
||||
};
|
||||
var setItem = new JObject
|
||||
{
|
||||
["id"] = setId, ["type"] = "set", ["title"] = title, ["desc"] = "", ["qty"] = "",
|
||||
["price_net"] = net, ["total_net"] = net, ["vat"] = rate, ["setId"] = JValue.CreateNull()
|
||||
};
|
||||
|
||||
// Mutate the itm array in place (avoids Newtonsoft re-parenting): drop members (setonly)
|
||||
// or null their prices + neutralise set membership (setprice), then prepend the set row.
|
||||
if (removeMembers) lines.Clear();
|
||||
else
|
||||
foreach (var lineTok in lines)
|
||||
{
|
||||
if (lineTok is not JObject co) continue;
|
||||
DemoteSet(co, "typ"); co["SetItmId"] = JValue.CreateNull();
|
||||
NullLinePrice(co);
|
||||
}
|
||||
lines.Insert(0, setItm);
|
||||
|
||||
if (block["items"] is JArray items)
|
||||
{
|
||||
if (removeMembers) items.Clear();
|
||||
else
|
||||
foreach (var itemTok in items)
|
||||
{
|
||||
if (itemTok is not JObject io) continue;
|
||||
DemoteSet(io, "type"); io["setId"] = JValue.CreateNull();
|
||||
NullItemPrice(io);
|
||||
}
|
||||
items.Insert(0, setItem);
|
||||
}
|
||||
else
|
||||
{
|
||||
block["items"] = new JArray { setItem };
|
||||
}
|
||||
|
||||
mutated = true; converted++;
|
||||
}
|
||||
oldValue = "";
|
||||
newValue = (removeMembers ? "setonly" : "setprice") + ":" + converted;
|
||||
return mutated;
|
||||
}
|
||||
|
||||
/// <summary>Demotes a pre-existing set row to a plain line so only the freshly inserted block set row stays "set".</summary>
|
||||
private static void DemoteSet(JObject line, string typeKey)
|
||||
{
|
||||
if (line[typeKey] is { } t && string.Equals(Str(t), "set", StringComparison.OrdinalIgnoreCase))
|
||||
line[typeKey] = "other";
|
||||
}
|
||||
|
||||
/// <summary>Sets the editor <c>co</c> price fields to JSON null (empty cell, excluded from sums — ADR 0009).</summary>
|
||||
private static void NullLinePrice(JObject co)
|
||||
{
|
||||
co["v"] = JValue.CreateNull(); co["vt"] = JValue.CreateNull(); co["vv"] = JValue.CreateNull();
|
||||
co["vs"] = JValue.CreateNull(); co["vsv"] = JValue.CreateNull();
|
||||
}
|
||||
|
||||
/// <summary>Sets the items-contract price fields to JSON null (empty cell — ADR 0009).</summary>
|
||||
private static void NullItemPrice(JObject io)
|
||||
{
|
||||
io["price_net"] = JValue.CreateNull(); io["total_net"] = JValue.CreateNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reorders the service-request blocks to the id sequence the editor posts after a section
|
||||
/// drag (<c>Value</c> = ["id",…]). Named ids move into the given order; any not named are kept
|
||||
/// in their current relative order at the end. Totals are unaffected; item position numbers
|
||||
/// are renumbered by <see cref="Refresh"/> and pushed back to the browser via the view state.
|
||||
/// </summary>
|
||||
private static bool ReorderBlocks(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
|
||||
{
|
||||
if (d.Value is not JArray order) return false;
|
||||
var current = s.Req.OfType<JObject>().ToList();
|
||||
oldValue = string.Join(",", current.Select(b => Str(b["Id"])));
|
||||
|
||||
var byId = current.ToDictionary(b => Str(b["Id"]), b => b);
|
||||
var ordered = new List<JObject>();
|
||||
var seen = new HashSet<string>();
|
||||
foreach (var idTok in order)
|
||||
{
|
||||
string id = Str(idTok);
|
||||
if (byId.TryGetValue(id, out var blk) && seen.Add(id)) ordered.Add(blk);
|
||||
}
|
||||
foreach (var b in current) // append blocks the order list didn't mention, in place
|
||||
if (seen.Add(Str(b["Id"]))) ordered.Add(b);
|
||||
|
||||
newValue = string.Join(",", ordered.Select(b => Str(b["Id"])));
|
||||
if (oldValue == newValue) return false; // no-op reorder
|
||||
|
||||
s.Req.Clear();
|
||||
foreach (var b in ordered) s.Req.Add(b);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Strips the editor's HTML from a block's heading (<c>text</c>/<c>nme</c>) before it is cached.</summary>
|
||||
private static void SanitizeBlockText(JObject block)
|
||||
{
|
||||
if (block["text"] != null) block["text"] = HtmlToPlain(Str(block["text"]));
|
||||
if (block["nme"] != null) block["nme"] = HtmlToPlain(Str(block["nme"]));
|
||||
}
|
||||
|
||||
// ── View state / history ────────────────────────────────────────────────
|
||||
public object BuildState(InvoiceDraftSession session)
|
||||
{
|
||||
session.Touch();
|
||||
return new
|
||||
{
|
||||
token = session.Token,
|
||||
version = session.Version,
|
||||
invid = session.InvId,
|
||||
isDraft = session.IsDraft,
|
||||
admin = session.Admin,
|
||||
@new = session.New,
|
||||
req = session.Req,
|
||||
sums = new
|
||||
{
|
||||
total_net = session.Sums.TotalNet,
|
||||
total_gross = session.Sums.TotalGross,
|
||||
total_vat = session.Sums.TotalVat,
|
||||
service_net = session.Sums.ServiceNet,
|
||||
service_vat = session.Sums.ServiceVat,
|
||||
vat = session.Sums.VatByRate,
|
||||
block_net = session.Sums.NetByBlock
|
||||
},
|
||||
validation = session.ValidationMessages.Select(v => new { field = v.Field, severity = v.Severity, message = v.Message }),
|
||||
historyCount = session.History.Count,
|
||||
notes = BuildNotes(session),
|
||||
setDisplay = BuildSetDisplay(session)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Authoritative per-item set-pricing display flags (ADR 0006/set-pricing): for every block
|
||||
/// that contains a "set" item, runs the same <see cref="InvoiceSetPricing.Build"/> the PDF
|
||||
/// uses (keyed by <c>admin.setmode</c>) and returns a flat map of item id → whether its
|
||||
/// price/total should be shown and whether it is a set header. The online editor applies
|
||||
/// this to the rendered rows so it always matches the PDF without duplicating the pricing
|
||||
/// rules client-side.
|
||||
/// </summary>
|
||||
private static Dictionary<string, object> BuildSetDisplay(InvoiceDraftSession session)
|
||||
{
|
||||
var result = new Dictionary<string, object>();
|
||||
var mode = InvoiceSetPricing.ParseMode(Str(session.Admin["setmode"]));
|
||||
foreach (var blockTok in session.Req)
|
||||
{
|
||||
if (blockTok is not JObject block || block["items"] is not JArray itemsArr) continue;
|
||||
List<Dictionary<string, object?>>? items = itemsArr.ToObject<List<Dictionary<string, object?>>>();
|
||||
// Only function-1 (SetItmId-linked) sets get display flags; block-mode sets (ADR 0009)
|
||||
// clear setId and render flat, so BuildSetDisplay must not sweep them into Build here.
|
||||
if (items == null || !InvoiceSetPricing.HasSetMembers(items)) continue;
|
||||
|
||||
foreach (var line in InvoiceSetPricing.Build(items, mode))
|
||||
{
|
||||
if (string.IsNullOrEmpty(line.Id)) continue;
|
||||
result[line.Id] = new { p = line.ShowPrice, h = line.IsSetHeader };
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders the backend-authoritative notice paragraphs (see <see cref="FuchsPdf.BuildInvoiceNotes"/>)
|
||||
/// for the editor's read-only preview — the same text the PDF will show, generated from the
|
||||
/// same builder, so the two can never drift apart.
|
||||
/// </summary>
|
||||
private IEnumerable<object> BuildNotes(InvoiceDraftSession session)
|
||||
{
|
||||
var fds = BuildFdsData(session);
|
||||
fds.InvoiceRegistration = SynthesizeRegistration(session);
|
||||
bool p13b = AsBool(session.Admin["p13b"]);
|
||||
return FuchsPdf.BuildInvoiceNotes(new FuchsPdf.FdsTextBlocks(), fds, p13b)
|
||||
.Select(n => new { text = n.Text, style = n.Style });
|
||||
}
|
||||
|
||||
public IReadOnlyList<ChangeHistoryEntry> GetHistory(string token) =>
|
||||
_cache.Get(token)?.History ?? (IReadOnlyList<ChangeHistoryEntry>)Array.Empty<ChangeHistoryEntry>();
|
||||
|
||||
// ── Flush / preview ────────────────────────────────────────────────────────
|
||||
public async Task<FdsInvoiceData?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec)
|
||||
{
|
||||
var session = _cache.Get(token);
|
||||
if (session == null) return null;
|
||||
|
||||
var fds = BuildFdsData(session);
|
||||
bool change = !string.IsNullOrEmpty(session.InvId);
|
||||
var reg = await _invoices.RegisterInvoiceAsync(fds, change, session.InvId, userAccountId, dbSec);
|
||||
if (!string.IsNullOrEmpty(reg.Id))
|
||||
{
|
||||
session.InvId = reg.Id;
|
||||
_cache.Set(session);
|
||||
_logger.LogInformation("Draft {Token} flushed to DB invoice {InvId} (change={Change}, user={User})",
|
||||
token, reg.Id, change, userAccountId);
|
||||
}
|
||||
return reg;
|
||||
}
|
||||
|
||||
public Document? RenderPreview(string token)
|
||||
{
|
||||
var session = _cache.Get(token);
|
||||
if (session == null) return null;
|
||||
var fds = BuildFdsData(session);
|
||||
fds.InvoiceRegistration = SynthesizeRegistration(session);
|
||||
fds.IsDraft = true;
|
||||
return _invoices.GenerateInvoicePdf(fds, draft: true);
|
||||
}
|
||||
|
||||
public bool Close(string token) => _cache.Remove(token) != null;
|
||||
|
||||
// ── Internals ──────────────────────────────────────────────────────────────
|
||||
private static void Refresh(InvoiceDraftSession session)
|
||||
{
|
||||
InvoiceDraftCalculator.RecomputeLineValues(session);
|
||||
InvoiceDraftCalculator.RecomputeTotals(session);
|
||||
InvoiceDraftCalculator.RecomputePositions(session);
|
||||
InvoiceDraftCalculator.Validate(session);
|
||||
}
|
||||
|
||||
private static string NewToken() => Guid.NewGuid().ToString("N");
|
||||
|
||||
private static JObject? FindBlock(InvoiceDraftSession s, string blockId)
|
||||
{
|
||||
foreach (var b in s.Req)
|
||||
if (b is JObject bo && Str(bo["Id"]) == blockId) return bo;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the <see cref="FdsInvoiceData"/> from the session — the server-side equivalent
|
||||
/// of <c>invcPayload</c>. The session already holds the editor's <c>req</c> block shape
|
||||
/// (<c>itm</c>/<c>items</c> line arrays) that registration and the PDF consume, so the
|
||||
/// blocks pass through unchanged; only the header/total normalisation is applied.
|
||||
/// </summary>
|
||||
private FdsInvoiceData BuildFdsData(InvoiceDraftSession session)
|
||||
{
|
||||
var adm = (JObject)session.Admin.DeepClone();
|
||||
var nw = (JObject)session.New.DeepClone();
|
||||
nw["total_net"] = session.Sums.TotalNet;
|
||||
nw["total_gross"] = session.Sums.TotalGross;
|
||||
nw["title"] = nw["invoicetitle"] ?? nw["title"] ?? "";
|
||||
nw["provisionlocation"] = nw["loc"] ?? nw["provisionlocation"] ?? "";
|
||||
nw["paymentterm"] = adm["paymentterms"] ?? nw["paymentterm"] ?? "";
|
||||
adm["customerid"] = adm["customerid"] ?? adm["CustomerId"];
|
||||
|
||||
var vat = new JObject();
|
||||
foreach (var kv in session.Sums.VatByRate) vat[kv.Key] = kv.Value;
|
||||
var sms = new JObject
|
||||
{
|
||||
["ttn"] = session.Sums.TotalNet,
|
||||
["ttb"] = session.Sums.TotalGross,
|
||||
["ttvat"] = session.Sums.TotalVat,
|
||||
["tscn"] = session.Sums.ServiceNet,
|
||||
["tscvat"] = session.Sums.ServiceVat,
|
||||
["vat"] = vat
|
||||
};
|
||||
|
||||
var jobj = new JObject
|
||||
{
|
||||
["admin"] = adm,
|
||||
["new"] = nw,
|
||||
["sms"] = sms,
|
||||
["req"] = session.Req.DeepClone()
|
||||
};
|
||||
return new FdsInvoiceData(jobj);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Synthesises the <c>InvoiceRegistration</c> dictionary a draft PDF render needs,
|
||||
/// straight from the cached session — so a preview requires no DB round-trip and no
|
||||
/// client upload. Mirrors the columns <c>fds__getInvoice</c> would return for a draft.
|
||||
/// </summary>
|
||||
private GenericObjectDictionary SynthesizeRegistration(InvoiceDraftSession session)
|
||||
{
|
||||
string title = Str(session.New["invoicetitle"]).ne(Str(session.New["title"]));
|
||||
string loc = Str(session.New["provisionlocation"]).ne(Str(session.New["loc"]));
|
||||
var d = new Dictionary<string, object>
|
||||
{
|
||||
["Id"] = session.InvId,
|
||||
["InvoiceType"] = Str(session.Admin["type"]).ne("R"),
|
||||
["InvoiceId"] = "",
|
||||
["InvoiceTitle"] = title,
|
||||
["SendToAddress"] = Str(session.New["invoiceaddress"]),
|
||||
["SendToEmail"] = Str(session.New["invoiceemail"]),
|
||||
["ProvisionLocation"] = loc,
|
||||
["ProvisionPeriod"] = Str(session.New["provisionperiod"]),
|
||||
["PaymentTerm"] = Str(session.Admin["paymentterms"]).ne(Str(session.New["paymentterm"])),
|
||||
["InvoiceBalance"] = session.Sums.TotalGross,
|
||||
["InvoiceBalance_net"] = session.Sums.TotalNet,
|
||||
["CustomValues"] = Str(session.New["CustomValues"]),
|
||||
["SendToAddressJson"] = InvoiceRecipientAddress.FromCustomValues(Str(session.New["CustomValues"]))?
|
||||
.ToJson().ToString(Newtonsoft.Json.Formatting.None) ?? "",
|
||||
["InvoiceOptions"] = BuildInvoiceOptions(session),
|
||||
["DateCreated"] = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
|
||||
};
|
||||
|
||||
int idx = 0;
|
||||
foreach (var kv in session.Sums.VatByRate)
|
||||
{
|
||||
idx++;
|
||||
if (idx > 2) break;
|
||||
d[$"InvoiceVAT_{idx}"] = kv.Key;
|
||||
d[$"InvoiceVAT_net{idx}"] = kv.Value;
|
||||
}
|
||||
return new GenericObjectDictionary(d);
|
||||
}
|
||||
|
||||
/// <summary>Builds the InvoiceOptions CSV (§13b + setmode) from the session admin flags — matches <see cref="FdsInvoiceData.BuildInvoiceOptions"/>.</summary>
|
||||
private static string BuildInvoiceOptions(InvoiceDraftSession session)
|
||||
{
|
||||
var tokens = new List<string>();
|
||||
if (AsBool(session.Admin["p13b"])) tokens.Add("§13b");
|
||||
string setmode = Str(session.Admin["setmode"]).Trim().ToLowerInvariant();
|
||||
// Mirrors FdsInvoiceData.BuildInvoiceOptions: persist any explicitly-chosen mode
|
||||
// (including the default "setprice") so it is distinguishable from "never touched".
|
||||
if (setmode is "setprice" or "setonly") tokens.Add("setmode:" + setmode);
|
||||
return string.Join(",", tokens);
|
||||
}
|
||||
|
||||
// ── token helpers ─────────────────────────────────────────────────────────
|
||||
private static string Str(JToken? t) =>
|
||||
t == null || t.Type == JTokenType.Null ? "" : t.Type == JTokenType.String ? t.Value<string>() ?? "" : t.ToString();
|
||||
|
||||
private static bool AsBool(JToken? t)
|
||||
{
|
||||
if (t == null || t.Type == JTokenType.Null) return false;
|
||||
if (t.Type == JTokenType.Boolean) return t.Value<bool>();
|
||||
string s = Str(t).Trim().ToLowerInvariant();
|
||||
return s is "1" or "true" or "yes" or "ja" or "on";
|
||||
}
|
||||
|
||||
private static JObject TryParseObject(string json)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(json) && json.TrimStart().StartsWith('{'))
|
||||
{
|
||||
try { return JObject.Parse(json); } catch { /* fall through */ }
|
||||
}
|
||||
return new JObject();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts the editor's HTML (TinyMCE-wrapped inline edits, e.g. <c><p>18.06.2026</p></c>)
|
||||
/// to plain text: line-break-producing tags become newlines, remaining tags are stripped and
|
||||
/// entities decoded. Multi-line fields (address, Leistungsort) keep their line breaks — the PDF
|
||||
/// splits those on <c>\n</c>/<c><br></c> — while single-line fields collapse to one line.
|
||||
/// Blank lines are removed so a stray <c><p></p></c> never becomes an empty row.
|
||||
/// </summary>
|
||||
internal static string HtmlToPlain(string? raw)
|
||||
{
|
||||
if (string.IsNullOrEmpty(raw)) return "";
|
||||
if (raw.IndexOf('<') < 0 && raw.IndexOf('&') < 0) return raw.Trim();
|
||||
|
||||
// Turn line-break / block-close tags into newlines before stripping the rest.
|
||||
string s = Regex.Replace(raw, @"<\s*br\s*/?\s*>", "\n", RegexOptions.IgnoreCase);
|
||||
s = Regex.Replace(s, @"</\s*(p|div|li|tr|h[1-6])\s*>", "\n", RegexOptions.IgnoreCase);
|
||||
|
||||
var doc = new HtmlAgilityPack.HtmlDocument();
|
||||
doc.LoadHtml(s);
|
||||
string text = System.Net.WebUtility.HtmlDecode(doc.DocumentNode.InnerText);
|
||||
|
||||
var lines = text.Replace("\r\n", "\n").Replace('\r', '\n')
|
||||
.Split('\n')
|
||||
.Select(l => l.Trim())
|
||||
.Where(l => l.Length > 0);
|
||||
return string.Join("\n", lines).Trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using Fuchs.Notifications;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Background monitor for the invoice draft cache (ADR 0006). Because a draft's
|
||||
/// truth lives only in server memory until the user saves, idle sessions must not
|
||||
/// vanish silently: this service warns the editing browser <b>before</b> a session's
|
||||
/// idle TTL lapses ("bitte zwischenspeichern"), and when the TTL is finally reached
|
||||
/// it evicts the session and tells the browser to close the editor with a reason.
|
||||
/// All hints travel over the <see cref="DraftPreviewHub"/> via <see cref="IDraftNotifier"/>.
|
||||
/// </summary>
|
||||
public sealed class InvoiceDraftExpiryService : BackgroundService
|
||||
{
|
||||
private readonly IInvoiceDraftCache _cache;
|
||||
private readonly IDraftNotifier _notifier;
|
||||
private readonly ILogger<InvoiceDraftExpiryService> _logger;
|
||||
private readonly TimeSpan _warnLead;
|
||||
private readonly TimeSpan _interval;
|
||||
|
||||
public InvoiceDraftExpiryService(IInvoiceDraftCache cache, IDraftNotifier notifier,
|
||||
IConfiguration configuration, ILogger<InvoiceDraftExpiryService> logger)
|
||||
{
|
||||
_cache = cache;
|
||||
_notifier = notifier;
|
||||
_logger = logger;
|
||||
int warnMinutes = configuration.GetValue("Fuchs:DraftEditing:ExpiryWarnMinutes", 5);
|
||||
_warnLead = TimeSpan.FromMinutes(Math.Clamp(warnMinutes, 1, Math.Max(1, (int)cache.IdleTtl.TotalMinutes - 1)));
|
||||
_interval = TimeSpan.FromSeconds(30);
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
using var timer = new PeriodicTimer(_interval);
|
||||
try
|
||||
{
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken))
|
||||
await SweepAsync(stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) { /* shutting down */ }
|
||||
}
|
||||
|
||||
/// <summary>One pass over all live sessions. Internal so it can be driven directly from unit tests.</summary>
|
||||
internal async Task SweepAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
DateTime now = DateTime.UtcNow;
|
||||
foreach (var session in _cache.Snapshot())
|
||||
{
|
||||
TimeSpan idle = now - session.LastAccessUtc;
|
||||
if (idle >= _cache.IdleTtl)
|
||||
{
|
||||
_cache.Remove(session.Token);
|
||||
_logger.LogInformation("Draft {Token} evicted after {Idle} idle (user={User})",
|
||||
session.Token, idle, session.UserAccountId);
|
||||
await _notifier.SignalClosedAsync(session.Token, "expired", cancellationToken);
|
||||
}
|
||||
else if (idle >= _cache.IdleTtl - _warnLead && !session.ExpiryWarningSent)
|
||||
{
|
||||
session.ExpiryWarningSent = true;
|
||||
int secondsLeft = (int)Math.Max(0, (_cache.IdleTtl - idle).TotalSeconds);
|
||||
await _notifier.SignalExpiringAsync(session.Token, secondsLeft, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
using System.Text;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Structured invoice-recipient (buyer) address held in the draft cache and persisted as JSON
|
||||
/// (interim: inside the <c>CustomValues</c> blob under <c>sendToAddress</c>; a dedicated
|
||||
/// <c>SendToAddressJson</c> column is planned — see ADR 0012 / the SQL plan). It replaces the
|
||||
/// former free-text <c>invoiceaddress</c> as the source of truth, while a composed free-text
|
||||
/// block (<see cref="Compose"/>) keeps the existing PDF/<c>SendToAddress</c> path unchanged.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The field set targets EN 16931 / DATEV: <see cref="Name"/> (BT-44), <see cref="Contact"/>
|
||||
/// (z.Hd.), <see cref="AddressLine2"/> (BT-51), <see cref="Street"/> (BT-50),
|
||||
/// <see cref="PostalCode"/> (BT-53), <see cref="City"/> (BT-52), <see cref="CountryCode"/>
|
||||
/// (BT-55, mandatory), <see cref="VatId"/> (BT-48, optional — empty for a private person, which
|
||||
/// must stay effortless: B2C invoices need no VAT id and are still EN 16931-valid).
|
||||
/// </remarks>
|
||||
public sealed class InvoiceRecipientAddress
|
||||
{
|
||||
/// <summary>The JSON key under which the structured address rides inside <c>CustomValues</c>.</summary>
|
||||
public const string CustomValuesKey = "sendToAddress";
|
||||
|
||||
public string Name { get; set; } = "";
|
||||
public string Contact { get; set; } = "";
|
||||
public string AddressLine2 { get; set; } = "";
|
||||
public string Street { get; set; } = "";
|
||||
public string PostalCode { get; set; } = "";
|
||||
public string City { get; set; } = "";
|
||||
|
||||
/// <summary>ISO 3166-1 alpha-2 country code (BT-55). Defaults to <c>DE</c>.</summary>
|
||||
public string CountryCode { get; set; } = "DE";
|
||||
|
||||
/// <summary>Buyer VAT identifier (BT-48). Empty for a private person (B2C).</summary>
|
||||
public string VatId { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Leitweg-ID / buyer reference (BT-10). When set, the invoice is treated as B2G and emitted
|
||||
/// as <b>XRechnung</b> (the mandatory form for German public authorities); empty for B2B/B2C.
|
||||
/// </summary>
|
||||
public string LeitwegId { get; set; } = "";
|
||||
|
||||
/// <summary>True when no VAT id is set — a private person / B2C recipient.</summary>
|
||||
public bool IsPrivatePerson => string.IsNullOrWhiteSpace(VatId);
|
||||
|
||||
/// <summary>True when a Leitweg-ID is present — a B2G recipient (→ XRechnung).</summary>
|
||||
public bool IsPublicAuthority => !string.IsNullOrWhiteSpace(LeitwegId);
|
||||
|
||||
/// <summary>Reads a structured address from a JSON object (tolerant of missing keys).</summary>
|
||||
public static InvoiceRecipientAddress FromJson(JObject? o)
|
||||
{
|
||||
o ??= new JObject();
|
||||
return new InvoiceRecipientAddress
|
||||
{
|
||||
Name = S(o, "name"),
|
||||
Contact = S(o, "contact"),
|
||||
AddressLine2 = S(o, "line2", "addressLine2"),
|
||||
Street = S(o, "street"),
|
||||
PostalCode = S(o, "postalCode", "zip", "plz"),
|
||||
City = S(o, "city", "ort"),
|
||||
CountryCode = S(o, "countryCode", "country").ToUpperInvariant() is { Length: > 0 } cc ? cc : "DE",
|
||||
VatId = S(o, "vatId", "ustid"),
|
||||
LeitwegId = S(o, "leitwegId", "leitweg", "buyerReference"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Parses the structured address out of a <c>CustomValues</c> JSON blob, if present.</summary>
|
||||
public static InvoiceRecipientAddress? FromCustomValues(string? customValuesJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(customValuesJson)) return null;
|
||||
try
|
||||
{
|
||||
var cv = JObject.Parse(customValuesJson);
|
||||
return cv[CustomValuesKey] is JObject a ? FromJson(a) : null;
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
/// <summary>Serialises to a JSON object for the cache / <c>CustomValues</c> blob.</summary>
|
||||
public JObject ToJson() => new()
|
||||
{
|
||||
["name"] = Name,
|
||||
["contact"] = Contact,
|
||||
["line2"] = AddressLine2,
|
||||
["street"] = Street,
|
||||
["postalCode"] = PostalCode,
|
||||
["city"] = City,
|
||||
["countryCode"] = CountryCode,
|
||||
["vatId"] = VatId,
|
||||
["leitwegId"] = LeitwegId,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Composes the multi-line free-text postal block (for the PDF and the legacy
|
||||
/// <c>SendToAddress</c> column). The VAT id is intentionally excluded — it is not part of the
|
||||
/// postal address, only of the structured eRechnung data (BT-48). Foreign countries append
|
||||
/// the country code line; domestic (DE) omits it, matching the current letter layout.
|
||||
/// </summary>
|
||||
public string Compose()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
void Line(string? v) { if (!string.IsNullOrWhiteSpace(v)) sb.Append(sb.Length > 0 ? "\n" : "").Append(v!.Trim()); }
|
||||
|
||||
Line(Name);
|
||||
if (!string.IsNullOrWhiteSpace(Contact)) Line($"z.Hd. {Contact.Trim()}");
|
||||
Line(AddressLine2);
|
||||
Line(Street);
|
||||
string cityLine = $"{PostalCode} {City}".Trim();
|
||||
Line(cityLine);
|
||||
if (!string.IsNullOrWhiteSpace(CountryCode) && !CountryCode.Equals("DE", StringComparison.OrdinalIgnoreCase))
|
||||
Line(CountryCode.ToUpperInvariant());
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The EN 16931 fields still missing for a formally valid buyer (BG-7). Buyer name (BT-44)
|
||||
/// and country code (BT-55) are mandatory; a postal address needs a city or post code. VAT id
|
||||
/// is <b>not</b> required (a private person is valid without one). An empty result means the
|
||||
/// recipient is EN 16931 / DATEV conformant — the editor surfaces this as a hint.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> MissingForEn16931()
|
||||
{
|
||||
var missing = new List<string>();
|
||||
if (string.IsNullOrWhiteSpace(Name)) missing.Add("Name");
|
||||
if (string.IsNullOrWhiteSpace(CountryCode)) missing.Add("Land");
|
||||
if (string.IsNullOrWhiteSpace(City) && string.IsNullOrWhiteSpace(PostalCode)) missing.Add("Ort/PLZ");
|
||||
return missing;
|
||||
}
|
||||
|
||||
/// <summary>True when the recipient satisfies the EN 16931 mandatory buyer fields.</summary>
|
||||
public bool IsEn16931Conformant => MissingForEn16931().Count == 0;
|
||||
|
||||
private static string S(JObject o, params string[] keys)
|
||||
{
|
||||
foreach (var k in keys)
|
||||
if (o[k] is { } t && t.Type != JTokenType.Null)
|
||||
return t.Type == JTokenType.String ? t.Value<string>()?.Trim() ?? "" : t.ToString().Trim();
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -26,15 +26,17 @@ public class InvoiceService : IInvoiceService
|
||||
private readonly IPdfService _pdf;
|
||||
private readonly IBlobStorageService _blobStorage;
|
||||
private readonly IEventService _events;
|
||||
private readonly IERechnungService _erechnung;
|
||||
private readonly ILogger<InvoiceService> _logger;
|
||||
|
||||
public InvoiceService(Fuchs_intranet intranet, IPdfService pdf, IBlobStorageService blobStorage,
|
||||
IEventService events, ILogger<InvoiceService> logger)
|
||||
IEventService events, IERechnungService erechnung, ILogger<InvoiceService> logger)
|
||||
{
|
||||
_intranet = intranet;
|
||||
_pdf = pdf;
|
||||
_blobStorage = blobStorage;
|
||||
_events = events;
|
||||
_erechnung = erechnung;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -63,10 +65,64 @@ public class InvoiceService : IInvoiceService
|
||||
|
||||
inv.InvoiceRegistration = new GenericObjectDictionary(dset.Table("inv").FirstRow.toObjectDictionary());
|
||||
inv.IsDraft = inv.InvoiceRegistration.getItem("IsFinal", false) is not true;
|
||||
_logger.LogDebug("LoadInvoiceAsync loaded id={Id} draft={Draft}", inv.Id, inv.IsDraft);
|
||||
// Reconstruct the service-request blocks + line items so the PDF renders the positions.
|
||||
// Without this the reloaded/finalized invoice showed an empty item table (only the header
|
||||
// + totals came from InvoiceRegistration), i.e. it did not match the cached preview.
|
||||
inv.Req = BuildPdfRequestBlocks(dset);
|
||||
_logger.LogDebug("LoadInvoiceAsync loaded id={Id} draft={Draft} blocks={Blocks}", inv.Id, inv.IsDraft, inv.Req?.Count ?? 0);
|
||||
return inv;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rebuilds the invoice's service-request groups and line items from the persisted
|
||||
/// <c>req</c>/<c>itm</c> tables (<c>fds__getInvoice</c>) into the exact block shape the PDF
|
||||
/// consumes (<see cref="FdsInvoiceData.InvoiceBlocks"/> → item contract
|
||||
/// <c>type/title/desc/qty/price_net/total_net</c>). Item order follows the persisted
|
||||
/// <c>SortOrder</c>, so a reordered draft renders in its saved order — making the finalized
|
||||
/// PDF and the re-downloaded (<c>idoc</c>) document identical to the cached preview.
|
||||
/// </summary>
|
||||
private static List<Dictionary<string, object>> BuildPdfRequestBlocks(SQLDataSet dset)
|
||||
{
|
||||
var blocks = new List<Dictionary<string, object>>();
|
||||
if (!dset.Contains("req")) return blocks;
|
||||
|
||||
var reqTable = dset.Tables("req");
|
||||
string reqSort = reqTable.Columns.Contains("order") ? "order" : "";
|
||||
foreach (DataRow rq in reqTable.Select("", reqSort))
|
||||
{
|
||||
var rdic = rq.toObjectDictionary();
|
||||
var items = new List<Dictionary<string, object?>>();
|
||||
if (dset.Contains("itm"))
|
||||
{
|
||||
var itmTable = dset.Tables("itm");
|
||||
string itmSort = itmTable.Columns.Contains("order") ? "order" : "";
|
||||
foreach (DataRow it in itmTable.Select($"[InvRqId] = '{rdic.nz("Id")}'", itmSort))
|
||||
{
|
||||
var d = it.toObjectDictionary();
|
||||
// The persisted "Text" holds the item's rendered HTML (the editor's co.t); it is
|
||||
// the full title+description, so it maps to the contract's desc (title stays empty).
|
||||
items.Add(new Dictionary<string, object?>
|
||||
{
|
||||
["id"] = d.nz("mfr__item"),
|
||||
["type"] = d.nz("Type"),
|
||||
["title"] = "",
|
||||
["desc"] = d.nz("Text"),
|
||||
["qty"] = d.nz("Quantity"),
|
||||
["price_net"] = d.no("value", 0),
|
||||
["total_net"] = d.no("value_total", 0)
|
||||
});
|
||||
}
|
||||
}
|
||||
blocks.Add(new Dictionary<string, object>
|
||||
{
|
||||
["Id"] = rdic.nz("mfr__servicerequest"),
|
||||
["text"] = System.Net.WebUtility.HtmlDecode(rdic.nz("title")),
|
||||
["items"] = items
|
||||
});
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
public async Task<FdsInvoiceData> RegisterInvoiceAsync(FdsInvoiceData invoice, bool change, string invId,
|
||||
string userAccountId, DatabaseSecurity dbSec)
|
||||
{
|
||||
@@ -79,13 +135,13 @@ public class InvoiceService : IInvoiceService
|
||||
var sqlParts = new List<string> { "DECLARE @Id varchar(10);" };
|
||||
if (!change)
|
||||
{
|
||||
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice] @InvoiceType, @InvoiceTitle, @InvoiceBalance, @InvoiceBalance_net, @InvoiceVAT_net1, @InvoiceVAT_1, @PaymentTerm, @CustomerId, @SendToAddress, @SendToEmail, @ProvisionPeriod, @CustomValues, @authuser, @Id OUTPUT;");
|
||||
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice] @InvoiceType, @InvoiceTitle, @InvoiceBalance, @InvoiceBalance_net, @InvoiceVAT_net1, @InvoiceVAT_1, @PaymentTerm, @CustomerId, @SendToAddress, @SendToEmail, @ProvisionPeriod, @CustomValues, @authuser, @Id OUTPUT, @SendToAddressJson;");
|
||||
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice_Details] @Id, @InvoiceService_net, @InvoiceService_VAT, @InvoiceOptions, @authuser;");
|
||||
}
|
||||
else
|
||||
{
|
||||
pl.Add(SQL_VarChar("@InvId", invId));
|
||||
sqlParts.Add("EXECUTE [dbo].[fds__setInvoice] @InvId, @InvoiceType, @InvoiceTitle, @InvoiceBalance, @InvoiceBalance_net, @InvoiceVAT_net1, @InvoiceVAT_1, @PaymentTerm, @CustomerId, @SendToAddress, @SendToEmail, @ProvisionPeriod, @CustomValues, @authuser, @Id OUTPUT;");
|
||||
sqlParts.Add("EXECUTE [dbo].[fds__setInvoice] @InvId, @InvoiceType, @InvoiceTitle, @InvoiceBalance, @InvoiceBalance_net, @InvoiceVAT_net1, @InvoiceVAT_1, @PaymentTerm, @CustomerId, @SendToAddress, @SendToEmail, @ProvisionPeriod, @CustomValues, @authuser, @Id OUTPUT, @SendToAddressJson;");
|
||||
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice_Details] @Id, @InvoiceService_net, @InvoiceService_VAT, @InvoiceOptions, @authuser;");
|
||||
}
|
||||
if (invoice.RawProvisionLocation.Length > 0)
|
||||
@@ -212,7 +268,7 @@ public class InvoiceService : IInvoiceService
|
||||
var reg = invoice.InvoiceRegistration;
|
||||
var tb = new FuchsPdf.FdsTextBlocks
|
||||
{
|
||||
AdminRef = reg?.getString("Id") ?? "",
|
||||
AdminRef = (reg?.getString("InvoiceId") ?? "").ne(reg?.getString("Id") ?? ""),
|
||||
Address = reg?.getString("SendToAddress") is { Length: > 0 } sa
|
||||
? sa.Replace("<br>", "\n").Replace("<br/>", "\n").Split('\n').Select(t => t.Trim()).ToArray()
|
||||
: Array.Empty<string>(),
|
||||
@@ -228,8 +284,19 @@ public class InvoiceService : IInvoiceService
|
||||
return doc;
|
||||
}
|
||||
|
||||
public Task<byte[]> RenderInvoicePdfBytesAsync(FdsInvoiceData invoice, bool draft)
|
||||
=> Task.FromResult(_pdf.DocToPdfBytes(GenerateInvoicePdf(invoice, draft)));
|
||||
public async Task<byte[]> RenderInvoicePdfBytesAsync(FdsInvoiceData invoice, bool draft)
|
||||
{
|
||||
var doc = GenerateInvoicePdf(invoice, draft);
|
||||
// Finalized invoices are emitted as a ZUGFeRD/Factur-X hybrid when eRechnung is enabled:
|
||||
// eRechnungLib embeds the CII XML into the render-only visual PDF and owns the single
|
||||
// PDF/A-3 layer (ADR 0012). Drafts/previews keep the plain PDF/A. Any failure falls back.
|
||||
if (!draft && _erechnung.Enabled)
|
||||
{
|
||||
var hybrid = await _erechnung.TryBuildHybridPdfAsync(invoice, _pdf.DocToPdfBytesRaw(doc));
|
||||
if (hybrid is { Length: > 0 }) return hybrid;
|
||||
}
|
||||
return _pdf.DocToPdfBytes(doc);
|
||||
}
|
||||
|
||||
public async Task<byte[]> StoreInvoiceDocumentFileAsync(FdsInvoiceData invoice, bool draft,
|
||||
string userAccountId, DatabaseSecurity dbSec)
|
||||
|
||||
+4
-2
@@ -1,7 +1,7 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace fds;
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a named job with its own execution schedule for use with <see cref="PeriodicHostedService"/>.
|
||||
@@ -13,6 +13,8 @@ public sealed record PeriodicJobDefinition(
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="BackgroundService"/> that runs multiple independent jobs, each on its own <see cref="PeriodicTimer"/>.
|
||||
/// Hosts the MFR ERP sync that formerly ran as the standalone <c>Fuchs_DataService</c> Windows Service
|
||||
/// (Topshelf); it is now registered in-process from <c>Program.cs</c>, gated by <c>Fds:SyncEnabled</c>.
|
||||
/// </summary>
|
||||
public sealed class PeriodicHostedService : BackgroundService
|
||||
{
|
||||
@@ -14,7 +14,7 @@ namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Outbound communication service backed by the ProcessWeb Mailer API
|
||||
/// (POST https://api.processweb.de/api/mailer?fn=push_com).
|
||||
/// (POST https://api.processweb.de/mailer/push_com).
|
||||
/// When <c>ProcessWebComSettings.Enabled</c> is <c>false</c> the service
|
||||
/// only logs the intended communication without calling the API.
|
||||
/// </summary>
|
||||
@@ -71,6 +71,9 @@ public class ProcessWebComService : IComService
|
||||
_logger.LogWarning("SendEmailAsync: invalid email address '{Email}' for ref {Reference}", email, reference);
|
||||
FuchsTelemetry.EmailsFailed.Add(1, new KeyValuePair<string, object?>("reason", "invalid-email"));
|
||||
act?.SetStatus(ActivityStatusCode.Error, "invalid-email");
|
||||
// Every attempt is audit-logged, including this early rejection (previously it returned unlogged).
|
||||
await WriteAuditLogAsync(reference, "", "", default, false,
|
||||
[$"Invalid recipient email address: '{email}'"]);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -105,10 +108,9 @@ public class ProcessWebComService : IComService
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
var payload = new
|
||||
var communication = new
|
||||
{
|
||||
comType = "email",
|
||||
recipient = email,
|
||||
to = email,
|
||||
subject,
|
||||
body,
|
||||
attachments = attachmentPayload
|
||||
@@ -117,7 +119,7 @@ public class ProcessWebComService : IComService
|
||||
_logger.LogDebug("SendEmailAsync ref={Reference} to={Email} attachments={Count}",
|
||||
reference, email, attachmentPayload.Length);
|
||||
|
||||
var (ok, responseBody) = await PostToApiAsync("push_com", payload);
|
||||
var (ok, status, responseBody) = await PostToApiAsync(reference, "e", communication);
|
||||
if (ok)
|
||||
{
|
||||
success = true;
|
||||
@@ -125,13 +127,17 @@ public class ProcessWebComService : IComService
|
||||
}
|
||||
else
|
||||
{
|
||||
errors.Add($"API error: {responseBody}");
|
||||
_logger.LogWarning("SendEmailAsync API error for {Reference}: {Body}", reference, responseBody);
|
||||
// Persist the mailer's raw response so an unsuccessful send is diagnosable from the log.
|
||||
errors.Add($"API error {(int)status} ({status}): {responseBody}");
|
||||
_logger.LogWarning("SendEmailAsync API error for {Reference}: {Status} {Body}",
|
||||
reference, (int)status, responseBody);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add("Beim Versenden ist ein Fehler aufgetreten.");
|
||||
// Capture the exception detail in the audit log (not just a generic message) so a
|
||||
// transport/serialization failure is diagnosable after the fact.
|
||||
errors.Add($"Exception while sending: {ex.GetType().Name}: {ex.Message}");
|
||||
act?.SetStatus(ActivityStatusCode.Error, ex.Message);
|
||||
_logger.LogError(ex, "SendEmailAsync failed for {Reference}", reference);
|
||||
}
|
||||
@@ -166,14 +172,13 @@ public class ProcessWebComService : IComService
|
||||
|
||||
try
|
||||
{
|
||||
var payload = new
|
||||
var communication = new
|
||||
{
|
||||
comType = "sms",
|
||||
recipient = mobile,
|
||||
to = mobile,
|
||||
body = message
|
||||
};
|
||||
|
||||
var (ok, responseBody) = await PostToApiAsync("push_com", payload);
|
||||
var (ok, status, responseBody) = await PostToApiAsync(mobile, "s", communication);
|
||||
if (ok)
|
||||
{
|
||||
FuchsTelemetry.SmsSent.Add(1);
|
||||
@@ -181,7 +186,7 @@ public class ProcessWebComService : IComService
|
||||
return true;
|
||||
}
|
||||
|
||||
_logger.LogWarning("SendSmsAsync API error for {Mobile}: {Body}", mobile, responseBody);
|
||||
_logger.LogWarning("SendSmsAsync API error for {Mobile}: {Status} {Body}", mobile, (int)status, responseBody);
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -193,9 +198,19 @@ public class ProcessWebComService : IComService
|
||||
|
||||
// ── Private helpers ────────────────────────────────────────────────────────
|
||||
|
||||
private async Task<(bool ok, string body)> PostToApiAsync(string fn, object payload)
|
||||
private async Task<(bool ok, System.Net.HttpStatusCode status, string body)> PostToApiAsync(string reference, string comtype, object communication)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient("ProcessWebMailer");
|
||||
|
||||
// The Mailer API expects a fixed envelope: { serverid, comtype, communication, reference }.
|
||||
// "communication" carries the actual channel-specific payload (to/subject/body/attachments).
|
||||
var payload = new
|
||||
{
|
||||
serverid = _settings.ServerId,
|
||||
comtype,
|
||||
communication,
|
||||
reference
|
||||
};
|
||||
var json = JsonConvert.SerializeObject(payload);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
@@ -204,25 +219,23 @@ public class ProcessWebComService : IComService
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Basic", credentials);
|
||||
|
||||
var response = await client.PostAsync($"{_settings.BaseUrl}/api/mailer?fn={fn}", content);
|
||||
var response = await client.PostAsync($"{_settings.BaseUrl}/mailer/push_com", content);
|
||||
string responseBody = await response.Content.ReadAsStringAsync();
|
||||
return (response.IsSuccessStatusCode, responseBody);
|
||||
return (response.IsSuccessStatusCode, response.StatusCode, responseBody);
|
||||
}
|
||||
|
||||
private async Task WriteAuditLogAsync(string reference, string guid, string config,
|
||||
/// <summary>
|
||||
/// Persists one communication attempt to <c>[dbo].[fds__logEmail]</c>. Called on every path —
|
||||
/// success, API error (with the mailer's raw response in <paramref name="errors"/>), exceptions,
|
||||
/// invalid address and the disabled-service case — so the email log is always complete. Wrapped
|
||||
/// in try/catch so a logging failure never breaks (or masks) the send itself. Overridable for tests.
|
||||
/// </summary>
|
||||
protected virtual async Task WriteAuditLogAsync(string reference, string guid, string config,
|
||||
DateTime sent, bool success, IEnumerable<string> errors)
|
||||
{
|
||||
try
|
||||
{
|
||||
var pl = new List<SqlParameter>
|
||||
{
|
||||
SQL_VarChar("@Ref", reference),
|
||||
SQL_VarChar("@guid", guid),
|
||||
SQL_DateTime("@DateSent", sent == default ? DBNull.Value : (object)sent),
|
||||
SQL_NVarChar("@config", config, dbNull_IfEmpty: true),
|
||||
SQL_Bit("@success", success),
|
||||
SQL_NVarChar("@log", JsonConvert.SerializeObject(errors.ToList()))
|
||||
};
|
||||
var pl = BuildAuditLogParameters(reference, guid, config, sent, success, errors);
|
||||
await setSQLValue_async(
|
||||
"EXECUTE [dbo].[fds__logEmail] @Ref, @guid, @DateSent, @config, @success, @log;",
|
||||
_intranet.Intranet__SQLConnectionString, pl,
|
||||
@@ -234,6 +247,28 @@ public class ProcessWebComService : IComService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the parameter list for <c>[dbo].[fds__logEmail]</c>. <c>fds__emaillog</c> declares
|
||||
/// <c>config</c> and <c>DateSent</c> as NOT NULL, so neither may be sent as NULL:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>@config</c> is stored as an empty string (never NULL) when unset.</item>
|
||||
/// <item><c>@DateSent</c> falls back to the current (attempt) time when the send did not
|
||||
/// complete — the <c>success</c> flag distinguishes a real send-time from a failed attempt.</item>
|
||||
/// </list>
|
||||
/// Pure/side-effect-free so the NOT NULL contract can be unit-tested without a database.
|
||||
/// </summary>
|
||||
internal static List<SqlParameter> BuildAuditLogParameters(string reference, string guid,
|
||||
string config, DateTime sent, bool success, IEnumerable<string> errors) =>
|
||||
new()
|
||||
{
|
||||
SQL_VarChar("@Ref", reference),
|
||||
SQL_VarChar("@guid", guid ?? ""),
|
||||
SQL_DateTime("@DateSent", sent == default ? DateTime.UtcNow : sent),
|
||||
SQL_NVarChar("@config", config ?? ""),
|
||||
SQL_Bit("@success", success),
|
||||
SQL_NVarChar("@log", JsonConvert.SerializeObject(errors.ToList()))
|
||||
};
|
||||
|
||||
private string BuildSignature()
|
||||
{
|
||||
string sigPath = Path.Combine(AppContext.BaseDirectory,
|
||||
|
||||
@@ -14,6 +14,9 @@ public class ProcessWebComSettings
|
||||
/// <summary>API token used for HTTP Basic authentication.</summary>
|
||||
public string Token { get; set; } = "";
|
||||
|
||||
/// <summary>Server identifier assigned by ProcessWeb, sent as "serverid" in every push_com call.</summary>
|
||||
public string ServerId { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// When <c>false</c> (default) the service is disabled and only logs the
|
||||
/// intended communication without actually calling the API.
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Fuchs.intranet;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Single-instance, in-memory implementation of <see cref="IReminderDraftCache"/> backed
|
||||
/// by a <see cref="ConcurrentDictionary{TKey,TValue}"/> keyed by session token — the
|
||||
/// reminder mirror of <see cref="InvoiceDraftCache"/>. A plain dictionary (rather than
|
||||
/// <c>IMemoryCache</c>) is used on purpose: the <see cref="ReminderDraftExpiryService"/>
|
||||
/// needs to enumerate sessions and warn the user <b>before</b> eviction, which opaque
|
||||
/// cache-entry expiry does not allow.
|
||||
///
|
||||
/// Idle TTL and the pre-expiry warning lead time are shared with invoices under
|
||||
/// <c>Fuchs:DraftEditing</c> (<c>IdleMinutes</c> / <c>ExpiryWarnMinutes</c>).
|
||||
/// </summary>
|
||||
public sealed class ReminderDraftCache : IReminderDraftCache
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ReminderDraftSession> _sessions = new(StringComparer.Ordinal);
|
||||
|
||||
public TimeSpan IdleTtl { get; }
|
||||
/// <summary>How long before the idle TTL a warning is emitted to the user.</summary>
|
||||
public TimeSpan ExpiryWarnLead { get; }
|
||||
|
||||
public ReminderDraftCache(IConfiguration configuration)
|
||||
{
|
||||
int idleMinutes = configuration.GetValue("Fuchs:DraftEditing:IdleMinutes", 30);
|
||||
int warnMinutes = configuration.GetValue("Fuchs:DraftEditing:ExpiryWarnMinutes", 5);
|
||||
IdleTtl = TimeSpan.FromMinutes(Math.Max(1, idleMinutes));
|
||||
ExpiryWarnLead = TimeSpan.FromMinutes(Math.Clamp(warnMinutes, 1, Math.Max(1, idleMinutes - 1)));
|
||||
}
|
||||
|
||||
public void Set(ReminderDraftSession session)
|
||||
{
|
||||
if (string.IsNullOrEmpty(session.Token)) throw new ArgumentException("Session has no token.", nameof(session));
|
||||
session.Touch();
|
||||
_sessions[session.Token] = session;
|
||||
}
|
||||
|
||||
public ReminderDraftSession? Get(string token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(token)) return null;
|
||||
if (_sessions.TryGetValue(token, out var s))
|
||||
{
|
||||
s.Touch();
|
||||
// A touch resets the idle window, so a fresh warning is due next time it lapses.
|
||||
s.ExpiryWarningSent = false;
|
||||
return s;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public ReminderDraftSession? Remove(string token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(token)) return null;
|
||||
return _sessions.TryRemove(token, out var s) ? s : null;
|
||||
}
|
||||
|
||||
public IReadOnlyList<ReminderDraftSession> Snapshot() => _sessions.Values.ToList();
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
using System.Globalization;
|
||||
using Fuchs.intranet;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MigraDoc.DocumentObjectModel;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using OCORE.security;
|
||||
using static OCORE.commons;
|
||||
using static OCORE.OCORE_dictionaries;
|
||||
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Backend-authoritative reminder draft editing (ADR 0006) — the reminder mirror of
|
||||
/// <see cref="InvoiceDraftEditService"/>. Holds the truth in a
|
||||
/// <see cref="ReminderDraftSession"/> (via <see cref="IReminderDraftCache"/>), applies
|
||||
/// single edits, aggregates the open amount with <see cref="ReminderDraftCalculator"/>,
|
||||
/// renders previews and flushes to the DB by reusing the existing
|
||||
/// <see cref="IReminderService"/> registration path — no new persistence. The session
|
||||
/// stores the editor's own block shape (<c>new</c>/<c>rem</c>), which the PDF/persistence
|
||||
/// already consume, so nothing is re-shaped server-side.
|
||||
/// </summary>
|
||||
public sealed class ReminderDraftEditService : IReminderDraftService
|
||||
{
|
||||
private readonly IReminderDraftCache _cache;
|
||||
private readonly IReminderService _reminders;
|
||||
private readonly ILogger<ReminderDraftEditService> _logger;
|
||||
|
||||
public ReminderDraftEditService(IReminderDraftCache cache, IReminderService reminders,
|
||||
ILogger<ReminderDraftEditService> logger)
|
||||
{
|
||||
_cache = cache;
|
||||
_reminders = reminders;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// ── Open ─────────────────────────────────────────────────────────────────
|
||||
public ReminderDraftSession OpenFromPayload(JObject payload, string userAccountId)
|
||||
{
|
||||
var session = new ReminderDraftSession
|
||||
{
|
||||
Token = NewToken(),
|
||||
UserAccountId = userAccountId,
|
||||
RemId = payload["remid"]?.Value<string>() ?? payload["id"]?.Value<string>() ?? ""
|
||||
};
|
||||
session.New = payload["new"] as JObject ?? new JObject();
|
||||
session.Rem = payload["rem"] as JObject ?? new JObject();
|
||||
Refresh(session);
|
||||
_cache.Set(session);
|
||||
_logger.LogInformation("Reminder draft session {Token} opened from payload (remId={RemId}, user={User})",
|
||||
session.Token, session.RemId, userAccountId);
|
||||
return session;
|
||||
}
|
||||
|
||||
public ReminderDraftSession? Get(string token) => _cache.Get(token);
|
||||
|
||||
// ── Patch ──────────────────────────────────────────────────────────────────
|
||||
public ReminderDraftSession? ApplyPatch(string token, ReminderDraftDelta delta)
|
||||
{
|
||||
var session = _cache.Get(token);
|
||||
if (session == null) return null;
|
||||
|
||||
string oldValue = "", newValue = "";
|
||||
bool mutated = ApplyDelta(session, delta, ref oldValue, ref newValue);
|
||||
if (!mutated)
|
||||
{
|
||||
_logger.LogDebug("Reminder draft {Token}: no-op patch target={Target} ref={Ref}", token, delta.Target, delta.Ref);
|
||||
return session;
|
||||
}
|
||||
|
||||
Refresh(session);
|
||||
session.Version++;
|
||||
session.History.Add(new ChangeHistoryEntry
|
||||
{
|
||||
UserAccountId = session.UserAccountId,
|
||||
Target = delta.Target,
|
||||
Ref = delta.Ref,
|
||||
OldValue = oldValue,
|
||||
NewValue = newValue,
|
||||
Version = session.Version
|
||||
});
|
||||
_cache.Set(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies one delta to the payload; returns whether anything changed and captures the prior
|
||||
/// and new value for the change history. Scalar text fields are sanitised from the editor's
|
||||
/// HTML (TinyMCE wraps inline edits in <c><p>…</p></c>) to plain text (via
|
||||
/// <see cref="InvoiceDraftEditService.HtmlToPlain"/>) — the backend is the single source of
|
||||
/// truth (ADR 0006), so no HTML ever reaches the DB or the PDF.
|
||||
/// </summary>
|
||||
private static bool ApplyDelta(ReminderDraftSession s, ReminderDraftDelta d, ref string oldValue, ref string newValue)
|
||||
{
|
||||
switch (d.Target)
|
||||
{
|
||||
case "email": return SetNewText(s, "invoiceemail", d, ref oldValue, ref newValue);
|
||||
case "address": return SetNewText(s, "invoiceaddress", d, ref oldValue, ref newValue);
|
||||
case "subject": return SetNewText(s, "subject", d, ref oldValue, ref newValue);
|
||||
case "text": return SetNewText(s, "text", d, ref oldValue, ref newValue);
|
||||
case "amount": return SetNewNumber(s, "amount", d, ref oldValue, ref newValue);
|
||||
case "amount_payed": return SetNewNumber(s, "amount_payed", d, ref oldValue, ref newValue);
|
||||
case "contact": return SetContact(s, d, ref oldValue, ref newValue);
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool SetNewText(ReminderDraftSession s, string key, ReminderDraftDelta d, ref string oldValue, ref string newValue)
|
||||
{
|
||||
oldValue = Str(s.New[key]);
|
||||
newValue = InvoiceDraftEditService.HtmlToPlain(d.ValueString);
|
||||
s.New[key] = newValue;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Stores a numeric field, normalising German/invariant input to an invariant decimal string.</summary>
|
||||
private static bool SetNewNumber(ReminderDraftSession s, string key, ReminderDraftDelta d, ref string oldValue, ref string newValue)
|
||||
{
|
||||
oldValue = Str(s.New[key]);
|
||||
decimal parsed = ReminderDraftCalculator.Dec(d.Value ?? JValue.CreateString(InvoiceDraftEditService.HtmlToPlain(d.ValueString)));
|
||||
newValue = parsed.ToString(CultureInfo.InvariantCulture);
|
||||
s.New[key] = newValue;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool SetContact(ReminderDraftSession s, ReminderDraftDelta d, ref string oldValue, ref string newValue)
|
||||
{
|
||||
JObject prev = TryParseObject(Str(s.New["CustomValues"]));
|
||||
oldValue = ContactLabel(Str(prev["contactName"]), Str(prev["contactEmail"]));
|
||||
JObject cvo = (JObject)prev.DeepClone();
|
||||
if (d.Value is JObject vo)
|
||||
{
|
||||
cvo["contactName"] = vo["name"] ?? vo["contactName"] ?? "";
|
||||
cvo["contactEmail"] = vo["email"] ?? vo["contactEmail"] ?? "";
|
||||
}
|
||||
s.New["CustomValues"] = cvo.ToString(Newtonsoft.Json.Formatting.None);
|
||||
newValue = ContactLabel(Str(cvo["contactName"]), Str(cvo["contactEmail"]));
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string ContactLabel(string name, string email) =>
|
||||
string.IsNullOrEmpty(name) ? email : string.IsNullOrEmpty(email) ? name : $"{name} <{email}>";
|
||||
|
||||
// ── View state / history ────────────────────────────────────────────────
|
||||
public object BuildState(ReminderDraftSession session)
|
||||
{
|
||||
session.Touch();
|
||||
return new
|
||||
{
|
||||
token = session.Token,
|
||||
version = session.Version,
|
||||
remid = session.RemId,
|
||||
isDraft = session.IsDraft,
|
||||
@new = session.New,
|
||||
rem = session.Rem,
|
||||
sums = new
|
||||
{
|
||||
amount_total = session.Sums.AmountTotal,
|
||||
amount_payed = session.Sums.AmountPayed,
|
||||
amount_open = session.Sums.AmountOpen
|
||||
},
|
||||
validation = session.ValidationMessages.Select(v => new { field = v.Field, severity = v.Severity, message = v.Message }),
|
||||
historyCount = session.History.Count,
|
||||
notes = BuildNotes(session)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders the backend-authoritative intro/closing notice paragraphs (see
|
||||
/// <see cref="FuchsPdf.BuildReminderNotes"/>) for the editor's read-only preview — the same
|
||||
/// text the PDF will show, generated from the same builder, so the two can never drift apart.
|
||||
/// </summary>
|
||||
private object BuildNotes(ReminderDraftSession session)
|
||||
{
|
||||
var fds = BuildReminderData(session);
|
||||
fds.ReminderRegistration = SynthesizeRegistration(session);
|
||||
var (intro, closing) = FuchsPdf.BuildReminderNotes(new FuchsPdf.FdsTextBlocks(), fds);
|
||||
return new { intro, closing };
|
||||
}
|
||||
|
||||
public IReadOnlyList<ChangeHistoryEntry> GetHistory(string token) =>
|
||||
_cache.Get(token)?.History ?? (IReadOnlyList<ChangeHistoryEntry>)Array.Empty<ChangeHistoryEntry>();
|
||||
|
||||
// ── Flush / preview ────────────────────────────────────────────────────────
|
||||
public async Task<FdsReminderData?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec)
|
||||
{
|
||||
var session = _cache.Get(token);
|
||||
if (session == null) return null;
|
||||
|
||||
var fds = BuildReminderData(session);
|
||||
bool change = !string.IsNullOrEmpty(session.RemId);
|
||||
var reg = await _reminders.RegisterReminderAsync(fds, change, session.RemId, userAccountId, dbSec);
|
||||
if (!string.IsNullOrEmpty(reg.Id))
|
||||
{
|
||||
session.RemId = reg.Id;
|
||||
_cache.Set(session);
|
||||
_logger.LogInformation("Reminder draft {Token} flushed to DB reminder {RemId} (change={Change}, user={User})",
|
||||
token, reg.Id, change, userAccountId);
|
||||
}
|
||||
return reg;
|
||||
}
|
||||
|
||||
public Document? RenderPreview(string token)
|
||||
{
|
||||
var session = _cache.Get(token);
|
||||
if (session == null) return null;
|
||||
var fds = BuildReminderData(session);
|
||||
fds.ReminderRegistration = SynthesizeRegistration(session);
|
||||
fds.IsDraft = true;
|
||||
return _reminders.GenerateReminderPdf(fds, draft: true);
|
||||
}
|
||||
|
||||
public bool Close(string token) => _cache.Remove(token) != null;
|
||||
|
||||
// ── Internals ──────────────────────────────────────────────────────────────
|
||||
private static void Refresh(ReminderDraftSession session)
|
||||
{
|
||||
ReminderDraftCalculator.RecomputeTotals(session);
|
||||
ReminderDraftCalculator.Validate(session);
|
||||
}
|
||||
|
||||
private static string NewToken() => Guid.NewGuid().ToString("N");
|
||||
|
||||
/// <summary>
|
||||
/// Builds the <see cref="FdsReminderData"/> from the session — the server-side equivalent of
|
||||
/// the editor's <c>remc</c> payload. The session already holds the editor's <c>new</c>/<c>rem</c>
|
||||
/// shape that registration consumes, so the blocks pass through unchanged.
|
||||
/// </summary>
|
||||
private static FdsReminderData BuildReminderData(ReminderDraftSession session)
|
||||
{
|
||||
var jobj = new JObject
|
||||
{
|
||||
["new"] = session.New.DeepClone(),
|
||||
["rem"] = session.Rem.DeepClone()
|
||||
};
|
||||
return new FdsReminderData(jobj);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Synthesises the <c>ReminderRegistration</c> dictionary a draft PDF render needs, straight
|
||||
/// from the cached session — so a preview requires no DB round-trip and no client upload.
|
||||
/// Mirrors the columns <c>fds__getReminder</c>/<c>fds__createReminder</c> would return for a
|
||||
/// draft, including the single-invoice <c>invoices</c> row the reminder table renders.
|
||||
/// </summary>
|
||||
private static GenericObjectDictionary SynthesizeRegistration(ReminderDraftSession session)
|
||||
{
|
||||
string invoiceId = Str(session.Rem["invoiceid"]).ne(Str(session.Rem["InvoiceId"]));
|
||||
var invoices = new JArray
|
||||
{
|
||||
new JObject
|
||||
{
|
||||
["InvoiceDate"] = Str(session.Rem["invoicedate"]),
|
||||
["DocumentName"] = "",
|
||||
["InvoiceTitle"] = string.IsNullOrEmpty(invoiceId) ? "" : $"Rechnung {invoiceId}",
|
||||
["InvoiceBalance"] = session.Sums.AmountTotal,
|
||||
["amount_open"] = session.Sums.AmountOpen
|
||||
}
|
||||
};
|
||||
|
||||
var d = new Dictionary<string, object>
|
||||
{
|
||||
["Id"] = session.RemId,
|
||||
["type"] = Str(session.Rem["type"]).ne("R"),
|
||||
["subject"] = Str(session.New["subject"]),
|
||||
["SendToAddress"] = Str(session.New["invoiceaddress"]),
|
||||
["SendToEmail"] = Str(session.New["invoiceemail"]),
|
||||
["InvoiceId"] = invoiceId,
|
||||
["amount"] = session.Sums.AmountTotal,
|
||||
["amount_payed"] = session.Sums.AmountPayed,
|
||||
["amount_open"] = session.Sums.AmountOpen,
|
||||
["PaymentTerm"] = Str(session.Rem["paymentterm"]),
|
||||
["invoices"] = invoices,
|
||||
["CustomValues"] = Str(session.New["CustomValues"]),
|
||||
["IsFinal"] = false,
|
||||
["DateCreated"] = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
|
||||
};
|
||||
return new GenericObjectDictionary(d);
|
||||
}
|
||||
|
||||
// ── token helpers ─────────────────────────────────────────────────────────
|
||||
private static string Str(JToken? t) =>
|
||||
t == null || t.Type == JTokenType.Null ? "" : t.Type == JTokenType.String ? t.Value<string>() ?? "" : t.ToString();
|
||||
|
||||
private static JObject TryParseObject(string json)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(json) && json.TrimStart().StartsWith('{'))
|
||||
{
|
||||
try { return JObject.Parse(json); } catch { /* fall through */ }
|
||||
}
|
||||
return new JObject();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user