Merge pull request 'Add backend-authoritative invoice draft editing (ADR 0006/0007)' (#1) from feature/backend-authoritative-draft-editing into main
Playwright Tests / test (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
Reviewed-on: http://localhost:3000/Stefan/Fuchs_Intranet/pulls/1
This commit was merged in pull request #1.
This commit is contained in:
@@ -47,8 +47,8 @@
|
|||||||
|
|
||||||
## Services & Dependency Injection
|
## 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.
|
- 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`.
|
- `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`) are scoped. Register in `Program.cs`.
|
- 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.
|
- `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.
|
- Data access stays SQL-first via OCORE helpers (`getSQLDataSet_async`, `setSQLValue_async`) + stored procedures; no EF Core.
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
|
|
||||||
## Services & Dependency Injection
|
## 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.
|
- 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.
|
- `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).
|
- Data access is SQL-first via OCORE helpers + stored procedures (no EF Core).
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
|
|
||||||
## Services & Dependency Injection
|
## 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.
|
- 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.
|
- `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).
|
- 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"]);
|
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 ────────────────────────────────────────────────
|
// ── Field-list edge cases ────────────────────────────────────────────────
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("")]
|
[InlineData("")]
|
||||||
|
|||||||
@@ -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>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
|
||||||
<PackageReference Include="xunit" Version="2.9.3" />
|
<PackageReference Include="xunit.v3" Version="3.2.2" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<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,842 @@
|
|||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Fuchs.intranet;
|
||||||
|
using Fuchs.Services;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using MigraDoc.DocumentObjectModel;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using OCORE.security;
|
||||||
|
using Xunit;
|
||||||
|
using static OCORE.OCORE_dictionaries;
|
||||||
|
|
||||||
|
namespace Fuchs.Tests;
|
||||||
|
|
||||||
|
/// <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_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.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using Fuchs.intranet;
|
using Fuchs.intranet;
|
||||||
@@ -31,16 +31,25 @@ public class InvoiceOptionsTests
|
|||||||
=> Assert.Equal("", InvoiceOptionsFor(new { type = "r" }));
|
=> Assert.Equal("", InvoiceOptionsFor(new { type = "r" }));
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void DefaultSetPrice_OmitsToken()
|
public void ExplicitDefaultSetPrice_EmitsToken()
|
||||||
=> Assert.Equal("", InvoiceOptionsFor(new { type = "r", setmode = "setprice" }));
|
// 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]
|
[Theory]
|
||||||
[InlineData("itemprices", "setmode:itemprices")]
|
|
||||||
[InlineData("setonly", "setmode:setonly")]
|
[InlineData("setonly", "setmode:setonly")]
|
||||||
[InlineData("ITEMPRICES", "setmode:itemprices")] // case-insensitive
|
[InlineData("SETONLY", "setmode:setonly")] // case-insensitive
|
||||||
public void SetMode_EmitsToken(string mode, string expected)
|
public void SetMode_EmitsToken(string mode, string expected)
|
||||||
=> Assert.Equal(expected, InvoiceOptionsFor(new { type = "r", setmode = mode }));
|
=> 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]
|
[Fact]
|
||||||
public void UnknownSetMode_OmitsToken()
|
public void UnknownSetMode_OmitsToken()
|
||||||
=> Assert.Equal("", InvoiceOptionsFor(new { type = "r", setmode = "garbage" }));
|
=> Assert.Equal("", InvoiceOptionsFor(new { type = "r", setmode = "garbage" }));
|
||||||
@@ -103,9 +112,11 @@ public class InvoiceOptionsTests
|
|||||||
public void ModeFromInvoiceOptions_RoundTripsBackendEmission()
|
public void ModeFromInvoiceOptions_RoundTripsBackendEmission()
|
||||||
{
|
{
|
||||||
// The token this side emits must parse back to the same mode on the PDF side.
|
// 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,
|
Assert.Equal(SetDisplayMode.SetOnly,
|
||||||
InvoiceSetPricing.ModeFromInvoiceOptions(InvoiceOptionsFor(new { type = "r", p13b = true, setmode = "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" })));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using Fuchs.intranet;
|
using Fuchs.intranet;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
@@ -40,11 +40,11 @@ public class InvoiceSetPricingTests
|
|||||||
|
|
||||||
// ── Mode parsing ────────────────────────────────────────────────────────
|
// ── Mode parsing ────────────────────────────────────────────────────────
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("itemprices", SetDisplayMode.ItemPrices)]
|
|
||||||
[InlineData("items", SetDisplayMode.ItemPrices)]
|
|
||||||
[InlineData("setonly", SetDisplayMode.SetOnly)]
|
[InlineData("setonly", SetDisplayMode.SetOnly)]
|
||||||
|
[InlineData("set_only", SetDisplayMode.SetOnly)]
|
||||||
[InlineData("setprice", SetDisplayMode.SetPrice)]
|
[InlineData("setprice", SetDisplayMode.SetPrice)]
|
||||||
[InlineData("", SetDisplayMode.SetPrice)]
|
[InlineData("", SetDisplayMode.SetPrice)]
|
||||||
|
[InlineData("itemprices", SetDisplayMode.SetPrice)] // removed mode — falls back to default, never throws
|
||||||
[InlineData("garbage", SetDisplayMode.SetPrice)]
|
[InlineData("garbage", SetDisplayMode.SetPrice)]
|
||||||
public void ParseMode_Works(string raw, SetDisplayMode expected)
|
public void ParseMode_Works(string raw, SetDisplayMode expected)
|
||||||
=> Assert.Equal(expected, InvoiceSetPricing.ParseMode(raw));
|
=> Assert.Equal(expected, InvoiceSetPricing.ParseMode(raw));
|
||||||
@@ -52,10 +52,12 @@ public class InvoiceSetPricingTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void ModeFromInvoiceOptions_ReadsToken()
|
public void ModeFromInvoiceOptions_ReadsToken()
|
||||||
{
|
{
|
||||||
Assert.Equal(SetDisplayMode.ItemPrices, InvoiceSetPricing.ModeFromInvoiceOptions("§13b,setmode:itemprices"));
|
Assert.Equal(SetDisplayMode.SetOnly, InvoiceSetPricing.ModeFromInvoiceOptions("§13b,setmode:setonly"));
|
||||||
Assert.Equal(SetDisplayMode.SetOnly, InvoiceSetPricing.ModeFromInvoiceOptions("setmode:setonly"));
|
Assert.Equal(SetDisplayMode.SetPrice, InvoiceSetPricing.ModeFromInvoiceOptions("§13b")); // default
|
||||||
Assert.Equal(SetDisplayMode.SetPrice, InvoiceSetPricing.ModeFromInvoiceOptions("§13b")); // default
|
Assert.Equal(SetDisplayMode.SetPrice, InvoiceSetPricing.ModeFromInvoiceOptions(null));
|
||||||
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]
|
[Fact]
|
||||||
@@ -85,22 +87,6 @@ public class InvoiceSetPricingTests
|
|||||||
Assert.Equal(50.00m, lines[3].TotalNet);
|
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 ──────────────────────────────────────────────
|
// ── SetOnly: members removed ──────────────────────────────────────────────
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Build_SetOnly_RemovesMembers()
|
public void Build_SetOnly_RemovesMembers()
|
||||||
@@ -115,18 +101,45 @@ public class InvoiceSetPricingTests
|
|||||||
Assert.Equal("Anfahrt", lines[1].Title);
|
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]
|
[Fact]
|
||||||
public void Build_HeaderTotalZero_UsesSumOfMembers()
|
public void Build_HeaderTotalZero_UnconvertedSet_HeaderBlankMembersPriced()
|
||||||
{
|
{
|
||||||
var items = new List<Dictionary<string, object?>>
|
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", "A", "120.00", "120.00"),
|
||||||
Member("7", "B", "80.00", "80.00")
|
Member("7", "B", "80.00", "80.00")
|
||||||
};
|
};
|
||||||
var lines = InvoiceSetPricing.Build(items, SetDisplayMode.SetPrice);
|
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 ───────────────────────────────────────
|
// ── No sets: pass-through unchanged ───────────────────────────────────────
|
||||||
@@ -164,7 +177,7 @@ public class InvoiceSetPricingTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Build_TextLine_AsSetMember_NoPriceEvenInItemPrices()
|
public void Build_TextLine_AsSetMember_NoPriceInSetPriceMode()
|
||||||
{
|
{
|
||||||
var items = new List<Dictionary<string, object?>>
|
var items = new List<Dictionary<string, object?>>
|
||||||
{
|
{
|
||||||
@@ -172,10 +185,10 @@ public class InvoiceSetPricingTests
|
|||||||
new() { ["type"] = "title", ["setId"] = "10", ["title"] = "Hinweis", ["total_net"] = "" },
|
new() { ["type"] = "title", ["setId"] = "10", ["title"] = "Hinweis", ["total_net"] = "" },
|
||||||
Member("10", "Waschbecken", "600.00", "600.00")
|
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");
|
var note = lines.First(l => l.Title == "Hinweis");
|
||||||
Assert.False(note.ShowPrice); // text member stays blank
|
Assert.False(note.ShowPrice); // text member stays blank (already blank in SetPrice mode)
|
||||||
Assert.True(lines.First(l => l.Title == "Waschbecken").ShowPrice);
|
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) ─
|
// ── Set price equals sum of member prices across modes (no double counting) ─
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Diagnostics.Metrics;
|
using System.Diagnostics.Metrics;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Threading;
|
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 sealed class StubHttpClientFactory : IHttpClientFactory
|
||||||
{
|
{
|
||||||
private readonly HttpMessageHandler _handler;
|
private readonly HttpMessageHandler _handler;
|
||||||
@@ -55,6 +62,41 @@ public class ProcessWebComServiceTests
|
|||||||
public HttpClient CreateClient(string name) => new(_handler, disposeHandler: false);
|
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)
|
private static ProcessWebComService CreateService(StubHandler handler, bool enabled = true, string? overrideRecipient = null)
|
||||||
{
|
{
|
||||||
var settings = Options.Create(new ProcessWebComSettings
|
var settings = Options.Create(new ProcessWebComSettings
|
||||||
@@ -129,6 +171,102 @@ public class ProcessWebComServiceTests
|
|||||||
Assert.Equal(0, handler.CallCount);
|
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 ─────────────────────────────────
|
// ── Dev/test recipient override safety net ─────────────────────────────────
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task SendEmailAsync_OverrideRecipientSet_RedirectsToOverrideAddress()
|
public async Task SendEmailAsync_OverrideRecipientSet_RedirectsToOverrideAddress()
|
||||||
@@ -141,7 +279,7 @@ public class ProcessWebComServiceTests
|
|||||||
Assert.True(result);
|
Assert.True(result);
|
||||||
Assert.Equal(1, handler.CallCount);
|
Assert.Equal(1, handler.CallCount);
|
||||||
var json = JObject.Parse(handler.LastRequestBody!);
|
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]
|
[Fact]
|
||||||
@@ -153,7 +291,7 @@ public class ProcessWebComServiceTests
|
|||||||
await svc.SendEmailAsync("inv_ov2", "Rechnung 123", "<p>hi</p>", "realcustomer@example.de", "Kunde");
|
await svc.SendEmailAsync("inv_ov2", "Rechnung 123", "<p>hi</p>", "realcustomer@example.de", "Kunde");
|
||||||
|
|
||||||
var json = JObject.Parse(handler.LastRequestBody!);
|
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("realcustomer@example.de", subject);
|
||||||
Assert.Contains("Rechnung 123", subject);
|
Assert.Contains("Rechnung 123", subject);
|
||||||
}
|
}
|
||||||
@@ -169,7 +307,7 @@ public class ProcessWebComServiceTests
|
|||||||
Assert.True(result);
|
Assert.True(result);
|
||||||
Assert.Equal(1, handler.CallCount);
|
Assert.Equal(1, handler.CallCount);
|
||||||
var json = JObject.Parse(handler.LastRequestBody!);
|
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]
|
[Fact]
|
||||||
@@ -181,7 +319,7 @@ public class ProcessWebComServiceTests
|
|||||||
await svc.SendEmailAsync("inv_ov4", "Subject", "<p>hi</p>", "realcustomer@example.de", "Kunde");
|
await svc.SendEmailAsync("inv_ov4", "Subject", "<p>hi</p>", "realcustomer@example.de", "Kunde");
|
||||||
|
|
||||||
var json = JObject.Parse(handler.LastRequestBody!);
|
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 ───
|
// ── 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
|
// 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.
|
// 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.
|
// 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
|
// satisfied by asserting that field no longer carries the original recipient
|
||||||
// once an override is configured.
|
// once an override is configured.
|
||||||
public static IEnumerable<object[]> AppSettingsEnvironments()
|
public static IEnumerable<object[]> AppSettingsEnvironments()
|
||||||
@@ -236,7 +374,7 @@ public class ProcessWebComServiceTests
|
|||||||
Assert.True(result);
|
Assert.True(result);
|
||||||
Assert.Equal(1, handler.CallCount);
|
Assert.Equal(1, handler.CallCount);
|
||||||
var json = JObject.Parse(handler.LastRequestBody!);
|
var json = JObject.Parse(handler.LastRequestBody!);
|
||||||
string sentRecipient = json["recipient"]!.ToString();
|
string sentRecipient = json["communication"]!["to"]!.ToString();
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(overrideRecipient))
|
if (!string.IsNullOrWhiteSpace(overrideRecipient))
|
||||||
{
|
{
|
||||||
@@ -266,7 +404,7 @@ public class ProcessWebComServiceTests
|
|||||||
Assert.True(result);
|
Assert.True(result);
|
||||||
Assert.NotNull(handler.LastRequestBody);
|
Assert.NotNull(handler.LastRequestBody);
|
||||||
var json = JObject.Parse(handler.LastRequestBody!);
|
var json = JObject.Parse(handler.LastRequestBody!);
|
||||||
var att = (JArray)json["attachments"]!;
|
var att = (JArray)json["communication"]!["attachments"]!;
|
||||||
Assert.Single(att);
|
Assert.Single(att);
|
||||||
Assert.Equal("Rechnung.pdf", att[0]!["filename"]!.ToString());
|
Assert.Equal("Rechnung.pdf", att[0]!["filename"]!.ToString());
|
||||||
Assert.Equal("application/pdf", att[0]!["mimeType"]!.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);
|
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]
|
[Fact]
|
||||||
public async Task ExecuteAsync_ProbeThrows_ServiceDoesNotThrowAndContinuesRemainingChecks()
|
public async Task ExecuteAsync_ProbeThrows_ServiceDoesNotThrowAndContinuesRemainingChecks()
|
||||||
{
|
{
|
||||||
@@ -250,8 +298,9 @@ public class StartupSelfTestServiceTests
|
|||||||
IServiceProvider serviceProvider,
|
IServiceProvider serviceProvider,
|
||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
IOptions<StartupSelfTestSettings> settings,
|
IOptions<StartupSelfTestSettings> settings,
|
||||||
Microsoft.Extensions.Logging.ILogger<StartupSelfTestService> logger)
|
Microsoft.Extensions.Logging.ILogger<StartupSelfTestService> logger,
|
||||||
: base(serviceProvider, configuration, settings, 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" });
|
: 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":
|
case "vfi":
|
||||||
{
|
{
|
||||||
if (!HasForm("invid")) return BadRequest400();
|
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 });
|
fds.FdsMfr.UpdateNeed.Reset, new[] { relId });
|
||||||
return await JSONAsync(new { ok = true });
|
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:
|
default:
|
||||||
_logger.LogWarning("Do_Process_Invoices: unhandled action id={Id}, user={User}", id, UserAccountID);
|
_logger.LogWarning("Do_Process_Invoices: unhandled action id={Id}, user={User}", id, UserAccountID);
|
||||||
return await JSONAsync(new { ok = true });
|
return await JSONAsync(new { ok = true });
|
||||||
|
|||||||
@@ -78,9 +78,17 @@ public partial class IntranetController
|
|||||||
var ldic = BuildInvoiceRequestList(sqldset);
|
var ldic = BuildInvoiceRequestList(sqldset);
|
||||||
var adminDic = sqldset.Table("admin").FirstRow.toObjectDictionary();
|
var adminDic = sqldset.Table("admin").FirstRow.toObjectDictionary();
|
||||||
var invDic = sqldset.Table("inv").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)
|
if (has13b)
|
||||||
adminDic["p13b"] = true;
|
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}",
|
_logger.LogDebug("HandleInvoiceGet invoiceId={InvoiceId} requestCount={ReqCount} has13b={Has13b} user={User}",
|
||||||
invoiceId, ldic.Count, has13b, UserAccountID);
|
invoiceId, ldic.Count, has13b, UserAccountID);
|
||||||
return await JSONAsync(new { admin = adminDic, inv = invDic, req = ldic });
|
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 "idoc": return await HandleReminderIdoc(fn, id, code);
|
||||||
case "resend": return await HandleReminderResend(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":
|
case "lrem":
|
||||||
{
|
{
|
||||||
if (!HasForm("id")) { _logger.LogWarning("Reminder lrem: missing form field 'id', user={User}", UserAccountID); return BadRequest400(); }
|
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.");
|
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)
|
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); }
|
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"
|
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)
|
? 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));
|
: _pdf.DocToPdfBytes(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft));
|
||||||
return ct != null
|
if (ct == null)
|
||||||
? await FileContentResultAsync(ct, "application/pdf", filename, inline: true)
|
return await InvoiceIssueResult("Die Rechnungs-PDF konnte aufgrund eines Fehlers nicht erstellt werden.", fdInv.Id);
|
||||||
: 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));
|
var imgcol = await _pdf.DocToImageCollectionAsync(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft));
|
||||||
return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
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 IInvoiceService _invoices;
|
||||||
private readonly IReminderService _reminders;
|
private readonly IReminderService _reminders;
|
||||||
private readonly IEventService _events;
|
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> _allowedNonAuth = new() { "spwc", "spw" };
|
||||||
private readonly List<string> _allowedGet = new()
|
private readonly List<string> _allowedGet = new()
|
||||||
{
|
{
|
||||||
@@ -62,7 +66,11 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
|||||||
IReportService reports,
|
IReportService reports,
|
||||||
IInvoiceService invoices,
|
IInvoiceService invoices,
|
||||||
IReminderService reminders,
|
IReminderService reminders,
|
||||||
IEventService events)
|
IEventService events,
|
||||||
|
IInvoiceDraftService invoiceDrafts,
|
||||||
|
IReminderDraftService reminderDrafts,
|
||||||
|
IDraftNotifier draftNotifier,
|
||||||
|
ISystemStatusService systemStatus)
|
||||||
{
|
{
|
||||||
_intranet = intranet;
|
_intranet = intranet;
|
||||||
_mfr = mfr;
|
_mfr = mfr;
|
||||||
@@ -76,6 +84,10 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
|||||||
_invoices = invoices;
|
_invoices = invoices;
|
||||||
_reminders = reminders;
|
_reminders = reminders;
|
||||||
_events = events;
|
_events = events;
|
||||||
|
_invoiceDrafts = invoiceDrafts;
|
||||||
|
_reminderDrafts = reminderDrafts;
|
||||||
|
_draftNotifier = draftNotifier;
|
||||||
|
_systemStatus = systemStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Merged query-string + form parameters (form wins) for report processing.</summary>
|
/// <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),
|
"rem" => await Do_Process_Reminder(fn, id, code),
|
||||||
"rep" => await Do_Process_Reports(fn, id, code),
|
"rep" => await Do_Process_Reports(fn, id, code),
|
||||||
"bam" => await Do_Process_Bankings(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),
|
"auth" => await HandleAuth(fn, id, code),
|
||||||
"spwc" => await HandleSendPasswordCode(fn, id, code),
|
"spwc" => await HandleSendPasswordCode(fn, id, code),
|
||||||
"spw" => await HandleSendPassword(fn, id, code),
|
"spw" => await HandleSendPassword(fn, id, code),
|
||||||
|
|||||||
+12
-10
@@ -12,7 +12,7 @@ The **Fuchs Intranet** solution is a line-of-business web application for **Seba
|
|||||||
| Project | Type | Purpose |
|
| Project | Type | Purpose |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **Fuchs** | ASP.NET Core Web (MVC) | Main web application — the intranet |
|
| **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`. |
|
| **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). |
|
| **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 |
|
| **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 — in Fuchs/Services; BackgroundService + │
|
||||||
│ PeriodicHostedService — BackgroundService with PeriodicTimer │
|
│ PeriodicTimer, registered in Program.cs │
|
||||||
|
│ when Fds:SyncEnabled (ADR 0010) │
|
||||||
│ FdsMfr.cs (IFdsMfr) — MFR sync orchestration │
|
│ FdsMfr.cs (IFdsMfr) — MFR sync orchestration │
|
||||||
│ FdsMfrClient.cs — MFR REST client wrapper │
|
│ FdsMfrClient.cs — MFR REST client wrapper │
|
||||||
│ FdsShared.cs — FdsConfig (appsettings.json reader) │
|
│ FdsShared.cs — FdsConfig (reads the host's IConfiguration) │
|
||||||
│ FdsZip.cs — 7-Zip archive handling (DATEV export) │
|
│ (DATEV zip) — OCORE.zip (System.IO.Compression), no 7-Zip │
|
||||||
│ FdsDebug.cs — Debug/file logging │
|
│ FdsDebug.cs — Debug/file logging │
|
||||||
│ │
|
│ │
|
||||||
│ Jobs: MfrSync (every N min) │
|
│ Jobs: MfrSync (every Fds:ExecutionFrequency_Minutes) │
|
||||||
│ → UpdateIfNecessary_async (entity table sync) │
|
│ → UpdateIfNecessary_async (entity table sync) │
|
||||||
│ → UpdateRequested_async (on-demand entity refresh) │
|
│ → UpdateRequested_async (on-demand entity refresh) │
|
||||||
│ → GetInvoiceFiles_async (invoice PDF download) │
|
│ → GetInvoiceFiles_async (invoice PDF download) │
|
||||||
@@ -167,8 +168,9 @@ OCORE_Charting (standalone — referenced by solution but no direct project ref
|
|||||||
|
|
||||||
### 4.3 Service Layer (Dependency Injection)
|
### 4.3 Service Layer (Dependency Injection)
|
||||||
Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces, injected into `IntranetController`:
|
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`.
|
||||||
Stateless services (`IPdfService`, `IBankingService`, `IMfrClientFactory`) are singletons; DB/request-scoped services are scoped (see `Program.cs`).
|
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()`).
|
`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.
|
`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 +178,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.
|
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
|
### 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
|
### 4.6 Authentication
|
||||||
Cookie-based authentication (`CookieAuthenticationDefaults`) with custom claims (`FuchsUserIdentity`). SQL-based user/password verification.
|
Cookie-based authentication (`CookieAuthenticationDefaults`) with custom claims (`FuchsUserIdentity`). SQL-based user/password verification.
|
||||||
@@ -409,7 +411,7 @@ public class MfrClientFactory : IMfrClientFactory, IDisposable
|
|||||||
2. ✅ **Resolved** — `FdsInvoiceData`/`FdsReminderData` are now pure data holders; DB + PDF logic moved to `IInvoiceService`/`IReminderService`.
|
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).
|
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`).
|
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,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.
|
||||||
@@ -1,5 +1,17 @@
|
|||||||
# Evaluation — Backend-cached invoice editing over SignalR
|
# 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
|
**Idea (as proposed):** hold invoices that users are editing in a **server-side
|
||||||
cache**, keep a **SignalR / WebSocket** connection open, apply each front-end
|
cache**, keep a **SignalR / WebSocket** connection open, apply each front-end
|
||||||
change **in the backend**, and **push the recomputed state back** to the browser.
|
change **in the backend**, and **push the recomputed state back** to the browser.
|
||||||
|
|||||||
@@ -94,8 +94,10 @@ payload; see `EVAL_live_invoice_editing.md` for the rationale.
|
|||||||
location/period (inline edit fields, `fm(...)` helper in `fis.inv_shared.js`).
|
location/period (inline edit fields, `fm(...)` helper in `fis.inv_shared.js`).
|
||||||
- **§13b reverse-charge** toggle (`$inv.sp13b`) — suppresses VAT lines/columns.
|
- **§13b reverse-charge** toggle (`$inv.sp13b`) — suppresses VAT lines/columns.
|
||||||
- **Set-pricing display mode** (`$inv.ssetmode` / `setSetmode`) — `SetPrice`
|
- **Set-pricing display mode** (`$inv.ssetmode` / `setSetmode`) — `SetPrice`
|
||||||
(default) / `ItemPrices` / `SetOnly`; see `INVOICE_SET_PRICING.md`. Purely
|
(default) / `SetOnly`; see `INVOICE_SET_PRICING.md`. Purely presentational
|
||||||
presentational — totals never change.
|
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`).
|
- **Contact person** for the invoice (`$inv.sctp`, stored in `CustomValues`).
|
||||||
|
|
||||||
All of this recalculates client-side totals live via the `fds.inv` event
|
All of this recalculates client-side totals live via the `fds.inv` event
|
||||||
@@ -122,7 +124,7 @@ On the server, `RegisterInvoiceAsync` (in `InvoiceService`) turns the posted
|
|||||||
JSON into SQL parameters and calls, in one batch:
|
JSON into SQL parameters and calls, in one batch:
|
||||||
- **New invoice**: `fds__createInvoice` (allocates the `Id`, returns a fresh
|
- **New invoice**: `fds__createInvoice` (allocates the `Id`, returns a fresh
|
||||||
row) → `fds__createInvoice_Details` (service net/VAT + `InvoiceOptions`,
|
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)
|
- **Existing draft**: `fds__setInvoice` (same parameter set, updates in place)
|
||||||
→ `fds__createInvoice_Details` again.
|
→ `fds__createInvoice_Details` again.
|
||||||
|
|
||||||
@@ -337,9 +339,18 @@ flowchart TD
|
|||||||
|
|
||||||
## 10. Key invariants worth remembering
|
## 10. Key invariants worth remembering
|
||||||
|
|
||||||
- **Stateless editor**: every preview/save/finalise call re-posts the full
|
> **⚠️ Updated (2026-07-10):** the "stateless editor" invariant below describes the
|
||||||
`invc` JSON; the server never holds a partial invoice in memory or session
|
> **legacy** draft-editing flow. Invoice draft editing is being moved to a
|
||||||
between requests (see `EVAL_live_invoice_editing.md`).
|
> **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`
|
- **Totals come from the registration, not the rendered lines**: `sms.ttn`
|
||||||
/`sms.ttb` (posted) become `InvoiceBalance`/`InvoiceBalance_net`; display
|
/`sms.ttb` (posted) become `InvoiceBalance`/`InvoiceBalance_net`; display
|
||||||
mode (set pricing) never changes what the customer owes.
|
mode (set pricing) never changes what the customer owes.
|
||||||
|
|||||||
@@ -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]`
|
> Governed by ADR [0009](Decisions/0009-block-setprice-modes-are-irreversible-mutations.md)
|
||||||
(`[Type] = 'set'`) should normally be shown as a single **set price** on the
|
> (the two menu modes) and ADR [0008](Decisions/0008-invoices-and-reminders-fully-backend-authoritative.md)
|
||||||
invoice instead of being broken up into their member items and summed.
|
> (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 |
|
| # | Operation | Trigger | Grouped by | Effect |
|
||||||
|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| **SetPrice** (default) | shown **with price** | shown **without price** | the new default |
|
| **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** |
|
||||||
| **ItemPrices** | shown as a heading **without price** | shown **with price** | the previous behaviour |
|
| **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** |
|
||||||
| **SetOnly** | shown **with price** | **removed** | compact |
|
| **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
|
Only operation **1** reads `SetItmId`. Operations **2** and **3** ignore it entirely; their only
|
||||||
> balance (`InvoiceBalance` / `InvoiceBalance_net`), not by summing the rendered
|
grouping key is the service request (the editor tbody block). See "All set-price functions" below
|
||||||
> lines, so switching modes is purely presentational. The set price always
|
for the full breakdown.
|
||||||
> equals the sum of its members (computed as a fallback when the set header
|
|
||||||
> carries no own price).
|
|
||||||
|
|
||||||
## 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:
|
- `Fuchs/code/InvoiceSetPricing.cs` — the authoritative transformation:
|
||||||
`SetDisplayMode` + `Build(items, mode)` → ordered `InvoiceSetLine`s, each with
|
`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
|
Wired in `Fuchs/js/intranet/modules/fis.inv_shared.js` (bundled to
|
||||||
`wwwroot/web/fis.inv.de.js` via gulp `min:js`):
|
`wwwroot/web/fis.inv.de.js` via gulp `min:js`):
|
||||||
|
|
||||||
1. **Mode** — a 3-way switch (`$inv.ssetmode`, menu entry `setm`, label
|
1. **Menu modes** — the "Set-Preisanzeige" menu (`$inv.ssetmode` → `$inv.setSetmode`,
|
||||||
`$ict.setm`) writes the choice onto `admin.setmode`
|
menu entry `setm`, label `$ict.setm`) offers "Set mit Preis" and "Nur Set mit
|
||||||
(`setprice` | `itemprices` | `setonly`). The back-end
|
Preis". Selecting one posts a **mutating delta** (grouped by service-request
|
||||||
`FdsInvoiceData.BuildInvoiceOptions` turns that into the
|
block) to `inv/dpatch`; the server rewrites the block's items in the cached
|
||||||
`setmode:<mode>` token inside `@InvoiceOptions` (default `setprice` omitted),
|
session (inserts the set row, blanks or removes members) and pushes the new
|
||||||
persisted by `fds__createInvoice_Details` and read back by
|
state back via `draftReady`/`dstate`. The browser performs **no** grouping or
|
||||||
`InvoiceSetPricing.ModeFromInvoiceOptions`. This rides the **same `admin`
|
pricing math (ADR 0008/0009) — it only posts the chosen mode and re-renders the
|
||||||
channel as `§13b`** (the posted payload is `{admin, req, sms, new}` — `inv`
|
server's `req`/`sums`. There is no persisted `admin.setmode` display flag for
|
||||||
is not sent).
|
these two modes.
|
||||||
|
|
||||||
2. **Item shape** — `$inv.invSumUpdate` now posts each request block's
|
2. **Item shape** — `$inv.invSumUpdate` now posts each request block's
|
||||||
`items[]` in the back-end contract shape via `$inv.itemToContract`:
|
`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
|
`FdsInvoiceData.InvoiceItems` does not read — so line items never reached the
|
||||||
C# PDF. This change closes that gap for **all** invoices, not just sets.)
|
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
|
3. **Set flags (function 1 only)** — `invSumUpdate` tags items as it builds `items[]`:
|
||||||
`type === 'set'` is a header (`id` = its set id); the **following items in the
|
an item with `type === 'set'` is a header (`id` = its set id); a member item's
|
||||||
same block become its members** (`setId` = the header's id) until the next set
|
`setId` is taken directly from the server-computed `SetItmId` field on the row
|
||||||
header. `mfr__items` has a `Type='set'` header but **no explicit member link**,
|
(`rrx.SetItmId`, populated by `fds__prepInvoice`'s `[SetItmID]` window function,
|
||||||
so this "header claims the following items in its block" rule is the convention
|
anchored on the still-unconverted, zero-priced `'set'` header that owns it) —
|
||||||
— adjust in `invSumUpdate` if mfr later exposes a real grouping.
|
**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`)
|
### Editor → backend field normalization (`$inv.invcPayload`)
|
||||||
The editor's internal model keeps the long-standing key names, but the migrated C#
|
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
|
`sms.vat`, so non-19 % rates are stored correctly. Single-rate procs still store only
|
||||||
the highest rate.
|
the highest rate.
|
||||||
|
|
||||||
The editor's running **total stays the member sum in every mode**, matching the
|
The editor's running **total is unaffected by any set-price operation**, matching
|
||||||
registration balance — switching modes is purely presentational.
|
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
|
### Why the trigger lives in the editor
|
||||||
Set grouping is only known where the request/item tree is rendered (front-end).
|
The choice of *when* to apply a set-price operation is only known where the invoice
|
||||||
The back-end intentionally stays the single, tested authority for *how* a chosen
|
is being composed (front-end), but the operation itself is executed **server-side**
|
||||||
mode maps to printed lines, so the editor only needs to pick the mode and tag the
|
against the cached draft session — the editor merely names the target (a set-item
|
||||||
items — it does not re-implement the pricing rules.
|
`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
|
## Persistence note
|
||||||
Draft/preview PDFs render straight from the posted `invc` JSON, so the contract
|
Draft/preview PDFs render straight from the cached draft session, so the contract
|
||||||
works end-to-end for previews and creation. `setmode` persists via
|
works end-to-end for previews and creation. The two menu modes bake their result
|
||||||
`InvoiceOptions`; the finalised document is rendered once and stored as a file, so
|
directly into the session's items (a set row plus blanked/removed members), so no
|
||||||
re-rendering from line items is not needed for correctness. Persisting the
|
`setmode:` display token is needed or persisted; the finalised document is
|
||||||
per-item `type`/`setId` flags (an SSDT + `fds__createInvoice_Details` change) is
|
rendered once and stored as a file. Persisting the per-item `type`/`setId` flags
|
||||||
only required if a finalised invoice must be **re-generated** from stored items in
|
(an SSDT + `fds__createInvoice_Details` change) is only required if a finalised
|
||||||
a different mode later — not done here.
|
invoice must be **re-generated** from stored items later — not done here.
|
||||||
|
|||||||
+6
-10
@@ -24,7 +24,6 @@
|
|||||||
<ProjectReference Include="..\CAMTParser\CAMTParser.csproj" />
|
<ProjectReference Include="..\CAMTParser\CAMTParser.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Content Include="code\7z.dll" CopyToOutputDirectory="PreserveNewest" />
|
|
||||||
<Content Include="Data\**" CopyToOutputDirectory="PreserveNewest" />
|
<Content Include="Data\**" CopyToOutputDirectory="PreserveNewest" />
|
||||||
<Content Include="favicon.ico" />
|
<Content Include="favicon.ico" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
@@ -34,18 +33,17 @@
|
|||||||
<PackageReference Include="HtmlAgilityPack" Version="1.12.4" />
|
<PackageReference Include="HtmlAgilityPack" Version="1.12.4" />
|
||||||
<PackageReference Include="MailKit" Version="4.17.0" />
|
<PackageReference Include="MailKit" Version="4.17.0" />
|
||||||
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
|
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
|
||||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.16.0" />
|
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.17.0" />
|
||||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.16.0" />
|
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.17.0" />
|
||||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.16.0" />
|
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.16.0" />
|
||||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" 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="OpenTelemetry.Instrumentation.SqlClient" Version="1.16.0" />
|
||||||
<PackageReference Include="Portable.BouncyCastle" Version="1.9.0" />
|
<PackageReference Include="Portable.BouncyCastle" Version="1.9.0" />
|
||||||
<PackageReference Include="QRCoder" Version="1.8.0" />
|
<PackageReference Include="QRCoder" Version="1.8.0" />
|
||||||
<PackageReference Include="PDFsharp" Version="6.2.4" />
|
<PackageReference Include="PDFsharp" Version="6.2.4" />
|
||||||
<PackageReference Include="PDFsharp-MigraDoc" 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="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 -->
|
<!-- Updated packages -->
|
||||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||||
<PackageReference Include="MimeKit" Version="4.17.0" />
|
<PackageReference Include="MimeKit" Version="4.17.0" />
|
||||||
@@ -53,8 +51,8 @@
|
|||||||
<!-- New packages (needed for .NET 10) -->
|
<!-- New packages (needed for .NET 10) -->
|
||||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.2" />
|
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.2" />
|
||||||
<PackageReference Include="SixLabors.ImageSharp" Version="4.0.0" />
|
<PackageReference Include="SixLabors.ImageSharp" Version="4.0.0" />
|
||||||
<PackageReference Include="System.Configuration.ConfigurationManager" Version="10.0.9" />
|
<PackageReference Include="System.Configuration.ConfigurationManager" Version="10.0.10" />
|
||||||
<PackageReference Include="System.Drawing.Common" Version="10.0.9" />
|
<PackageReference Include="System.Drawing.Common" Version="10.0.10" />
|
||||||
<PackageReference Include="Azure.Storage.Blobs" Version="12.29.1" />
|
<PackageReference Include="Azure.Storage.Blobs" Version="12.29.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -66,14 +64,12 @@
|
|||||||
|
|
||||||
|
|
||||||
<!-- UsingTask für plattformneutrales Zippen nach Publish -->
|
<!-- 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>
|
<ParameterGroup>
|
||||||
<Source ParameterType="System.String" Required="true" />
|
<Source ParameterType="System.String" Required="true" />
|
||||||
<Destination ParameterType="System.String" Required="true" />
|
<Destination ParameterType="System.String" Required="true" />
|
||||||
</ParameterGroup>
|
</ParameterGroup>
|
||||||
<Task>
|
<Task>
|
||||||
<Reference Include="System.IO.Compression" />
|
|
||||||
<Reference Include="System.IO.Compression.FileSystem" />
|
|
||||||
<Code Type="Fragment" Language="cs">
|
<Code Type="Fragment" Language="cs">
|
||||||
<![CDATA[
|
<![CDATA[
|
||||||
if (System.IO.File.Exists(Destination)) {
|
if (System.IO.File.Exists(Destination)) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ public enum DomainEventType
|
|||||||
InvoiceFileCreationFailed,
|
InvoiceFileCreationFailed,
|
||||||
InvoiceSendFailed,
|
InvoiceSendFailed,
|
||||||
ReminderDraftCreated,
|
ReminderDraftCreated,
|
||||||
|
ReminderDraftUpdated,
|
||||||
ReminderFileCreated,
|
ReminderFileCreated,
|
||||||
ReminderSentToCustomer,
|
ReminderSentToCustomer,
|
||||||
ReminderResentToCustomer,
|
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)
|
public Task ReminderDraftCreatedAsync(FdsReminderData reminder, string userAccountId)
|
||||||
=> PublishAsync(new DomainEvent(DomainEventType.ReminderDraftCreated, userAccountId, "Mahnentwurf", ReminderContext(reminder)));
|
=> 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)
|
public Task ReminderFileCreatedAsync(FdsReminderData reminder, string fileName, string userAccountId)
|
||||||
{
|
{
|
||||||
var ctx = ReminderContext(reminder);
|
var ctx = ReminderContext(reminder);
|
||||||
@@ -176,6 +182,8 @@ public sealed class EventService : IEventService
|
|||||||
Ctx(domainEvent, "message"),
|
Ctx(domainEvent, "message"),
|
||||||
DomainEventType.ReminderDraftCreated =>
|
DomainEventType.ReminderDraftCreated =>
|
||||||
$"Mahnentwurf {Ctx(domainEvent, "title")} wurde erstellt.",
|
$"Mahnentwurf {Ctx(domainEvent, "title")} wurde erstellt.",
|
||||||
|
DomainEventType.ReminderDraftUpdated =>
|
||||||
|
$"Mahnentwurf {Ctx(domainEvent, "title")} wurde aktualisiert.",
|
||||||
DomainEventType.ReminderFileCreated =>
|
DomainEventType.ReminderFileCreated =>
|
||||||
$"Mahndatei {Ctx(domainEvent, "fileName")} wurde erstellt.",
|
$"Mahndatei {Ctx(domainEvent, "fileName")} wurde erstellt.",
|
||||||
DomainEventType.ReminderSentToCustomer =>
|
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 InvoiceIssueAsync(string message, string userAccountId, string invoiceId = "");
|
||||||
|
|
||||||
Task ReminderDraftCreatedAsync(FdsReminderData reminder, string userAccountId);
|
Task ReminderDraftCreatedAsync(FdsReminderData reminder, string userAccountId);
|
||||||
|
Task ReminderDraftRegisteredAsync(FdsReminderData reminder, bool changed, string userAccountId);
|
||||||
Task ReminderFileCreatedAsync(FdsReminderData reminder, string fileName, string userAccountId);
|
Task ReminderFileCreatedAsync(FdsReminderData reminder, string fileName, string userAccountId);
|
||||||
Task ReminderSentToCustomerAsync(FdsReminderData reminder, string email, string userAccountId, bool resent = false);
|
Task ReminderSentToCustomerAsync(FdsReminderData reminder, string email, string userAccountId, bool resent = false);
|
||||||
Task ReminderMarkedSentAsync(string reminderId, string reminderTitle, string userAccountId);
|
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.");
|
Meter.CreateCounter<long>("fuchs.blobstorage.uploads", "{upload}", "Number of documents successfully archived to Azure Blob Storage.");
|
||||||
public static readonly Counter<long> BlobUploadsFailed =
|
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.");
|
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) ───────────────────
|
// ── Performance histograms (durations in milliseconds) ───────────────────
|
||||||
public static readonly Histogram<double> PdfRenderDuration =
|
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
|
// FDS MFR singleton — ILogger<FdsMfr> and ILoggerFactory are supplied by the ASP.NET Core DI container
|
||||||
builder.Services.AddSingleton<fds.IFdsMfr, fds.FdsMfr>();
|
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
|
// MVC with Razor view support
|
||||||
builder.Services.AddControllersWithViews();
|
builder.Services.AddControllersWithViews();
|
||||||
builder.Services.AddSignalR();
|
builder.Services.AddSignalR();
|
||||||
@@ -98,6 +123,9 @@ public class Program
|
|||||||
builder.Services.Configure<StartupSelfTestSettings>(builder.Configuration.GetSection("Fuchs:StartupChecks"));
|
builder.Services.Configure<StartupSelfTestSettings>(builder.Configuration.GetSection("Fuchs:StartupChecks"));
|
||||||
builder.Services.AddHttpClient("ProcessWebMailer");
|
builder.Services.AddHttpClient("ProcessWebMailer");
|
||||||
builder.Services.AddScoped<IComService, ProcessWebComService>();
|
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>();
|
builder.Services.AddHostedService<StartupSelfTestService>();
|
||||||
|
|
||||||
// Business services (DI migration — replaces the static helper / Active-Record pattern)
|
// Business services (DI migration — replaces the static helper / Active-Record pattern)
|
||||||
@@ -110,6 +138,24 @@ public class Program
|
|||||||
builder.Services.AddScoped<IReminderService, ReminderService>();
|
builder.Services.AddScoped<IReminderService, ReminderService>();
|
||||||
builder.Services.AddScoped<IEventService, EventService>();
|
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.
|
// Secondary archive: invoice/reminder PDFs additionally stored in Azure Blob Storage.
|
||||||
// Disabled by default (Fuchs:AzureStorage:Enabled) — see AzureBlobStorageService.
|
// Disabled by default (Fuchs:AzureStorage:Enabled) — see AzureBlobStorageService.
|
||||||
builder.Services.Configure<AzureBlobStorageSettings>(builder.Configuration.GetSection("Fuchs:AzureStorage"));
|
builder.Services.Configure<AzureBlobStorageSettings>(builder.Configuration.GetSection("Fuchs:AzureStorage"));
|
||||||
@@ -184,6 +230,7 @@ public class Program
|
|||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
app.MapHub<NotificationHub>("/notifications");
|
app.MapHub<NotificationHub>("/notifications");
|
||||||
|
app.MapHub<DraftPreviewHub>("/draftpreview");
|
||||||
|
|
||||||
// Intranet routes (root-level — this IS the website)
|
// Intranet routes (root-level — this IS the website)
|
||||||
app.MapControllerRoute(
|
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) =>
|
private static string BuildBlobName(string documentId, string fileName) =>
|
||||||
$"{documentId}/{(string.IsNullOrWhiteSpace(fileName) ? $"{documentId}.pdf" : fileName)}";
|
$"{documentId}/{(string.IsNullOrWhiteSpace(fileName) ? $"{documentId}.pdf" : fileName)}";
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ public static class DocumentMetadataBuilder
|
|||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(field)) continue;
|
if (string.IsNullOrWhiteSpace(field)) continue;
|
||||||
if (!TryGetValue(row, field, out object? value)) continue;
|
if (!TryGetValue(row, field, out object? value)) continue;
|
||||||
metadata[field] = Stringify(value);
|
metadata[field] = ToAsciiHeaderValue(Stringify(value));
|
||||||
}
|
}
|
||||||
return metadata;
|
return metadata;
|
||||||
}
|
}
|
||||||
@@ -55,4 +55,24 @@ public static class DocumentMetadataBuilder
|
|||||||
DateTime dt => dt.ToString("O"),
|
DateTime dt => dt.ToString("O"),
|
||||||
_ => value.ToString() ?? ""
|
_ => 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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,4 +44,49 @@ public interface IBlobStorageService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
Task<bool> ExistsAsync(string containerName, string documentId, string fileName,
|
Task<bool> ExistsAsync(string containerName, string documentId, string fileName,
|
||||||
CancellationToken cancellationToken = default);
|
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>
|
/// <summary>
|
||||||
/// Abstraction for outbound communication: email and SMS.
|
/// 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>
|
/// </summary>
|
||||||
public interface IComService
|
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();
|
||||||
|
}
|
||||||
@@ -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,667 @@
|
|||||||
|
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": return 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>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"]),
|
||||||
|
["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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,10 +63,64 @@ public class InvoiceService : IInvoiceService
|
|||||||
|
|
||||||
inv.InvoiceRegistration = new GenericObjectDictionary(dset.Table("inv").FirstRow.toObjectDictionary());
|
inv.InvoiceRegistration = new GenericObjectDictionary(dset.Table("inv").FirstRow.toObjectDictionary());
|
||||||
inv.IsDraft = inv.InvoiceRegistration.getItem("IsFinal", false) is not true;
|
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;
|
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,
|
public async Task<FdsInvoiceData> RegisterInvoiceAsync(FdsInvoiceData invoice, bool change, string invId,
|
||||||
string userAccountId, DatabaseSecurity dbSec)
|
string userAccountId, DatabaseSecurity dbSec)
|
||||||
{
|
{
|
||||||
@@ -212,7 +266,7 @@ public class InvoiceService : IInvoiceService
|
|||||||
var reg = invoice.InvoiceRegistration;
|
var reg = invoice.InvoiceRegistration;
|
||||||
var tb = new FuchsPdf.FdsTextBlocks
|
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
|
Address = reg?.getString("SendToAddress") is { Length: > 0 } sa
|
||||||
? sa.Replace("<br>", "\n").Replace("<br/>", "\n").Split('\n').Select(t => t.Trim()).ToArray()
|
? sa.Replace("<br>", "\n").Replace("<br/>", "\n").Split('\n').Select(t => t.Trim()).ToArray()
|
||||||
: Array.Empty<string>(),
|
: Array.Empty<string>(),
|
||||||
|
|||||||
+4
-2
@@ -1,7 +1,7 @@
|
|||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace fds;
|
namespace Fuchs.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Defines a named job with its own execution schedule for use with <see cref="PeriodicHostedService"/>.
|
/// Defines a named job with its own execution schedule for use with <see cref="PeriodicHostedService"/>.
|
||||||
@@ -13,6 +13,8 @@ public sealed record PeriodicJobDefinition(
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A <see cref="BackgroundService"/> that runs multiple independent jobs, each on its own <see cref="PeriodicTimer"/>.
|
/// 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>
|
/// </summary>
|
||||||
public sealed class PeriodicHostedService : BackgroundService
|
public sealed class PeriodicHostedService : BackgroundService
|
||||||
{
|
{
|
||||||
@@ -14,7 +14,7 @@ namespace Fuchs.Services;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Outbound communication service backed by the ProcessWeb Mailer API
|
/// 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
|
/// When <c>ProcessWebComSettings.Enabled</c> is <c>false</c> the service
|
||||||
/// only logs the intended communication without calling the API.
|
/// only logs the intended communication without calling the API.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -71,6 +71,9 @@ public class ProcessWebComService : IComService
|
|||||||
_logger.LogWarning("SendEmailAsync: invalid email address '{Email}' for ref {Reference}", email, reference);
|
_logger.LogWarning("SendEmailAsync: invalid email address '{Email}' for ref {Reference}", email, reference);
|
||||||
FuchsTelemetry.EmailsFailed.Add(1, new KeyValuePair<string, object?>("reason", "invalid-email"));
|
FuchsTelemetry.EmailsFailed.Add(1, new KeyValuePair<string, object?>("reason", "invalid-email"));
|
||||||
act?.SetStatus(ActivityStatusCode.Error, "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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,10 +108,9 @@ public class ProcessWebComService : IComService
|
|||||||
})
|
})
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
var payload = new
|
var communication = new
|
||||||
{
|
{
|
||||||
comType = "email",
|
to = email,
|
||||||
recipient = email,
|
|
||||||
subject,
|
subject,
|
||||||
body,
|
body,
|
||||||
attachments = attachmentPayload
|
attachments = attachmentPayload
|
||||||
@@ -117,7 +119,7 @@ public class ProcessWebComService : IComService
|
|||||||
_logger.LogDebug("SendEmailAsync ref={Reference} to={Email} attachments={Count}",
|
_logger.LogDebug("SendEmailAsync ref={Reference} to={Email} attachments={Count}",
|
||||||
reference, email, attachmentPayload.Length);
|
reference, email, attachmentPayload.Length);
|
||||||
|
|
||||||
var (ok, responseBody) = await PostToApiAsync("push_com", payload);
|
var (ok, status, responseBody) = await PostToApiAsync(reference, "e", communication);
|
||||||
if (ok)
|
if (ok)
|
||||||
{
|
{
|
||||||
success = true;
|
success = true;
|
||||||
@@ -125,13 +127,17 @@ public class ProcessWebComService : IComService
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
errors.Add($"API error: {responseBody}");
|
// Persist the mailer's raw response so an unsuccessful send is diagnosable from the log.
|
||||||
_logger.LogWarning("SendEmailAsync API error for {Reference}: {Body}", reference, responseBody);
|
errors.Add($"API error {(int)status} ({status}): {responseBody}");
|
||||||
|
_logger.LogWarning("SendEmailAsync API error for {Reference}: {Status} {Body}",
|
||||||
|
reference, (int)status, responseBody);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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);
|
act?.SetStatus(ActivityStatusCode.Error, ex.Message);
|
||||||
_logger.LogError(ex, "SendEmailAsync failed for {Reference}", reference);
|
_logger.LogError(ex, "SendEmailAsync failed for {Reference}", reference);
|
||||||
}
|
}
|
||||||
@@ -166,14 +172,13 @@ public class ProcessWebComService : IComService
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var payload = new
|
var communication = new
|
||||||
{
|
{
|
||||||
comType = "sms",
|
to = mobile,
|
||||||
recipient = mobile,
|
body = message
|
||||||
body = message
|
|
||||||
};
|
};
|
||||||
|
|
||||||
var (ok, responseBody) = await PostToApiAsync("push_com", payload);
|
var (ok, status, responseBody) = await PostToApiAsync(mobile, "s", communication);
|
||||||
if (ok)
|
if (ok)
|
||||||
{
|
{
|
||||||
FuchsTelemetry.SmsSent.Add(1);
|
FuchsTelemetry.SmsSent.Add(1);
|
||||||
@@ -181,7 +186,7 @@ public class ProcessWebComService : IComService
|
|||||||
return true;
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -193,10 +198,20 @@ public class ProcessWebComService : IComService
|
|||||||
|
|
||||||
// ── Private helpers ────────────────────────────────────────────────────────
|
// ── 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");
|
var client = _httpClientFactory.CreateClient("ProcessWebMailer");
|
||||||
var json = JsonConvert.SerializeObject(payload);
|
|
||||||
|
// 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");
|
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||||
|
|
||||||
string credentials = Convert.ToBase64String(
|
string credentials = Convert.ToBase64String(
|
||||||
@@ -204,25 +219,23 @@ public class ProcessWebComService : IComService
|
|||||||
client.DefaultRequestHeaders.Authorization =
|
client.DefaultRequestHeaders.Authorization =
|
||||||
new AuthenticationHeaderValue("Basic", credentials);
|
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();
|
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)
|
DateTime sent, bool success, IEnumerable<string> errors)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var pl = new List<SqlParameter>
|
var pl = BuildAuditLogParameters(reference, guid, config, sent, success, errors);
|
||||||
{
|
|
||||||
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()))
|
|
||||||
};
|
|
||||||
await setSQLValue_async(
|
await setSQLValue_async(
|
||||||
"EXECUTE [dbo].[fds__logEmail] @Ref, @guid, @DateSent, @config, @success, @log;",
|
"EXECUTE [dbo].[fds__logEmail] @Ref, @guid, @DateSent, @config, @success, @log;",
|
||||||
_intranet.Intranet__SQLConnectionString, pl,
|
_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()
|
private string BuildSignature()
|
||||||
{
|
{
|
||||||
string sigPath = Path.Combine(AppContext.BaseDirectory,
|
string sigPath = Path.Combine(AppContext.BaseDirectory,
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ public class ProcessWebComSettings
|
|||||||
/// <summary>API token used for HTTP Basic authentication.</summary>
|
/// <summary>API token used for HTTP Basic authentication.</summary>
|
||||||
public string Token { get; set; } = "";
|
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>
|
/// <summary>
|
||||||
/// When <c>false</c> (default) the service is disabled and only logs the
|
/// When <c>false</c> (default) the service is disabled and only logs the
|
||||||
/// intended communication without actually calling the API.
|
/// 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
using Fuchs.Notifications;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Fuchs.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Background monitor for the reminder draft cache (ADR 0006) — the reminder mirror of
|
||||||
|
/// <see cref="InvoiceDraftExpiryService"/>. 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 shared <see cref="DraftPreviewHub"/>
|
||||||
|
/// via <see cref="IDraftNotifier"/> (the token-keyed groups serve invoices and reminders alike).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ReminderDraftExpiryService : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly IReminderDraftCache _cache;
|
||||||
|
private readonly IDraftNotifier _notifier;
|
||||||
|
private readonly ILogger<ReminderDraftExpiryService> _logger;
|
||||||
|
private readonly TimeSpan _warnLead;
|
||||||
|
private readonly TimeSpan _interval;
|
||||||
|
|
||||||
|
public ReminderDraftExpiryService(IReminderDraftCache cache, IDraftNotifier notifier,
|
||||||
|
IConfiguration configuration, ILogger<ReminderDraftExpiryService> 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("Reminder 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,46 @@
|
|||||||
|
namespace Fuchs.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Process-lifetime holder for the result of the one-shot <see cref="StartupSelfTestService"/> run.
|
||||||
|
/// Registered as a singleton: the startup service writes the report once at boot, and the Admin
|
||||||
|
/// module reads it back (via <see cref="ISystemStatusService.GetInfo"/>) as a non-refreshable
|
||||||
|
/// widget — the checks are inherently a startup event and are never re-run per request.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class StartupCheckReporter
|
||||||
|
{
|
||||||
|
private volatile StartupCheckReport? _report;
|
||||||
|
|
||||||
|
/// <summary>The latest (and only) startup report, or <c>null</c> if the service has not run yet.</summary>
|
||||||
|
public StartupCheckReport? Latest => _report;
|
||||||
|
|
||||||
|
public void Set(StartupCheckReport report) => _report = report;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Snapshot of a completed startup self-test run. Serialized to the Admin frontend.</summary>
|
||||||
|
public sealed class StartupCheckReport
|
||||||
|
{
|
||||||
|
/// <summary><c>false</c> when <c>Fuchs:StartupChecks:Enabled</c> was off, so nothing was probed.</summary>
|
||||||
|
public bool Ran { get; init; }
|
||||||
|
|
||||||
|
/// <summary>When the run finished (UTC); <c>null</c> when it did not run.</summary>
|
||||||
|
public DateTimeOffset? CompletedUtc { get; init; }
|
||||||
|
|
||||||
|
/// <summary>The machine the startup run executed on.</summary>
|
||||||
|
public string MachineName { get; init; } = "";
|
||||||
|
|
||||||
|
/// <summary>Per-check outcomes (only meaningful for enabled checks).</summary>
|
||||||
|
public IReadOnlyList<StartupCheckItem> Items { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One startup check's configured/enabled state and its boot-time result.</summary>
|
||||||
|
public sealed class StartupCheckItem
|
||||||
|
{
|
||||||
|
/// <summary>Stable key: <c>KeyVault</c> | <c>Database</c> | <c>MFR</c> | <c>PdfLicense</c> | <c>Mailer</c>.</summary>
|
||||||
|
public string Name { get; init; } = "";
|
||||||
|
|
||||||
|
/// <summary>Whether this check was enabled (and therefore actually ran).</summary>
|
||||||
|
public bool Enabled { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Result of the check; only meaningful when <see cref="Enabled"/> is true.</summary>
|
||||||
|
public bool Ok { get; init; }
|
||||||
|
}
|
||||||
@@ -20,17 +20,20 @@ public class StartupSelfTestService : BackgroundService
|
|||||||
private readonly IConfiguration _configuration;
|
private readonly IConfiguration _configuration;
|
||||||
private readonly StartupSelfTestSettings _settings;
|
private readonly StartupSelfTestSettings _settings;
|
||||||
private readonly ILogger<StartupSelfTestService> _logger;
|
private readonly ILogger<StartupSelfTestService> _logger;
|
||||||
|
private readonly StartupCheckReporter? _reporter;
|
||||||
|
|
||||||
public StartupSelfTestService(
|
public StartupSelfTestService(
|
||||||
IServiceProvider serviceProvider,
|
IServiceProvider serviceProvider,
|
||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
IOptions<StartupSelfTestSettings> settings,
|
IOptions<StartupSelfTestSettings> settings,
|
||||||
ILogger<StartupSelfTestService> logger)
|
ILogger<StartupSelfTestService> logger,
|
||||||
|
StartupCheckReporter? reporter = null)
|
||||||
{
|
{
|
||||||
_serviceProvider = serviceProvider;
|
_serviceProvider = serviceProvider;
|
||||||
_configuration = configuration;
|
_configuration = configuration;
|
||||||
_settings = settings.Value;
|
_settings = settings.Value;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_reporter = reporter;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
@@ -41,6 +44,7 @@ public class StartupSelfTestService : BackgroundService
|
|||||||
if (!_settings.Enabled)
|
if (!_settings.Enabled)
|
||||||
{
|
{
|
||||||
_logger.LogDebug("StartupSelfTestService skipped - Fuchs:StartupChecks:Enabled is false.");
|
_logger.LogDebug("StartupSelfTestService skipped - Fuchs:StartupChecks:Enabled is false.");
|
||||||
|
_reporter?.Set(new StartupCheckReport { Ran = false, MachineName = Environment.MachineName });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,6 +128,21 @@ public class StartupSelfTestService : BackgroundService
|
|||||||
mfrOk,
|
mfrOk,
|
||||||
mailerOk,
|
mailerOk,
|
||||||
pdfLicenseOk);
|
pdfLicenseOk);
|
||||||
|
|
||||||
|
_reporter?.Set(new StartupCheckReport
|
||||||
|
{
|
||||||
|
Ran = true,
|
||||||
|
CompletedUtc = DateTimeOffset.UtcNow,
|
||||||
|
MachineName = Environment.MachineName,
|
||||||
|
Items =
|
||||||
|
[
|
||||||
|
new StartupCheckItem { Name = "KeyVault", Enabled = _settings.CheckKeyVault, Ok = keyVaultOk },
|
||||||
|
new StartupCheckItem { Name = "Database", Enabled = _settings.CheckDatabase, Ok = databaseOk },
|
||||||
|
new StartupCheckItem { Name = "MFR", Enabled = _settings.CheckMfr, Ok = mfrOk },
|
||||||
|
new StartupCheckItem { Name = "PdfLicense", Enabled = _settings.CheckPdfLicense, Ok = pdfLicenseOk },
|
||||||
|
new StartupCheckItem { Name = "Mailer", Enabled = _settings.SendStartupEmail, Ok = mailerOk },
|
||||||
|
],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
namespace Fuchs.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result of a single connectivity/access probe run by <see cref="ISystemStatusService"/>.
|
||||||
|
/// Serialized directly to the Admin module frontend, so property names are the JSON contract.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SystemProbeResult
|
||||||
|
{
|
||||||
|
/// <summary>Probe identifier: <c>database</c>, <c>keyvault</c>, <c>blob</c> or <c>mfr</c>.</summary>
|
||||||
|
public string Component { get; init; } = "";
|
||||||
|
|
||||||
|
/// <summary><c>ok</c> | <c>error</c> | <c>disabled</c> | <c>unconfigured</c>.</summary>
|
||||||
|
public string Status { get; init; } = "unconfigured";
|
||||||
|
|
||||||
|
/// <summary><c>true</c> only when the component was reachable and access succeeded.</summary>
|
||||||
|
public bool Ok { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Short human-readable summary (German), safe to show in the UI.</summary>
|
||||||
|
public string Message { get; init; } = "";
|
||||||
|
|
||||||
|
/// <summary>Optional extra detail (server name, account name, error text — never secrets).</summary>
|
||||||
|
public string? Detail { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Round-trip duration of the probe in milliseconds.</summary>
|
||||||
|
public long DurationMs { get; init; }
|
||||||
|
|
||||||
|
/// <summary>When the probe ran (UTC).</summary>
|
||||||
|
public DateTimeOffset CheckedUtc { get; init; } = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional extra label/value pairs a probe wants shown alongside its result — e.g. the blob
|
||||||
|
/// probe reports the file count per container here. <c>null</c> for probes that have none.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<ProbeMetric>? Metrics { get; init; }
|
||||||
|
|
||||||
|
public static SystemProbeResult Disabled(string component, string message) =>
|
||||||
|
new() { Component = component, Status = "disabled", Ok = false, Message = message };
|
||||||
|
|
||||||
|
public static SystemProbeResult Unconfigured(string component, string message) =>
|
||||||
|
new() { Component = component, Status = "unconfigured", Ok = false, Message = message };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A named measurement a probe surfaces for display (e.g. "dev-fuchs-invoices" → "42 Dateien").</summary>
|
||||||
|
public sealed class ProbeMetric
|
||||||
|
{
|
||||||
|
public string Label { get; init; } = "";
|
||||||
|
public string Value { get; init; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Passive configuration/environment snapshot (no network calls). Complements the
|
||||||
|
/// active <see cref="SystemProbeResult"/> probes with "how is this configured" facts.
|
||||||
|
/// Secrets are never included — only presence flags and non-sensitive settings.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SystemInfoSnapshot
|
||||||
|
{
|
||||||
|
public string MachineName { get; init; } = "";
|
||||||
|
public string OsDescription { get; init; } = "";
|
||||||
|
public string FrameworkDescription { get; init; } = "";
|
||||||
|
public string Environment { get; init; } = "";
|
||||||
|
public bool IsTestDeployment { get; init; }
|
||||||
|
public int ProcessId { get; init; }
|
||||||
|
public DateTimeOffset ProcessStartUtc { get; init; }
|
||||||
|
public double UptimeHours { get; init; }
|
||||||
|
|
||||||
|
public DatabaseInfo Database { get; init; } = new();
|
||||||
|
public EmailInfo Email { get; init; } = new();
|
||||||
|
public BlobInfo Blob { get; init; } = new();
|
||||||
|
public MfrInfo Mfr { get; init; } = new();
|
||||||
|
public KeyVaultInfo KeyVault { get; init; } = new();
|
||||||
|
|
||||||
|
/// <summary>Result of the one-shot startup self-test, or <c>null</c> if it has not run.</summary>
|
||||||
|
public StartupCheckReport? StartupChecks { get; init; }
|
||||||
|
|
||||||
|
public sealed class DatabaseInfo
|
||||||
|
{
|
||||||
|
public bool Configured { get; init; }
|
||||||
|
public string? Server { get; init; }
|
||||||
|
public string? Catalog { get; init; }
|
||||||
|
public string? UserId { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class EmailInfo
|
||||||
|
{
|
||||||
|
public bool MailerEnabled { get; init; }
|
||||||
|
public string? BaseUrl { get; init; }
|
||||||
|
public string? AccountId { get; init; }
|
||||||
|
public string? ServerId { get; init; }
|
||||||
|
public bool TokenConfigured { get; init; }
|
||||||
|
public bool OverrideActive { get; init; }
|
||||||
|
public string? OverrideRecipient { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class BlobInfo
|
||||||
|
{
|
||||||
|
public bool Enabled { get; init; }
|
||||||
|
public bool Configured { get; init; }
|
||||||
|
public string? InvoiceContainer { get; init; }
|
||||||
|
public string? ReminderContainer { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class MfrInfo
|
||||||
|
{
|
||||||
|
public string? Host { get; init; }
|
||||||
|
public bool CredentialsConfigured { get; init; }
|
||||||
|
public bool SyncEnabled { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class KeyVaultInfo
|
||||||
|
{
|
||||||
|
public string? VaultUri { get; init; }
|
||||||
|
public string? AppName { get; init; }
|
||||||
|
public int ManagedSecretCount { get; init; }
|
||||||
|
public bool ClientRegistered { get; init; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Outcome of an Admin test-email send.</summary>
|
||||||
|
public sealed class EmailTestResult
|
||||||
|
{
|
||||||
|
public bool Sent { get; init; }
|
||||||
|
public string Message { get; init; } = "";
|
||||||
|
|
||||||
|
/// <summary>Address the caller requested.</summary>
|
||||||
|
public string RequestedRecipient { get; init; } = "";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When the dev/test override is active, the address the mail was actually redirected to
|
||||||
|
/// (see <see cref="FuchsEmailSettings.OverrideRecipient"/>); otherwise <c>null</c>.
|
||||||
|
/// </summary>
|
||||||
|
public string? OverrideRecipient { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using Azure;
|
||||||
|
using Azure.Security.KeyVault.Secrets;
|
||||||
|
using Fuchs.Observability;
|
||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace Fuchs.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read-only diagnostics for the Admin module: a passive configuration snapshot plus live
|
||||||
|
/// connectivity/access probes (database, Key Vault, blob storage, MFR ERP) and a test-email
|
||||||
|
/// sender. Every probe is wrapped so it reports failure via <see cref="SystemProbeResult"/>
|
||||||
|
/// instead of throwing. No secrets are ever returned — only presence flags and non-sensitive
|
||||||
|
/// values (server/account names, error messages).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SystemStatusService : ISystemStatusService
|
||||||
|
{
|
||||||
|
private static readonly string[] Components = { "database", "keyvault", "blob", "mfr" };
|
||||||
|
|
||||||
|
private readonly IConfiguration _configuration;
|
||||||
|
private readonly IHostEnvironment _environment;
|
||||||
|
private readonly IServiceProvider _serviceProvider;
|
||||||
|
private readonly IBlobStorageService _blob;
|
||||||
|
private readonly IMfrClientFactory _mfrFactory;
|
||||||
|
private readonly IComService _comService;
|
||||||
|
private readonly ProcessWebComSettings _mailer;
|
||||||
|
private readonly FuchsEmailSettings _email;
|
||||||
|
private readonly AzureBlobStorageSettings _blobSettings;
|
||||||
|
private readonly StartupCheckReporter _startupChecks;
|
||||||
|
private readonly ILogger<SystemStatusService> _logger;
|
||||||
|
|
||||||
|
public SystemStatusService(
|
||||||
|
IConfiguration configuration,
|
||||||
|
IHostEnvironment environment,
|
||||||
|
IServiceProvider serviceProvider,
|
||||||
|
IBlobStorageService blob,
|
||||||
|
IMfrClientFactory mfrFactory,
|
||||||
|
IComService comService,
|
||||||
|
IOptions<ProcessWebComSettings> mailer,
|
||||||
|
IOptions<FuchsEmailSettings> email,
|
||||||
|
IOptions<AzureBlobStorageSettings> blobSettings,
|
||||||
|
StartupCheckReporter startupChecks,
|
||||||
|
ILogger<SystemStatusService> logger)
|
||||||
|
{
|
||||||
|
_configuration = configuration;
|
||||||
|
_environment = environment;
|
||||||
|
_serviceProvider = serviceProvider;
|
||||||
|
_blob = blob;
|
||||||
|
_mfrFactory = mfrFactory;
|
||||||
|
_comService = comService;
|
||||||
|
_mailer = mailer.Value;
|
||||||
|
_email = email.Value;
|
||||||
|
_blobSettings = blobSettings.Value;
|
||||||
|
_startupChecks = startupChecks;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<string> ProbeComponents => Components;
|
||||||
|
|
||||||
|
public SystemInfoSnapshot GetInfo()
|
||||||
|
{
|
||||||
|
using var process = Process.GetCurrentProcess();
|
||||||
|
DateTimeOffset startUtc;
|
||||||
|
try { startUtc = process.StartTime.ToUniversalTime(); }
|
||||||
|
catch { startUtc = DateTimeOffset.UtcNow; }
|
||||||
|
|
||||||
|
return new SystemInfoSnapshot
|
||||||
|
{
|
||||||
|
MachineName = Environment.MachineName,
|
||||||
|
OsDescription = System.Runtime.InteropServices.RuntimeInformation.OSDescription,
|
||||||
|
FrameworkDescription = System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription,
|
||||||
|
Environment = _environment.EnvironmentName,
|
||||||
|
IsTestDeployment = _configuration.GetValue("Fuchs:IsTestDeployment", false),
|
||||||
|
ProcessId = process.Id,
|
||||||
|
ProcessStartUtc = startUtc,
|
||||||
|
UptimeHours = Math.Round((DateTimeOffset.UtcNow - startUtc).TotalHours, 2),
|
||||||
|
Database = BuildDatabaseInfo(),
|
||||||
|
Email = BuildEmailInfo(),
|
||||||
|
Blob = BuildBlobInfo(),
|
||||||
|
Mfr = BuildMfrInfo(),
|
||||||
|
KeyVault = BuildKeyVaultInfo(),
|
||||||
|
StartupChecks = _startupChecks.Latest,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private SystemInfoSnapshot.DatabaseInfo BuildDatabaseInfo()
|
||||||
|
{
|
||||||
|
string? cs = _configuration.GetConnectionString("fuchs_fds_ConnectionString");
|
||||||
|
if (string.IsNullOrWhiteSpace(cs))
|
||||||
|
return new SystemInfoSnapshot.DatabaseInfo { Configured = false };
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var b = new SqlConnectionStringBuilder(cs);
|
||||||
|
return new SystemInfoSnapshot.DatabaseInfo
|
||||||
|
{
|
||||||
|
Configured = true,
|
||||||
|
Server = b.DataSource,
|
||||||
|
Catalog = b.InitialCatalog,
|
||||||
|
UserId = b.UserID, // login name only; the password is never surfaced
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return new SystemInfoSnapshot.DatabaseInfo { Configured = true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private SystemInfoSnapshot.EmailInfo BuildEmailInfo() => new()
|
||||||
|
{
|
||||||
|
MailerEnabled = _mailer.Enabled,
|
||||||
|
BaseUrl = _mailer.BaseUrl,
|
||||||
|
AccountId = _mailer.AccountId,
|
||||||
|
ServerId = _mailer.ServerId,
|
||||||
|
TokenConfigured = !string.IsNullOrWhiteSpace(_mailer.Token) && _mailer.Token != "MANAGED_BY_KEYVAULT",
|
||||||
|
OverrideActive = !string.IsNullOrWhiteSpace(_email.OverrideRecipient),
|
||||||
|
OverrideRecipient = string.IsNullOrWhiteSpace(_email.OverrideRecipient) ? null : _email.OverrideRecipient,
|
||||||
|
};
|
||||||
|
|
||||||
|
private SystemInfoSnapshot.BlobInfo BuildBlobInfo()
|
||||||
|
{
|
||||||
|
string? cs = _configuration.GetConnectionString("AzureBlobStorage_ConnectionString");
|
||||||
|
return new SystemInfoSnapshot.BlobInfo
|
||||||
|
{
|
||||||
|
Enabled = _blobSettings.Enabled,
|
||||||
|
Configured = !string.IsNullOrWhiteSpace(cs) && cs != "MANAGED_BY_KEYVAULT",
|
||||||
|
InvoiceContainer = _blobSettings.InvoiceContainer,
|
||||||
|
ReminderContainer = _blobSettings.ReminderContainer,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private SystemInfoSnapshot.MfrInfo BuildMfrInfo()
|
||||||
|
{
|
||||||
|
string? host = _configuration["Fds:MFR_host"];
|
||||||
|
string? user = _configuration["Fds:MFR_UserName"];
|
||||||
|
return new SystemInfoSnapshot.MfrInfo
|
||||||
|
{
|
||||||
|
Host = host,
|
||||||
|
CredentialsConfigured = !string.IsNullOrWhiteSpace(user) && user != "MANAGED_BY_KEYVAULT",
|
||||||
|
SyncEnabled = _configuration.GetValue("Fds:SyncEnabled", false),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private SystemInfoSnapshot.KeyVaultInfo BuildKeyVaultInfo()
|
||||||
|
{
|
||||||
|
string[] keys = _configuration.GetSection("SecretManagement:ManagedSecretKeys").Get<string[]>() ?? [];
|
||||||
|
return new SystemInfoSnapshot.KeyVaultInfo
|
||||||
|
{
|
||||||
|
VaultUri = _configuration["SecretManagement:VaultUri"],
|
||||||
|
AppName = _configuration["SecretManagement:AppName"],
|
||||||
|
ManagedSecretCount = keys.Length,
|
||||||
|
ClientRegistered = _serviceProvider.GetService<SecretClient>() != null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<SystemProbeResult>> ProbeAllAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var tasks = Components.Select(c => ProbeAsync(c, cancellationToken));
|
||||||
|
return await Task.WhenAll(tasks);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SystemProbeResult> ProbeAsync(string component, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
component = (component ?? "").Trim().ToLowerInvariant();
|
||||||
|
using var act = FuchsTelemetry.StartActivity("systemstatus.probe");
|
||||||
|
act?.SetTag("fuchs.systemstatus.component", component);
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
|
||||||
|
SystemProbeResult result;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
result = component switch
|
||||||
|
{
|
||||||
|
"database" => await ProbeDatabaseAsync(cancellationToken),
|
||||||
|
"keyvault" => await ProbeKeyVaultAsync(cancellationToken),
|
||||||
|
"blob" => await ProbeBlobAsync(cancellationToken),
|
||||||
|
"mfr" => await ProbeMfrAsync(cancellationToken),
|
||||||
|
_ => new SystemProbeResult
|
||||||
|
{
|
||||||
|
Component = component,
|
||||||
|
Status = "error",
|
||||||
|
Ok = false,
|
||||||
|
Message = $"Unbekannte Komponente '{component}'.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Defense in depth: individual probes already catch, but never let a probe throw.
|
||||||
|
_logger.LogWarning(ex, "System status probe {Component} threw unexpectedly.", component);
|
||||||
|
result = new SystemProbeResult
|
||||||
|
{
|
||||||
|
Component = component,
|
||||||
|
Status = "error",
|
||||||
|
Ok = false,
|
||||||
|
Message = "Unerwarteter Fehler bei der Prüfung.",
|
||||||
|
Detail = ex.Message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
sw.Stop();
|
||||||
|
result = new SystemProbeResult
|
||||||
|
{
|
||||||
|
Component = result.Component,
|
||||||
|
Status = result.Status,
|
||||||
|
Ok = result.Ok,
|
||||||
|
Message = result.Message,
|
||||||
|
Detail = result.Detail,
|
||||||
|
Metrics = result.Metrics,
|
||||||
|
DurationMs = sw.ElapsedMilliseconds,
|
||||||
|
CheckedUtc = DateTimeOffset.UtcNow,
|
||||||
|
};
|
||||||
|
|
||||||
|
act?.SetTag("fuchs.systemstatus.status", result.Status);
|
||||||
|
FuchsTelemetry.SystemProbes.Add(1,
|
||||||
|
new KeyValuePair<string, object?>("component", component),
|
||||||
|
new KeyValuePair<string, object?>("status", result.Status));
|
||||||
|
_logger.LogInformation("System status probe {Component} => {Status} in {Ms} ms.",
|
||||||
|
component, result.Status, sw.ElapsedMilliseconds);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<SystemProbeResult> ProbeDatabaseAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
string? cs = _configuration.GetConnectionString("fuchs_fds_ConnectionString");
|
||||||
|
if (string.IsNullOrWhiteSpace(cs))
|
||||||
|
return SystemProbeResult.Unconfigured("database", "Keine Verbindungszeichenfolge konfiguriert.");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var conn = new SqlConnection(cs);
|
||||||
|
await conn.OpenAsync(ct);
|
||||||
|
await using var cmd = new SqlCommand(
|
||||||
|
"SELECT CONVERT(nvarchar(256), @@SERVERNAME), DB_NAME(), SUSER_SNAME();", conn);
|
||||||
|
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||||
|
string server = "", db = "", login = "";
|
||||||
|
if (await reader.ReadAsync(ct))
|
||||||
|
{
|
||||||
|
server = reader.IsDBNull(0) ? "" : reader.GetString(0);
|
||||||
|
db = reader.IsDBNull(1) ? "" : reader.GetString(1);
|
||||||
|
login = reader.IsDBNull(2) ? "" : reader.GetString(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SystemProbeResult
|
||||||
|
{
|
||||||
|
Component = "database",
|
||||||
|
Status = "ok",
|
||||||
|
Ok = true,
|
||||||
|
Message = "Verbindung und Anmeldung erfolgreich.",
|
||||||
|
Detail = $"{server} / {db} (Login: {login})",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new SystemProbeResult
|
||||||
|
{
|
||||||
|
Component = "database",
|
||||||
|
Status = "error",
|
||||||
|
Ok = false,
|
||||||
|
Message = "Verbindung oder Zugriff fehlgeschlagen.",
|
||||||
|
Detail = ex.Message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<SystemProbeResult> ProbeKeyVaultAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
string appName = _configuration["SecretManagement:AppName"] ?? "";
|
||||||
|
string[] managedKeys = _configuration.GetSection("SecretManagement:ManagedSecretKeys").Get<string[]>() ?? [];
|
||||||
|
if (string.IsNullOrWhiteSpace(appName) || managedKeys.Length == 0)
|
||||||
|
return SystemProbeResult.Unconfigured("keyvault", "SecretManagement-Einstellungen unvollständig.");
|
||||||
|
|
||||||
|
var secretClient = _serviceProvider.GetService<SecretClient>();
|
||||||
|
if (secretClient is null)
|
||||||
|
return SystemProbeResult.Unconfigured("keyvault", "SecretClient ist nicht registriert.");
|
||||||
|
|
||||||
|
string probeName = $"{appName}--{managedKeys[0]}";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
KeyVaultSecret secret = await secretClient.GetSecretAsync(probeName, version: null, ct);
|
||||||
|
bool hasValue = !string.IsNullOrWhiteSpace(secret.Value);
|
||||||
|
return new SystemProbeResult
|
||||||
|
{
|
||||||
|
Component = "keyvault",
|
||||||
|
Status = hasValue ? "ok" : "error",
|
||||||
|
Ok = hasValue,
|
||||||
|
Message = hasValue ? "Zugriff auf Key Vault erfolgreich." : "Secret ist leer.",
|
||||||
|
Detail = $"Geprüft: {probeName}",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (RequestFailedException ex)
|
||||||
|
{
|
||||||
|
return new SystemProbeResult
|
||||||
|
{
|
||||||
|
Component = "keyvault",
|
||||||
|
Status = "error",
|
||||||
|
Ok = false,
|
||||||
|
Message = "Zugriff auf Key Vault fehlgeschlagen.",
|
||||||
|
Detail = $"Status {ex.Status}: {ex.Message}",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new SystemProbeResult
|
||||||
|
{
|
||||||
|
Component = "keyvault",
|
||||||
|
Status = "error",
|
||||||
|
Ok = false,
|
||||||
|
Message = "Zugriff auf Key Vault fehlgeschlagen.",
|
||||||
|
Detail = ex.Message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<SystemProbeResult> ProbeBlobAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
BlobConnectivity c = await _blob.CheckConnectivityAsync(ct);
|
||||||
|
if (!c.Enabled)
|
||||||
|
return SystemProbeResult.Disabled("blob", "Azure Blob Storage ist deaktiviert (Fuchs:AzureStorage:Enabled=false).");
|
||||||
|
if (!c.Configured)
|
||||||
|
return SystemProbeResult.Unconfigured("blob", c.Detail ?? "Keine Verbindungszeichenfolge konfiguriert.");
|
||||||
|
|
||||||
|
List<ProbeMetric>? metrics = c.Containers?
|
||||||
|
.Select(x => new ProbeMetric
|
||||||
|
{
|
||||||
|
Label = x.Name,
|
||||||
|
Value = !x.Exists ? "nicht vorhanden"
|
||||||
|
: x.FileCount < 0 ? "Anzahl nicht ermittelbar"
|
||||||
|
: $"{x.FileCount} Datei{(x.FileCount == 1 ? "" : "en")}",
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
string message = c.Reachable ? "Verbindung und Zugriff erfolgreich." : "Zugriff fehlgeschlagen.";
|
||||||
|
if (c.Reachable && c.Containers is { Count: > 0 })
|
||||||
|
{
|
||||||
|
int total = c.Containers.Where(x => x.FileCount > 0).Sum(x => x.FileCount);
|
||||||
|
message += $" {total} Datei{(total == 1 ? "" : "en")} gesamt.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SystemProbeResult
|
||||||
|
{
|
||||||
|
Component = "blob",
|
||||||
|
Status = c.Reachable ? "ok" : "error",
|
||||||
|
Ok = c.Reachable,
|
||||||
|
Message = message,
|
||||||
|
Detail = string.IsNullOrWhiteSpace(c.AccountName) ? c.Detail : $"{c.AccountName}: {c.Detail}",
|
||||||
|
Metrics = metrics,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<SystemProbeResult> ProbeMfrAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var client = _mfrFactory.Create();
|
||||||
|
string entities = await client.GetEntities(throwErrorIfNotOk: true);
|
||||||
|
bool ok = !string.IsNullOrWhiteSpace(entities);
|
||||||
|
return new SystemProbeResult
|
||||||
|
{
|
||||||
|
Component = "mfr",
|
||||||
|
Status = ok ? "ok" : "error",
|
||||||
|
Ok = ok,
|
||||||
|
Message = ok ? "Verbindung und Zugriff erfolgreich." : "Leere Antwort erhalten.",
|
||||||
|
Detail = _configuration["Fds:MFR_host"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new SystemProbeResult
|
||||||
|
{
|
||||||
|
Component = "mfr",
|
||||||
|
Status = "error",
|
||||||
|
Ok = false,
|
||||||
|
Message = "Verbindung oder Zugriff fehlgeschlagen.",
|
||||||
|
Detail = ex.Message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<EmailTestResult> SendTestEmailAsync(string to, string subject, string body,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
to = (to ?? "").Trim();
|
||||||
|
subject = (subject ?? "").Trim();
|
||||||
|
body = body ?? "";
|
||||||
|
|
||||||
|
if (to.Length == 0 || subject.Length == 0)
|
||||||
|
return new EmailTestResult { Sent = false, Message = "Empfänger und Betreff sind erforderlich.", RequestedRecipient = to };
|
||||||
|
|
||||||
|
// Body arrives as plain text from the Admin form; wrap the (HTML-encoded) lines in <p>.
|
||||||
|
string html = "<p>" + System.Net.WebUtility.HtmlEncode(body).Replace("\r\n", "\n").Replace("\n", "<br/>") + "</p>";
|
||||||
|
|
||||||
|
bool sent = await _comService.SendEmailAsync("admin_test", subject, html, to, "Admin Test", attachments: null);
|
||||||
|
|
||||||
|
string? overrideTo = string.IsNullOrWhiteSpace(_email.OverrideRecipient) ? null : _email.OverrideRecipient;
|
||||||
|
string message = sent
|
||||||
|
? (overrideTo is null
|
||||||
|
? "Test-E-Mail wurde an den Mailer übergeben."
|
||||||
|
: $"Test-E-Mail wurde an den Mailer übergeben (durch OverrideRecipient umgeleitet an {overrideTo}).")
|
||||||
|
: "Test-E-Mail wurde vom Mailer nicht akzeptiert (ggf. ist der Mailer deaktiviert).";
|
||||||
|
|
||||||
|
return new EmailTestResult
|
||||||
|
{
|
||||||
|
Sent = sent,
|
||||||
|
Message = message,
|
||||||
|
RequestedRecipient = to,
|
||||||
|
OverrideRecipient = overrideTo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,10 @@
|
|||||||
string appName = ViewData["AppName"] as string ?? "Fuchs Intranet";
|
string appName = ViewData["AppName"] as string ?? "Fuchs Intranet";
|
||||||
string fullName = ViewData["FullName"] as string ?? "";
|
string fullName = ViewData["FullName"] as string ?? "";
|
||||||
string pageTitle = ViewData["Title"] as string ?? "Intranet";
|
string pageTitle = ViewData["Title"] as string ?? "Intranet";
|
||||||
|
// When the deployment is flagged as a test/staging instance (Fuchs:IsTestDeployment = true),
|
||||||
|
// a distinctive green background is injected below so a test instance is never mistaken for
|
||||||
|
// production. Defaults to false (production) in appsettings.json.
|
||||||
|
bool isTestDeployment = Configuration.GetValue<bool>("Fuchs:IsTestDeployment");
|
||||||
string? debugDbTarget = null;
|
string? debugDbTarget = null;
|
||||||
if (HostEnvironment.IsDevelopment())
|
if (HostEnvironment.IsDevelopment())
|
||||||
{
|
{
|
||||||
@@ -56,6 +60,15 @@
|
|||||||
|
|
||||||
@await RenderSectionAsync("CustomHeader", required: false)
|
@await RenderSectionAsync("CustomHeader", required: false)
|
||||||
|
|
||||||
|
@if (isTestDeployment)
|
||||||
|
{
|
||||||
|
<style>
|
||||||
|
body, html, body > header > nav, body > main > #topbar > nav {
|
||||||
|
background-color: #13a143 !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
}
|
||||||
|
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
$ocms.auth = @Html.Raw(JsonConvert.SerializeObject(authinfo));
|
$ocms.auth = @Html.Raw(JsonConvert.SerializeObject(authinfo));
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -26,16 +26,22 @@
|
|||||||
"CheckMfr": false,
|
"CheckMfr": false,
|
||||||
"CheckPdfLicense": true
|
"CheckPdfLicense": true
|
||||||
},
|
},
|
||||||
|
"Mailer": {
|
||||||
|
"Enabled": true
|
||||||
|
},
|
||||||
"Email": {
|
"Email": {
|
||||||
"OverrideRecipient": "service@emails.processweb.de"
|
"OverrideRecipient": "info@processweb.de"
|
||||||
},
|
},
|
||||||
"AzureStorage": {
|
"AzureStorage": {
|
||||||
"Enabled": false,
|
"Enabled": true,
|
||||||
"InvoiceContainer": "dev-fuchs-invoices",
|
"InvoiceContainer": "dev-fuchs-invoices",
|
||||||
"ReminderContainer": "dev-fuchs-reminders"
|
"ReminderContainer": "dev-fuchs-reminders"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Fds": {
|
"Fds": {
|
||||||
|
"SyncEnabled": false,
|
||||||
|
"ExecutionFrequency_Minutes": 15,
|
||||||
|
"DebugDetails": true,
|
||||||
"MFR_host": "portal.mobilefieldreport.com",
|
"MFR_host": "portal.mobilefieldreport.com",
|
||||||
"MFR_UserName": "system@sebastian-fuchs---bad-und-heizung-gmbh-und-co-kg.com",
|
"MFR_UserName": "system@sebastian-fuchs---bad-und-heizung-gmbh-und-co-kg.com",
|
||||||
"MFR_Password": "0oT4G3H2"
|
"MFR_Password": "0oT4G3H2"
|
||||||
|
|||||||
+10
-5
@@ -32,6 +32,7 @@
|
|||||||
"AzureBlobStorage_ConnectionString": "MANAGED_BY_KEYVAULT"
|
"AzureBlobStorage_ConnectionString": "MANAGED_BY_KEYVAULT"
|
||||||
},
|
},
|
||||||
"Fuchs": {
|
"Fuchs": {
|
||||||
|
"IsTestDeployment": true,
|
||||||
"ocms_guid": "00094b8f-a822-4e9c-b627-87802f93fca8",
|
"ocms_guid": "00094b8f-a822-4e9c-b627-87802f93fca8",
|
||||||
"ocms_valid_locales_csv": "de,en",
|
"ocms_valid_locales_csv": "de,en",
|
||||||
"ocms_default_locale": "de",
|
"ocms_default_locale": "de",
|
||||||
@@ -44,10 +45,11 @@
|
|||||||
"BaseUrl": "https://api.processweb.de",
|
"BaseUrl": "https://api.processweb.de",
|
||||||
"AccountId": "82d87114-c8c3-4d33-95e5-4c781a9229ab",
|
"AccountId": "82d87114-c8c3-4d33-95e5-4c781a9229ab",
|
||||||
"Token": "MANAGED_BY_KEYVAULT",
|
"Token": "MANAGED_BY_KEYVAULT",
|
||||||
"Enabled": false
|
"ServerId": "87AAB12E-29E0-47AB-8DD1-5F5D49B4AC2E",
|
||||||
|
"Enabled": true
|
||||||
},
|
},
|
||||||
"Email": {
|
"Email": {
|
||||||
"OverrideRecipient": ""
|
"OverrideRecipient": "info@processweb.de"
|
||||||
},
|
},
|
||||||
"StartupChecks": {
|
"StartupChecks": {
|
||||||
"Enabled": false,
|
"Enabled": false,
|
||||||
@@ -59,9 +61,9 @@
|
|||||||
"StartupEmailRecipientName": "Startup Monitor"
|
"StartupEmailRecipientName": "Startup Monitor"
|
||||||
},
|
},
|
||||||
"AzureStorage": {
|
"AzureStorage": {
|
||||||
"Enabled": false,
|
"Enabled": true,
|
||||||
"InvoiceContainer": "fuchs-invoices",
|
"InvoiceContainer": "dev-fuchs-invoices",
|
||||||
"ReminderContainer": "fuchs-reminders",
|
"ReminderContainer": "dev-fuchs-reminders",
|
||||||
"MetadataFields": [ "Id", "Version", "InvoiceId", "InvoiceTitle", "InvId", "DocumentName", "file_guid" ]
|
"MetadataFields": [ "Id", "Version", "InvoiceId", "InvoiceTitle", "InvId", "DocumentName", "file_guid" ]
|
||||||
},
|
},
|
||||||
"Telemetry": {
|
"Telemetry": {
|
||||||
@@ -70,6 +72,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Fds": {
|
"Fds": {
|
||||||
|
"SyncEnabled": true,
|
||||||
|
"ExecutionFrequency_Minutes": 15,
|
||||||
|
"DebugDetails": false,
|
||||||
"MFR_host": "portal.mobilefieldreport.com",
|
"MFR_host": "portal.mobilefieldreport.com",
|
||||||
"MFR_UserName": "MANAGED_BY_KEYVAULT",
|
"MFR_UserName": "MANAGED_BY_KEYVAULT",
|
||||||
"MFR_Password": "MANAGED_BY_KEYVAULT"
|
"MFR_Password": "MANAGED_BY_KEYVAULT"
|
||||||
|
|||||||
@@ -151,5 +151,24 @@
|
|||||||
"js/intranet/modules/fis.bam.scss"
|
"js/intranet/modules/fis.bam.scss"
|
||||||
],
|
],
|
||||||
"minify": { "enabled": true }
|
"minify": { "enabled": true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "intranet:admin",
|
||||||
|
"outputFileName": "wwwroot/web/fis.admin.de.js",
|
||||||
|
"inputFiles": [
|
||||||
|
"js/intranet/modules/fis.admin_txt_de.js",
|
||||||
|
"js/intranet/modules/fis.admin.js"
|
||||||
|
],
|
||||||
|
"minify": { "enabled": true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "intranet:admin",
|
||||||
|
"outputFileName": "wwwroot/web/fis.admin.min.css",
|
||||||
|
"inputFiles": [
|
||||||
|
"css/intranet/oci_variables.scss",
|
||||||
|
"css/intranet/fis_variables.scss",
|
||||||
|
"js/intranet/modules/fis.admin.scss"
|
||||||
|
],
|
||||||
|
"minify": { "enabled": true }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
Binary file not shown.
@@ -1,4 +1,4 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using Microsoft.Data.SqlClient;
|
using Microsoft.Data.SqlClient;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
using static OCORE.OCORE_dictionaries;
|
using static OCORE.OCORE_dictionaries;
|
||||||
@@ -19,7 +19,7 @@ public class FdsInvoiceData
|
|||||||
public GenericObjectDictionary? Admin { get; private set; }
|
public GenericObjectDictionary? Admin { get; private set; }
|
||||||
public GenericObjectDictionary? NewValues { get; private set; }
|
public GenericObjectDictionary? NewValues { get; private set; }
|
||||||
public GenericObjectDictionary? Sms { get; private set; }
|
public GenericObjectDictionary? Sms { get; private set; }
|
||||||
public List<Dictionary<string, object>>? Req { get; private set; }
|
public List<Dictionary<string, object>>? Req { get; internal set; }
|
||||||
|
|
||||||
public GenericObjectDictionary? InvoiceRegistration { get; internal set; }
|
public GenericObjectDictionary? InvoiceRegistration { get; internal set; }
|
||||||
public bool IsDraft { get; internal set; } = true;
|
public bool IsDraft { get; internal set; } = true;
|
||||||
@@ -46,9 +46,25 @@ public class FdsInvoiceData
|
|||||||
get
|
get
|
||||||
{
|
{
|
||||||
var result = new List<Dictionary<string, object?>>();
|
var result = new List<Dictionary<string, object?>>();
|
||||||
|
foreach (var block in InvoiceBlocks) result.AddRange(block.Items);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The service-request groups as they should render on the invoice: each block carries its
|
||||||
|
/// heading (the section title the editor shows) and its line items. The PDF renders a heading
|
||||||
|
/// row per block followed by that block's items, so the online editor and the PDF stay in sync.
|
||||||
|
/// </summary>
|
||||||
|
public List<InvoiceBlock> InvoiceBlocks
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var result = new List<InvoiceBlock>();
|
||||||
if (Req == null) return result;
|
if (Req == null) return result;
|
||||||
foreach (var req in Req)
|
foreach (var req in Req)
|
||||||
{
|
{
|
||||||
|
var items = new List<Dictionary<string, object?>>();
|
||||||
if (req.TryGetValue("items", out var itmsObj))
|
if (req.TryGetValue("items", out var itmsObj))
|
||||||
{
|
{
|
||||||
IEnumerable<Dictionary<string, object?>>? itms =
|
IEnumerable<Dictionary<string, object?>>? itms =
|
||||||
@@ -56,8 +72,12 @@ public class FdsInvoiceData
|
|||||||
?? (itmsObj is JArray ja
|
?? (itmsObj is JArray ja
|
||||||
? ja.ToObject<List<Dictionary<string, object?>>>()
|
? ja.ToObject<List<Dictionary<string, object?>>>()
|
||||||
: null);
|
: null);
|
||||||
if (itms != null) result.AddRange(itms);
|
if (itms != null) items.AddRange(itms);
|
||||||
}
|
}
|
||||||
|
string heading = "";
|
||||||
|
if (req.TryGetValue("text", out var th) && th != null) heading = th.ToString() ?? "";
|
||||||
|
if (heading.Length == 0 && req.TryGetValue("nme", out var nh) && nh != null) heading = nh.ToString() ?? "";
|
||||||
|
result.Add(new InvoiceBlock { Heading = heading, Items = items });
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -151,7 +171,11 @@ public class FdsInvoiceData
|
|||||||
var tokens = new List<string>();
|
var tokens = new List<string>();
|
||||||
if (Admin?.no("p13b", false) is true) tokens.Add("§13b");
|
if (Admin?.no("p13b", false) is true) tokens.Add("§13b");
|
||||||
string setmode = (Admin?.nz("setmode") ?? "").Trim().ToLowerInvariant();
|
string setmode = (Admin?.nz("setmode") ?? "").Trim().ToLowerInvariant();
|
||||||
if (setmode is "itemprices" or "setonly") tokens.Add("setmode:" + setmode);
|
// Persist any explicitly-chosen mode, including the default "setprice" — otherwise an
|
||||||
|
// explicit user choice of the default is indistinguishable from an invoice that was never
|
||||||
|
// switched to set-pricing at all (needed so the editor can hide the "Set-Preisanzeige"
|
||||||
|
// menu entry once a mode has been chosen; see InvoiceDraftEditService.BuildInvoiceOptions).
|
||||||
|
if (setmode is "setprice" or "setonly") tokens.Add("setmode:" + setmode);
|
||||||
return string.Join(",", tokens);
|
return string.Join(",", tokens);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,3 +220,12 @@ public class FdsInvoiceData
|
|||||||
return double.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var d) ? d : 0;
|
return double.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var d) ? d : 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>A service-request group as it renders on the invoice: a heading plus its line items.</summary>
|
||||||
|
public sealed class InvoiceBlock
|
||||||
|
{
|
||||||
|
/// <summary>The section heading (editor's <c>text</c>/<c>nme</c>); empty when the group has none.</summary>
|
||||||
|
public string Heading { get; init; } = "";
|
||||||
|
/// <summary>The group's line items (the editor's <c>items</c> contract).</summary>
|
||||||
|
public List<Dictionary<string, object?>> Items { get; init; } = new();
|
||||||
|
}
|
||||||
|
|||||||
+278
-150
@@ -112,21 +112,40 @@ public static class FuchsPdf
|
|||||||
"Kreissparkasse D\u00fcsseldorf", "IBAN: DE52 3015 0200 0002 0914 78", "BIC: WELADED1KSD",
|
"Kreissparkasse D\u00fcsseldorf", "IBAN: DE52 3015 0200 0002 0914 78", "BIC: WELADED1KSD",
|
||||||
"Stadtsparkasse D\u00fcsseldorf", "IBAN: DE76 3005 0110 0045 0148 00", "BIC: DUSSDEDDXXX"
|
"Stadtsparkasse D\u00fcsseldorf", "IBAN: DE76 3005 0110 0045 0148 00", "BIC: DUSSDEDDXXX"
|
||||||
};
|
};
|
||||||
|
// Reminder greeting/intro, keyed by the actual reminder type (f = Zahlungserinnerung,
|
||||||
|
// m = Mahnung, l = letzte außergerichtliche Mahnung) — the single source of truth for
|
||||||
|
// both the PDF (ApplyReminder) and the online editor (via BuildReminderNotes).
|
||||||
public Dictionary<string, string[]> ReminderTexts_before { get; } = new()
|
public Dictionary<string, string[]> ReminderTexts_before { get; } = new()
|
||||||
{
|
{
|
||||||
["f"] = new[] {
|
["f"] = new[] {
|
||||||
"Sehr geehrte Damen und Herren,",
|
"Sehr geehrte Damen und Herren,",
|
||||||
"wir erlauben uns, Sie auf die nachfolgende offene Rechnung aufmerksam zu machen."
|
"ein Mahnschreiben sollte kurz, freundlich und erfolgreich sein. Kurz ist es, freundlich sowieso; ob es auch erfolgreich ist, h\u00e4ngt von Ihnen ab."
|
||||||
},
|
},
|
||||||
["s"] = new[] {
|
["m"] = new[] {
|
||||||
"Sehr geehrte Damen und Herren,",
|
"Sehr geehrte Damen und Herren,",
|
||||||
"trotz unserer Ersten Mahnung haben wir bis heute noch keinen Zahlungseingang feststellen k\u00f6nnen. " +
|
"nun m\u00fcssen wir Sie noch einmal anschreiben.",
|
||||||
"Wir bitten Sie, den offenen Betrag inkl. der Mahnzinsen und Mahnkosten umgehend auf unser Konto zu \u00fcberweisen."
|
"Wahrscheinlich haben Sie triftige Gr\u00fcnde daf\u00fcr, warum Sie die Zahlung unserer Forderung nicht vornehmen und auch nicht auf unsere Mahnung reagieren. Sollten wir dar\u00fcber nicht einmal sprechen?",
|
||||||
|
"Bitte nehmen Sie umgehend in dieser Sache mit uns Kontakt auf."
|
||||||
},
|
},
|
||||||
["l"] = Array.Empty<string>()
|
["l"] = new[] {
|
||||||
|
"Sehr geehrte Damen und Herren,",
|
||||||
|
"Eine DRITTE MAHNUNG zu erhalten bereitet Ihnen bestimmt ebenso wenig Freude wie uns, sie zu verschicken. Leider haben wir auf unsere zweite Mahnung noch keine Antwort von Ihnen erhalten.",
|
||||||
|
"Wir bitten Sie, den offenen Betrag innerhalb der n\u00e4chsten 7 Werktage nach Erhalt dieses Schreibens zu begleichen. Nach Ablauf dieser Frist erfolgt keine weitere Mahnung mehr.",
|
||||||
|
"Sollte die Forderung bis dahin nicht beglichen sein, er\u00f6ffnen wir das gerichtliche Mahnverfahren. Sollten Sie die Rechnung inzwischen beglichen haben, so betrachten Sie bitte dieses Schreiben als gegenstandslos."
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Extra closing sentence appended for reminder type <c>m</c> (Mahnung) — <c>f</c>'s closing is generated
|
||||||
|
/// dynamically (amount/IBAN + "gegenstandslos"); <c>l</c> gets no extra closing note.</summary>
|
||||||
|
public Dictionary<string, string> ReminderClosingExtra { get; } = new()
|
||||||
|
{
|
||||||
|
["m"] = "Um Ihnen zus\u00e4tzliche Kosten f\u00fcr weitere Mahnungen zu ersparen, bitten wir Sie nunmehr um die \u00dcberweisung des noch zu zahlenden Gesamtbetrages inklusive der ggf. bereits f\u00e4lligen Mahnzinsen und Mahngeb\u00fchren innerhalb von einer Woche."
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>One notice/explanatory paragraph, as rendered both in the PDF and the online editor.</summary>
|
||||||
|
public sealed record PdfNote(string Text, string Style = "InvoiceNotes");
|
||||||
|
|
||||||
// ── Currency / parse helpers ───────────────────────────────────────────────
|
// ── Currency / parse helpers ───────────────────────────────────────────────
|
||||||
public static string Currency(object? input, string returnobject = "?")
|
public static string Currency(object? input, string returnobject = "?")
|
||||||
{
|
{
|
||||||
@@ -137,6 +156,34 @@ public static class FuchsPdf
|
|||||||
public static string TranslatePaymentTerm(string pt) =>
|
public static string TranslatePaymentTerm(string pt) =>
|
||||||
pt.Replace("wd", " Werktagen").Replace("d", " Tagen").Replace("wk", " Wochen").ne("10 Tagen");
|
pt.Replace("wd", " Werktagen").Replace("d", " Tagen").Replace("wk", " Wochen").ne("10 Tagen");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps one editor item-contract entry to a display line, mirroring the online editor: the
|
||||||
|
/// item's own price/total is shown, a set header is emphasised, and free-text/heading lines
|
||||||
|
/// (type <c>text</c>/<c>title</c>) show neither a price nor a position number. This is the flat
|
||||||
|
/// (non-collapsing) rendering used for every set mode except the explicit <c>setonly</c>.
|
||||||
|
/// </summary>
|
||||||
|
private static InvoiceSetLine MapItemToLine(Dictionary<string, object?> i)
|
||||||
|
{
|
||||||
|
string type = i.nz("type", "").ToLowerInvariant();
|
||||||
|
bool isText = type is "text" or "title";
|
||||||
|
ParseDec(i.no("price_net", 0), out decimal price);
|
||||||
|
ParseDec(i.no("total_net", 0), out decimal total);
|
||||||
|
// ADR 0009: a null/absent total_net means "no price" — render an empty cell (not 0,00 €),
|
||||||
|
// distinct from a genuine 0. This is how block-mode (Set mit Preis) nulled members print.
|
||||||
|
bool hasPrice = i.TryGetValue("total_net", out var tv) && tv != null;
|
||||||
|
return new InvoiceSetLine
|
||||||
|
{
|
||||||
|
Title = i.nz("title", ""),
|
||||||
|
Desc = i.nz("desc", ""),
|
||||||
|
Qty = i.nz("qty", ""),
|
||||||
|
PriceNet = price,
|
||||||
|
TotalNet = total,
|
||||||
|
ShowPrice = !isText && hasPrice,
|
||||||
|
Numbered = !isText, // numbered like the editor: every line except free-text/heading, even price-blanked set members
|
||||||
|
IsSetHeader = type == "set"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parses a numeric value coming from JSON deserialization (long/double), SQL
|
/// Parses a numeric value coming from JSON deserialization (long/double), SQL
|
||||||
/// (decimal), or an already-invariant numeric string. Numeric CLR types are
|
/// (decimal), or an already-invariant numeric string. Numeric CLR types are
|
||||||
@@ -505,7 +552,7 @@ public static class FuchsPdf
|
|||||||
ParagraphFormat ClonePf()
|
ParagraphFormat ClonePf()
|
||||||
{
|
{
|
||||||
var pf = doc.Styles["Normal"]!.ParagraphFormat.Clone();
|
var pf = doc.Styles["Normal"]!.ParagraphFormat.Clone();
|
||||||
pf.Borders.Distance = 3;
|
pf.Borders.Distance = 0;
|
||||||
pf.Alignment = ParagraphAlignment.Left;
|
pf.Alignment = ParagraphAlignment.Left;
|
||||||
return pf;
|
return pf;
|
||||||
}
|
}
|
||||||
@@ -533,126 +580,21 @@ public static class FuchsPdf
|
|||||||
AddStyle(doc, "InvoiceNotes_ucb", "InvoiceNotes", s => s.Font = new Font(tgtFont, 9) { Bold = true });
|
AddStyle(doc, "InvoiceNotes_ucb", "InvoiceNotes", s => s.Font = new Font(tgtFont, 9) { Bold = true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── ApplyInvoice ──────────────────────────────────────────────────────────
|
/// <summary>
|
||||||
public static void ApplyInvoice(Document doc, FdsTextBlocks tb, FdsInvoiceData inv, bool draft = false)
|
/// Builds the ordered list of standard invoice notice paragraphs (ported from legacy
|
||||||
|
/// fuchs_fds_pdf.vb) — pure data, no MigraDoc dependency, so it can be reused by both
|
||||||
|
/// <see cref="ApplyInvoice"/> and the online editor's draft view-state (ADR: notice texts
|
||||||
|
/// are backend-authoritative — the editor renders exactly what the PDF will show).
|
||||||
|
/// </summary>
|
||||||
|
public static List<PdfNote> BuildInvoiceNotes(FdsTextBlocks tb, FdsInvoiceData inv, bool p13b)
|
||||||
{
|
{
|
||||||
string invoiceType = inv.InvoiceType;
|
var notes = new List<PdfNote>();
|
||||||
bool p13b = inv.InvoiceRegistration?.getString("InvoiceOptions")
|
|
||||||
.Split(',').Contains("§13b") ?? false;
|
|
||||||
Apply_Invoice_Styles(doc);
|
|
||||||
|
|
||||||
var sec = doc.Sections.Cast<Section>().First();
|
|
||||||
sec.PageSetup.RightMargin = cm(1.5);
|
|
||||||
|
|
||||||
// Title
|
|
||||||
{
|
|
||||||
var p = sec.AddParagraph();
|
|
||||||
p.Style = "SubjectBig";
|
|
||||||
p.Format.SpaceBefore = cm(8.65);
|
|
||||||
p.AddText($"{inv.InvoiceTitle} Nr. {inv.InvoiceId}");
|
|
||||||
p.Format.SpaceAfter = cm(0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Provision location
|
|
||||||
if (inv.ProvisionLocation.Length > 0)
|
|
||||||
{
|
|
||||||
var p = sec.AddParagraph();
|
|
||||||
p.Style = "AddressBox";
|
|
||||||
p.Format.Font.Size = 10;
|
|
||||||
p.Format.LineSpacingRule = LineSpacingRule.Exactly;
|
|
||||||
p.Format.LineSpacing = 12;
|
|
||||||
p.AddFormattedText(tb.ProvisionLoc_Label, TextFormat.Bold);
|
|
||||||
p.AddLineBreak();
|
|
||||||
foreach (string t in inv.ProvisionLocation) { p.AddText(t); p.AddLineBreak(); }
|
|
||||||
}
|
|
||||||
sec.AddParagraph().Format.SpaceBefore = cm(0.7);
|
|
||||||
|
|
||||||
// Invoice items table
|
|
||||||
var tbl = sec.AddTable();
|
|
||||||
tbl.Style = "Table";
|
|
||||||
tbl.Format.SpaceBefore = mm(1); tbl.Format.SpaceAfter = mm(1);
|
|
||||||
tbl.Borders.Color = Colors.White; tbl.Borders.Width = 0.25;
|
|
||||||
tbl.Borders.Left.Width = 0.5; tbl.Borders.Right.Width = 0.5;
|
|
||||||
tbl.Rows.LeftIndent = 0; tbl.Rows.Height = cm(0.6);
|
|
||||||
tbl.Rows.HeightRule = RowHeightRule.AtLeast;
|
|
||||||
|
|
||||||
double pageW = sec.PageSetup.PageWidth.Centimeter
|
|
||||||
- sec.PageSetup.LeftMargin.Centimeter
|
|
||||||
- sec.PageSetup.RightMargin.Centimeter;
|
|
||||||
double[] colWidths = { pageW * 0.06, pageW * 0.42, pageW * 0.12, pageW * 0.18, pageW * 0.22 };
|
|
||||||
foreach (double w in colWidths) tbl.AddColumn(cm(w));
|
|
||||||
|
|
||||||
// Header row
|
|
||||||
string[] headers = { "Pos.", "Bezeichnung", "Menge", "Einzelpreis", "Gesamtpreis" };
|
|
||||||
var hRow = tbl.AddRow();
|
|
||||||
hRow.Shading = GrayShading();
|
|
||||||
for (int i = 0; i < headers.Length; i++)
|
|
||||||
{
|
|
||||||
var p = hRow.Cells[i].AddParagraph(); p.Style = "TblCell_Head";
|
|
||||||
p.AddFormattedText(headers[i], TextFormat.Bold);
|
|
||||||
hRow.Cells[i].Format.Alignment = i >= 2 ? ParagraphAlignment.Right : ParagraphAlignment.Left;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Data rows — resolved through the set-display mode (see InvoiceSetPricing).
|
|
||||||
// For invoices without sets this passes items through unchanged; for sets it
|
|
||||||
// emits set header + members per the chosen mode, blanking price cells where
|
|
||||||
// a line should show no price. Totals come from the registration balance, so
|
|
||||||
// the mode is purely presentational.
|
|
||||||
var setMode = InvoiceSetPricing.ModeFromInvoiceOptions(inv.InvoiceRegistration?.getString("InvoiceOptions"));
|
|
||||||
int pos = 1;
|
|
||||||
foreach (var line in InvoiceSetPricing.Build(inv.InvoiceItems, setMode))
|
|
||||||
{
|
|
||||||
var row = tbl.AddRow();
|
|
||||||
row.HeightRule = RowHeightRule.Auto;
|
|
||||||
row.Cells[0].AddParagraph(pos.ToString()).Style = "TblCell_Base";
|
|
||||||
var titleCell = row.Cells[1].AddParagraph();
|
|
||||||
titleCell.Style = "TblCell_RTitle";
|
|
||||||
if (line.IsSetHeader) titleCell.AddFormattedText(line.Title, TextFormat.Bold);
|
|
||||||
else titleCell.AddText(line.Title);
|
|
||||||
if (!string.IsNullOrEmpty(line.Desc)) row.Cells[1].AddHtml($"<div>{line.Desc}</div>");
|
|
||||||
row.Cells[2].AddParagraph(line.Qty).Style = "TblCell_Base";
|
|
||||||
row.Cells[3].AddParagraph(line.ShowPrice ? Currency(line.PriceNet) : "").Style = "TblCell_Base";
|
|
||||||
row.Cells[4].AddParagraph(line.ShowPrice ? Currency(line.TotalNet) : "").Style = "TblCell_RSum";
|
|
||||||
row.Cells[2].Format.Alignment = ParagraphAlignment.Right;
|
|
||||||
row.Cells[3].Format.Alignment = ParagraphAlignment.Right;
|
|
||||||
row.Cells[4].Format.Alignment = ParagraphAlignment.Right;
|
|
||||||
pos++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Totals
|
|
||||||
void TotalRow(string label, string value, string style = "TblCell_TSum", bool topBorder = false)
|
|
||||||
{
|
|
||||||
var r = tbl.AddRow(); r.Shading = LightShading();
|
|
||||||
if (topBorder) r.Borders.Top.Width = 0.5;
|
|
||||||
var lp = r.Cells[3].AddParagraph(); lp.Style = style; lp.AddText(label);
|
|
||||||
r.Cells[3].Format.Alignment = ParagraphAlignment.Left;
|
|
||||||
var vp = r.Cells[4].AddParagraph(); vp.Style = style; vp.AddText(value);
|
|
||||||
r.Cells[4].Format.Alignment = ParagraphAlignment.Right;
|
|
||||||
}
|
|
||||||
|
|
||||||
ParseDec(inv.InvoiceRegistration?.getItem("InvoiceBalance_net"), out decimal netSum);
|
|
||||||
TotalRow("Nettobetrag:", Currency(netSum), "TblCell_TNet", topBorder: true);
|
|
||||||
|
|
||||||
// VAT rows
|
|
||||||
foreach (var vr in inv.VatRows)
|
|
||||||
{
|
|
||||||
string pct = vr.Key;
|
|
||||||
ParseDec(vr.Value.no("vat_amount", 0), out decimal vatAmt);
|
|
||||||
TotalRow($"MwSt. {pct}%:", Currency(vatAmt), "TblCell_TVat");
|
|
||||||
}
|
|
||||||
|
|
||||||
ParseDec(inv.InvoiceRegistration?.getItem("InvoiceBalance"), out decimal gross);
|
|
||||||
TotalRow("Rechnungsbetrag:", Currency(gross), "TblCell_TSum");
|
|
||||||
|
|
||||||
|
|
||||||
// ── Standard invoice notes (ported from legacy fuchs_fds_pdf.vb) ──────────
|
|
||||||
var reg = inv.InvoiceRegistration;
|
|
||||||
void Note(string text, string style = "InvoiceNotes")
|
void Note(string text, string style = "InvoiceNotes")
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(text)) return;
|
if (!string.IsNullOrEmpty(text)) notes.Add(new PdfNote(text, style));
|
||||||
var np = sec.AddParagraph(); np.Style = style; np.AddText(text);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var reg = inv.InvoiceRegistration;
|
||||||
ParseDec(reg?.getItem("InvoiceService"), out decimal invService);
|
ParseDec(reg?.getItem("InvoiceService"), out decimal invService);
|
||||||
string serviceGross = Currency(reg?.getItem("InvoiceService"));
|
string serviceGross = Currency(reg?.getItem("InvoiceService"));
|
||||||
|
|
||||||
@@ -729,8 +671,182 @@ public static class FuchsPdf
|
|||||||
"die Umwelt zu schützen. Deshalb konzentrieren wir uns auf die Aufforstung von Flächen.");
|
"die Umwelt zu schützen. Deshalb konzentrieren wir uns auf die Aufforstung von Flächen.");
|
||||||
Note("Wir bedanken uns herzlich für Ihren Auftrag.");
|
Note("Wir bedanken uns herzlich für Ihren Auftrag.");
|
||||||
|
|
||||||
|
return notes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the reminder intro/closing notice paragraphs (greeting + closing bank/amount and
|
||||||
|
/// "gegenstandslos"/Mahnzinsen sentences), keyed by the actual reminder type. Pure data — no
|
||||||
|
/// MigraDoc dependency — reused by <see cref="ApplyReminder"/> and the online editor's draft
|
||||||
|
/// view-state so the editor shows exactly what the PDF will show.
|
||||||
|
/// </summary>
|
||||||
|
public static (string[] Intro, string[] Closing) BuildReminderNotes(FdsTextBlocks tb, FdsReminderData rem)
|
||||||
|
{
|
||||||
|
string rtype = rem.ReminderType;
|
||||||
|
string[] intro = tb.ReminderTexts_before.TryGetValue(rtype, out var i) ? i : Array.Empty<string>();
|
||||||
|
|
||||||
|
ParseDec(rem.ReminderRegistration?.getItem("amount_open"), out decimal openTotal);
|
||||||
|
string terms = TranslatePaymentTerm(rem.ReminderRegistration?.getString("PaymentTerm") ?? "");
|
||||||
|
|
||||||
|
var closing = new List<string>();
|
||||||
|
switch (rtype)
|
||||||
|
{
|
||||||
|
case "f":
|
||||||
|
closing.Add(
|
||||||
|
$"Bitte überweisen Sie den offenen Betrag von {Currency(openTotal)} innerhalb von {terms} auf unser Konto:\n" +
|
||||||
|
"IBAN: DE76\u00a03005\u00a00110\u00a00045\u00a00148\u00a000, BIC DUSSSDEDDXXX (Stadtsparkasse Düsseldorf)");
|
||||||
|
closing.Add("Sollten Sie den Betrag bereits überwiesen haben, so bitten wir Sie, diese Zahlungserinnerung als gegenstandslos zu betrachten.");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
if (tb.ReminderClosingExtra.TryGetValue(rtype, out var extra)) closing.Add(extra);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return (intro, closing.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ApplyInvoice ──────────────────────────────────────────────────────────
|
||||||
|
public static void ApplyInvoice(Document doc, FdsTextBlocks tb, FdsInvoiceData inv, bool draft = false)
|
||||||
|
{
|
||||||
|
string invoiceType = inv.InvoiceType;
|
||||||
|
bool p13b = inv.InvoiceRegistration?.getString("InvoiceOptions")
|
||||||
|
.Split(',').Contains("§13b") ?? false;
|
||||||
|
Apply_Invoice_Styles(doc);
|
||||||
|
|
||||||
|
var sec = doc.Sections.Cast<Section>().First();
|
||||||
|
sec.PageSetup.RightMargin = cm(1.5);
|
||||||
|
|
||||||
|
// Title
|
||||||
|
{
|
||||||
|
var p = sec.AddParagraph();
|
||||||
|
p.Style = "SubjectBig";
|
||||||
|
p.Format.SpaceBefore = cm(8.65);
|
||||||
|
p.AddText($"{inv.InvoiceTitle} Nr. {inv.InvoiceId}");
|
||||||
|
p.Format.SpaceAfter = cm(0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provision location
|
||||||
|
if (inv.ProvisionLocation.Length > 0)
|
||||||
|
{
|
||||||
|
var p = sec.AddParagraph();
|
||||||
|
p.Style = "AddressBox";
|
||||||
|
p.Format.Font.Size = 10;
|
||||||
|
p.Format.LineSpacingRule = LineSpacingRule.Exactly;
|
||||||
|
p.Format.LineSpacing = 12;
|
||||||
|
p.AddFormattedText(tb.ProvisionLoc_Label, TextFormat.Bold);
|
||||||
|
p.AddLineBreak();
|
||||||
|
foreach (string t in inv.ProvisionLocation) { p.AddText(t); p.AddLineBreak(); }
|
||||||
|
}
|
||||||
|
sec.AddParagraph().Format.SpaceBefore = cm(0.7);
|
||||||
|
|
||||||
|
// Invoice items table
|
||||||
|
var tbl = sec.AddTable();
|
||||||
|
tbl.Style = "Table";
|
||||||
|
tbl.Format.SpaceBefore = mm(1); tbl.Format.SpaceAfter = mm(1);
|
||||||
|
tbl.Borders.Color = Colors.White; tbl.Borders.Width = 0.25;
|
||||||
|
tbl.Borders.Left.Width = 0.5; tbl.Borders.Right.Width = 0.5;
|
||||||
|
tbl.Rows.LeftIndent = 0; tbl.Rows.Height = cm(0.6);
|
||||||
|
tbl.Rows.HeightRule = RowHeightRule.AtLeast;
|
||||||
|
|
||||||
|
double pageW = sec.PageSetup.PageWidth.Centimeter
|
||||||
|
- sec.PageSetup.LeftMargin.Centimeter
|
||||||
|
- sec.PageSetup.RightMargin.Centimeter;
|
||||||
|
double[] colWidths = { pageW * 0.06, pageW * 0.42, pageW * 0.12, pageW * 0.18, pageW * 0.22 };
|
||||||
|
foreach (double w in colWidths) tbl.AddColumn(cm(w));
|
||||||
|
|
||||||
|
// Header row
|
||||||
|
string[] headers = { "Pos.", "Bezeichnung", "Menge", "Einzelpreis", "Gesamtpreis" };
|
||||||
|
var hRow = tbl.AddRow();
|
||||||
|
hRow.Shading = GrayShading();
|
||||||
|
for (int i = 0; i < headers.Length; i++)
|
||||||
|
{
|
||||||
|
var p = hRow.Cells[i].AddParagraph(); p.Style = "TblCell_Head";
|
||||||
|
p.AddFormattedText(headers[i], TextFormat.Bold);
|
||||||
|
hRow.Cells[i].Format.Alignment = i >= 2 ? ParagraphAlignment.Right : ParagraphAlignment.Left;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data rows — grouped by service-request block (see FdsInvoiceData.InvoiceBlocks).
|
||||||
|
// Each section prints its heading; positions are numbered the same way the editor numbers
|
||||||
|
// them (every line except free-text/heading lines, including a set header). The chosen
|
||||||
|
// set-display mode (see INVOICE_SET_PRICING.md) governs how "set" items and their members
|
||||||
|
// are shown: SetPrice (default) prices the set, blanks members; SetOnly prices the set
|
||||||
|
// and drops the members entirely.
|
||||||
|
// Blocks without any set items are unaffected and always render flat.
|
||||||
|
var setMode = InvoiceSetPricing.ModeFromInvoiceOptions(inv.InvoiceRegistration?.getString("InvoiceOptions"));
|
||||||
|
int pos = 0;
|
||||||
|
foreach (var block in inv.InvoiceBlocks)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(block.Heading))
|
||||||
|
{
|
||||||
|
var hr = tbl.AddRow();
|
||||||
|
hr.HeightRule = RowHeightRule.Auto;
|
||||||
|
hr.Cells[1].MergeRight = 3; // span Bezeichnung … Gesamtpreis
|
||||||
|
hr.Cells[1].AddParagraph().WithStyle("TblCell_RTitle").AddFormattedText(block.Heading, TextFormat.Bold);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function-1 (SetItmId-linked) sets still collapse via Build; block-mode sets (ADR 0009)
|
||||||
|
// and plain blocks render flat — their set row is emphasised on its own and nulled
|
||||||
|
// members map to blank cells (see MapItemToLine).
|
||||||
|
var lines = InvoiceSetPricing.HasSetMembers(block.Items)
|
||||||
|
? InvoiceSetPricing.Build(block.Items, setMode)
|
||||||
|
: block.Items.Select(MapItemToLine).ToList();
|
||||||
|
|
||||||
|
foreach (var line in lines)
|
||||||
|
{
|
||||||
|
bool numbered = line.Numbered; // every line except free-text/heading (mirrors the editor's RecomputePositions), even price-blanked set members
|
||||||
|
var row = tbl.AddRow();
|
||||||
|
row.HeightRule = RowHeightRule.Auto;
|
||||||
|
row.Cells[0].AddParagraph(numbered ? (++pos).ToString() : "").Style = "TblCell_Base";
|
||||||
|
if (!string.IsNullOrEmpty(line.Title)) // skip the empty paragraph that added a blank line before free text
|
||||||
|
{
|
||||||
|
var titleCell = row.Cells[1].AddParagraph();
|
||||||
|
titleCell.Style = "TblCell_RTitle";
|
||||||
|
if (line.IsSetHeader) titleCell.AddFormattedText(line.Title, TextFormat.Bold);
|
||||||
|
else titleCell.AddText(line.Title);
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrEmpty(line.Desc)) row.Cells[1].AddHtml($"<div>{line.Desc}</div>");
|
||||||
|
row.Cells[2].AddParagraph(line.Qty).Style = "TblCell_Base";
|
||||||
|
row.Cells[3].AddParagraph(line.ShowPrice ? Currency(line.PriceNet) : "").Style = "TblCell_Base";
|
||||||
|
row.Cells[4].AddParagraph(line.ShowPrice ? Currency(line.TotalNet) : "").Style = "TblCell_RSum";
|
||||||
|
row.Cells[2].Format.Alignment = ParagraphAlignment.Right;
|
||||||
|
row.Cells[3].Format.Alignment = ParagraphAlignment.Right;
|
||||||
|
row.Cells[4].Format.Alignment = ParagraphAlignment.Right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Totals
|
||||||
|
void TotalRow(string label, string value, string style = "TblCell_TSum", bool topBorder = false)
|
||||||
|
{
|
||||||
|
var r = tbl.AddRow(); r.Shading = LightShading();
|
||||||
|
if (topBorder) r.Borders.Top.Width = 0.5;
|
||||||
|
var lp = r.Cells[3].AddParagraph(); lp.Style = style; lp.AddText(label);
|
||||||
|
r.Cells[3].Format.Alignment = ParagraphAlignment.Left;
|
||||||
|
var vp = r.Cells[4].AddParagraph(); vp.Style = style; vp.AddText(value);
|
||||||
|
r.Cells[4].Format.Alignment = ParagraphAlignment.Right;
|
||||||
|
}
|
||||||
|
|
||||||
|
ParseDec(inv.InvoiceRegistration?.getItem("InvoiceBalance_net"), out decimal netSum);
|
||||||
|
TotalRow("Nettobetrag:", Currency(netSum), "TblCell_TNet", topBorder: true);
|
||||||
|
|
||||||
|
// VAT rows
|
||||||
|
foreach (var vr in inv.VatRows)
|
||||||
|
{
|
||||||
|
string pct = vr.Key;
|
||||||
|
ParseDec(vr.Value.no("vat_amount", 0), out decimal vatAmt);
|
||||||
|
TotalRow($"MwSt. {pct}%:", Currency(vatAmt), "TblCell_TVat");
|
||||||
|
}
|
||||||
|
|
||||||
|
ParseDec(inv.InvoiceRegistration?.getItem("InvoiceBalance"), out decimal gross);
|
||||||
|
TotalRow("Rechnungsbetrag:", Currency(gross), "TblCell_TSum");
|
||||||
|
|
||||||
|
|
||||||
|
// ── Standard invoice notes (ported from legacy fuchs_fds_pdf.vb) ──────────
|
||||||
|
foreach (var note in BuildInvoiceNotes(tb, inv, p13b))
|
||||||
|
{
|
||||||
|
var np = sec.AddParagraph(); np.Style = note.Style; np.AddText(note.Text);
|
||||||
|
}
|
||||||
|
|
||||||
// GiroCode payment QR (only on finalized invoices with a positive balance)
|
// GiroCode payment QR (only on finalized invoices with a positive balance)
|
||||||
ParseDec(reg?.getItem("InvoiceBalance"), out decimal payAmount);
|
ParseDec(inv.InvoiceRegistration?.getItem("InvoiceBalance"), out decimal payAmount);
|
||||||
if (!inv.IsDraft && payAmount > 0 && !string.IsNullOrWhiteSpace(inv.InvoiceId))
|
if (!inv.IsDraft && payAmount > 0 && !string.IsNullOrWhiteSpace(inv.InvoiceId))
|
||||||
AddGirocode(sec, payAmount, $"{inv.InvoiceTitle.ne("Rechnung")} {inv.InvoiceId}");
|
AddGirocode(sec, payAmount, $"{inv.InvoiceTitle.ne("Rechnung")} {inv.InvoiceId}");
|
||||||
}
|
}
|
||||||
@@ -752,31 +868,37 @@ public static class FuchsPdf
|
|||||||
p.AddText(rem.ReminderTitle.ne("Zahlungserinnerung"));
|
p.AddText(rem.ReminderTitle.ne("Zahlungserinnerung"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (introLines, closingLines) = BuildReminderNotes(tb, rem);
|
||||||
|
|
||||||
// Opening text
|
// Opening text
|
||||||
if (tb.ReminderTexts_before.TryGetValue(rtype, out var intro))
|
if (introLines.Length > 0)
|
||||||
{
|
{
|
||||||
var p = sec.AddParagraph(); p.Style = "BodyText";
|
var p = sec.AddParagraph(); p.Style = "BodyText";
|
||||||
p.Format.SpaceBefore = cm(0.5);
|
p.Format.SpaceBefore = cm(0.5);
|
||||||
foreach (string line in intro) { p.AddText(line); p.AddLineBreak(); }
|
foreach (string line in introLines) { p.AddText(line); p.AddLineBreak(); }
|
||||||
}
|
}
|
||||||
|
sec.AddParagraph().Format.SpaceBefore = cm(0.7);
|
||||||
|
|
||||||
// Reminder items table
|
// Reminder items table — mirrors the online editor's remHR columns exactly
|
||||||
|
// (Rechnung, vom, Rechnungsbetrag, bereits bezahlt, noch offen).
|
||||||
var tbl = sec.AddTable();
|
var tbl = sec.AddTable();
|
||||||
tbl.Style = "Table";
|
tbl.Style = "Table";
|
||||||
|
tbl.Format.SpaceBefore = mm(1); tbl.Format.SpaceAfter = mm(1);
|
||||||
tbl.Borders.Color = Colors.White; tbl.Borders.Width = 0.25;
|
tbl.Borders.Color = Colors.White; tbl.Borders.Width = 0.25;
|
||||||
|
tbl.Borders.Left.Width = 0.5; tbl.Borders.Right.Width = 0.5;
|
||||||
tbl.Rows.LeftIndent = 0; tbl.Rows.Height = cm(0.6);
|
tbl.Rows.LeftIndent = 0; tbl.Rows.Height = cm(0.6);
|
||||||
tbl.Rows.HeightRule = RowHeightRule.AtLeast;
|
tbl.Rows.HeightRule = RowHeightRule.AtLeast;
|
||||||
tbl.Format.SpaceBefore = mm(4);
|
|
||||||
|
|
||||||
double pageW = sec.PageSetup.PageWidth.Centimeter
|
double pageW = sec.PageSetup.PageWidth.Centimeter
|
||||||
- sec.PageSetup.LeftMargin.Centimeter
|
- sec.PageSetup.LeftMargin.Centimeter
|
||||||
- sec.PageSetup.RightMargin.Centimeter;
|
- sec.PageSetup.RightMargin.Centimeter;
|
||||||
|
tbl.AddColumn(cm(pageW * 0.20));
|
||||||
tbl.AddColumn(cm(pageW * 0.15));
|
tbl.AddColumn(cm(pageW * 0.15));
|
||||||
tbl.AddColumn(cm(pageW * 0.45));
|
tbl.AddColumn(cm(pageW * 0.22));
|
||||||
tbl.AddColumn(cm(pageW * 0.20));
|
tbl.AddColumn(cm(pageW * 0.21));
|
||||||
tbl.AddColumn(cm(pageW * 0.20));
|
tbl.AddColumn(cm(pageW * 0.22));
|
||||||
|
|
||||||
string[] headers = { "Datum", "Bezeichnung", "Betrag", "Offen" };
|
string[] headers = { "Rechnung", "vom", "Rechnungsbetrag", "bereits bezahlt", "noch offen" };
|
||||||
var hRow = tbl.AddRow(); hRow.Shading = GrayShading();
|
var hRow = tbl.AddRow(); hRow.Shading = GrayShading();
|
||||||
for (int i = 0; i < headers.Length; i++)
|
for (int i = 0; i < headers.Length; i++)
|
||||||
{
|
{
|
||||||
@@ -785,33 +907,39 @@ public static class FuchsPdf
|
|||||||
hRow.Cells[i].Format.Alignment = i >= 2 ? ParagraphAlignment.Right : ParagraphAlignment.Left;
|
hRow.Cells[i].Format.Alignment = i >= 2 ? ParagraphAlignment.Right : ParagraphAlignment.Left;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var itm in rem.ReminderItems)
|
ParseDec(rem.ReminderRegistration?.getItem("amount"), out decimal amountTotal);
|
||||||
{
|
ParseDec(rem.ReminderRegistration?.getItem("amount_payed"), out decimal amountPayed);
|
||||||
var row = tbl.AddRow(); row.HeightRule = RowHeightRule.Auto;
|
ParseDec(rem.ReminderRegistration?.getItem("amount_open"), out decimal openTotal);
|
||||||
row.Cells[0].AddParagraph(itm.nz("InvoiceDate", ""));
|
string invoiceDate = rem.ReminderRegistration?.getString("InvoiceDate") is { Length: > 0 } d
|
||||||
row.Cells[1].AddParagraph(itm.nz("DocumentName", "").ne(itm.nz("InvoiceTitle", "")));
|
&& DateTime.TryParse(d, out var dd) ? dd.ToString("dd.MM.yy") : "";
|
||||||
row.Cells[2].AddParagraph(Currency(itm.no("InvoiceBalance", 0)));
|
|
||||||
row.Cells[3].AddParagraph(Currency(itm.no("amount_open", 0)));
|
var row = tbl.AddRow(); row.HeightRule = RowHeightRule.Auto;
|
||||||
row.Cells[2].Format.Alignment = ParagraphAlignment.Right;
|
row.Cells[0].AddParagraph(rem.InvoiceId);
|
||||||
row.Cells[3].Format.Alignment = ParagraphAlignment.Right;
|
row.Cells[1].AddParagraph(invoiceDate);
|
||||||
}
|
row.Cells[2].AddParagraph(Currency(amountTotal));
|
||||||
|
row.Cells[3].AddParagraph(Currency(amountPayed));
|
||||||
|
row.Cells[4].AddParagraph(Currency(openTotal));
|
||||||
|
for (int i = 2; i <= 4; i++) row.Cells[i].Format.Alignment = ParagraphAlignment.Right;
|
||||||
|
|
||||||
// Open total
|
// Open total
|
||||||
ParseDec(rem.ReminderRegistration?.getItem("amount_open"), out decimal openTotal);
|
|
||||||
var sumRow = tbl.AddRow(); sumRow.Shading = LightShading();
|
var sumRow = tbl.AddRow(); sumRow.Shading = LightShading();
|
||||||
sumRow.Borders.Top.Width = 0.5;
|
sumRow.Borders.Top.Width = 0.5;
|
||||||
sumRow.Cells[2].AddParagraph().AddFormattedText("Offener Betrag:", TextFormat.Bold);
|
sumRow.Cells[3].AddParagraph().AddFormattedText("Offener Betrag:", TextFormat.Bold);
|
||||||
var sv = sumRow.Cells[3].AddParagraph();
|
var sv = sumRow.Cells[4].AddParagraph();
|
||||||
sv.AddFormattedText(Currency(openTotal), TextFormat.Bold);
|
sv.AddFormattedText(Currency(openTotal), TextFormat.Bold);
|
||||||
sumRow.Cells[3].Format.Alignment = ParagraphAlignment.Right;
|
sumRow.Cells[4].Format.Alignment = ParagraphAlignment.Right;
|
||||||
|
|
||||||
// Closing text + QR code
|
// Closing text + QR code
|
||||||
var closing = sec.AddParagraph(); closing.Style = "BodyText";
|
if (closingLines.Length > 0)
|
||||||
closing.Format.SpaceBefore = cm(0.8);
|
{
|
||||||
string terms = TranslatePaymentTerm(rem.ReminderRegistration?.getString("PaymentTerm") ?? "");
|
var closing = sec.AddParagraph(); closing.Style = "BodyText";
|
||||||
closing.AddText(
|
closing.Format.SpaceBefore = cm(0.8);
|
||||||
$"Bitte \u00fcberweisen Sie den offenen Betrag von {Currency(openTotal)} innerhalb von {terms} auf unser Konto:\n" +
|
for (int i = 0; i < closingLines.Length; i++)
|
||||||
"IBAN: DE76\u00a03005\u00a00110\u00a00045\u00a00148\u00a000, BIC DUSSSDEDDXXX (Stadtsparkasse D\u00fcsseldorf)");
|
{
|
||||||
|
if (i > 0) closing.AddLineBreak();
|
||||||
|
closing.AddText(closingLines[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// GiroCode payment QR (only on finalized reminders with a positive open amount)
|
// GiroCode payment QR (only on finalized reminders with a positive open amount)
|
||||||
if (!rem.IsDraft && openTotal > 0 && !string.IsNullOrWhiteSpace(rem.InvoiceId))
|
if (!rem.IsDraft && openTotal > 0 && !string.IsNullOrWhiteSpace(rem.InvoiceId))
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
|
namespace Fuchs.intranet;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Server-side, pure aggregation of an invoice draft's totals/VAT — the authoritative
|
||||||
|
/// replacement for the browser's <c>invSumUpdate</c> footer math (ADR 0006). The user's
|
||||||
|
/// requirement is that the <b>sums</b> live in the backend cache, not the frontend.
|
||||||
|
///
|
||||||
|
/// It reads each block's persisted line contract (<c>block.itm</c> = 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) — exactly the shape the editor already posts and the PDF/persistence
|
||||||
|
/// already consume — so no line data is re-derived or re-shaped. It then applies the §13b
|
||||||
|
/// reverse-charge rule and validates. Static/pure, hence exhaustively unit-testable.
|
||||||
|
/// </summary>
|
||||||
|
public static class InvoiceDraftCalculator
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Recomputes every line's own net/VAT/service values from its raw quantity (<c>qn</c>),
|
||||||
|
/// unit price (<c>v</c>) and VAT rate (<c>vat</c>) — the authoritative, server-side port of
|
||||||
|
/// the former client-side <c>quantChange</c>/<c>setVat</c> multiplication (ADR 0006/0008: the
|
||||||
|
/// online editor performs no arithmetic at all, not even a single line's net = qty × price).
|
||||||
|
/// Only lines that actually carry a raw quantity and a positive unit price are recomputed
|
||||||
|
/// (mirrors <c>quantChange</c>'s own guard); lines without both (headings, free text,
|
||||||
|
/// combined-sum rows, and set members that have been zeroed by
|
||||||
|
/// <see cref="Fuchs.Services.InvoiceDraftEditService"/>'s "Auf Setpreis umstellen" conversion)
|
||||||
|
/// keep whatever value they already carry, so a set header's synthesised sum is never
|
||||||
|
/// clobbered by a subsequent recompute.
|
||||||
|
/// </summary>
|
||||||
|
public static void RecomputeLineValues(InvoiceDraftSession session)
|
||||||
|
{
|
||||||
|
foreach (var blockTok in session.Req)
|
||||||
|
{
|
||||||
|
if (blockTok is not JObject block || block["itm"] is not JArray lines) continue;
|
||||||
|
foreach (var lineTok in lines)
|
||||||
|
{
|
||||||
|
if (lineTok is not JObject co) continue;
|
||||||
|
decimal qty = Dec(co["qn"]);
|
||||||
|
decimal price = Dec(co["v"]);
|
||||||
|
if (qty <= 0 || price <= 0) continue; // no raw qty/price posted -> leave the value as delivered
|
||||||
|
|
||||||
|
string rate = NormalizeRate(Str(co["vat"]));
|
||||||
|
decimal vatFactor = rate.Length > 0 && decimal.TryParse(rate, NumberStyles.Any, CultureInfo.InvariantCulture, out decimal r)
|
||||||
|
? r / 100m : 0m;
|
||||||
|
decimal netVal = Math.Round(qty * price, 2, MidpointRounding.AwayFromZero);
|
||||||
|
decimal vatVal = Math.Round(netVal * vatFactor, 2, MidpointRounding.AwayFromZero);
|
||||||
|
|
||||||
|
co["vt"] = netVal;
|
||||||
|
co["vv"] = vatVal;
|
||||||
|
bool isService = string.Equals(Str(co["typ"]), "Service", StringComparison.OrdinalIgnoreCase);
|
||||||
|
co["vs"] = isService ? netVal : 0;
|
||||||
|
co["vsv"] = isService ? vatVal : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Aggregates every block's line values into the draft's totals — the port of
|
||||||
|
/// <c>invSumUpdate</c>'s <c>csms</c> accumulation plus §13b (VAT suppressed → gross = net).
|
||||||
|
/// VAT is grouped by the line's rate string (matching the editor's <c>sms.vat</c> map).
|
||||||
|
/// </summary>
|
||||||
|
public static void RecomputeTotals(InvoiceDraftSession session)
|
||||||
|
{
|
||||||
|
var sums = new InvoiceDraftSums();
|
||||||
|
bool p13b = Flag(session.Admin, "p13b");
|
||||||
|
|
||||||
|
foreach (var blockTok in session.Req)
|
||||||
|
{
|
||||||
|
if (blockTok is not JObject block) continue;
|
||||||
|
decimal blockNet = 0;
|
||||||
|
string blockId = Str(block["Id"]);
|
||||||
|
if (block["itm"] is JArray lines)
|
||||||
|
{
|
||||||
|
foreach (var lineTok in lines)
|
||||||
|
{
|
||||||
|
if (lineTok is not JObject co) continue;
|
||||||
|
decimal netVal = Dec(co["vt"]);
|
||||||
|
decimal vatVal = Dec(co["vv"]);
|
||||||
|
decimal svcNet = Dec(co["vs"]);
|
||||||
|
decimal svcVat = Dec(co["vsv"]);
|
||||||
|
|
||||||
|
sums.ServiceNet += svcNet;
|
||||||
|
sums.ServiceVat += svcVat;
|
||||||
|
sums.TotalNet += netVal;
|
||||||
|
sums.TotalVat += vatVal;
|
||||||
|
sums.TotalGross += netVal + vatVal;
|
||||||
|
blockNet += netVal;
|
||||||
|
|
||||||
|
string rate = NormalizeRate(Str(co["vat"]));
|
||||||
|
if (rate.Length > 0)
|
||||||
|
sums.VatByRate[rate] = sums.VatByRate.GetValueOrDefault(rate) + vatVal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrEmpty(blockId))
|
||||||
|
sums.NetByBlock[blockId] = sums.NetByBlock.GetValueOrDefault(blockId) + blockNet;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p13b)
|
||||||
|
{
|
||||||
|
// Reverse-charge: no VAT lines, gross equals net (mirrors invSumUpdate's else-branch).
|
||||||
|
sums.TotalGross = sums.TotalNet;
|
||||||
|
sums.TotalVat = 0;
|
||||||
|
sums.VatByRate.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
session.Sums = sums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renumbers the visible line positions authoritatively (the port of the client-side
|
||||||
|
/// numbering in <c>invSumUpdate</c>): priced lines are numbered sequentially across the whole
|
||||||
|
/// invoice — matching the PDF's <c>Pos.</c> column — while heading/free-text lines
|
||||||
|
/// (<c>typ</c> = "text"/"title") carry no number. The result is written onto each line's
|
||||||
|
/// <c>p</c> field so it flows back to the browser (via the view state) and into the PDF; this
|
||||||
|
/// keeps the online editor and the PDF preview showing the same position numbers, including
|
||||||
|
/// after a reorder.
|
||||||
|
/// </summary>
|
||||||
|
public static void RecomputePositions(InvoiceDraftSession session)
|
||||||
|
{
|
||||||
|
int pos = 0;
|
||||||
|
foreach (var blockTok in session.Req)
|
||||||
|
{
|
||||||
|
if (blockTok is not JObject block || block["itm"] is not JArray lines) continue;
|
||||||
|
foreach (var lineTok in lines)
|
||||||
|
{
|
||||||
|
if (lineTok is not JObject co) continue;
|
||||||
|
string typ = Str(co["typ"]).Trim().ToLowerInvariant();
|
||||||
|
bool numbered = typ is not ("text" or "title"); // only headings/free-text carry no number (mirrors invSumUpdate)
|
||||||
|
co["p"] = numbered ? (JToken)(++pos) : (JToken)"";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Refreshes the draft's plausibility / consistency findings. "error" severity marks
|
||||||
|
/// issues that should block a clean finalise; "warning" is advisory. Kept in German,
|
||||||
|
/// user-readable, so the frontend can render them directly.
|
||||||
|
/// </summary>
|
||||||
|
public static void Validate(InvoiceDraftSession session)
|
||||||
|
{
|
||||||
|
session.ValidationMessages.Clear();
|
||||||
|
void Add(string field, string sev, string msg) =>
|
||||||
|
session.ValidationMessages.Add(new InvoiceDraftValidationMessage(field, sev, msg));
|
||||||
|
|
||||||
|
string email = Str(session.New["invoiceemail"]).Trim();
|
||||||
|
if (email.Length == 0)
|
||||||
|
Add("email", "warning", "Es ist keine E-Mail-Adresse hinterlegt — die Rechnung kann nicht per E-Mail versandt werden.");
|
||||||
|
else if (!IsValidEmail(email))
|
||||||
|
Add("email", "error", "Die E-Mail-Adresse ist ungültig.");
|
||||||
|
|
||||||
|
if (Str(session.New["invoiceaddress"]).Trim().Length == 0)
|
||||||
|
Add("address", "warning", "Es ist keine Rechnungsanschrift hinterlegt.");
|
||||||
|
|
||||||
|
if (!HasAnyItem(session))
|
||||||
|
Add("items", "error", "Die Rechnung enthält keine Positionen.");
|
||||||
|
|
||||||
|
if (!Flag(session.Admin, "p13b"))
|
||||||
|
foreach (var rate in session.Sums.VatByRate.Keys)
|
||||||
|
if (!IsKnownVatRate(rate))
|
||||||
|
Add("vat", "warning", $"Ungewöhnlicher Umsatzsteuersatz: {rate}%.");
|
||||||
|
|
||||||
|
if (session.Sums.TotalGross < 0)
|
||||||
|
Add("total", "warning", "Der Rechnungsbetrag ist negativ.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── helpers ──────────────────────────────────────────────────────────────
|
||||||
|
private static bool HasAnyItem(InvoiceDraftSession session)
|
||||||
|
{
|
||||||
|
foreach (var blockTok in session.Req)
|
||||||
|
if (blockTok is JObject block && block["itm"] is JArray lines && lines.Count > 0)
|
||||||
|
return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Parses a JToken to a decimal, tolerating German ("12,50") and invariant ("12.50") strings and "%".</summary>
|
||||||
|
internal static decimal Dec(JToken? token)
|
||||||
|
{
|
||||||
|
if (token == null || token.Type == JTokenType.Null) return 0;
|
||||||
|
if (token.Type is JTokenType.Float or JTokenType.Integer) return token.Value<decimal>();
|
||||||
|
return FuchsPdf.ParseDec(Str(token), out decimal d) ? d : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Str(JToken? token) =>
|
||||||
|
token == null || token.Type == JTokenType.Null ? "" : token.Type == JTokenType.String ? token.Value<string>() ?? "" : token.ToString();
|
||||||
|
|
||||||
|
private static bool Flag(JObject obj, string key)
|
||||||
|
{
|
||||||
|
var t = obj[key];
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Normalises a VAT rate string ("19,0%", "7%", "19") to a canonical numeric string ("19", "7").</summary>
|
||||||
|
internal static string NormalizeRate(string? raw)
|
||||||
|
{
|
||||||
|
string s = (raw ?? "").Replace("%", "").Trim().Replace(',', '.');
|
||||||
|
if (s.Length == 0) return "";
|
||||||
|
if (!double.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out double d) || d == 0) return "";
|
||||||
|
return d == Math.Floor(d)
|
||||||
|
? ((long)d).ToString(CultureInfo.InvariantCulture)
|
||||||
|
: d.ToString(CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsKnownVatRate(string rate) => rate is "0" or "7" or "19";
|
||||||
|
|
||||||
|
private static bool IsValidEmail(string email)
|
||||||
|
{
|
||||||
|
int at = email.IndexOf('@');
|
||||||
|
if (at <= 0 || at != email.LastIndexOf('@')) return false;
|
||||||
|
int dot = email.IndexOf('.', at);
|
||||||
|
return dot > at + 1 && dot < email.Length - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
|
namespace Fuchs.intranet;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Server-side, in-memory editing state for a single invoice draft — the
|
||||||
|
/// authoritative source of truth while a back-office user is editing a draft in
|
||||||
|
/// the browser (see ADR 0006). The browser is a pure view/input layer: it posts
|
||||||
|
/// single changes (<see cref="Fuchs.Services.InvoiceDraftDelta"/>), the server
|
||||||
|
/// mutates this session, recomputes totals/VAT (replacing the former client-side
|
||||||
|
/// <c>invSumUpdate</c>) and validates, then signals the browser to re-fetch.
|
||||||
|
///
|
||||||
|
/// This is a <b>data holder</b> only — all calculation, validation, persistence
|
||||||
|
/// and rendering live in <see cref="Fuchs.Services.IInvoiceDraftService"/>
|
||||||
|
/// (mirroring the <see cref="FdsInvoiceData"/> / <see cref="Fuchs.Services.IInvoiceService"/>
|
||||||
|
/// split). The editable payload is kept as the exact JSON shape the editor already
|
||||||
|
/// speaks (<c>admin</c> / <c>new</c> / <c>req</c>), so flushing to the DB can reuse
|
||||||
|
/// <see cref="Fuchs.Services.IInvoiceService.RegisterInvoiceAsync"/> unchanged.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class InvoiceDraftSession
|
||||||
|
{
|
||||||
|
/// <summary>Opaque per-editor token; also the SignalR group name for targeted signals.</summary>
|
||||||
|
public string Token { get; init; } = "";
|
||||||
|
|
||||||
|
/// <summary>Owning user account id (drafts are single-user; used for auth + events).</summary>
|
||||||
|
public string UserAccountId { get; init; } = "";
|
||||||
|
|
||||||
|
/// <summary>DB invoice id once the session has been flushed (Zwischenspeichern); empty while cache-only.</summary>
|
||||||
|
public string InvId { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>Always true here — sessions only ever hold unfinalised drafts.</summary>
|
||||||
|
public bool IsDraft { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>Bumped on every applied mutation; the browser refetches when the signalled version changes.</summary>
|
||||||
|
public int Version { get; set; }
|
||||||
|
|
||||||
|
/// <summary>UTC of the last read/write; drives the idle sliding-TTL and expiry warnings.</summary>
|
||||||
|
public DateTime LastAccessUtc { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
/// <summary>Guards against sending more than one expiry warning per idle window.</summary>
|
||||||
|
public bool ExpiryWarningSent { get; set; }
|
||||||
|
|
||||||
|
// ── Editable payload (exact editor JSON shape) ───────────────────────────
|
||||||
|
/// <summary>Header/admin flags: type, customerid, p13b, setmode, paymentterms…</summary>
|
||||||
|
public JObject Admin { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>Recipient/new fields: title/invoicetitle, invoiceaddress, invoiceemail, provisionlocation/-period, CustomValues…</summary>
|
||||||
|
public JObject New { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>Service-request blocks; each block is a JObject with an <c>items</c> JArray (the line items).</summary>
|
||||||
|
public JArray Req { get; set; } = new();
|
||||||
|
|
||||||
|
// ── Computed (by the draft service; never trusted from the client) ───────
|
||||||
|
/// <summary>Server-computed totals/VAT — the values the client used to compute in <c>invSumUpdate</c>.</summary>
|
||||||
|
public InvoiceDraftSums Sums { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>Plausibility / consistency results, refreshed on every recompute.</summary>
|
||||||
|
public List<InvoiceDraftValidationMessage> ValidationMessages { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>Automatic change history, appended on every applied patch. Cache-only (never persisted).</summary>
|
||||||
|
public List<ChangeHistoryEntry> History { get; } = new();
|
||||||
|
|
||||||
|
public void Touch() => LastAccessUtc = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Server-computed invoice totals — the authoritative replacement for the browser's <c>sms</c> object.</summary>
|
||||||
|
public sealed class InvoiceDraftSums
|
||||||
|
{
|
||||||
|
/// <summary>Total net (<c>ttn</c>).</summary>
|
||||||
|
public decimal TotalNet { get; set; }
|
||||||
|
/// <summary>Total gross (<c>ttb</c>); equals net when §13b reverse-charge is active.</summary>
|
||||||
|
public decimal TotalGross { get; set; }
|
||||||
|
/// <summary>Total VAT (<c>ttvat</c>).</summary>
|
||||||
|
public decimal TotalVat { get; set; }
|
||||||
|
/// <summary>Service net (<c>tscn</c>) — the service-refund base.</summary>
|
||||||
|
public decimal ServiceNet { get; set; }
|
||||||
|
/// <summary>Service VAT (<c>tscvat</c>).</summary>
|
||||||
|
public decimal ServiceVat { get; set; }
|
||||||
|
/// <summary>VAT amount per rate string (e.g. "19" → 123.45), matching the editor's <c>sms.vat</c> map.</summary>
|
||||||
|
public Dictionary<string, decimal> VatByRate { get; } = new();
|
||||||
|
/// <summary>Net per block, keyed by block id — feeds the per-block sub-sum row.</summary>
|
||||||
|
public Dictionary<string, decimal> NetByBlock { get; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A single plausibility/consistency finding for the draft.</summary>
|
||||||
|
/// <param name="Field">Logical field the message relates to (e.g. "email", "address", "items").</param>
|
||||||
|
/// <param name="Severity">"error" blocks a clean finalise; "warning"/"info" are advisory.</param>
|
||||||
|
/// <param name="Message">German, user-readable text.</param>
|
||||||
|
public readonly record struct InvoiceDraftValidationMessage(string Field, string Severity, string Message);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One automatically-recorded change in the draft's history (shown in the
|
||||||
|
/// "Änderungshistorie" dialog). Captured on every applied patch; lives only for
|
||||||
|
/// the cache lifetime of the session and is never persisted to the database.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ChangeHistoryEntry
|
||||||
|
{
|
||||||
|
public DateTime TimestampUtc { get; init; } = DateTime.UtcNow;
|
||||||
|
/// <summary>User account id that made the change.</summary>
|
||||||
|
public string UserAccountId { get; init; } = "";
|
||||||
|
/// <summary>The change target/op as sent by the editor (e.g. "item.qty", "email", "p13b").</summary>
|
||||||
|
public string Target { get; init; } = "";
|
||||||
|
/// <summary>Optional item/block id the change applied to.</summary>
|
||||||
|
public string Ref { get; init; } = "";
|
||||||
|
/// <summary>Previous value, stringified for display (may be empty).</summary>
|
||||||
|
public string OldValue { get; init; } = "";
|
||||||
|
/// <summary>New value, stringified for display (may be empty).</summary>
|
||||||
|
public string NewValue { get; init; } = "";
|
||||||
|
/// <summary>Version the session reached after applying this change.</summary>
|
||||||
|
public int Version { get; init; }
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
using static OCORE.commons;
|
using static OCORE.commons;
|
||||||
using static OCORE.OCORE_dictionaries;
|
using static OCORE.OCORE_dictionaries;
|
||||||
|
|
||||||
namespace Fuchs.intranet;
|
namespace Fuchs.intranet;
|
||||||
@@ -13,8 +13,6 @@ public enum SetDisplayMode
|
|||||||
{
|
{
|
||||||
/// <summary>Default: show the set as one priced line; member items listed without price.</summary>
|
/// <summary>Default: show the set as one priced line; member items listed without price.</summary>
|
||||||
SetPrice,
|
SetPrice,
|
||||||
/// <summary>Show each member item with its own price; the set line is a header without price.</summary>
|
|
||||||
ItemPrices,
|
|
||||||
/// <summary>Show only the set as one priced line; member items are removed entirely.</summary>
|
/// <summary>Show only the set as one priced line; member items are removed entirely.</summary>
|
||||||
SetOnly
|
SetOnly
|
||||||
}
|
}
|
||||||
@@ -22,6 +20,9 @@ public enum SetDisplayMode
|
|||||||
/// <summary>A resolved invoice display line (after applying the set display mode).</summary>
|
/// <summary>A resolved invoice display line (after applying the set display mode).</summary>
|
||||||
public sealed class InvoiceSetLine
|
public sealed class InvoiceSetLine
|
||||||
{
|
{
|
||||||
|
/// <summary>The source item's id — lets callers (e.g. the online editor) map a resolved
|
||||||
|
/// display line back to the DOM row it should update.</summary>
|
||||||
|
public string Id { get; init; } = "";
|
||||||
public string Title { get; init; } = "";
|
public string Title { get; init; } = "";
|
||||||
public string Desc { get; init; } = "";
|
public string Desc { get; init; } = "";
|
||||||
public string Qty { get; init; } = "1";
|
public string Qty { get; init; } = "1";
|
||||||
@@ -29,6 +30,14 @@ public sealed class InvoiceSetLine
|
|||||||
public decimal TotalNet { get; init; }
|
public decimal TotalNet { get; init; }
|
||||||
/// <summary>When false, the price/total cells are rendered blank (e.g. set members in SetPrice mode).</summary>
|
/// <summary>When false, the price/total cells are rendered blank (e.g. set members in SetPrice mode).</summary>
|
||||||
public bool ShowPrice { get; init; } = true;
|
public bool ShowPrice { get; init; } = true;
|
||||||
|
/// <summary>
|
||||||
|
/// Whether this line carries a position number. Mirrors the editor's numbering
|
||||||
|
/// (<c>InvoiceDraftCalculator.RecomputePositions</c>): every line is numbered <b>except</b>
|
||||||
|
/// free-text/heading lines — independent of whether a price is shown, so a price-blanked set
|
||||||
|
/// member (SetPrice mode / block-mode nulled member) is still numbered exactly like in the
|
||||||
|
/// online editor.
|
||||||
|
/// </summary>
|
||||||
|
public bool Numbered { get; init; } = true;
|
||||||
/// <summary>True for the set header line (rendered emphasised).</summary>
|
/// <summary>True for the set header line (rendered emphasised).</summary>
|
||||||
public bool IsSetHeader { get; init; }
|
public bool IsSetHeader { get; init; }
|
||||||
}
|
}
|
||||||
@@ -40,17 +49,24 @@ public sealed class InvoiceSetLine
|
|||||||
/// header's <c>id</c>. Items that belong to no set pass through unchanged.
|
/// header's <c>id</c>. Items that belong to no set pass through unchanged.
|
||||||
///
|
///
|
||||||
/// The invoice total is taken from the registration balance, not from these
|
/// The invoice total is taken from the registration balance, not from these
|
||||||
/// lines, so switching modes is purely presentational and never changes the
|
/// lines, so calling <see cref="Build"/> is a pure, non-mutating transformation
|
||||||
/// invoice sum — set price always equals the sum of its members.
|
/// of the items it is given and never changes the invoice sum. A set header
|
||||||
|
/// only adopts the chosen <see cref="SetDisplayMode"/> once it has actually been
|
||||||
|
/// converted (its own <c>total_net</c> is non-zero, via the one-way set-item
|
||||||
|
/// switch, <c>InvoiceDraftEditService.ApplyItemSetPrice</c>) — until then the
|
||||||
|
/// header renders blank and each member keeps its own individual price, exactly
|
||||||
|
/// like a non-set item. (The choice of which mode to apply is itself a
|
||||||
|
/// persisted, effectively one-way decision on the draft session — see
|
||||||
|
/// <c>Fuchs/Docs/INVOICE_SET_PRICING.md</c> — but that persistence lives outside
|
||||||
|
/// this class.)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class InvoiceSetPricing
|
public static class InvoiceSetPricing
|
||||||
{
|
{
|
||||||
public static SetDisplayMode ParseMode(string? raw) =>
|
public static SetDisplayMode ParseMode(string? raw) =>
|
||||||
(raw ?? "").Trim().ToLowerInvariant() switch
|
(raw ?? "").Trim().ToLowerInvariant() switch
|
||||||
{
|
{
|
||||||
"itemprices" or "items" or "item" => SetDisplayMode.ItemPrices,
|
"setonly" or "set_only" => SetDisplayMode.SetOnly,
|
||||||
"setonly" or "set_only" => SetDisplayMode.SetOnly,
|
_ => SetDisplayMode.SetPrice
|
||||||
_ => SetDisplayMode.SetPrice
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>Reads the set mode from an InvoiceOptions CSV token like "setmode:itemprices".</summary>
|
/// <summary>Reads the set mode from an InvoiceOptions CSV token like "setmode:itemprices".</summary>
|
||||||
@@ -68,9 +84,21 @@ public static class InvoiceSetPricing
|
|||||||
public static bool ContainsSets(IEnumerable<Dictionary<string, object?>> items) =>
|
public static bool ContainsSets(IEnumerable<Dictionary<string, object?>> items) =>
|
||||||
items.Any(IsSetHeader) || items.Any(i => !string.IsNullOrEmpty(SetIdOf(i)));
|
items.Any(IsSetHeader) || items.Any(i => !string.IsNullOrEmpty(SetIdOf(i)));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True if the block carries a <c>SetItmId</c>-linked set (a member points at a header via
|
||||||
|
/// <c>setId</c>) — i.e. the function-1 "set-item switch" grouping. This is the only case that
|
||||||
|
/// still routes through <see cref="Build"/>; the two menu modes (ADR 0009) clear <c>setId</c>
|
||||||
|
/// and render flat (their inserted set row is emphasised on its own, members null out to blank).
|
||||||
|
/// </summary>
|
||||||
|
public static bool HasSetMembers(IEnumerable<Dictionary<string, object?>> items) =>
|
||||||
|
items.Any(i => !string.IsNullOrEmpty(SetIdOf(i)));
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Produces the ordered display lines for the given items and mode.
|
/// Produces the ordered display lines for the given items and mode.
|
||||||
/// Standalone items are always shown with their price.
|
/// Standalone items are always shown with their price. A set header whose
|
||||||
|
/// own <c>total_net</c> is still zero (not yet converted via the set-item
|
||||||
|
/// switch) is rendered blank with its members individually priced,
|
||||||
|
/// regardless of <paramref name="mode"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static List<InvoiceSetLine> Build(IReadOnlyList<Dictionary<string, object?>> items, SetDisplayMode mode)
|
public static List<InvoiceSetLine> Build(IReadOnlyList<Dictionary<string, object?>> items, SetDisplayMode mode)
|
||||||
{
|
{
|
||||||
@@ -87,22 +115,29 @@ public static class InvoiceSetPricing
|
|||||||
{
|
{
|
||||||
if (IsSetHeader(item))
|
if (IsSetHeader(item))
|
||||||
{
|
{
|
||||||
string setId = HeaderIdOf(item);
|
string setId = HeaderIdOf(item);
|
||||||
var members = membersBySet.TryGetValue(setId, out var m) ? m : new List<Dictionary<string, object?>>();
|
var members = membersBySet.TryGetValue(setId, out var m) ? m : new List<Dictionary<string, object?>>();
|
||||||
decimal setTot = HeaderTotal(item, members);
|
FuchsPdf.ParseDec(item.no("total_net", 0), out decimal headerOwnTotal);
|
||||||
|
|
||||||
|
if (headerOwnTotal == 0)
|
||||||
|
{
|
||||||
|
// Not yet converted (the set-item switch, ApplyItemSetPrice, hasn't run): the
|
||||||
|
// header still carries its as-delivered zero price, so the chosen display mode
|
||||||
|
// does not apply yet — show the header blank and each member with its own,
|
||||||
|
// individual price, exactly as if there were no set grouping at all.
|
||||||
|
result.Add(HeaderLine(item, 0, showPrice: false));
|
||||||
|
foreach (var mem in members) result.Add(MemberLine(mem, showPrice: true));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
switch (mode)
|
switch (mode)
|
||||||
{
|
{
|
||||||
case SetDisplayMode.SetPrice:
|
case SetDisplayMode.SetPrice:
|
||||||
result.Add(HeaderLine(item, setTot, showPrice: true));
|
result.Add(HeaderLine(item, headerOwnTotal, showPrice: true));
|
||||||
foreach (var mem in members) result.Add(MemberLine(mem, showPrice: false));
|
foreach (var mem in members) result.Add(MemberLine(mem, showPrice: false));
|
||||||
break;
|
break;
|
||||||
case SetDisplayMode.ItemPrices:
|
|
||||||
result.Add(HeaderLine(item, setTot, showPrice: false)); // grouping title, no price (avoid double count)
|
|
||||||
foreach (var mem in members) result.Add(MemberLine(mem, showPrice: true));
|
|
||||||
break;
|
|
||||||
case SetDisplayMode.SetOnly:
|
case SetDisplayMode.SetOnly:
|
||||||
result.Add(HeaderLine(item, setTot, showPrice: true));
|
result.Add(HeaderLine(item, headerOwnTotal, showPrice: true));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -145,23 +180,16 @@ public static class InvoiceSetPricing
|
|||||||
return string.IsNullOrEmpty(s) ? i.nz("id", "") : s;
|
return string.IsNullOrEmpty(s) ? i.nz("id", "") : s;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static decimal HeaderTotal(Dictionary<string, object?> header, List<Dictionary<string, object?>> members)
|
|
||||||
{
|
|
||||||
FuchsPdf.ParseDec(header.no("total_net", 0), out decimal headerTot);
|
|
||||||
if (headerTot != 0) return headerTot;
|
|
||||||
decimal sum = 0;
|
|
||||||
foreach (var m in members) { FuchsPdf.ParseDec(m.no("total_net", 0), out decimal t); sum += t; }
|
|
||||||
return sum;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static InvoiceSetLine HeaderLine(Dictionary<string, object?> i, decimal setTotal, bool showPrice) => new()
|
private static InvoiceSetLine HeaderLine(Dictionary<string, object?> i, decimal setTotal, bool showPrice) => new()
|
||||||
{
|
{
|
||||||
|
Id = i.nz("id", ""),
|
||||||
Title = i.nz("title", ""),
|
Title = i.nz("title", ""),
|
||||||
Desc = i.nz("desc", ""),
|
Desc = i.nz("desc", ""),
|
||||||
Qty = i.nz("qty", "1"),
|
Qty = i.nz("qty", "1"),
|
||||||
PriceNet = setTotal,
|
PriceNet = setTotal,
|
||||||
TotalNet = setTotal,
|
TotalNet = setTotal,
|
||||||
ShowPrice = showPrice,
|
ShowPrice = showPrice,
|
||||||
|
Numbered = true, // a set header is always numbered
|
||||||
IsSetHeader = true
|
IsSetHeader = true
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -171,12 +199,14 @@ public static class InvoiceSetPricing
|
|||||||
FuchsPdf.ParseDec(i.no("total_net", 0), out decimal total);
|
FuchsPdf.ParseDec(i.no("total_net", 0), out decimal total);
|
||||||
return new InvoiceSetLine
|
return new InvoiceSetLine
|
||||||
{
|
{
|
||||||
|
Id = i.nz("id", ""),
|
||||||
Title = i.nz("title", ""),
|
Title = i.nz("title", ""),
|
||||||
Desc = i.nz("desc", ""),
|
Desc = i.nz("desc", ""),
|
||||||
Qty = i.nz("qty", "1"),
|
Qty = i.nz("qty", "1"),
|
||||||
PriceNet = price,
|
PriceNet = price,
|
||||||
TotalNet = total,
|
TotalNet = total,
|
||||||
ShowPrice = showPrice && !IsNoPriceLine(i), // headings/free text print no price
|
ShowPrice = showPrice && !IsNoPriceLine(i), // headings/free text print no price
|
||||||
|
Numbered = !IsNoPriceLine(i), // …but are still numbered unless heading/free-text
|
||||||
IsSetHeader = false
|
IsSetHeader = false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user