Compare commits

..
17 Commits
Author SHA1 Message Date
Stefan 6c38ee3d55 Refactor code structure for improved readability and maintainability
Playwright Tests / test (push) Has been cancelled
2026-07-21 09:45:38 +02:00
Stefan 4b28672d71 Update release notes and presentation for E-Rechnung enhancements 2026-07-21 09:45:17 +02:00
StefanandClaude Sonnet 5 7a9eb94a13 Track main branch for all submodules in .gitmodules
Playwright Tests / test (push) Has been cancelled
Sets submodule.<name>.branch = main so `git submodule update --remote`
resolves each submodule to the tip of main without needing a
per-invocation --branch argument.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 09:20:10 +02:00
StefanandClaude Sonnet 5 4bb6cce9f3 Update submodules to latest origin/main
Playwright Tests / test (push) Has been cancelled
Fast-forward all five submodules (OCORE, OCORE_web, OCORE_web_pdf,
OCORE_Charting, eRechnungLib) to the tip of their respective main
branches.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 09:16:34 +02:00
Stefan 235c9587f0 Merge pull request 'Emit invoices as validated ZUGFeRD (DATEV) and XRechnung (B2G)' (#2) from feature/erechnung-validation-b2g into main
Playwright Tests / test (push) Has been cancelled
Reviewed-on: http://localhost:3000/Stefan/Fuchs_Intranet/pulls/2
2026-07-21 09:05:58 +02:00
StefanandClaude Opus 4.8 00e72c96d4 Emit invoices as validated ZUGFeRD (DATEV) and XRechnung (B2G)
Playwright Tests / test (pull_request) Has been cancelled
Completes the eRechnung output path: finalized invoices are emitted as a
ZUGFeRD/Factur-X EN 16931 hybrid (DATEV) or, when a Leitweg-ID is present, as
XRechnung 3.0 for B2G. Both are externally validated as ACCEPTED (0 errors) and
PDF/A-3B COMPLIANT against the ProcessWeb eInvoice service.

- ERechnungMapper: FdsInvoiceData -> EN 16931 model. Seller master data now from
  Fuchs:ERechnung:Seller config (VAT id BT-31, Steuernummer BT-32, optional
  Handelsregister BT-30, IBAN/BIC, contact). Adds payment terms BT-20/BT-9
  (BR-CO-25), buyer VAT id, §13b reverse charge, and the service date/period
  (BT-72 / BG-14) parsed from ProvisionPeriod (BR-DE-TMP-32). B2G -> XRechnung
  profile with the required electronic addresses/contact.
- ERechnungValidator: client for POST /validatepdf (EN 16931 XML + PDF/A-3 in
  one call). A pure "scenario not matched" with zero errors is not treated as a
  hard failure; real errors optionally withhold the hybrid (FailOnError).
- ERechnungSettings: seller + validation config; wired in Program.cs; ServiceUrl
  in appsettings.
- Online editor: structured German-only dialogs for the recipient address
  (incl. Leitweg-ID) and the service date/period (single date or range).
- Bumps the eRechnungLib submodule to the CII rsm-namespace / PDF-A subtype fix.

Fuchs.Tests 514/514, eRechnungLib 97/97.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 00:05:24 +02:00
Stefan d94974ce06 Update eRechnungLib subproject to latest commit
Playwright Tests / test (push) Has been cancelled
2026-07-18 18:01:57 +02:00
Stefan 628802db19 Add function to retrieve company address as JSON and update invoice procedures
- Created a new function `fds__getCompanyAddressJson` to return a company's postal address as a structured JSON object.
- Modified stored procedures `fds__createInvoice`, `fds__setInvoice`, and `fds__prepInvoice` to include a new parameter `@SendToAddressJson` for handling the address data.
- Updated the invoice table and user-defined types to accommodate the new `SendToAddressJson` field.
- Ensured that the address data is properly retrieved and stored in the invoice records.
2026-07-18 18:01:24 +02:00
Stefan 5ccd85c38f 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
Reviewed-on: http://localhost:3000/Stefan/Fuchs_Intranet/pulls/1
2026-07-16 19:46:52 +02:00
Stefan 9b2c99f697 Enable Mailer: set Mailer:Enabled to true
Playwright Tests / test (pull_request) Has been cancelled
The "Enabled" setting under the "Mailer" configuration was changed from false to true, activating mailer functionality. No other changes were made.
2026-07-16 16:42:28 +02:00
Stefan a45ca014ca - 2026-07-16 16:04:09 +02:00
Stefan c812d94d99 Improve audit logging, error handling, and metadata encoding
- Ensure all email/SMS send attempts are audit-logged, including API errors, exceptions, invalid addresses, and disabled service cases; logs now include raw API responses or exception details for better diagnostics.
- Fix audit log parameter builder to never pass NULL for NOT NULL columns (`config`, `DateSent`).
- Add unit tests covering all send paths and audit log correctness.
- Percent-encode non-ASCII/control chars in `DocumentMetadataBuilder` for safe HTTP header metadata; add tests.
- Simplify admin test mail dialog JS and improve success handler robustness.
- Set `charset=utf-8` in admin controller JSON responses for OCORE consistency.
- Update minified JS to match new admin dialog logic.
2026-07-16 16:03:58 +02:00
Stefan 8a0ebeeb1e Add German localization and styling for administration module
- Introduced new JavaScript file `fis.admin_txt_de.js` for German translations of administration-related terms and messages.
- Created `fis.admin.css` for styling the administration interface, including layout, cards, and buttons.
- Added `fis.admin.de.js` for the main functionality of the administration module, implementing features such as system status checks and email testing.
- Minified version of the German JavaScript file created as `fis.admin.de.min.js`.
- Minified CSS file created as `fis.admin.min.css` for optimized loading.
2026-07-16 14:59:14 +02:00
Stefan f6079af0de Update subproject commits for OCORE, OCORE_Charting, OCORE_web, and eRechnungLib 2026-07-16 13:35:03 +02:00
Stefan 49e3ed2673 Add unit tests for Fuchs_DataService and related components
- Introduced comprehensive unit tests for the Fuchs_DataService library, covering DATEV header formatting, CSV/XML generation, and FdsMfrClient construction.
- Implemented tests for FdsMfr.UpdateNeed parsing and FdsShared utility helpers, ensuring correct functionality and stability.
- Added tests for FdsConfig and FdsMfrClient to validate configuration resolution and client construction.

Document decisions on backend-authoritative invoice and reminder handling

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

Define irreversible mutations for set-price modes in invoices

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

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

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

Add publish profile and periodic hosted service for job scheduling

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

Add dotnet-tools.json for EF Core CLI tools

- Included dotnet-tools.json to manage the version of dotnet-ef for Entity Framework Core migrations and commands.
2026-07-16 13:34:23 +02:00
Stefan f724b9b59d - 2026-07-13 22:40:14 +02:00
Stefan 5f85b75c22 added improvements on set proces and minor other improvements 2026-07-13 22:40:08 +02:00
187 changed files with 9029 additions and 8718 deletions
+2 -2
View File
@@ -47,8 +47,8 @@
## Services & Dependency Injection
- Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces; inject them into `IntranetController` (constructor injection). Do **not** reintroduce static God-classes or pass the whole controller into helpers.
- `IComService` (email/SMS via ProcessWeb Mailer API, attachments sent inline as base64; honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService` (MigraDoc render), `IInvoiceService`, `IReminderService`, `IReportService` (SQL report engine via `FuchsVisualization`), `IWidgetService`, `IBankingService`, `IMfrClientFactory`.
- Lifetimes: stateless services (`IPdfService`, `IBankingService`, `IMfrClientFactory`) are singletons; request-scoped DB services (`IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IComService`) are scoped. Register in `Program.cs`.
- `IComService` (email/SMS via ProcessWeb Mailer API, attachments sent inline as base64; honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService` (MigraDoc render), `IInvoiceService`, `IReminderService`, `IReportService` (SQL report engine via `FuchsVisualization`), `IWidgetService`, `IBankingService`, `IMfrClientFactory`, `ISystemStatusService` (Admin module diagnostics: config snapshot + DB/Key Vault/blob/MFR connectivity probes + test-email; restricted to `fds_sys` > 4).
- Lifetimes: stateless services (`IPdfService`, `IBankingService`, `IMfrClientFactory`) are singletons; request-scoped DB services (`IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IComService`, `ISystemStatusService`) are scoped. Register in `Program.cs`.
- `FdsInvoiceData` / `FdsReminderData` are **pure data holders** (parse + properties). Loading, persistence and PDF generation belong in the services — never `Task.Run(...).Wait()` sync-over-async.
- Data access stays SQL-first via OCORE helpers (`getSQLDataSet_async`, `setSQLValue_async`) + stored procedures; no EF Core.
+5
View File
@@ -1,15 +1,20 @@
[submodule "OCORE"]
path = OCORE
url = https://git.processweb.de/Stefan/OCORE.git
branch = main
[submodule "OCORE_web"]
path = OCORE_web
url = https://git.processweb.de/Stefan/OCORE_web.git
branch = main
[submodule "OCORE_web_pdf"]
path = OCORE_web_pdf
url = https://git.processweb.de/Stefan/OCORE_web_pdf.git
branch = main
[submodule "OCORE_Charting"]
path = OCORE_Charting
url = https://git.processweb.de/Stefan/OCORE_Charting.git
branch = main
[submodule "eRechnungLib"]
path = eRechnungLib
url = https://git.processweb.de/ProcessWeb_Tools/eRechnungLib.git
branch = main
+1 -1
View File
@@ -37,7 +37,7 @@
## Services & Dependency Injection
- Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces, injected into `IntranetController`. Do not reintroduce static God-classes or pass the controller into helpers.
- Services: `IComService` (ProcessWeb Mailer API; honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`. Stateless ones are singletons; DB/request-scoped ones are scoped (see `Program.cs`).
- Services: `IComService` (ProcessWeb Mailer API; honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`, `ISystemStatusService` (Admin module diagnostics: config snapshot + DB/Key Vault/blob/MFR connectivity probes + test-email; restricted to `fds_sys` > 4). Stateless ones are singletons; DB/request-scoped ones are scoped (see `Program.cs`).
- `FdsInvoiceData` / `FdsReminderData` are **pure data holders**; load/persist/render belongs in services. No `Task.Run(...).Wait()` sync-over-async.
- Data access is SQL-first via OCORE helpers + stored procedures (no EF Core).
+1 -1
View File
@@ -37,7 +37,7 @@
## Services & Dependency Injection
- Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces, injected into `IntranetController`. Do not reintroduce static God-classes or pass the controller into helpers.
- Services: `IComService` (ProcessWeb Mailer API; honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`. Stateless ones are singletons; DB/request-scoped ones are scoped (see `Program.cs`).
- Services: `IComService` (ProcessWeb Mailer API; honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`, `ISystemStatusService` (Admin module diagnostics: config snapshot + DB/Key Vault/blob/MFR connectivity probes + test-email; restricted to `fds_sys` > 4). Stateless ones are singletons; DB/request-scoped ones are scoped (see `Program.cs`).
- `FdsInvoiceData` / `FdsReminderData` are **pure data holders**; load/persist/render belongs in services. No `Task.Run(...).Wait()` sync-over-async.
- Data access is SQL-first via OCORE helpers + stored procedures (no EF Core).
Binary file not shown.
-131
View File
@@ -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>
-186
View File
@@ -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>
-13
View File
@@ -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
View File
@@ -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
View File
@@ -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>
-134
View File
@@ -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
-146
View File
@@ -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
-226
View File
@@ -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
-500
View File
@@ -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
View File
@@ -1 +0,0 @@
Fuchs_Dataservice.exe install --autostart
-9
View File
@@ -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
View File
@@ -1 +0,0 @@
Fuchs_Dataservice.exe uninstall
@@ -110,6 +110,20 @@ public class DocumentMetadataBuilderTests
Assert.Equal(guid.ToString(), metadata["file_guid"]);
}
[Theory]
[InlineData("Zahlungserinnerung für Müller", "Zahlungserinnerung f%C3%BCr M%C3%BCller")]
[InlineData("Rechnung\r\nKunde", "Rechnung%0D%0AKunde")]
public void Build_NonAsciiOrControlCharacters_PercentEncodesUtf8ForHttpHeaders(
string value, string expected)
{
var row = new Dictionary<string, object?> { ["DocumentName"] = value };
var metadata = DocumentMetadataBuilder.Build(row, new[] { "DocumentName" });
Assert.Equal(expected, metadata["DocumentName"]);
Assert.All(metadata["DocumentName"], c => Assert.InRange(c, '\u0020', '\u007e'));
}
// ── Field-list edge cases ────────────────────────────────────────────────
[Theory]
[InlineData("")]
+192
View File
@@ -0,0 +1,192 @@
using System.Collections.Generic;
using System.Text;
using eRechnungLib.Model.CodeLists;
using eRechnungLib.Profiles;
using Fuchs.intranet;
using Fuchs.Services;
using Newtonsoft.Json.Linq;
using Xunit;
using static OCORE.OCORE_dictionaries;
namespace Fuchs.Tests;
/// <summary>
/// Verifies the FdsInvoiceData → EN 16931 model mapping and the ZUGFeRD (EN 16931) hybrid
/// production: structured buyer, lines/totals, effortless B2C, and §13b reverse charge. See
/// ADR 0012.
/// </summary>
public class ERechnungMapperTests
{
private static FdsInvoiceData BuildInvoice(string sendToAddressJson, string vat = "19",
string invoiceOptions = "", bool withItem = true, string provisionPeriod = "")
{
var items = withItem
? "[{'id':'900','type':'material','title':'Reparatur','desc':'Vor Ort','qty':2,'price_net':50,'total_net':100,'vat':'" + vat + "'}]"
: "[]";
var jobj = JObject.Parse("{'req':[{'Id':'1','text':'Auftrag','items':" + items + "}]}");
var inv = new FdsInvoiceData(jobj)
{
InvoiceRegistration = new GenericObjectDictionary(new Dictionary<string, object>
{
["Id"] = "42",
["InvoiceId"] = "R2026-0007",
["InvoiceTitle"] = "Rechnung",
["DateCreated"] = "2026-07-17 10:00:00",
["InvoiceOptions"] = invoiceOptions,
["InvoiceBalance_net"] = "100",
["InvoiceVAT_1"] = vat,
["ProvisionPeriod"] = provisionPeriod,
["SendToAddressJson"] = sendToAddressJson,
})
};
return inv;
}
private const string B2BAddress =
"{'name':'Muster GmbH','street':'Hauptstr. 1','postalCode':'40223','city':'Düsseldorf','countryCode':'DE','vatId':'DE123456789'}";
[Fact]
public void BuildEInvoice_MapsSellerBuyerAndLines_WithConsistentTotals()
{
var model = ERechnungMapper.BuildEInvoice(BuildInvoice(B2BAddress)).Model;
Assert.Equal("Sebastian Fuchs GmbH & Co. KG", model.Seller.Name);
Assert.Equal("DE", model.Seller.Address.Country.Value);
Assert.Equal("Muster GmbH", model.Buyer.Name);
Assert.Equal("40223", model.Buyer.Address.PostalCode);
Assert.Equal("DE123456789", model.Buyer.VatId);
Assert.Equal("R2026-0007", model.InvoiceNumber);
var line = Assert.Single(model.Lines);
Assert.Equal(100m, line.NetAmount);
Assert.Equal(VatCategoryCode.StandardRate, line.VatCategory);
Assert.NotNull(model.Totals);
Assert.Equal(100m, model.Totals!.TaxExclusiveAmount);
Assert.Equal(19m, model.Totals.TaxTotalAmount);
Assert.Equal(119m, model.Totals.TaxInclusiveAmount);
}
[Fact]
public void ToZugferd_EN16931_ProducesHybridPdfWithEmbeddedCii()
{
var einvoice = ERechnungMapper.BuildEInvoice(BuildInvoice(B2BAddress));
var result = einvoice.ToZugferd(ZugferdProfile.EN16931);
Assert.True(result.Success);
Assert.StartsWith("%PDF", Encoding.ASCII.GetString(result.Value!, 0, 4));
// The CII XML is embedded and carries the invoice number.
string content = Encoding.Latin1.GetString(result.Value!);
Assert.Contains("CrossIndustryInvoice", content);
// No PDFA-ICC warning: the bundled sRGB profile is present, so the output intent is set.
Assert.DoesNotContain(result.Validation.Warnings, m => m.RuleId == "PDFA-ICC");
}
[Fact]
public void PrivatePerson_NoVatId_MapsWithoutBuyerTaxRegistration_AndProducesHybrid()
{
var b2c = "{'name':'Max Mustermann','street':'Weg 2','postalCode':'50667','city':'Köln','countryCode':'DE'}";
var einvoice = ERechnungMapper.BuildEInvoice(BuildInvoice(b2c));
Assert.Null(einvoice.Model.Buyer.VatId);
var result = einvoice.ToZugferd(ZugferdProfile.EN16931);
Assert.True(result.Success);
Assert.StartsWith("%PDF", Encoding.ASCII.GetString(result.Value!, 0, 4));
}
[Fact]
public void ReverseCharge_13b_SetsCategoryAeAndExemptionReason()
{
var model = ERechnungMapper.BuildEInvoice(BuildInvoice(B2BAddress, vat: "0", invoiceOptions: "§13b")).Model;
var line = Assert.Single(model.Lines);
Assert.Equal(VatCategoryCode.ReverseCharge, line.VatCategory);
Assert.Equal(0m, line.VatRate);
Assert.True(model.VatExemptionReasons.ContainsKey(VatCategoryCode.ReverseCharge));
Assert.Equal(0m, model.Totals!.TaxTotalAmount);
}
[Fact]
public void LumpSumInvoice_NoItems_SynthesisesSingleLineFromTotal()
{
var model = ERechnungMapper.BuildEInvoice(BuildInvoice(B2BAddress, withItem: false)).Model;
var line = Assert.Single(model.Lines);
Assert.Equal(100m, line.NetAmount);
Assert.Equal(119m, model.Totals!.TaxInclusiveAmount);
}
[Theory]
[InlineData("DE", "DE")]
[InlineData("Deutschland", "DE")]
[InlineData("Österreich", "AT")]
[InlineData("", "DE")]
public void NormalizeCountry_MapsNamesAndCodes(string raw, string expected)
=> Assert.Equal(expected, ERechnungMapper.NormalizeCountry(raw).Value);
[Fact]
public void ServicePeriod_SingleDate_MapsToDeliveryDate()
{
var model = ERechnungMapper.BuildEInvoice(BuildInvoice(B2BAddress, provisionPeriod: "18.06.2026")).Model;
Assert.NotNull(model.Delivery);
Assert.Equal(new System.DateOnly(2026, 6, 18), model.Delivery!.DeliveryDate);
Assert.Null(model.InvoicingPeriod);
}
[Fact]
public void ServicePeriod_DateRange_MapsToInvoicingPeriod()
{
var model = ERechnungMapper.BuildEInvoice(BuildInvoice(B2BAddress, provisionPeriod: "01.06.2026 - 30.06.2026")).Model;
Assert.NotNull(model.InvoicingPeriod);
Assert.Equal(new System.DateOnly(2026, 6, 1), model.InvoicingPeriod!.StartDate);
Assert.Equal(new System.DateOnly(2026, 6, 30), model.InvoicingPeriod.EndDate);
}
[Fact]
public void Seller_ComesFromSettings_WhenProvided()
{
var seller = new ERechnungSellerSettings { Name = "Test Handwerk GmbH", VatId = "DE999999999", Iban = "DE00" };
var model = ERechnungMapper.BuildEInvoice(BuildInvoice(B2BAddress), seller).Model;
Assert.Equal("Test Handwerk GmbH", model.Seller.Name);
Assert.Equal("DE999999999", model.Seller.VatId);
}
[Fact]
public void B2G_WithLeitwegId_SetsBuyerReference_AndSellerContact()
{
var b2g = "{'name':'Stadt Düsseldorf','street':'Marktplatz 2','postalCode':'40213','city':'Düsseldorf','countryCode':'DE','leitwegId':'05111-12345-67'}";
var model = ERechnungMapper.BuildEInvoice(BuildInvoice(b2g)).Model;
Assert.Equal("05111-12345-67", model.BuyerReference); // BT-10 (Leitweg-ID)
Assert.NotNull(model.Seller.ElectronicAddress); // BT-34 (XRechnung)
Assert.NotNull(model.Seller.Contact); // BG-6 (BR-DE-5/6/7)
Assert.Equal("info@sanitaerfuchs.de", model.Seller.Contact!.Email);
}
[Fact]
public void B2B_NoLeitwegId_LeavesBuyerReferenceUnset()
{
var model = ERechnungMapper.BuildEInvoice(BuildInvoice(B2BAddress)).Model;
Assert.True(string.IsNullOrEmpty(model.BuyerReference));
}
[Fact]
public void Seller_HasVatIdAndPaymentTerms_ForBrCo25AndBrCo26()
{
var model = ERechnungMapper.BuildEInvoice(BuildInvoice(B2BAddress)).Model;
// BR-CO-26: seller VAT id (BT-31) present, in addition to the Steuernummer (BT-32).
Assert.Equal("DE286366012", model.Seller.VatId);
// BR-CO-25: payment terms (BT-20) / due date (BT-9) present for a positive amount due.
Assert.NotNull(model.PaymentTerms);
Assert.False(string.IsNullOrWhiteSpace(model.PaymentTerms!.Description));
}
[Fact]
public void B2G_ToZugferdXRechnung_ProducesHybridWithXRechnungCustomization()
{
var b2g = "{'name':'Stadt Düsseldorf','street':'Marktplatz 2','postalCode':'40213','city':'Düsseldorf','countryCode':'DE','leitwegId':'05111-12345-67'}";
var result = ERechnungMapper.BuildEInvoice(BuildInvoice(b2g)).ToZugferd(ZugferdProfile.XRechnung);
Assert.True(result.Success);
string content = System.Text.Encoding.Latin1.GetString(result.Value!);
Assert.Contains("xrechnung_3.0", content); // XRechnung 3.0 CIUS customization id
}
}
+114
View File
@@ -0,0 +1,114 @@
using System;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Fuchs.Services;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Exercises the external eRechnung validator client against a stubbed HTTP endpoint: response
/// parsing, the not-configured short-circuit, and unreachable/error handling (never throws).
/// </summary>
public class ERechnungValidatorTests
{
private sealed class StubHandler : HttpMessageHandler
{
private readonly HttpStatusCode _status;
private readonly string _body;
public HttpRequestMessage? Last;
public StubHandler(HttpStatusCode status, string body) { _status = status; _body = body; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
Last = request;
return Task.FromResult(new HttpResponseMessage(_status) { Content = new StringContent(_body) });
}
}
private sealed class StubFactory : IHttpClientFactory
{
private readonly HttpMessageHandler _handler;
public StubFactory(HttpMessageHandler handler) => _handler = handler;
public HttpClient CreateClient(string name) => new(_handler, disposeHandler: false);
}
private static ProcessWebERechnungValidator Make(HttpMessageHandler handler, bool enabled = true,
string url = "https://validator.test/api/eInvoice")
{
var settings = Options.Create(new ERechnungSettings
{
Validation = new ERechnungValidationSettings { Enabled = enabled, ServiceUrl = url }
});
return new ProcessWebERechnungValidator(new StubFactory(handler), settings,
NullLogger<ProcessWebERechnungValidator>.Instance);
}
[Fact]
public async Task ValidatePdf_BothPass_ReturnsIsValid()
{
var handler = new StubHandler(HttpStatusCode.OK,
"{\"isValid\":true,\"summary\":\"ok\",\"xml\":{\"isValid\":true},\"pdfa\":{\"isCompliant\":true}}");
var result = await Make(handler).ValidatePdfAsync(new byte[] { 1, 2, 3 });
Assert.True(result.IsValid);
Assert.True(result.XmlValid);
Assert.True(result.PdfACompliant);
Assert.EndsWith("/validatepdf", handler.Last!.RequestUri!.ToString());
Assert.Equal("application/pdf", handler.Last.Content!.Headers.ContentType!.MediaType);
}
[Theory]
[InlineData("{\"xml\":{\"isValid\":false,\"errorCount\":3},\"pdfa\":{\"isCompliant\":true}}", false, true)]
[InlineData("{\"xml\":{\"isValid\":true},\"pdfa\":{\"isCompliant\":false}}", true, false)]
public async Task ValidatePdf_PartialFailure_IsNotValid(string body, bool xml, bool pdfa)
{
var result = await Make(new StubHandler(HttpStatusCode.OK, body)).ValidatePdfAsync(new byte[] { 1 });
Assert.False(result.IsValid);
Assert.Equal(xml, result.XmlValid);
Assert.Equal(pdfa, result.PdfACompliant);
Assert.True(result.HasHardError); // real PDF/A or XML errors are hard failures
}
[Fact]
public async Task ValidatePdf_PdfAOk_ButXmlScenarioNotMatched_IsNotHardError()
{
// EN 16931 ZUGFeRD checked by an XRechnung-only scenario set: PDF/A compliant, XML rejected
// with zero errors → not strictly valid, but not a hard error (must not withhold the invoice).
var body = "{\"xml\":{\"isValid\":false,\"scenarioMatched\":false,\"errorCount\":0},\"pdfa\":{\"isCompliant\":true}}";
var result = await Make(new StubHandler(HttpStatusCode.OK, body)).ValidatePdfAsync(new byte[] { 1 });
Assert.True(result.PdfACompliant);
Assert.False(result.ScenarioMatched);
Assert.False(result.IsValid);
Assert.False(result.HasHardError);
}
[Fact]
public async Task ValidatePdf_Disabled_ReturnsNotConfigured()
{
var result = await Make(new StubHandler(HttpStatusCode.OK, "{}"), enabled: false).ValidatePdfAsync(new byte[] { 1 });
Assert.False(result.Configured);
Assert.False(result.IsValid);
}
[Fact]
public async Task ValidatePdf_ServerError_IsReachedFalse_AndDoesNotThrow()
{
var result = await Make(new StubHandler(HttpStatusCode.BadGateway, "validator down")).ValidatePdfAsync(new byte[] { 1 });
Assert.False(result.Reached);
Assert.False(result.IsValid);
}
[Fact]
public async Task ValidatePdf_Enabled_RequiresNonEmptyUrl()
{
var v = Make(new StubHandler(HttpStatusCode.OK, "{}"), url: "");
Assert.False(v.Enabled);
var result = await v.ValidatePdfAsync(new byte[] { 1 });
Assert.False(result.Configured);
}
}
+446
View File
@@ -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);
}
}
+2 -2
View File
@@ -9,8 +9,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageReference Include="xunit.v3" Version="3.2.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
+83 -1
View File
@@ -1,4 +1,4 @@
using Fuchs.intranet;
using Fuchs.intranet;
using Newtonsoft.Json.Linq;
using Xunit;
@@ -22,6 +22,88 @@ public class InvoiceDraftCalculatorTests
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()
{
+399 -5
View File
@@ -1,4 +1,4 @@
using System.Linq;
using System.Linq;
using System.Threading.Tasks;
using Fuchs.intranet;
using Fuchs.Services;
@@ -84,6 +84,37 @@ public class InvoiceDraftServiceTests
Assert.Equal(1, h.Version);
}
[Fact]
public void ApplyPatch_StructuredAddress_ComposesFreeTextAndPersistsJson()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var addr = JObject.Parse(@"{'name':'Muster GmbH','street':'Weg 1','postalCode':'40223','city':'Düsseldorf','countryCode':'DE','vatId':'DE123456789'}");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "address", Value = addr });
Assert.NotNull(s2);
// Free-text block is composed for the PDF / SendToAddress path.
Assert.Equal("Muster GmbH\nWeg 1\n40223 Düsseldorf", s2!.New["invoiceaddress"]!.Value<string>());
// Structured JSON rides inside the CustomValues blob (interim persistence).
var parsed = InvoiceRecipientAddress.FromCustomValues(s2.New["CustomValues"]!.Value<string>());
Assert.NotNull(parsed);
Assert.Equal("DE123456789", parsed!.VatId);
Assert.True(parsed.IsEn16931Conformant);
}
[Fact]
public void ApplyPatch_Address_PlainString_KeepsLegacyFreeTextBehavior()
{
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "address", Value = JToken.FromObject("Weg 9\n50667 Köln") });
Assert.Equal("Weg 9\n50667 Köln", s2!.New["invoiceaddress"]!.Value<string>());
Assert.Null(InvoiceRecipientAddress.FromCustomValues(s2.New["CustomValues"]?.Value<string>()));
}
[Fact]
public void ApplyPatch_BlockReplace_RecomputesTotals()
{
@@ -205,9 +236,9 @@ public class InvoiceDraftServiceTests
var (svc, _, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "setmode", Value = JToken.FromObject("itemprices") });
var s2 = svc.ApplyPatch(s.Token, new InvoiceDraftDelta { Target = "setmode", Value = JToken.FromObject("setonly") });
Assert.Equal("itemprices", s2!.Admin["setmode"]!.Value<string>());
Assert.Equal("setonly", s2!.Admin["setmode"]!.Value<string>());
}
[Fact]
@@ -240,6 +271,249 @@ public class InvoiceDraftServiceTests
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()
{
@@ -306,7 +580,7 @@ public class InvoiceDraftServiceTests
var (svc, inv, _) = NewService();
var payload = Payload();
payload["admin"]!["p13b"] = true;
payload["admin"]!["setmode"] = "itemprices";
payload["admin"]!["setmode"] = "setonly";
var s = svc.OpenFromPayload(payload, "user1");
await svc.FlushToDbAsync(s.Token, "user1", null!);
@@ -314,7 +588,7 @@ public class InvoiceDraftServiceTests
var options = inv.Registered!.BuildInvoiceParams(change: false, invId: "")
.First(p => p.ParameterName == "@InvoiceOptions").Value?.ToString() ?? "";
Assert.Contains("§13b", options);
Assert.Contains("setmode:itemprices", options);
Assert.Contains("setmode:setonly", options);
}
[Fact]
@@ -476,4 +750,124 @@ public class InvoiceDraftServiceTests
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");
}
}
+18 -7
View File
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using Fuchs.intranet;
@@ -31,16 +31,25 @@ public class InvoiceOptionsTests
=> Assert.Equal("", InvoiceOptionsFor(new { type = "r" }));
[Fact]
public void DefaultSetPrice_OmitsToken()
=> Assert.Equal("", InvoiceOptionsFor(new { type = "r", setmode = "setprice" }));
public void ExplicitDefaultSetPrice_EmitsToken()
// An explicit choice of the default mode must still be persisted — otherwise it is
// indistinguishable from an invoice that was never switched to set-pricing at all
// (the editor relies on this to hide the "Set-Preisanzeige" menu entry once chosen).
=> Assert.Equal("setmode:setprice", InvoiceOptionsFor(new { type = "r", setmode = "setprice" }));
[Theory]
[InlineData("itemprices", "setmode:itemprices")]
[InlineData("setonly", "setmode:setonly")]
[InlineData("ITEMPRICES", "setmode:itemprices")] // case-insensitive
[InlineData("SETONLY", "setmode:setonly")] // case-insensitive
public void SetMode_EmitsToken(string mode, string expected)
=> Assert.Equal(expected, InvoiceOptionsFor(new { type = "r", setmode = mode }));
[Fact]
public void RemovedItemPricesMode_OmitsToken()
// "itemprices" was a valid mode before the button/mode was removed. Any invoice options
// still carrying the stale value (or a client re-posting it) must not be persisted as a
// recognized token — it is treated the same as an unknown/garbage mode.
=> Assert.Equal("", InvoiceOptionsFor(new { type = "r", setmode = "itemprices" }));
[Fact]
public void UnknownSetMode_OmitsToken()
=> Assert.Equal("", InvoiceOptionsFor(new { type = "r", setmode = "garbage" }));
@@ -103,9 +112,11 @@ public class InvoiceOptionsTests
public void ModeFromInvoiceOptions_RoundTripsBackendEmission()
{
// The token this side emits must parse back to the same mode on the PDF side.
Assert.Equal(SetDisplayMode.ItemPrices,
InvoiceSetPricing.ModeFromInvoiceOptions(InvoiceOptionsFor(new { type = "r", setmode = "itemprices" })));
Assert.Equal(SetDisplayMode.SetOnly,
InvoiceSetPricing.ModeFromInvoiceOptions(InvoiceOptionsFor(new { type = "r", p13b = true, setmode = "setonly" })));
// A removed mode never round-trips as itself — it is never persisted, so reading it back
// always yields the default SetPrice.
Assert.Equal(SetDisplayMode.SetPrice,
InvoiceSetPricing.ModeFromInvoiceOptions(InvoiceOptionsFor(new { type = "r", setmode = "itemprices" })));
}
}
+104
View File
@@ -0,0 +1,104 @@
using Fuchs.Services;
using Newtonsoft.Json.Linq;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Pure-logic tests for the structured invoice recipient address: free-text composition for the
/// PDF, EN 16931 / DATEV conformity detection, effortless B2C (no VAT id), and JSON round-tripping
/// through the <c>CustomValues</c> blob. See ADR 0012.
/// </summary>
public class InvoiceRecipientAddressTests
{
private static InvoiceRecipientAddress FullB2B() => new()
{
Name = "Muster GmbH",
Contact = "Frau Schmidt",
Street = "Hauptstraße 1",
PostalCode = "40223",
City = "Düsseldorf",
CountryCode = "DE",
VatId = "DE123456789",
};
[Fact]
public void Compose_DomesticFullAddress_OmitsCountryLineAndVatId()
{
var text = FullB2B().Compose();
Assert.Equal("Muster GmbH\nz.Hd. Frau Schmidt\nHauptstraße 1\n40223 Düsseldorf", text);
Assert.DoesNotContain("DE123456789", text); // VAT id never in the postal block
Assert.DoesNotContain("\nDE", text); // domestic country code suppressed
}
[Fact]
public void Compose_ForeignCountry_AppendsCountryCodeLine()
{
var addr = FullB2B();
addr.CountryCode = "AT";
addr.City = "Wien";
addr.PostalCode = "1010";
Assert.EndsWith("1010 Wien\nAT", addr.Compose());
}
[Fact]
public void PrivatePerson_NoVatId_IsB2CAndStillEn16931Conformant()
{
var addr = new InvoiceRecipientAddress
{
Name = "Max Mustermann",
Street = "Weg 2",
PostalCode = "50667",
City = "Köln",
CountryCode = "DE",
// no VatId
};
Assert.True(addr.IsPrivatePerson);
Assert.True(addr.IsEn16931Conformant);
Assert.Empty(addr.MissingForEn16931());
}
[Theory]
[InlineData("", "DE", "Köln", new[] { "Name" })]
[InlineData("Firma", "", "Köln", new[] { "Land" })]
[InlineData("Firma", "DE", "", new[] { "Ort/PLZ" })]
public void MissingForEn16931_FlagsMandatoryGaps(string name, string country, string city, string[] expected)
{
var addr = new InvoiceRecipientAddress { Name = name, CountryCode = country, City = city };
Assert.Equal(expected, addr.MissingForEn16931());
Assert.False(addr.IsEn16931Conformant);
}
[Fact]
public void FromJson_AcceptsAlternateKeys()
{
var addr = InvoiceRecipientAddress.FromJson(JObject.Parse(
@"{'name':'X','plz':'12345','ort':'Ort','country':'de','ustid':'DE9'}"));
Assert.Equal("12345", addr.PostalCode);
Assert.Equal("Ort", addr.City);
Assert.Equal("DE", addr.CountryCode); // uppercased
Assert.Equal("DE9", addr.VatId);
}
[Fact]
public void FromCustomValues_RoundTripsThroughTheBlob()
{
var cv = new JObject
{
["contactName"] = "someone",
[InvoiceRecipientAddress.CustomValuesKey] = FullB2B().ToJson(),
};
var parsed = InvoiceRecipientAddress.FromCustomValues(cv.ToString());
Assert.NotNull(parsed);
Assert.Equal("Muster GmbH", parsed!.Name);
Assert.Equal("DE123456789", parsed.VatId);
}
[Fact]
public void FromCustomValues_ReturnsNull_WhenNoStructuredAddressPresent()
{
Assert.Null(InvoiceRecipientAddress.FromCustomValues(@"{'contactName':'x'}"));
Assert.Null(InvoiceRecipientAddress.FromCustomValues(""));
Assert.Null(InvoiceRecipientAddress.FromCustomValues("not json"));
}
}
+42 -29
View File
@@ -1,4 +1,4 @@
using System.Collections.Generic;
using System.Collections.Generic;
using System.Linq;
using Fuchs.intranet;
using Xunit;
@@ -40,11 +40,11 @@ public class InvoiceSetPricingTests
// ── Mode parsing ────────────────────────────────────────────────────────
[Theory]
[InlineData("itemprices", SetDisplayMode.ItemPrices)]
[InlineData("items", SetDisplayMode.ItemPrices)]
[InlineData("setonly", SetDisplayMode.SetOnly)]
[InlineData("set_only", SetDisplayMode.SetOnly)]
[InlineData("setprice", SetDisplayMode.SetPrice)]
[InlineData("", SetDisplayMode.SetPrice)]
[InlineData("itemprices", SetDisplayMode.SetPrice)] // removed mode — falls back to default, never throws
[InlineData("garbage", SetDisplayMode.SetPrice)]
public void ParseMode_Works(string raw, SetDisplayMode expected)
=> Assert.Equal(expected, InvoiceSetPricing.ParseMode(raw));
@@ -52,10 +52,12 @@ public class InvoiceSetPricingTests
[Fact]
public void ModeFromInvoiceOptions_ReadsToken()
{
Assert.Equal(SetDisplayMode.ItemPrices, InvoiceSetPricing.ModeFromInvoiceOptions("§13b,setmode:itemprices"));
Assert.Equal(SetDisplayMode.SetOnly, InvoiceSetPricing.ModeFromInvoiceOptions("setmode:setonly"));
Assert.Equal(SetDisplayMode.SetOnly, InvoiceSetPricing.ModeFromInvoiceOptions("§13b,setmode:setonly"));
Assert.Equal(SetDisplayMode.SetPrice, InvoiceSetPricing.ModeFromInvoiceOptions("§13b")); // default
Assert.Equal(SetDisplayMode.SetPrice, InvoiceSetPricing.ModeFromInvoiceOptions(null));
// A stale/removed "itemprices" token (e.g. from an invoice created before the mode was
// dropped) must not throw — it degrades gracefully to the default SetPrice mode.
Assert.Equal(SetDisplayMode.SetPrice, InvoiceSetPricing.ModeFromInvoiceOptions("setmode:itemprices"));
}
[Fact]
@@ -85,22 +87,6 @@ public class InvoiceSetPricingTests
Assert.Equal(50.00m, lines[3].TotalNet);
}
// ── ItemPrices: members priced, set header blank ──────────────────────────
[Fact]
public void Build_ItemPrices_MembersPricedHeaderBlank()
{
var lines = InvoiceSetPricing.Build(Sample(), SetDisplayMode.ItemPrices);
Assert.Equal(4, lines.Count);
Assert.True(lines[0].IsSetHeader);
Assert.False(lines[0].ShowPrice); // set header is just a title now
Assert.True(lines[1].ShowPrice);
Assert.Equal(600.00m, lines[1].TotalNet);
Assert.True(lines[2].ShowPrice);
Assert.Equal(400.00m, lines[2].TotalNet);
Assert.True(lines[3].ShowPrice); // standalone
}
// ── SetOnly: members removed ──────────────────────────────────────────────
[Fact]
public void Build_SetOnly_RemovesMembers()
@@ -115,18 +101,45 @@ public class InvoiceSetPricingTests
Assert.Equal("Anfahrt", lines[1].Title);
}
// ── Set price falls back to sum of members when header total is 0 ─────────
// ── Unconverted set (header total still 0): shown blank, members individually priced ──
[Fact]
public void Build_HeaderTotalZero_UsesSumOfMembers()
public void Build_HeaderTotalZero_UnconvertedSet_HeaderBlankMembersPriced()
{
var items = new List<Dictionary<string, object?>>
{
SetHeader("7", "Set ohne Preis"), // total 0
SetHeader("7", "Set ohne Preis"), // total 0 — not yet converted
Member("7", "A", "120.00", "120.00"),
Member("7", "B", "80.00", "80.00")
};
var lines = InvoiceSetPricing.Build(items, SetDisplayMode.SetPrice);
Assert.Equal(200.00m, lines[0].TotalNet); // 120 + 80
Assert.Equal(3, lines.Count);
Assert.True(lines[0].IsSetHeader);
Assert.False(lines[0].ShowPrice); // header not priced yet
Assert.Equal(0m, lines[0].TotalNet);
Assert.True(lines[1].ShowPrice); // members keep their own price
Assert.Equal(120.00m, lines[1].TotalNet);
Assert.True(lines[2].ShowPrice);
Assert.Equal(80.00m, lines[2].TotalNet);
}
// ── SetOnly on an unconverted set also just passes items through unchanged ──
[Fact]
public void Build_HeaderTotalZero_UnconvertedSet_SetOnlyModeStillPassesThrough()
{
var items = new List<Dictionary<string, object?>>
{
SetHeader("7", "Set ohne Preis"),
Member("7", "A", "120.00", "120.00"),
Member("7", "B", "80.00", "80.00")
};
var lines = InvoiceSetPricing.Build(items, SetDisplayMode.SetOnly);
Assert.Equal(3, lines.Count); // members not dropped before conversion
Assert.False(lines[0].ShowPrice);
Assert.True(lines[1].ShowPrice);
Assert.True(lines[2].ShowPrice);
}
// ── No sets: pass-through unchanged ───────────────────────────────────────
@@ -164,7 +177,7 @@ public class InvoiceSetPricingTests
}
[Fact]
public void Build_TextLine_AsSetMember_NoPriceEvenInItemPrices()
public void Build_TextLine_AsSetMember_NoPriceInSetPriceMode()
{
var items = new List<Dictionary<string, object?>>
{
@@ -172,10 +185,10 @@ public class InvoiceSetPricingTests
new() { ["type"] = "title", ["setId"] = "10", ["title"] = "Hinweis", ["total_net"] = "" },
Member("10", "Waschbecken", "600.00", "600.00")
};
var lines = InvoiceSetPricing.Build(items, SetDisplayMode.ItemPrices);
var lines = InvoiceSetPricing.Build(items, SetDisplayMode.SetPrice);
var note = lines.First(l => l.Title == "Hinweis");
Assert.False(note.ShowPrice); // text member stays blank
Assert.True(lines.First(l => l.Title == "Waschbecken").ShowPrice);
Assert.False(note.ShowPrice); // text member stays blank (already blank in SetPrice mode)
Assert.False(lines.First(l => l.Title == "Waschbecken").ShowPrice); // members blank in SetPrice mode too
}
// ── Set price equals sum of member prices across modes (no double counting) ─
+146 -8
View File
@@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Diagnostics.Metrics;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading;
@@ -48,6 +49,12 @@ public class ProcessWebComServiceTests
}
}
private sealed class ThrowingHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> throw new HttpRequestException("simulated transport failure");
}
private sealed class StubHttpClientFactory : IHttpClientFactory
{
private readonly HttpMessageHandler _handler;
@@ -55,6 +62,41 @@ public class ProcessWebComServiceTests
public HttpClient CreateClient(string name) => new(_handler, disposeHandler: false);
}
/// <summary>
/// Captures what would be written to <c>fds__logEmail</c> without hitting a database, by
/// overriding the audit-log write. Lets tests assert that every send path is logged and that
/// failures carry the service response / exception detail.
/// </summary>
private sealed class CapturingComService : ProcessWebComService
{
public readonly List<(bool success, List<string> log)> Entries = new();
public CapturingComService(
IOptions<ProcessWebComSettings> settings,
IOptions<FuchsEmailSettings> emailSettings,
IHttpClientFactory httpClientFactory)
: base(NullLogger<ProcessWebComService>.Instance, intranet: null!, settings, emailSettings, httpClientFactory)
{
}
protected override Task WriteAuditLogAsync(string reference, string guid, string config,
DateTime sent, bool success, IEnumerable<string> errors)
{
Entries.Add((success, errors.ToList()));
return Task.CompletedTask;
}
}
private static CapturingComService CreateCapturing(HttpMessageHandler handler, bool enabled = true, string? overrideRecipient = null)
{
var settings = Options.Create(new ProcessWebComSettings
{
Enabled = enabled, BaseUrl = "https://mailer.test", AccountId = "acct", Token = "tok"
});
var emailSettings = Options.Create(new FuchsEmailSettings { OverrideRecipient = overrideRecipient });
return new CapturingComService(settings, emailSettings, new StubHttpClientFactory(handler));
}
private static ProcessWebComService CreateService(StubHandler handler, bool enabled = true, string? overrideRecipient = null)
{
var settings = Options.Create(new ProcessWebComSettings
@@ -129,6 +171,102 @@ public class ProcessWebComServiceTests
Assert.Equal(0, handler.CallCount);
}
// ── Audit logging: every attempt is logged; failures carry the service response ─
[Fact]
public async Task SendEmailAsync_Success_LogsSuccessfulAttempt()
{
var svc = CreateCapturing(new StubHandler(HttpStatusCode.OK));
await svc.SendEmailAsync("inv_log_ok", "S", "<p>x</p>", "kunde@example.de", "Kunde");
var entry = Assert.Single(svc.Entries);
Assert.True(entry.success);
}
[Fact]
public async Task SendEmailAsync_ApiError_LogsFailureIncludingServiceResponse()
{
var svc = CreateCapturing(new StubHandler(HttpStatusCode.InternalServerError, "mailer rejected: quota exceeded"));
bool result = await svc.SendEmailAsync("inv_log_api", "S", "<p>x</p>", "kunde@example.de", "Kunde");
Assert.False(result);
var entry = Assert.Single(svc.Entries);
Assert.False(entry.success);
Assert.Contains(entry.log, l => l.Contains("mailer rejected: quota exceeded")); // raw service response
Assert.Contains(entry.log, l => l.Contains("500")); // HTTP status
}
[Fact]
public async Task SendEmailAsync_TransportException_LogsFailureIncludingExceptionDetail()
{
var svc = CreateCapturing(new ThrowingHandler());
bool result = await svc.SendEmailAsync("inv_log_ex", "S", "<p>x</p>", "kunde@example.de", "Kunde");
Assert.False(result);
var entry = Assert.Single(svc.Entries);
Assert.False(entry.success);
Assert.Contains(entry.log, l => l.Contains("Exception while sending"));
Assert.Contains(entry.log, l => l.Contains("simulated transport failure"));
}
[Theory]
[InlineData("not-an-email")]
[InlineData("")]
public async Task SendEmailAsync_InvalidEmail_StillLogsFailedAttempt(string badEmail)
{
var svc = CreateCapturing(new StubHandler(HttpStatusCode.OK));
bool result = await svc.SendEmailAsync("inv_log_bad", "S", "<p>x</p>", badEmail, "Kunde");
Assert.False(result);
var entry = Assert.Single(svc.Entries);
Assert.False(entry.success);
Assert.Contains(entry.log, l => l.Contains("Invalid recipient email address"));
}
[Fact]
public async Task SendEmailAsync_Disabled_LogsFailedAttempt()
{
var svc = CreateCapturing(new StubHandler(HttpStatusCode.OK), enabled: false);
await svc.SendEmailAsync("inv_log_dis", "S", "<p>x</p>", "kunde@example.de", "Kunde");
var entry = Assert.Single(svc.Entries);
Assert.False(entry.success);
Assert.Contains(entry.log, l => l.ToLowerInvariant().Contains("disabled"));
}
// ── Audit-log parameters honour the NOT NULL columns of fds__emaillog ──────
[Fact]
public void BuildAuditLogParameters_FailedAttempt_NeverPassesNullForNotNullColumns()
{
// fds__emaillog.config and .DateSent are NOT NULL; a failed/unset send must not send NULL
// (regression: it previously did, so every audit write failed and no email was ever logged).
var pl = ProcessWebComService.BuildAuditLogParameters(
"admin_test", guid: "", config: "", sent: default, success: false, errors: new[] { "err" });
object? config = pl.Single(p => p.ParameterName == "@config").Value;
object? dateSent = pl.Single(p => p.ParameterName == "@DateSent").Value;
Assert.Equal("", config);
Assert.NotEqual(DBNull.Value, config);
Assert.IsType<DateTime>(dateSent);
Assert.NotEqual((object)DBNull.Value, dateSent);
}
[Fact]
public void BuildAuditLogParameters_SuccessfulSend_KeepsProvidedSentTimestamp()
{
var when = new DateTime(2026, 7, 16, 12, 0, 0, DateTimeKind.Utc);
var pl = ProcessWebComService.BuildAuditLogParameters(
"inv_1", guid: "g1", config: "", sent: when, success: true, errors: Array.Empty<string>());
Assert.Equal(when, pl.Single(p => p.ParameterName == "@DateSent").Value);
}
// ── Dev/test recipient override safety net ─────────────────────────────────
[Fact]
public async Task SendEmailAsync_OverrideRecipientSet_RedirectsToOverrideAddress()
@@ -141,7 +279,7 @@ public class ProcessWebComServiceTests
Assert.True(result);
Assert.Equal(1, handler.CallCount);
var json = JObject.Parse(handler.LastRequestBody!);
Assert.Equal("dev-inbox@example.test", json["recipient"]!.ToString());
Assert.Equal("dev-inbox@example.test", json["communication"]!["to"]!.ToString());
}
[Fact]
@@ -153,7 +291,7 @@ public class ProcessWebComServiceTests
await svc.SendEmailAsync("inv_ov2", "Rechnung 123", "<p>hi</p>", "realcustomer@example.de", "Kunde");
var json = JObject.Parse(handler.LastRequestBody!);
string subject = json["subject"]!.ToString();
string subject = json["communication"]!["subject"]!.ToString();
Assert.Contains("realcustomer@example.de", subject);
Assert.Contains("Rechnung 123", subject);
}
@@ -169,7 +307,7 @@ public class ProcessWebComServiceTests
Assert.True(result);
Assert.Equal(1, handler.CallCount);
var json = JObject.Parse(handler.LastRequestBody!);
Assert.Equal("dev-inbox@example.test", json["recipient"]!.ToString());
Assert.Equal("dev-inbox@example.test", json["communication"]!["to"]!.ToString());
}
[Fact]
@@ -181,7 +319,7 @@ public class ProcessWebComServiceTests
await svc.SendEmailAsync("inv_ov4", "Subject", "<p>hi</p>", "realcustomer@example.de", "Kunde");
var json = JObject.Parse(handler.LastRequestBody!);
Assert.Equal("realcustomer@example.de", json["recipient"]!.ToString());
Assert.Equal("realcustomer@example.de", json["communication"]!["to"]!.ToString());
}
// ── Override enforcement across every real appsettings*.json environment ───
@@ -194,9 +332,9 @@ public class ProcessWebComServiceTests
// hand-typed literal - so a future edit that silently breaks the override key
// or its value in any appsettings*.json file would fail this test.
//
// Note: this API has a single "recipient" field - there is no distinct
// Note: this API has a single "communication.to" field - there is no distinct
// to/cc/bcc concept anywhere in the codebase (see ProcessWebComService.
// SendEmailAsync / payload.recipient). "to/cc/bcc cleared" is therefore fully
// SendEmailAsync / communication.to). "to/cc/bcc cleared" is therefore fully
// satisfied by asserting that field no longer carries the original recipient
// once an override is configured.
public static IEnumerable<object[]> AppSettingsEnvironments()
@@ -236,7 +374,7 @@ public class ProcessWebComServiceTests
Assert.True(result);
Assert.Equal(1, handler.CallCount);
var json = JObject.Parse(handler.LastRequestBody!);
string sentRecipient = json["recipient"]!.ToString();
string sentRecipient = json["communication"]!["to"]!.ToString();
if (!string.IsNullOrWhiteSpace(overrideRecipient))
{
@@ -266,7 +404,7 @@ public class ProcessWebComServiceTests
Assert.True(result);
Assert.NotNull(handler.LastRequestBody);
var json = JObject.Parse(handler.LastRequestBody!);
var att = (JArray)json["attachments"]!;
var att = (JArray)json["communication"]!["attachments"]!;
Assert.Single(att);
Assert.Equal("Rechnung.pdf", att[0]!["filename"]!.ToString());
Assert.Equal("application/pdf", att[0]!["mimeType"]!.ToString());
+51 -2
View File
@@ -205,6 +205,54 @@ public class StartupSelfTestServiceTests
Assert.Equal(1, service.MailerCalls);
}
[Fact]
public async Task RunOnce_Disabled_PublishesNotRunReport()
{
var reporter = new StartupCheckReporter();
using var service = new TestableStartupSelfTestService(
new ServiceCollection().BuildServiceProvider(),
CreateConfiguration(),
Options.Create(CreateSettings(enabled: false)),
NullLogger<StartupSelfTestService>.Instance,
reporter);
await service.RunForTestAsync(CancellationToken.None);
Assert.NotNull(reporter.Latest);
Assert.False(reporter.Latest!.Ran);
}
[Fact]
public async Task RunOnce_Enabled_PublishesReportWithPerCheckResults()
{
var reporter = new StartupCheckReporter();
using var service = new TestableStartupSelfTestService(
new ServiceCollection().BuildServiceProvider(),
CreateConfiguration(),
Options.Create(CreateSettings(enabled: true, checkKeyVault: true, checkDatabase: true, checkMfr: false, sendStartupEmail: true, startupRecipient: "ops@example.test", checkPdfLicense: false)),
NullLogger<StartupSelfTestService>.Instance,
reporter)
{
KeyVaultResult = true,
DatabaseResult = false,
MailerResult = true,
};
await service.RunForTestAsync(CancellationToken.None);
var report = reporter.Latest;
Assert.NotNull(report);
Assert.True(report!.Ran);
Assert.NotNull(report.CompletedUtc);
var kv = Assert.Single(report.Items, i => i.Name == "KeyVault");
Assert.True(kv.Enabled && kv.Ok);
var db = Assert.Single(report.Items, i => i.Name == "Database");
Assert.True(db.Enabled);
Assert.False(db.Ok); // intentionally-failing path
var mfr = Assert.Single(report.Items, i => i.Name == "MFR");
Assert.False(mfr.Enabled); // disabled check reported as not-enabled
}
[Fact]
public async Task ExecuteAsync_ProbeThrows_ServiceDoesNotThrowAndContinuesRemainingChecks()
{
@@ -250,8 +298,9 @@ public class StartupSelfTestServiceTests
IServiceProvider serviceProvider,
IConfiguration configuration,
IOptions<StartupSelfTestSettings> settings,
Microsoft.Extensions.Logging.ILogger<StartupSelfTestService> logger)
: base(serviceProvider, configuration, settings, logger)
Microsoft.Extensions.Logging.ILogger<StartupSelfTestService> logger,
StartupCheckReporter? reporter = null)
: base(serviceProvider, configuration, settings, logger, reporter)
{
}
+465
View File
@@ -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("&lt;script&gt;", capturedHtml); // HTML-encoded, not injected
Assert.DoesNotContain("<script>", capturedHtml);
Assert.Contains("<br/>", capturedHtml); // newline → <br/>
Assert.Null(r.OverrideRecipient);
}
[Fact]
public async Task SendTestEmailAsync_OverrideActive_ReportsOverrideRecipient()
{
var com = new Mock<IComService>();
com.Setup(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>?>()))
.ReturnsAsync(true);
var svc = Build(com: com, email: new FuchsEmailSettings { OverrideRecipient = "safety@example.test" });
var r = await svc.SendTestEmailAsync("dest@example.test", "Betreff", "Body");
Assert.True(r.Sent);
Assert.Equal("dest@example.test", r.RequestedRecipient);
Assert.Equal("safety@example.test", r.OverrideRecipient);
Assert.Contains("safety@example.test", r.Message);
}
[Fact]
public async Task SendTestEmailAsync_MailerRejects_ReturnsNotSent()
{
var com = new Mock<IComService>();
com.Setup(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>?>()))
.ReturnsAsync(false);
var svc = Build(com: com);
var r = await svc.SendTestEmailAsync("dest@example.test", "Betreff", "Body");
Assert.False(r.Sent);
Assert.Equal("dest@example.test", r.RequestedRecipient);
}
}
@@ -0,0 +1,107 @@
using Fuchs.Services;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
using OCORE.SQL;
using static OCORE.SQL.sql;
using static OCORE.web.mvc_helper_async;
namespace Fuchs.Controllers;
// Partial class: Admin / system-status module.
//
// Access is restricted to users whose "fds_sys" module authorization is greater than 4.
// The menu button is only shown, and the module script only loaded, for such users (frontend),
// but every data endpoint here ALSO enforces the level server-side (defense in depth) — the
// passive/probe data is diagnostic and must never be reachable by a lower-privileged session.
public partial class IntranetController
{
// fds_sys authorization must exceed this to use the Admin module.
private const int AdminMinAuthExclusive = 4;
private async Task<IActionResult> Do_Process_Admin(string fn, string id, string code)
{
_logger.LogDebug("Do_Process_Admin action={Action} code={Code} user={User}", id, code, UserAccountID);
// The auth probe is the one endpoint that answers for BOTH authorized and unauthorized
// users (the frontend uses manage>0 to decide whether to render the module at all).
int authLevel = await GetSystemAdminAuthAsync(fn, id, code);
bool authorized = authLevel > AdminMinAuthExclusive;
if (id.Equals("auth", StringComparison.OrdinalIgnoreCase))
return await JSONAsync(new { manage = authorized ? 1 : 0, level = authLevel });
if (!authorized)
{
_logger.LogWarning("Admin access denied for user={User} (fds_sys={Level}) action={Action}",
UserAccountID, authLevel, id);
return Unauthorized401();
}
var status = _systemStatus;
switch (id.ToLowerInvariant())
{
case "status":
{
var info = status.GetInfo();
var probes = await status.ProbeAllAsync(HttpContext.RequestAborted);
return AdminJson(new { info, probes });
}
case "info":
return AdminJson(new { info = status.GetInfo() });
case "probe":
{
// code carries the component id, e.g. /do/admin/probe/database
string component = string.IsNullOrWhiteSpace(code) ? Form("component") : code;
if (string.IsNullOrWhiteSpace(component))
return BadRequest400();
var probe = await status.ProbeAsync(component, HttpContext.RequestAborted);
return AdminJson(new { probe });
}
case "testmail":
{
if (!HasForm("to", "subject"))
return BadRequest400();
var result = await status.SendTestEmailAsync(
Form("to"), Form("subject"), Form("body"), HttpContext.RequestAborted);
_logger.LogInformation("Admin test email requested by user={User} to={To} sent={Sent}",
UserAccountID, result.RequestedRecipient, result.Sent);
return AdminJson(new { result });
}
default:
_logger.LogWarning("Admin: no handler for action={Action}, user={User}", id, UserAccountID);
return BadRequest400();
}
}
// The status DTOs (SystemInfoSnapshot / SystemProbeResult) are PascalCase; serialize them
// camelCase so the Admin frontend contract matches the lowercase convention used elsewhere.
private static readonly JsonSerializerSettings CamelCaseJson = new()
{
ContractResolver = new CamelCasePropertyNamesContractResolver(),
};
// Mirror OCORE's getJSONResult exactly (application/json; charset=utf-8) so the response
// parses identically to every other endpoint the frontend's postXT talks to.
private ContentResult AdminJson(object payload) =>
Content(JsonConvert.SerializeObject(payload, CamelCaseJson), "application/json; charset=utf-8");
/// <summary>
/// Resolves the calling user's <c>fds_sys</c> module authorization level via the
/// <c>fis_getModuleAuth</c> SQL function (same mechanism as <see cref="HandleAuth"/>).
/// Returns -3 when it cannot be determined (fail-closed).
/// </summary>
private async Task<int> GetSystemAdminAuthAsync(string fn, string id, string code)
{
var val = await getSQLValue_async<int>(
"SELECT [dbo].[fis_getModuleAuth](@module, @authuser);",
_intranet.Intranet__SQLConnectionString, -3,
StdParamlist(SQL_VarChar("@module", "fds_sys")),
Security: DbSec, options: SqlOpt(fn, id, code));
return val.Result;
}
}
@@ -222,6 +222,52 @@ public partial class IntranetController
: StatusCode(500, new { error = "not successful" });
}
case "man":
{
var pl = StdParamlist(
SQL_VarChar("@taID", Form("taID"), dbNull_IfEmpty: true),
SQL_Date("@ValueDate", Form("ValueDate")),
SQL_Decimal("@Amount", Form("Amount"), precision: 9, scale: 2),
SQL_NVarChar("@NameOfPayer", Form("NameOfPayer"), dbNull_IfEmpty: true),
SQL_VarChar("@SepaRemittanceInformation", Form("SepaRemittanceInformation"), dbNull_IfEmpty: true));
await getSQLDatatable_async(
"EXECUTE [dbo].[fds__setBankingtransaction_manual] @taID, @ValueDate, @Amount, @NameOfPayer, @SepaRemittanceInformation, @authuser;",
_intranet.Intranet__SQLConnectionString, pl,
Security: DbSec, options: SqlOpt(fn, id, code));
_logger.LogInformation(
"Manual banking transaction upserted taID={TaID} user={User}", Form("taID"), UserAccountID);
return await JSONAsync(new { ok = true });
}
case "mget":
{
if (!HasForm("taid")) return BadRequest400();
var pl = StdParamlist(SQL_VarChar("@taID", Form("taid"), dbNull_IfEmpty: true));
var res = await getSQLDatatable_async(
"EXECUTE [dbo].[fds__getBankingtransaction_manual] @taID, @authuser;",
_intranet.Intranet__SQLConnectionString, pl,
Security: DbSec, options: SqlOpt(fn, id, code));
return await JSONAsync(res.FirstRow.toObjectDictionary());
}
case "mdel":
{
if (!HasForm("taid")) return BadRequest400();
var pl = StdParamlist(
SQL_VarChar("@taID", Form("taid"), dbNull_IfEmpty: true),
SQL_Date("@ValueDate", (string?)null),
SQL_Decimal("@Amount", (string?)null, precision: 9, scale: 2),
SQL_NVarChar("@NameOfPayer", null, dbNull_IfEmpty: true),
SQL_VarChar("@SepaRemittanceInformation", null, dbNull_IfEmpty: true));
await getSQLDatatable_async(
"EXECUTE [dbo].[fds__setBankingtransaction_manual] @taID, @ValueDate, @Amount, @NameOfPayer, @SepaRemittanceInformation, @authuser;",
_intranet.Intranet__SQLConnectionString, pl,
Security: DbSec, options: SqlOpt(fn, id, code));
_logger.LogInformation(
"Manual banking transaction deleted taID={TaID} user={User}", Form("taid"), UserAccountID);
return await JSONAsync(new { ok = true });
}
case "vfi":
{
if (!HasForm("invid")) return BadRequest400();
@@ -78,9 +78,17 @@ public partial class IntranetController
var ldic = BuildInvoiceRequestList(sqldset);
var adminDic = sqldset.Table("admin").FirstRow.toObjectDictionary();
var invDic = sqldset.Table("inv").FirstRow.toObjectDictionary();
bool has13b = invDic.nz("InvoiceOptions", "").Split(',').Contains("§13b");
string invoiceOptions = invDic.nz("InvoiceOptions", "");
bool has13b = invoiceOptions.Split(',').Contains("§13b");
if (has13b)
adminDic["p13b"] = true;
// Carry the persisted set-pricing mode forward (see InvoiceSetPricing.cs / FdsInvoiceData.BuildInvoiceOptions):
// needed so the editor knows the invoice has already been explicitly switched to a set-display
// mode (including the default "setprice") and hides the "Set-Preisanzeige" menu entry accordingly.
string? setmodeToken = invoiceOptions.Split(',')
.FirstOrDefault(t => t.StartsWith("setmode:", StringComparison.OrdinalIgnoreCase));
if (setmodeToken != null)
adminDic["setmode"] = setmodeToken["setmode:".Length..].Trim().ToLowerInvariant();
_logger.LogDebug("HandleInvoiceGet invoiceId={InvoiceId} requestCount={ReqCount} has13b={Has13b} user={User}",
invoiceId, ldic.Count, has13b, UserAccountID);
return await JSONAsync(new { admin = adminDic, inv = invDic, req = ldic });
+5 -1
View File
@@ -38,6 +38,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
private readonly IInvoiceDraftService _invoiceDrafts;
private readonly IReminderDraftService _reminderDrafts;
private readonly IDraftNotifier _draftNotifier;
private readonly ISystemStatusService _systemStatus;
private readonly List<string> _allowedNonAuth = new() { "spwc", "spw" };
private readonly List<string> _allowedGet = new()
{
@@ -68,7 +69,8 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
IEventService events,
IInvoiceDraftService invoiceDrafts,
IReminderDraftService reminderDrafts,
IDraftNotifier draftNotifier)
IDraftNotifier draftNotifier,
ISystemStatusService systemStatus)
{
_intranet = intranet;
_mfr = mfr;
@@ -85,6 +87,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
_invoiceDrafts = invoiceDrafts;
_reminderDrafts = reminderDrafts;
_draftNotifier = draftNotifier;
_systemStatus = systemStatus;
}
/// <summary>Merged query-string + form parameters (form wins) for report processing.</summary>
@@ -163,6 +166,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
"rem" => await Do_Process_Reminder(fn, id, code),
"rep" => await Do_Process_Reports(fn, id, code),
"bam" => await Do_Process_Bankings(fn, id, code),
"admin" => await Do_Process_Admin(fn, id, code),
"auth" => await HandleAuth(fn, id, code),
"spwc" => await HandleSendPasswordCode(fn, id, code),
"spw" => await HandleSendPassword(fn, id, code),
+17 -10
View File
@@ -12,7 +12,7 @@ The **Fuchs Intranet** solution is a line-of-business web application for **Seba
| Project | Type | Purpose |
|---|---|---|
| **Fuchs** | ASP.NET Core Web (MVC) | Main web application — the intranet |
| **Fuchs_DataService** | Console / Windows Service (Topshelf) | Background data sync service (MFR ERP polling) |
| **Fuchs_DataService** | Class Library | MFR ERP sync logic (entity polling, invoice/DATEV files). Hosted in-process by **Fuchs** as a `PeriodicHostedService` (see ADR 0010) — no longer a standalone process. |
| **MFR_RESTClient** | Class Library | REST/OData client for the MFR ERP system. The REST/OData contract is documented in `MFR_RESTClient/Docs/mfr_interface_description.md`. |
| **Fuchs_Database** | SSDT (SQL project) | Source of truth for the `fuchs_fds` SQL schema (tables, table types, functions, stored procedures the backend calls). |
| **OCORE** | Class Library (shared) | Core utilities: SQL, crypto, email, IO, logging |
@@ -96,17 +96,18 @@ The **Fuchs Intranet** solution is a line-of-business web application for **Seba
│ │
▼ ▼
┌─────────────────────────────────────────────────────────────────────────┐
Fuchs_DataService (Windows Service / Console)
│ Fuchs_DataService (Class Library, hosted in-process)
│ │
FdsMain.cs — Topshelf host, job definitions
PeriodicHostedService — BackgroundService with PeriodicTimer
PeriodicHostedService — in Fuchs/Services; BackgroundService +
PeriodicTimer, registered in Program.cs
│ when Fds:SyncEnabled (ADR 0010) │
│ FdsMfr.cs (IFdsMfr) — MFR sync orchestration │
│ FdsMfrClient.cs — MFR REST client wrapper │
│ FdsShared.cs — FdsConfig (appsettings.json reader)
FdsZip.cs 7-Zip archive handling (DATEV export)
│ FdsShared.cs — FdsConfig (reads the host's IConfiguration)
(DATEV zip)OCORE.zip (System.IO.Compression), no 7-Zip
│ FdsDebug.cs — Debug/file logging │
│ │
│ Jobs: MfrSync (every N min)
│ Jobs: MfrSync (every Fds:ExecutionFrequency_Minutes)
│ → UpdateIfNecessary_async (entity table sync) │
│ → UpdateRequested_async (on-demand entity refresh) │
│ → GetInvoiceFiles_async (invoice PDF download) │
@@ -167,8 +168,14 @@ OCORE_Charting (standalone — referenced by solution but no direct project ref
### 4.3 Service Layer (Dependency Injection)
Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces, injected into `IntranetController`:
`IComService`, `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`.
`IComService`, `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`, `ISystemStatusService`, `IERechnungService`.
`IERechnungService` (singleton) maps a finalized invoice to the EN 16931 model and embeds the
CII XML into the render-only visual PDF to produce a ZUGFeRD/Factur-X **PDF/A-3** hybrid via the
`eRechnungLib` submodule (gated by `Fuchs:ERechnung:Enabled`; falls back to the plain PDF/A on
disable/failure). See [`Concepts/erechnung-output.md`](Concepts/erechnung-output.md) and ADR 0012.
Stateless services (`IPdfService`, `IBankingService`, `IMfrClientFactory`) are singletons; DB/request-scoped services are scoped (see `Program.cs`).
The **Admin** module (`Do_Process_Admin`, `ISystemStatusService`) surfaces a live system-status/diagnostics page (host, SQL/Key Vault/blob/MFR connectivity, email config, test-email) restricted to `fds_sys` > 4 — see ADR [0011](Decisions/0011-admin-module-system-status.md) and the [concept doc](Concepts/admin-system-status.md).
`FdsInvoiceData` / `FdsReminderData` are now **pure data holders** (parse + properties); loading, persistence and PDF generation live in the services (fully async — no `Task.Run(...).Wait()`).
`FuchsPdf` / `FuchsVisualization` remain as static rendering libraries used *by* the services. The earlier static, controller-coupled helpers (`FuchsWidgets`, `FuchsReports`, `Banking`, `FuchsFdsEmail`) have been removed.
@@ -176,7 +183,7 @@ Stateless services (`IPdfService`, `IBankingService`, `IMfrClientFactory`) are s
There is no ORM (no EF Core). All data access uses **ADO.NET via OCORE SQL helpers** (`getSQLDatatable_async`, `getSQLDataSet_async`, `setSQLValue_async`) calling stored procedures and inline SQL. `DataTable`/`DataRow` is the primary data transfer mechanism.
### 4.5 Background Service
`Fuchs_DataService` runs as a Windows Service (Topshelf) with a `PeriodicHostedService` that polls the MFR ERP system on a timer, syncing entities and downloading invoice files.
The MFR ERP sync runs **in-process inside the web app** as a `PeriodicHostedService` (`Fuchs/Services/PeriodicHostedService.cs`), registered in `Program.cs` when `Fds:SyncEnabled` is true. It polls the MFR ERP on a timer (`Fds:ExecutionFrequency_Minutes`), syncing entities and downloading invoice files via the sync logic in the `Fuchs_DataService` library. See ADR [0010](Decisions/0010-mfr-sync-hosted-in-web-app.md).
### 4.6 Authentication
Cookie-based authentication (`CookieAuthenticationDefaults`) with custom claims (`FuchsUserIdentity`). SQL-based user/password verification.
@@ -409,7 +416,7 @@ public class MfrClientFactory : IMfrClientFactory, IDisposable
2.**Resolved**`FdsInvoiceData`/`FdsReminderData` are now pure data holders; DB + PDF logic moved to `IInvoiceService`/`IReminderService`.
3.**Resolved**`FdsMfrClient` is created via `IMfrClientFactory` (no `new` in controllers).
4.**Resolved**`OCORE_Charting` is now used (transitively, via `OCORE_web`'s chart engine) by the report renderer (`FuchsVisualization`).
5. **Open****Topshelf** in `Fuchs_DataService` could be replaced with native `dotnet` Worker Service hosting for .NET 10 alignment.
5. **Resolved****Topshelf** removed; `Fuchs_DataService` is now a class library and the MFR sync is hosted in-process by the web app as a `PeriodicHostedService` (ADR 0010).
---
@@ -0,0 +1,97 @@
---
status: Active
lastUpdated: 2026-07-16
applyTo:
- "Fuchs/Controllers/IntranetController.Admin.cs"
- "Fuchs/Services/SystemStatusService.cs"
- "Fuchs/Services/ISystemStatusService.cs"
- "Fuchs/Services/SystemStatusModels.cs"
- "Fuchs/js/intranet/modules/fis.admin*.js"
- "Fuchs/js/intranet/modules/fis.admin.scss"
- "Fuchs/js/intranet/fis_main_menu.js"
relatedDecisions:
- "0011-admin-module-system-status.md"
---
# Admin / System-Status module
## Summary
The **Admin** module gives a privileged operator a live, in-app view of the running
deployment's configuration and health, plus a test-email tool. It answers "which host am I
on, can this instance reach SQL Server / Key Vault / Blob Storage / the MFR ERP, how is
email wired up (including the OverrideRecipient redirect), and does sending actually work".
Access is restricted to users whose `fds_sys` module authorization is greater than 4.
## How it works
### Authorization (two layers)
1. **Menu visibility** — at page load `fis_main_menu.js#addAdminMenuIfAuthorized` calls
`$fis.getAuth('fds_sys')`; only when the level is > 4 does it push the `init:admin`
button into `$ocms.ocmsmenu` and re-render `#mainmenu`. Users below the threshold never
see the button and never fetch the module script.
2. **Server-side gate** — every `/do/admin/*` endpoint resolves
`fis_getModuleAuth('fds_sys', authuser)` and returns 401 unless it is > 4. The only
exception is `admin/auth`, which returns `{ manage: 0 }` for unauthorized users so the
frontend can cleanly decline to render. This is defense in depth: hiding the button is
not a security control on its own.
### Request flow
```
click "Administration" → $ocms.init('admin')
POST /do/admin/auth → { manage, level } (manage>0 ⇒ authorized)
load /web/fis.admin.de.js + /web/fis.admin.css
$ocms.admin.init2() → init3() renders the page
POST /do/admin/status → { info, probes } full snapshot + all probes
per-card "Aktualisieren" → POST /do/admin/probe/<component> → { probe }
"Test-E-Mail senden" → POST /do/admin/testmail (to/subject/body) → { result }
```
### Backend (`ISystemStatusService` / `SystemStatusService`, scoped)
- `GetInfo()` — passive snapshot, no network calls: host (machine/OS/framework/environment/
test-deployment/uptime), database (server/catalog/login parsed from the connection string —
**never the password**), email (mailer enabled/base-url/account/server-id/token-present +
OverrideRecipient), blob (enabled/configured/containers), MFR (host/creds-present/sync),
Key Vault (vault-uri/app-prefix/managed-secret-count/client-registered).
- Probes (`database`, `keyvault`, `blob`, `mfr`) each return a `SystemProbeResult`
(`status` = `ok`/`error`/`disabled`/`unconfigured`, `ok`, `message`, `detail`, `durationMs`,
optional `metrics`). They never throw — failures are captured in the result. `database` runs
`SELECT @@SERVERNAME, DB_NAME(), SUSER_SNAME()`; `keyvault` reads the first managed secret via
the DI `SecretClient`; `blob` calls `IBlobStorageService.CheckConnectivityAsync` (account-info
request **plus a per-container blob count** for the invoice/reminder containers, surfaced as
`metrics` and totalled in the message); `mfr` calls `IMfrClientFactory.Create().GetEntities()`.
The generic `metrics` (label/value pairs) is how a probe reports extra facts for display — the
blob probe uses it for the file counts.
- `SendTestEmailAsync(to, subject, body)` HTML-encodes the body and sends via `IComService`, so
the `Fuchs:Email:OverrideRecipient` redirect applies exactly as for any other mail; the result
reports the requested recipient and, when active, the override target.
- Every probe emits the `fuchs.systemstatus.probes` counter tagged by component + status and an
`systemstatus.probe` activity span.
### Startup-checks widget (non-refreshable)
`StartupSelfTestService` runs the boot self-test once (Key Vault / Database / MFR / PDF-license /
mailer, gated by `Fuchs:StartupChecks:*`). It now also writes its outcome to the singleton
`StartupCheckReporter`, which `GetInfo()` returns as `StartupChecks`. The Admin page renders it as a
single **non-refreshable** card (there is no per-check retry — the live connectivity probes above
cover on-demand re-testing; the startup card is a historical record of the boot run). When the
self-test is disabled (`Enabled=false`, the appsettings default) or has not completed, the card shows
a "nicht ausgeführt" note. Each item shows OK / Fehler / übersprungen (a disabled check reports
`enabled=false`).
### Frontend (`fis.admin.js` + `fis.admin_txt_de.js` + `fis.admin.scss`)
Standard lazy-loaded module (same contract as `inv`/`rep`/`bam`). Renders a system card plus a
responsive grid of status cards; connectivity cards carry a colored status pill and a per-card
refresh button, the email card carries the test-email dialog. The topbar has an "Alle prüfen"
button that reloads the whole snapshot. Admin responses are serialized **camelCase** (the DTOs are
PascalCase) so the JS reads `probe.status`, `info.database.server`, etc.
## Key files
- `Fuchs/Controllers/IntranetController.Admin.cs``Do_Process_Admin` dispatch + auth gate + camelCase JSON helper.
- `Fuchs/Services/ISystemStatusService.cs`, `SystemStatusService.cs`, `SystemStatusModels.cs` — diagnostics service + DTOs.
- `Fuchs/Services/StartupCheckReport.cs` (`StartupCheckReporter` singleton) + `StartupSelfTestService.cs` — boot self-test result captured for the non-refreshable widget.
- `Fuchs/Services/IBlobStorageService.cs` / `AzureBlobStorageService.cs``CheckConnectivityAsync` + `BlobConnectivity`.
- `Fuchs/js/intranet/modules/fis.admin*.js`, `fis.admin.scss` — the module, texts, styles (bundled via `bdlconfig.json`).
- `Fuchs/js/intranet/fis_main_menu.js` (`addAdminMenuIfAuthorized`) + `fis_main_go.js` — conditional menu button.
- `Fuchs.Tests/SystemStatusServiceTests.cs` — service tests.
## Related decisions
- [0011 — Admin module gated on `fds_sys` > 4](../Decisions/0011-admin-module-system-status.md)
+84
View File
@@ -0,0 +1,84 @@
---
status: Active
lastUpdated: 2026-07-18
applyTo:
- "Fuchs/Services/ERechnungMapper.cs"
- "Fuchs/Services/ERechnungService.cs"
- "Fuchs/Services/ERechnungSettings.cs"
- "Fuchs/Services/InvoiceRecipientAddress.cs"
- "Fuchs/Services/InvoiceService.cs"
- "Fuchs/code/FuchsPdf.cs"
- "eRechnungLib/**"
relatedDecisions:
- "0005-pdf-generation-and-erechnung.md"
- "0012-erechnung-single-pdfa-engine-pipeline.md"
---
# eRechnung output (ZUGFeRD/Factur-X)
## Summary
Finalized invoices are emitted as a **ZUGFeRD 2.4 / Factur-X** hybrid: the FuchsPdf visual PDF
with the EN 16931 **CII XML** embedded, in a formally conformant **PDF/A-3**. This makes invoices
DATEV-ingestible and satisfies the B2B/B2G e-invoicing mandate. The library doing the structured
XML + PDF/A-3 work is the `eRechnungLib` submodule; Fuchs supplies the invoice data and the visual
PDF.
## How it works
```
Editor (structured recipient dialog)
→ InvoiceDraftEditService (address delta = JSON object, stored in CustomValues.sendToAddress)
→ fds__setInvoice/… (persisted; dedicated SendToAddressJson column + composed SendToAddress)
→ InvoiceService.RenderInvoicePdfBytesAsync(final)
├─ FuchsPdf.DocToPdfBytesRaw(doc) → visual PDF (no Spire PDF/A)
└─ IERechnungService.TryBuildHybridPdf
├─ ERechnungMapper.BuildEInvoice FdsInvoiceData → eRechnungLib.Model.Invoice
└─ EInvoice.ToZugferd(EN16931, raw) → PDF/A-3 + Factur-X hybrid (bundled sRGB ICC)
```
- **Single PDF/A engine (ADR 0012).** eRechnungLib owns the one PDF/A-3 layer. For the eRechnung
path the Spire PDF/A step is skipped (`DocToPdfBytesRaw`); Spire stays only for on-screen preview
rasterisation. This avoids a conflicting second output intent / `pdfaid` marker.
- **Structured recipient address.** `InvoiceRecipientAddress` holds the EN 16931 buyer fields
(name, street, post code, city, country BT-55, optional VAT id BT-48). It is edited via a
dialog form (`$inv.eAddress` in `fis.inv_shared.js`), prefilled from `fds__prepInvoice`'s
`invoiceaddressData`, and carried as a JSON object through the draft cache. A private person
(no VAT id) is fully valid — B2C stays effortless. The free-text `SendToAddress` is composed
from it so the PDF layout is unchanged.
- **Mapping.** `ERechnungMapper` maps the Fuchs invoice to the EN 16931 model: seller = Fuchs
(from `Fuchs:ERechnung:Seller` config — name, address, Steuernummer BT-32, **USt-IdNr BT-31**,
IBAN/BIC, contact; defaults mirror the FuchsPdf letterhead), buyer = the structured recipient,
lines from the invoice items, §13b → reverse charge (category AE + exemption reason), payment
terms BT-20/BT-9, and the service date/period (BT-72 or BG-14) parsed from the structured
`ProvisionPeriod`. VAT breakdown and totals are recomputed by the library.
- **Profile selection (B2B/B2C vs B2G).** Default is ZUGFeRD **EN 16931** (DATEV). When the
recipient carries a **Leitweg-ID** (`InvoiceRecipientAddress.LeitwegId``BuyerReference` BT-10),
the invoice is B2G and emitted as **XRechnung** (`ZugferdProfile.XRechnung`, embedded
`xrechnung.xml`); the mapper then also fills the seller electronic address/contact (BT-34/BG-6)
and buyer electronic address (BT-49) that XRechnung requires.
- **Feature flag & fallback.** Emission is gated by `Fuchs:ERechnung:Enabled` (off until fully
validated). `IERechnungService` returns `null` on disable **or any failure**, so
`RenderInvoicePdfBytesAsync` falls back to the plain Spire PDF/A — invoicing never breaks.
- **Formal verification.** `Fuchs:ERechnung:Validation` calls the ProcessWeb eInvoice service
(`POST {ServiceUrl}/validatepdf`, raw `application/pdf`) which checks the EN 16931 XML **and**
PDF/A-3 (veraPDF) in one call. Both the EN 16931-ZUGFeRD and the XRechnung 3.0 output are
externally **ACCEPTED** (0 errors) and **PDF/A-3B COMPLIANT**. Getting there required fixing two
eRechnungLib defects (CII root children must be `rsm:` not `ram:`; embedded-file `/Subtype` MIME
encoding) and completing the mapper (seller VAT id BT-31, payment terms BT-20/BT-9). The
validator's target feature scope is documented in
[`../eRechnung-Validator-Requirements.md`](../eRechnung-Validator-Requirements.md).
## Key files
- `Fuchs/Services/InvoiceRecipientAddress.cs` — structured buyer address, composition, conformity.
- `Fuchs/Services/ERechnungMapper.cs``FdsInvoiceData``eRechnungLib.Model.Invoice`.
- `Fuchs/Services/ERechnungService.cs` / `ERechnungSettings.cs` — hybrid production + config.
- `Fuchs/Services/InvoiceService.cs` — wiring in `RenderInvoicePdfBytesAsync`.
- `Fuchs/code/FuchsPdf.cs``DocToPdfBytesRaw` (render-only visual PDF).
- `Fuchs/js/intranet/modules/fis.inv_shared.js``$inv.eAddress` structured dialog.
- `Fuchs_Database``fds__invoices.SendToAddressJson`, `fds__getCompanyAddressJson`,
`fds__prepInvoice.invoiceaddressData`, `fds__createInvoice`/`setInvoice`/`getInvoice`.
- `eRechnungLib/**` — CII/UBL serialization, EN 16931 validation, `FacturXPdfBuilder` (PDF/A-3).
## Related decisions
- [`0005-pdf-generation-and-erechnung.md`](../Decisions/0005-pdf-generation-and-erechnung.md)
- [`0012-erechnung-single-pdfa-engine-pipeline.md`](../Decisions/0012-erechnung-single-pdfa-engine-pipeline.md)
+11 -4
View File
@@ -1,4 +1,4 @@
---
---
status: Active
lastUpdated: 2026-07-10
applyTo:
@@ -55,13 +55,19 @@ Expiry: Server (timer) --SignalR draftExpiring{token,secondsLeft}--> warn "bit
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:
`RecomputeItem` (quantity × price × VAT, the `quantChange` port), `RecomputeTotals`
`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.
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
@@ -85,7 +91,8 @@ Expiry: Server (timer) --SignalR draftExpiring{token,secondsLeft}--> warn "bit
`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 no longer computes totals.
"Ä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`.
@@ -1,4 +1,4 @@
---
---
status: Accepted
date: 2026-07-10
applyTo:
@@ -36,9 +36,11 @@ singleton `IInvoiceDraftCache` and orchestrated by the scoped `IInvoiceDraftServ
(`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` + `invSumUpdate`),
including the §13b reverse-charge rule and VAT-per-rate grouping. The browser never
computes totals; it renders the server's `sums`.
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
@@ -0,0 +1,114 @@
---
status: Accepted
date: 2026-07-15
applyTo:
- "Fuchs/Services/InvoiceDraft*"
- "Fuchs/Services/IInvoiceDraft*"
- "Fuchs/Services/ReminderDraft*"
- "Fuchs/Services/IReminderDraft*"
- "Fuchs/code/InvoiceDraftSession.cs"
- "Fuchs/code/InvoiceDraftCalculator.cs"
- "Fuchs/code/InvoiceSetPricing.cs"
- "Fuchs/code/ReminderDraftSession.cs"
- "Fuchs/code/ReminderDraftCalculator.cs"
- "Fuchs/code/FuchsPdf.cs"
- "Fuchs/js/intranet/**"
supersededBy: ""
---
# 0008 — Invoices and reminders (all kinds) are fully backend-authoritative; PDF and online editor must render identical content
## Context
ADR [0006](0006-backend-authoritative-draft-editing.md) established the backend-authoritative
draft-editing model for invoices and noted reminders were "intended to follow the identical
pattern as a second phase". Both are now implemented (`InvoiceDraftEditService` /
`ReminderDraftEditService`). In practice, ambiguity kept resurfacing about *which* invoice/
reminder kinds this covers and *which* kinds of change qualify as "must be computed server-side":
e.g. whether a purely presentational client-side re-render (set-price display toggle, item
reordering, position renumbering) was allowed to keep any client-side math, and whether this
applies uniformly to every invoice type (regular `r`, partial/Abschlag `i`, final `f`, storno
`c`) and every reminder stage, not just the pilot "regular invoice" flow. This decision closes
that ambiguity explicitly.
## Decision
**Every invoice (all `InvoiceType` kinds: regular, partial/Abschlagsrechnung, final/
Schlussrechnung, Storno/credit) and every reminder (all reminder stages/Mahnstufen) is
backend-authoritative while being drafted or previewed.** This generalises and makes explicit
what ADR 0006 already implied for the pilot flow:
- **Any calculation** (net/VAT/gross totals, per-rate VAT grouping, service-refund figures,
§13b reverse-charge suppression, set-price sums, open-amount for reminders, position/line
numbering) is performed exclusively by the server (`InvoiceDraftCalculator`,
`ReminderDraftCalculator`, `InvoiceSetPricing`). The browser never sums, subtracts, or
otherwise derives a monetary or positional value — it only displays server-computed values.
This includes the single-line arithmetic that used to run in `quantChange`/`setVat`
(`net_val = qty × price`, `vat_val = net_val × rate`, service-net/-VAT splits): those
handlers now only post the raw, unmultiplied field the user typed (`qn`/`v`/`vat`) and the
server (`InvoiceDraftCalculator.RecomputeLineValues`) computes every derived line value.
Likewise the invoice footer (net/VAT-by-rate/gross), the per-block "isum" cell, and the
service-refund note figures are rendered exclusively from `dstate.sums` (`$inv.d.footer`);
`$inv.invSumUpdate` no longer accumulates any of these — it only reassembles the row
contract array needed to post `req` to the server and (on first load) seeds the session.
- **Any setting** (§13b flag, set-pricing display mode, payment terms, contact, custom values,
…) is applied server-side via a named `InvoiceDraftDelta`/`ReminderDraftDelta` target and
reflected back through `dstate`. The client never mutates its local model as the source of
truth for a setting; it optimistically reflects the *request* but always re-renders from the
next `dstate`/`draftReady` refresh.
- **Any text change** (recipient email/address, invoice title, provision location/period,
section headings, item name/description/notes) is sanitised and stored server-side
(`InvoiceDraftEditService.HtmlToPlain` et al.); the server's stored value is the one that
reaches the PDF and any reloaded draft.
- **Any reordering** (drag-reorder of service-request blocks/sections, drag-reorder of item
rows within a block) is committed as a `block.order` (or equivalent) delta; the server
performs the actual reorder and renumbers positions (`InvoiceDraftCalculator.RecomputePositions`).
The client's drag interaction is input only — the rendered order after a refresh is the
server's order, not whatever the browser left in the DOM mid-drag.
- **Irreversible one-way conversions** (e.g. "Auf Setpreis umstellen" — switching a set's
member items from individual prices to a single set price) are likewise backend-only
operations (`item.setprice` delta / `InvoiceDraftEditService.ApplyItemSetPrice`), never
computed or applied in the browser.
- **The PDF must render 100% the same information and content as the online editor at any
given moment.** Both consume the identical authoritative session data:
- The online editor renders `dstate`'s `req`/`sums`/`setDisplay`/`notes` — all server-computed.
- The PDF preview (`inv/dpreview`, `rem/dpreview`) renders straight from the same cached
session via a synthesised registration (`InvoiceDraftEditService.RenderPreview` /
`ReminderDraftEditService`'s reminder equivalent) — **not** from a separate client upload
or a re-derived model.
- `FuchsPdf.BuildInvoiceNotes` (notice paragraphs) is called identically for both the
editor's `notes` array and the PDF body, so intro/closing texts can never drift between
the two renderings.
- Any new editor-visible fact (a new total, a new flag, a new note) must be added to the
shared session/service layer once, not duplicated as separate editor-only and PDF-only
logic.
- This applies for the full lifecycle while a document is a draft (open → edit → preview →
Zwischenspeichern) up to finalise; a finalised, persisted invoice/reminder is immutable
and is rendered straight from its stored DB data (no draft session involved) — that path
already has no client-side math to begin with.
## Consequences
- New invoice/reminder editor features must be modelled as a server-side delta + calculator
change, exactly as ADR 0006 already requires; this decision removes any residual excuse to
special-case a "just this one is presentational, do it in JS" shortcut for reordering,
display-mode toggles, or one-way conversions.
- Any PDF-only or editor-only special-casing found in review is a bug against this decision —
the shared session/service must be extended so both renderers read the same value/flag.
- Reminder "Mahnstufen" and every invoice type share this obligation; there is no partial/
Abschlagsrechnung, Schlussrechnung, or Storno exemption while such a document is still a
draft going through the same `dopen`/`dpatch`/`dpreview`/`dsave` flow.
- Test coverage for the cache/session layer (`InvoiceDraftEditService`, `ReminderDraftEditService`,
`InvoiceDraftCalculator`, `InvoiceSetPricing`) must exercise every mutating operation
(text edits, reordering, all three set-pricing display modes, the set-price conversion,
multi-rate VAT sums, full recompute) against mock datasets, since this is now the single
place all of these behaviours are guaranteed correct — see `Fuchs.Tests/InvoiceDraftServiceTests.cs`,
`Fuchs.Tests/ReminderDraftServiceTests.cs`, `Fuchs.Tests/InvoiceDraftCalculatorTests.cs`,
`Fuchs.Tests/InvoiceSetPricingTests.cs`.
## Alternatives considered
- **Scope this only to the invoice pilot flow** (leave reminders/other invoice kinds
ambiguous): rejected — the ambiguity itself was the problem being fixed; the underlying
session/service code already treats all kinds uniformly, so documenting anything narrower
would misrepresent the code.
- **Allow "purely cosmetic" client-side math for reordering/display toggles**: rejected —
history showed exactly this exception is where drift crept in (e.g. the set-price toggle
originally computed sums in the browser before being moved server-side); no exception is
granted.
@@ -0,0 +1,122 @@
---
status: Accepted
date: 2026-07-14
applyTo:
- "Fuchs/code/InvoiceSetPricing.cs"
- "Fuchs/Services/InvoiceDraft*"
- "Fuchs/Services/IInvoiceDraft*"
- "Fuchs/code/InvoiceDraftSession.cs"
- "Fuchs/code/InvoiceDraftCalculator.cs"
- "Fuchs/code/FuchsPdf.cs"
- "Fuchs/js/intranet/**"
- "Fuchs/Docs/INVOICE_SET_PRICING.md"
supersededBy: ""
---
# 0009 — The two menu set-price modes are per-service-request-block, irreversible cache mutations that insert a dedicated set row
## Context
ADRs [0006](0006-backend-authoritative-draft-editing.md) and
[0008](0008-invoices-and-reminders-fully-backend-authoritative.md) made draft editing
backend-authoritative. Under that model the set-price feature had **three** functions, of
which the two menu-driven ones ("Set mit Preis" / "Nur Set mit Preis") were framed as
whole-invoice **display modes** (`SetDisplayMode.SetPrice`/`SetOnly`): a non-mutating,
render-time transform (`InvoiceSetPricing.Build`) over explicit `type == "set"` header items
and their `SetItmId` members, persisted only as an `admin.setmode` flag. ADR 0008 calls them
"display-mode toggles" and treats them as presentational.
The product owner has redefined those two menu functions. They are **not** display toggles and
they are **not** keyed on set-item membership:
- Their grouping is the **service request** (`ServiceRequestId` = the editor's tbody block),
never `SetItmId`. Every block is treated as one set, whether or not it contains any
`type == "set"` item.
- Applying a mode is an **irreversible data change** written hard into the cached draft
dataset — not a reversible view flag. There is no toggle back; the user adjusts the result
by hand afterwards.
This decision records that redefinition. It **refines** ADR 0008's characterisation of these
two operations (from "non-mutating display toggle" to "mutating, one-way conversion"); ADR
0008's broader rule — every calculation server-side, and the PDF renders 100% the same content
as the editor — remains fully in force and is not superseded.
## Decision
There are three distinct set-price operations, kept clearly separated:
1. **Inline set-item switch — unchanged.** The row context button on a single `type == "set"`
item (`$inv.toSetPrice``item.setprice` delta → `InvoiceDraftEditService.ApplyItemSetPrice`).
It is `SetItmId`-based, sums the header's members onto the header, sets the members' prices to
`null` (empty cell, excluded from the sum — consistent with modes 2 & 3, not `0`), and is
one-way. This is the **only** set-price operation that reads `SetItmId`. The button is shown —
and the operation available — **only** for items that are `type == "set"` **and** carry a
`SetItmId` (and are still unconverted, i.e. own price `0`); an item missing either condition
never offers it.
2. **"Set mit Preis" (menu) — per-block, irreversible mutation.** Applied server-side to the
cached `InvoiceDraftSession`, grouped by `ServiceRequestId`. For **every** service-request
block:
- Insert one dedicated, emphasised **set row** at the top of the block, carrying the block's
aggregated value (net + VAT + service-net/-VAT splits) as its price. This row is a real,
editable line item with its own id, so the user can manually change the set value afterwards
as an ordinary item edit.
- **Null out** the price of every existing item row in the block (set the price fields to
`null`, **not** `0`) so the row renders with an **empty** price/total cell. `null` and `0`
are semantically distinct here: `null` means "no price — render an empty cell and exclude
from the block sum", whereas `0` would legitimately print `0,00 €`. The rows themselves are
retained.
3. **"Nur Set mit Preis" (menu) — per-block, irreversible mutation.** As above, grouped by
`ServiceRequestId`. For every block:
- Insert the same dedicated, emphasised set row carrying the block's aggregated value.
- **Remove** every existing item row in the block from the dataset entirely (the lines are
gone, not merely hidden).
Properties common to the two menu modes (2 and 3):
- **Mutation, not display.** The change is written into `InvoiceDraftSession.Req` (the "cache
dataset") as a mutating `InvoiceDraftDelta`, computed on the server (never in the browser).
There is no render-time `admin.setmode` grouping flag driving how lines are shown, and no
reversible toggle.
- **Irreversible.** There is no patch to undo it. The only ways back are discarding the draft
(reloads the DB state) or hand-editing the resulting rows.
- **`SetItmId` is irrelevant.** Membership is the block, full stop.
- **Total unchanged.** The inserted set row's value equals the sum of the block's original items,
which are then excluded from the sum — either because their price is `null` (mode 2, `null`
counts as no contribution) or because they are gone (mode 3). So `InvoiceBalance`/
`InvoiceBalance_net` are unaffected.
- **Editor and PDF render identically** (ADR 0008): the dedicated set row is emphasised in both,
and both read the same mutated session.
## Consequences
- For the two menu modes, `InvoiceSetPricing` stops being a non-mutating render transform over
`type == "set"` groups; the grouping/insert/blank/remove is a real mutation in
`InvoiceDraftEditService`, keyed on the block. The inline `item.setprice` switch (operation 1)
remains the sole `SetItmId`-based, set-item-scoped operation.
- The previous `admin.setmode` display-flag model for these two modes — persisted `setmode:`
`InvoiceOptions` token, "Set-Preisanzeige menu entry disappears while unset", `Build(...)`
choosing `ShowPrice` per member at render time — is retired. Because the operation is a
one-shot irreversible mutation, there is no persisted display state to toggle. Any residual
`setmode:` token must degrade safely (ignored) and is no longer (re-)persisted.
- New/changed behaviour must be modelled as a server-side delta + calculator/service change and
covered by tests (`InvoiceDraftServiceTests`, `InvoiceSetPricingTests`): for each menu mode,
assert the inserted set row's value equals the block sum, the total is unchanged, mode 2
nulls-but-keeps member rows while mode 3 removes them, and an empty block is a no-op.
- Reminders follow the identical pattern when/if the same feature is offered there (ADR 0006/0008
reminder mirror).
## Alternatives considered
Each of the following was raised and **explicitly decided against** as part of accepting this
decision — they are rejected choices, not open options to revisit without a superseding ADR:
- **Keep them as non-mutating display toggles** (the prior design): **explicitly rejected** by the
product owner — the set price must be a real, hand-editable value baked into the document, and
"Nur Set mit Preis" must actually drop the member lines, not just hide them.
- **Zero the blanked members' prices instead of nulling them**: **explicitly rejected**`0` is
ambiguous (it prints `0,00 €`), so the frontend could not tell an empty cell from a genuine
zero price. Blanked members are set to `null` precisely to make "no price" unambiguous.
- **Carry the set price on the existing block heading row** instead of a dedicated row:
**explicitly rejected** — a separate, individually-editable set row keeps the section-heading
semantics intact and gives the user a concrete line to adjust afterwards.
- **Group by `SetItmId`/`type == "set"` headers like the inline switch:** **explicitly rejected**
— the menu modes present each *service request* as one set, independent of any mfr set-item;
conflating the two groupings is exactly the ambiguity this decision removes.
@@ -0,0 +1,78 @@
---
status: Accepted
date: 2026-07-15
applyTo:
- "Fuchs_DataService/**"
- "Fuchs/Services/PeriodicHostedService.cs"
- "Fuchs/Program.cs"
supersededBy: ""
---
# 0010 — MFR ERP sync runs in-process in the web app; Fuchs_DataService is a library
## Context
`Fuchs_DataService` was a standalone console/Windows Service hosted by **Topshelf**.
It carried its own `appsettings.json`, its own file-based configuration bootstrap
(`FdsConfig.Initialize()` reading the file), its own logging provider
(`FdsLoggerProvider`/`AddFdsLogging`), and a machine-name guard in `Main()` that
disabled the service on developer PCs. In practice the web app (`Fuchs`) already
referenced the project, already called `fds.FdsConfig.Initialize(builder.Configuration)`,
already registered `IFdsMfr`, and already created `FdsMfrClient` via
`IMfrClientFactory` — so the sync logic and the web app were sharing the same code
and the same connection strings while the service kept a second, parallel copy of
configuration/logging/hosting.
Maintaining a separate process, a second `appsettings.json` (duplicating connection
strings + MFR credentials), Topshelf, and a machine-name guard added drift risk and
operational overhead for no benefit the web host couldn't provide.
## Decision
- **`Fuchs_DataService` is now a class library** (no `OutputType Exe`, no Topshelf,
no own `appsettings.json`, no `install.bat`/`un-install.bat`, no
`System.Configuration.ConfigurationManager`). It contains only the MFR sync
logic (`FdsMfr`/`IFdsMfr`, `FdsMfrClient`), the DATEV/zip helpers, `FdsShared`,
`FdsDebug`, and `FdsConfig`.
- **The host owns configuration.** `FdsConfig` keeps only
`Initialize(IConfiguration)` (the file-based overload is gone). The Fuchs web app
injects its `IConfiguration`; connection strings (`fuchs_fds_ConnectionString`)
and MFR credentials (`Fds:MFR_*`, Key Vault-managed) come from Fuchs.
- **The host owns logging.** `FdsLoggerProvider`/`AddFdsLogging` were removed; the
library uses only `ILogger`/`ILoggerFactory` injected from Fuchs's logging
(`AddFuchsLogging`). The library depends only on
`Microsoft.Extensions.Logging.Abstractions` + `Microsoft.Extensions.Configuration.Binder`.
- **The sync runs in-process.** `PeriodicHostedService` (the generic
multi-job `BackgroundService`) moved to `Fuchs/Services/` and is registered in
`Program.cs` as a hosted service. The single `MfrSync` job calls
`UpdateIfNecessary_async``UpdateRequested_async``GetInvoiceFiles_async`.
- **A config flag replaces the machine-name guard.** Registration is gated by
`Fds:SyncEnabled` (default `false` when unset): `true` in production
`appsettings.json`, `false` in `appsettings.Development.json`, so developer
machines never poll the ERP. Interval (`Fds:ExecutionFrequency_Minutes`, default
15) and debug verbosity (`Fds:DebugDetails`) also come from the `Fds` section.
## Consequences
- One process, one configuration surface, one logging pipeline. The sync inherits
the web app's OpenTelemetry, DI, and lifetime automatically.
- **Instance fan-out is a consideration:** the sync now runs in *every* web instance
where `Fds:SyncEnabled` is true. The intranet is deployed single-instance, so this
is acceptable; if Fuchs is ever scaled out, gate the sync to a single instance
(leader election / dedicated instance flag) to avoid concurrent MFR polling.
- Enabling/disabling the sync per environment is now a config change, not a
redeploy of a separate service.
- `Fuchs_DataService` is intentionally kept as a separate project (not folded into
`Fuchs`) so the sync logic stays isolated and unit-testable; `Fuchs.Tests` covers
it via `InternalsVisibleTo`.
- **The `Squid-Box.SevenZipSharp` native dependency (and the bundled `7z.dll`) was
removed** from both `Fuchs_DataService` and `Fuchs`. The only live archive use — the
DATEV export — is a plain, unencrypted zip, now produced via the native
`OCORE.zip.filesToZipArchive` (`System.IO.Compression`). The 7-Zip-only paths
(`.7z`/LZMA2, AES-encrypted archives, extraction, `FastAppend`) had no callers.
Trade-off accepted: `System.IO.Compression` cannot produce `.7z` or password/AES
archives; if that is ever required, a compression library must be reintroduced.
## Alternatives considered
- **Native `dotnet` Worker Service (separate process).** Would modernize off
Topshelf but keep the duplicate-config/duplicate-logging/second-process problem.
Rejected because the web app already hosts everything the sync needs.
- **Fold the code directly into `Fuchs`.** Rejected to preserve a clean, separately
testable sync library and avoid enlarging the web project.
@@ -0,0 +1,67 @@
---
status: Accepted
date: 2026-07-16
applyTo:
- "Fuchs/Controllers/IntranetController.Admin.cs"
- "Fuchs/Services/SystemStatusService.cs"
- "Fuchs/Services/ISystemStatusService.cs"
- "Fuchs/Services/SystemStatusModels.cs"
- "Fuchs/js/intranet/modules/fis.admin*.js"
- "Fuchs/js/intranet/modules/fis.admin.scss"
supersededBy: ""
---
# 0011 — Admin module gated on `fds_sys` > 4
## Context
Operators needed an in-app view of the running deployment's health: which host it
runs on, whether SQL Server / Azure Key Vault / Azure Blob Storage / the MFR ERP are
reachable, how the email service is configured (including the dev/test
`Fuchs:Email:OverrideRecipient` redirect), and a way to send a test email. The
`StartupSelfTestService` already probes most of these once at startup and writes the
result to the log — but that is invisible to a logged-in operator and cannot be re-run
on demand. This is privileged, infrastructure-revealing information (server names,
account names, connectivity state) that must not be exposed to ordinary users.
## Decision
- Add an **Admin** module (`admin`) alongside the existing invoice/reminder/report/banking
modules, following the same frontend module contract (`init:admin``admin/auth`
returns `{ manage }` → lazy-load `/web/fis.admin.de.js` + `/web/fis.admin.css`
`init2()`).
- Access is gated on the caller's **`fds_sys` module authorization being strictly greater
than 4** (`fis_getModuleAuth('fds_sys', authuser) > 4`). The menu button is only rendered,
and the module script only fetched, for such users; **every** Admin data endpoint
additionally re-checks the level server-side (defense in depth) and returns 401 otherwise.
`admin/auth` is the sole endpoint that answers for unauthorized users too (it returns
`manage: 0`), so the frontend can decide whether to render the module at all.
- Backend diagnostics live in a new DI-registered `ISystemStatusService` (scoped). It exposes
a passive `GetInfo()` snapshot and live, never-throwing connectivity probes
(`database`, `keyvault`, `blob`, `mfr`) plus `SendTestEmailAsync`. The test email goes
through the normal `IComService` pipeline, so the `OverrideRecipient` safety net applies
exactly as for any other outbound mail.
- The service never returns secrets — only presence flags and non-sensitive values
(server/catalog/login name, storage account name, error text). Responses are serialized
camelCase to match the frontend's lowercase convention.
## Consequences
- `fds_sys` is now a security-relevant authorization key: granting it a value > 4 exposes
infrastructure status and the ability to send test emails. Provision it deliberately.
- `SystemStatusService` is intentionally **separate** from `StartupSelfTestService` rather
than a shared refactor: the startup service's probe methods are `protected virtual` and its
tests override them, so folding both onto one probe surface would have broken that contract.
The two therefore duplicate a little probe logic (Key Vault secret read, MFR `GetEntities`,
SQL `SELECT`); keep them behaviourally aligned when either changes.
- New probes (or new status facts) belong in `SystemStatusService` behind the same
`SystemProbeResult` / `SystemInfoSnapshot` shapes; add the component id to
`SystemStatusService.Components` and a card in `fis.admin.js`.
- Blob connectivity is checked via a new `IBlobStorageService.CheckConnectivityAsync`
(account-info request); it too never throws.
## Alternatives considered
- **Reuse `StartupSelfTestService` directly.** Rejected — see Consequences; its virtual/overridden
probe surface is owned by its tests, and it is a one-shot `BackgroundService`, not a
request-scoped query service.
- **Gate only in the frontend (hide the menu button).** Rejected — the endpoints would still be
reachable by crafting the POST. Server-side enforcement on every endpoint is required.
- **A separate `fds_admin`/new module-auth key.** Rejected — `fds_sys` already models
system-level privilege; reusing it avoids a parallel permission to provision.
@@ -0,0 +1,81 @@
---
status: Accepted
date: 2026-07-17
applyTo:
- "Fuchs/code/FuchsPdf.cs"
- "Fuchs/Services/FuchsPdfService.cs"
- "Fuchs/Services/InvoiceService.cs"
- "Fuchs/Services/ERechnungSettings.cs"
- "eRechnungLib/**"
supersededBy: ""
---
# 0012 — eRechnung uses a single PDF/A engine (eRechnungLib owns PDF/A-3)
## Context
ADR 0005 established that invoices are emitted as eRechnung by embedding the CII
XML into the FuchsPdf-rendered visual PDF via `eRechnungLib.ToZugferd(...)`.
Two hard requirements then surfaced: the emitted invoice must (a) satisfy the
**ZUGFeRD 2.4 / Factur-X** standard for **DATEV** ingestion, and (b) be a
**formally verifiable PDF/A-3** (veraPDF-clean).
A conflict became apparent in the rendering pipeline. `FuchsPdf.DocToPdfBytes`
post-processes its MigraDoc/PdfSharp output to **PDF/A via Spire**
(`OCORE…pdfAFileContent`). `eRechnungLib`'s `FacturXPdfBuilder` **also** produces
a PDF/A layer (raises to PDF 1.7, writes `pdfaid` XMP, adds an sRGB output
intent, embeds `factur-x.xml` in `/AF`). Feeding a Spire-made PDF/A into
eRechnungLib stacks **two** PDF/A conversions → duplicate/*conflicting* output
intents and `pdfaid` markers, which veraPDF rejects. Spire's output is also
PDF/A-1/2 and does **not** carry the `/AF` associated-file structure ZUGFeRD
requires (PDF/A-3).
Separately, `eRechnungLib` shipped **no** sRGB ICC profile, so its output intent
was silently omitted (`PDFA-ICC` warning) — never formally PDF/A-3 conformant.
## Decision
- **eRechnungLib is the single PDF/A engine for eRechnung output.** The invoice
visual PDF is rendered by `FuchsPdf` **without** the Spire PDF/A step and handed
to `eRechnungLib.ToZugferd(ZugferdProfile.EN16931, rawPdfBytes)`, which owns the
one PDF/A-3 conversion and embeds the CII XML. The render-only path is
`FuchsPdf.DocToPdfBytesRaw` / `IPdfService.DocToPdfBytesRaw` (fonts still
embedded via `OCOREFontResolver`, no PDF/A post-processing).
- **Spire stays only for on-screen preview rasterisation** (`DocToImageCollection`
/ `BytesToImageCollection` for `sprep`/`sedit`). It is **not** part of the
eRechnung file's PDF/A path. `DocToPdfBytes` (render + Spire PDF/A) is unchanged
and remains the path for non-eRechnung documents (e.g. reminders).
- **A bundled sRGB ICC profile is required.** `eRechnungLib` ships
`Resources/Color/sRGB.icc` (sRGB IEC61966-2.1) so the PDF/A output intent is
always attached. A caller may override it per conversion via
`ConversionOptions.IccProfile`.
- **Profile is EN 16931.** MINIMUM / BASIC WL are not offered for real invoices —
DATEV needs at least EN 16931 (COMFORT) for full booking.
- **Formal conformance is verified by an external online service** (veraPDF for
PDF/A-3 + a ZUGFeRD/EN 16931 validator), behind the configurable
`Fuchs:ERechnung:Validation:ServiceUrl` seam. Until the URL is provisioned,
verification reports "not configured / skipped".
## Consequences
- The eRechnung invoice PDF and a plain Spire PDF/A must never both be produced
for the same document — pick the render-only path when emitting eRechnung.
- The incoming visual PDF must itself be PDF/A-friendly (fonts embedded —
handled; letterhead images must be **RGB, not CMYK**; transparency is allowed
because we target PDF/A-**3**).
- `Fuchs:ERechnung:Enabled` gates emission and stays `false` until the
`FdsInvoiceData``eRechnungLib.Model.Invoice` mapping is wired (the ADR 0005
follow-up). Open item for that mapping: the buyer address is currently a
free-text block (`SendToAddress`); EN 16931 needs **structured** buyer
fields (name/postcode/city/country, VAT id), so structured customer master
data must feed the mapping. Seller data (currently hard-coded in `FuchsPdf`:
name, address, tax number, IBAN/BIC) must be lifted into the seller model.
- `Fuchs.csproj` must add a project reference to `eRechnungLib` when the flow is
wired (not present yet).
## Alternatives considered
- **Keep Spire PDF/A and have eRechnungLib only embed the XML:** rejected — Spire
produces the wrong PDF/A part (1/2, no `/AF`) and a second conversion collides
with eRechnungLib's own output intent/XMP, failing veraPDF.
- **Drop Spire entirely:** rejected — Spire is still needed to rasterise PDFs to
the on-screen invoice/reminder preview images; PdfSharp/eRechnungLib cannot.
- **Ship no ICC and rely on callers:** rejected — formal PDF/A-3 requires an
output intent; bundling a profile makes conformance the default.
+17 -5
View File
@@ -90,12 +90,20 @@ payload; see `EVAL_live_invoice_editing.md` for the rationale.
### 4.1 What the user can change
- **Line items** — quantities, prices, notes, combine into one sum
(`$inv.rendersrq`, `$inv.quantChange`).
- **Recipient fields** — invoice title, address, email, provision
location/period (inline edit fields, `fm(...)` helper in `fis.inv_shared.js`).
- **Recipient fields** — invoice title, email, provision location/period (inline
edit fields, `fm(...)` helper in `fis.inv_shared.js`). The **recipient address**
is edited via a **structured dialog** (`$inv.eAddress`: name, street, PLZ, city,
country, optional VAT id) prefilled from `fds__prepInvoice`'s `invoiceaddressData`;
it drives the EN 16931 eRechnung and composes the free-text `SendToAddress` for the
PDF (see [`Concepts/erechnung-output.md`](Concepts/erechnung-output.md)). The **service
date/period** (Leistungsdatum/-zeitraum) is likewise a structured German-date dialog
(`$inv.eProvisionPeriod`) — a single date or a from/to range — mapped to BT-72 / BG-14.
- **§13b reverse-charge** toggle (`$inv.sp13b`) — suppresses VAT lines/columns.
- **Set-pricing display mode** (`$inv.ssetmode` / `setSetmode`) — `SetPrice`
(default) / `ItemPrices` / `SetOnly`; see `INVOICE_SET_PRICING.md`. Purely
presentational — totals never change.
(default) / `SetOnly`; see `INVOICE_SET_PRICING.md`. Purely presentational
while the set header itself has no own price; once a set is converted via
the item switch (`item.setprice`), the conversion is one-way and totals
recompute from that point on.
- **Contact person** for the invoice (`$inv.sctp`, stored in `CustomValues`).
All of this recalculates client-side totals live via the `fds.inv` event
@@ -122,7 +130,7 @@ On the server, `RegisterInvoiceAsync` (in `InvoiceService`) turns the posted
JSON into SQL parameters and calls, in one batch:
- **New invoice**: `fds__createInvoice` (allocates the `Id`, returns a fresh
row) → `fds__createInvoice_Details` (service net/VAT + `InvoiceOptions`,
e.g. `setmode:itemprices`, `§13b`).
e.g. `setmode:setonly`, `§13b`).
- **Existing draft**: `fds__setInvoice` (same parameter set, updates in place)
`fds__createInvoice_Details` again.
@@ -358,6 +366,10 @@ flowchart TD
- **Draft vs. final changes the rendered PDF**: draft = watermark overlay, no
GiroCode; final = no watermark, GiroCode payment QR added when there's a
positive balance.
- **Final invoices can be emitted as eRechnung**: when `Fuchs:ERechnung:Enabled`,
the final PDF is a ZUGFeRD/Factur-X **PDF/A-3 hybrid** (eRechnungLib embeds the
CII XML into the render-only visual PDF; any failure falls back to the plain
PDF/A). Off by default. See [`Concepts/erechnung-output.md`](Concepts/erechnung-output.md).
- **Email is best-effort and tracked**: `IsSent` is only set `true`
automatically after a *successful* send; a failed send still leaves a
correctly finalised, stored invoice that staff can resend or mark sent
+158 -45
View File
@@ -1,24 +1,54 @@
# Invoice "Set" Pricing — Design & Front-/Back-end Contract
# Invoice "Set" Pricing — Design & Front-/Back-end Contract
Customer requirement: items declared as a **set** in `[dbo].[mfr__items]`
(`[Type] = 'set'`) should normally be shown as a single **set price** on the
invoice instead of being broken up into their member items and summed.
> Governed by ADR [0009](Decisions/0009-block-setprice-modes-are-irreversible-mutations.md)
> (the two menu modes) and ADR [0008](Decisions/0008-invoices-and-reminders-fully-backend-authoritative.md)
> (everything server-side, PDF == editor). ADR 0009 **redefined** the two menu modes from the
> reversible, non-mutating display toggles this document previously described into irreversible,
> per-service-request-block mutations — the text below reflects the redefinition.
Three display modes (switchable in the invoice editor):
There are **three** separate set-price operations. They fall into two families that must not be
confused, because they group items by different keys and differ in whether they mutate the data:
| Mode | Set line | Member items | Use as |
|---|---|---|---|
| **SetPrice** (default) | shown **with price** | shown **without price** | the new default |
| **ItemPrices** | shown as a heading **without price** | shown **with price** | the previous behaviour |
| **SetOnly** | shown **with price** | **removed** | compact |
| # | Operation | Trigger | Grouped by | Effect |
|---|---|---|---|---|
| **1** | **Set-item switch** | row context button on a single `type == "set"` item (`$inv.toSetPrice`), shown only when it has a `SetItmId` | `SetItmId` (the mfr set-item and its members) | sums members onto the set header, sets the members' prices to `null`**one-way mutation** |
| **2** | **"Set mit Preis"** | editor menu ("Set-Preisanzeige") | **`ServiceRequestId`** (the whole block) | inserts a dedicated **set row** per block (block sum as its price) and sets every item's price to **`null`** (items shown **without price**) — **irreversible mutation** |
| **3** | **"Nur Set mit Preis"** | editor menu ("Set-Preisanzeige") | **`ServiceRequestId`** (the whole block) | inserts the dedicated **set row** per block and **removes** every item line from the block — **irreversible mutation** |
> **Totals are unaffected.** The invoice total is taken from the registration
> balance (`InvoiceBalance` / `InvoiceBalance_net`), not by summing the rendered
> lines, so switching modes is purely presentational. The set price always
> equals the sum of its members (computed as a fallback when the set header
> carries no own price).
Only operation **1** reads `SetItmId`. Operations **2** and **3** ignore it entirely; their only
grouping key is the service request (the editor tbody block). See "All set-price functions" below
for the full breakdown.
## Back-end (implemented + unit-tested)
> **The two menu modes are irreversible data changes, not display toggles.** Choosing "Set mit
> Preis" or "Nur Set mit Preis" rewrites the block's items in the authoritative, server-cached
> draft session (`InvoiceDraftSession.Req` — the "cache dataset"): a dedicated set row is inserted
> and members have their price set to `null` (mode 2) or are deleted (mode 3). There is **no**
> reversible toggle and **no** persisted `admin.setmode` render flag driving grouping. The only
> ways back are discarding the draft (reloads the DB state) or hand-editing the resulting rows —
> the set row is a real, editable line item precisely so the user can adjust the set value
> afterwards.
> **`ItemPrices` / `admin.setmode` display-flag model was removed.** The earlier design persisted
> a `setmode:<mode>` token in `InvoiceOptions` and re-rendered set-item groups per that flag at
> render time (`InvoiceSetPricing.Build`). Under ADR 0009 the two menu modes are one-shot
> mutations, so there is no display state to persist or toggle. A stale `setmode:`/`itemprices`
> token degrades safely (ignored) and is never (re-)persisted.
> **Totals are unaffected.** The invoice total is taken from the registration balance
> (`InvoiceBalance` / `InvoiceBalance_net`), not by summing the rendered lines. Each operation
> conserves the total: the inserted set row's value equals the sum of the items it blanks (mode 2)
> or removes (mode 3), and the set-item switch (mode 1) writes exactly the members' sum onto the
> header.
## Back-end
> **Migration status (ADR 0009).** The code below still reflects the previous
> `admin.setmode` + `InvoiceSetPricing.Build` **display-mode** implementation for
> functions 2 & 3. Under ADR 0009 those two functions become per-block mutations in
> `InvoiceDraftEditService` (insert set row + blank/remove members); the render-time
> `Build`/`ModeFromInvoiceOptions`/`setmode` display path for them is being retired.
> Function 1 (`ApplyItemSetPrice`) is unaffected. Update this section as the
> migration lands so it stays a faithful description of the code.
- `Fuchs/code/InvoiceSetPricing.cs` — the authoritative transformation:
`SetDisplayMode` + `Build(items, mode)` → ordered `InvoiceSetLine`s, each with
@@ -34,15 +64,15 @@ Three display modes (switchable in the invoice editor):
Wired in `Fuchs/js/intranet/modules/fis.inv_shared.js` (bundled to
`wwwroot/web/fis.inv.de.js` via gulp `min:js`):
1. **Mode**a 3-way switch (`$inv.ssetmode`, menu entry `setm`, label
`$ict.setm`) writes the choice onto `admin.setmode`
(`setprice` | `itemprices` | `setonly`). The back-end
`FdsInvoiceData.BuildInvoiceOptions` turns that into the
`setmode:<mode>` token inside `@InvoiceOptions` (default `setprice` omitted),
persisted by `fds__createInvoice_Details` and read back by
`InvoiceSetPricing.ModeFromInvoiceOptions`. This rides the **same `admin`
channel as `§13b`** (the posted payload is `{admin, req, sms, new}``inv`
is not sent).
1. **Menu modes**the "Set-Preisanzeige" menu (`$inv.ssetmode``$inv.setSetmode`,
menu entry `setm`, label `$ict.setm`) offers "Set mit Preis" and "Nur Set mit
Preis". Selecting one posts a **mutating delta** (grouped by service-request
block) to `inv/dpatch`; the server rewrites the block's items in the cached
session (inserts the set row, blanks or removes members) and pushes the new
state back via `draftReady`/`dstate`. The browser performs **no** grouping or
pricing math (ADR 0008/0009) — it only posts the chosen mode and re-renders the
server's `req`/`sums`. There is no persisted `admin.setmode` display flag for
these two modes.
2. **Item shape**`$inv.invSumUpdate` now posts each request block's
`items[]` in the back-end contract shape via `$inv.itemToContract`:
@@ -51,12 +81,20 @@ Wired in `Fuchs/js/intranet/modules/fis.inv_shared.js` (bundled to
`FdsInvoiceData.InvoiceItems` does not read — so line items never reached the
C# PDF. This change closes that gap for **all** invoices, not just sets.)
3. **Set flags**`invSumUpdate` tags items as it builds `items[]`: an item with
`type === 'set'` is a header (`id` = its set id); the **following items in the
same block become its members** (`setId` = the header's id) until the next set
header. `mfr__items` has a `Type='set'` header but **no explicit member link**,
so this "header claims the following items in its block" rule is the convention
— adjust in `invSumUpdate` if mfr later exposes a real grouping.
3. **Set flags (function 1 only)**`invSumUpdate` tags items as it builds `items[]`:
an item with `type === 'set'` is a header (`id` = its set id); a member item's
`setId` is taken directly from the server-computed `SetItmId` field on the row
(`rrx.SetItmId`, populated by `fds__prepInvoice`'s `[SetItmID]` window function,
anchored on the still-unconverted, zero-priced `'set'` header that owns it) —
**not** re-derived from row order in the browser. `mfr__items` itself still has no
explicit member link; `fds__prepInvoice` computes `SetItmId` per request from the
item list, so only items the server actually attributes to a set are tagged, and
unrelated items following a set in the list are never swept in. The header row's
own `SetItmId` self-references its own id (rather than being `null`); it is
explicitly excluded from being its own member both here (`sid !== citem.id`) and
in `InvoiceDraftEditService.ApplyItemSetPrice`. These flags feed **only** the
set-item switch (function 1); the two menu modes (functions 2 & 3) ignore
`SetItmId` and group by service-request block.
### Editor → backend field normalization (`$inv.invcPayload`)
The editor's internal model keeps the long-standing key names, but the migrated C#
@@ -80,20 +118,95 @@ no per-rate `vat_*` keys.
`sms.vat`, so non-19 % rates are stored correctly. Single-rate procs still store only
the highest rate.
The editor's running **total stays the member sum in every mode**, matching the
registration balance — switching modes is purely presentational.
The editor's running **total is unaffected by any set-price operation**, matching
the registration balance — each operation conserves the total (the set row's value
equals the members it blanks/removes; the set-item switch writes exactly the
members' sum onto the header).
### Why the switch lives in the editor
Set grouping is only known where the request/item tree is rendered (front-end).
The back-end intentionally stays the single, tested authority for *how* a chosen
mode maps to printed lines, so the editor only needs to pick the mode and tag the
items — it does not re-implement the pricing rules.
### Why the trigger lives in the editor
The choice of *when* to apply a set-price operation is only known where the invoice
is being composed (front-end), but the operation itself is executed **server-side**
against the cached draft session — the editor merely names the target (a set-item
`Ref` for function 1, or the chosen menu mode for functions 2 & 3) and re-renders
the server's result. The back-end stays the single, tested authority for how each
operation rewrites the lines; the editor never re-implements the grouping, the
per-block aggregation, or the pricing rules (ADR 0008/0009).
## All set-price functions: before/after comparison
There are **three** distinct functions, and all three are **mutations** of the
authoritative cached draft session (`InvoiceDraftSession.Req`) — none is a
transient, freely-reversible view flag. They differ in what they group by and
what they touch:
- **Function 1 — the set-item switch** (`item.setprice`) groups by `SetItmId`
(one mfr set-item and its members) and is triggered per set row.
- **Functions 2 & 3 — the two menu modes** ("Set mit Preis" / "Nur Set mit
Preis") group by `ServiceRequestId` (the whole block), ignore `SetItmId`
entirely, and are triggered once from the "Set-Preisanzeige" menu.
Only function 1 reads `SetItmId`. All three are one-way; the only escape hatch
is discarding the draft or hand-editing the resulting rows.
### 1. The set-item switch (`item.setprice` patch, single set, mutating)
Triggered from the invoice editor's row context menu (`$inv.toSetPrice`), applied
server-side by `InvoiceDraftEditService.ApplyItemSetPrice`. The context button is
shown — and the operation available — **only** on a row that is `type == "set"`
**and** carries a `SetItmId` (and is still unconverted, own price `0`); a row
missing either condition never offers it. It gives a set-item its "own price": the
members' values are summed onto the header and the members' prices are set to
`null` (empty cell, excluded from the sum — not `0`). This
conversion is **one-way** — there is no patch to move a converted set back to
separately-priced members; the user would re-edit the individual line prices by
hand. It is the **only** function keyed on `SetItmId`.
| Aspect | Before the switch | After the switch |
|---|---|---|
| Set header item (`type == "set"`, `id == Ref`) price | `0` (zero-priced, as delivered by `fds__prepInvoice`) | Price fields (`total_net`/`v`/`vt` + VAT amounts `vv`/`vs`/`vsv`) replaced by the sum of all its members' corresponding values |
| Member items (`SetItmId == Ref`, excluding the header itself) | Each shows its own individual `total_net` / VAT amounts | Each price field is set to **`null`** (`v`/`vt`/`vv`/`vs`/`vsv` all `null`, not `0`) → renders an **empty** price/total cell and is excluded from the sum; the row itself stays in the list |
| Membership determination | N/A — membership already fixed by the server (`fds__prepInvoice`'s `[SetItmID]` window function) | **Unchanged** — the switch only sums/nulls the items the server already tagged; it never re-derives or reassigns `SetItmId` |
| Items **not** tagged with this header's `SetItmId` (e.g. unrelated items following the set in the same block) | Untouched | **Still untouched** — never swept in, regardless of row order/position |
| Draft version / history | — | Version bumped by one; an `item.setprice` history entry recorded with old/new header value |
| Invoice total (`Sums.TotalNet`/`TotalGross`) | Sum of all individual item prices (header 0 + each member's own price) | **Unchanged** — same total, because the header received exactly the sum of its members |
| Idempotency / no-ops | `Ref` unknown, or `Ref` does not point at a `type == 'set'` header → **no-op**: no version bump, no history entry | Same guard still applies after conversion — re-issuing the patch against a non-header `Ref` remains a no-op |
### 2 & 3. The two menu modes (per service-request block, mutating)
Triggered once from the editor's "Set-Preisanzeige" menu (`$inv.ssetmode`
`$inv.setSetmode`) and applied server-side per **service-request block**
(`ServiceRequestId`), independent of any `type == "set"` item or `SetItmId`.
Both are **irreversible** and rewrite the block's items in the cached session.
For each block, a dedicated, emphasised **set row** is inserted (see "The
dedicated set row" below) carrying the block's aggregated value as its price;
then, depending on the mode, the block's original items are either blanked or
removed:
| Aspect | **"Set mit Preis"** (mode 2) | **"Nur Set mit Preis"** (mode 3) |
|---|---|---|
| Grouping key | `ServiceRequestId` (block) | `ServiceRequestId` (block) |
| Inserted set row | one per block, price = block's aggregated net (+ VAT/service splits) | one per block, same value |
| Original item rows | **kept**, but each price field (`v`/`vt`/`vv`/`vs`/`vsv`) is set to **`null`** (not `0`) → renders an **empty** price/total cell and is excluded from the block sum | **removed** from the block entirely |
| `SetItmId` | ignored | ignored |
| Reversibility | irreversible (discard draft or hand-edit) | irreversible (discard draft or hand-edit) |
| Invoice total | **unchanged** — the set row's value equals the sum of the block's members it blanks | **unchanged** — the set row's value equals the sum of the removed lines |
| Empty block | no-op | no-op |
### The dedicated set row
Both menu modes insert a **real, editable line item** (its own id, rendered
emphasised in the editor and the PDF), not a reused block-heading row and not a
render-only overlay. Because it is a genuine row in the cached dataset, the user
can adjust the set value afterwards with an ordinary item edit — that hand-edit
is the intended and only "undo" for the conversion (ADR
[0009](Decisions/0009-block-setprice-modes-are-irreversible-mutations.md)).
## Persistence note
Draft/preview PDFs render straight from the posted `invc` JSON, so the contract
works end-to-end for previews and creation. `setmode` persists via
`InvoiceOptions`; the finalised document is rendered once and stored as a file, so
re-rendering from line items is not needed for correctness. Persisting the
per-item `type`/`setId` flags (an SSDT + `fds__createInvoice_Details` change) is
only required if a finalised invoice must be **re-generated** from stored items in
a different mode later — not done here.
Draft/preview PDFs render straight from the cached draft session, so the contract
works end-to-end for previews and creation. The two menu modes bake their result
directly into the session's items (a set row plus blanked/removed members), so no
`setmode:` display token is needed or persisted; the finalised document is
rendered once and stored as a file. Persisting the per-item `type`/`setId` flags
(an SSDT + `fds__createInvoice_Details` change) is only required if a finalised
invoice must be **re-generated** from stored items later — not done here.
+489
View File
@@ -0,0 +1,489 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Fuchs Intranet — Das ist neu</title>
<style>
:root{
--blue:#1b4379; /* $fuchs_blau */
--blue-2:#2a5da3;
--accent:#56a532; /* $fuchs_akzent */
--accent-2:#74c14a;
--ink:#12243f;
--ink-2:#1a2f52;
--paper:#f4f6fa;
--card:#ffffff;
--text:#1c2430;
--muted:#586172;
--line:#e3e8f0; /* near $fuchs_lightgray */
--green:#56a532;
--shadow:0 18px 50px -20px rgba(18,36,63,.35);
}
*{box-sizing:border-box}
html{scroll-behavior:smooth}
body{
margin:0;
font-family:"Segoe UI",system-ui,-apple-system,Roboto,Helvetica,Arial,sans-serif;
color:var(--text);
background:var(--paper);
line-height:1.6;
-webkit-font-smoothing:antialiased;
}
.wrap{max-width:1080px;margin:0 auto;padding:0 24px}
/* ---------- HERO ---------- */
.hero{
position:relative;
color:#fff;
background:
radial-gradient(1200px 500px at 80% -10%, rgba(86,165,50,.38), transparent 60%),
radial-gradient(900px 500px at 0% 10%, rgba(42,93,163,.50), transparent 55%),
linear-gradient(160deg,#1b3a63 0%, #12243f 60%, #0b1727 100%);
overflow:hidden;
border-bottom:1px solid rgba(255,255,255,.06);
}
.hero::after{
content:"";position:absolute;inset:0;
background:linear-gradient(180deg,transparent 60%,rgba(0,0,0,.25));
pointer-events:none;
}
.hero .wrap{position:relative;z-index:2;padding:78px 24px 92px}
.eyebrow{
display:inline-flex;align-items:center;gap:9px;
font-size:.8rem;font-weight:600;letter-spacing:.14em;text-transform:uppercase;
color:var(--accent-2);
background:rgba(86,165,50,.12);
border:1px solid rgba(86,165,50,.28);
padding:7px 15px;border-radius:100px;
}
.eyebrow .dot{width:8px;height:8px;border-radius:50%;background:var(--accent);box-shadow:0 0 14px var(--accent)}
h1{
font-size:clamp(2.1rem,5vw,3.6rem);
line-height:1.08;margin:22px 0 16px;font-weight:800;letter-spacing:-.02em;
}
h1 .grad{
background:linear-gradient(92deg,var(--accent-2),#fff 70%);
-webkit-background-clip:text;background-clip:text;color:transparent;
}
.lede{font-size:clamp(1.05rem,2.2vw,1.28rem);color:#c7cdda;max-width:640px;margin:0}
.hero-meta{
display:flex;flex-wrap:wrap;gap:26px;margin-top:38px;
padding-top:26px;border-top:1px solid rgba(255,255,255,.1);
}
.hero-meta div{min-width:120px}
.hero-meta b{display:block;font-size:1.7rem;font-weight:800;color:#fff}
.hero-meta span{font-size:.86rem;color:#98a1b3}
/* ---------- SECTIONS ---------- */
section{padding:64px 0}
.section-head{max-width:680px;margin-bottom:40px}
.section-head .kicker{color:var(--accent);font-weight:700;font-size:.82rem;letter-spacing:.12em;text-transform:uppercase}
h2{font-size:clamp(1.6rem,3.4vw,2.3rem);margin:10px 0 12px;font-weight:800;letter-spacing:-.02em}
.section-head p{color:var(--muted);font-size:1.06rem;margin:0}
/* ---------- FEATURE CARDS ---------- */
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:22px}
.card{
background:var(--card);
border:1px solid var(--line);
border-radius:18px;
padding:28px 26px;
box-shadow:var(--shadow);
position:relative;
transition:transform .25s ease, box-shadow .25s ease;
overflow:hidden;
}
.card::before{
content:"";position:absolute;top:0;left:0;right:0;height:3px;
background:linear-gradient(90deg,var(--accent),var(--accent-2));
opacity:.9;
}
.card:hover{transform:translateY(-5px);box-shadow:0 26px 60px -24px rgba(20,24,33,.45)}
.card .ico{
width:48px;height:48px;border-radius:13px;display:grid;place-items:center;
background:linear-gradient(150deg,rgba(86,165,50,.16),rgba(116,193,74,.06));
border:1px solid rgba(86,165,50,.22);
font-size:1.5rem;margin-bottom:16px;
}
.card h3{margin:0 0 8px;font-size:1.18rem;font-weight:700}
.card p{margin:0;color:var(--muted);font-size:.97rem}
.card .tag{
display:inline-block;margin-top:16px;font-size:.75rem;font-weight:600;
color:var(--accent);background:rgba(86,165,50,.09);
border:1px solid rgba(86,165,50,.2);padding:4px 11px;border-radius:100px;
}
/* ---------- BEFORE / AFTER ---------- */
.compare{background:linear-gradient(180deg,#fff,#f2f4f9);border-top:1px solid var(--line);border-bottom:1px solid var(--line)}
.table-scroll{overflow-x:auto;border-radius:16px;box-shadow:var(--shadow);border:1px solid var(--line)}
table{border-collapse:collapse;width:100%;min-width:640px;background:#fff}
th,td{text-align:left;padding:16px 20px;border-bottom:1px solid var(--line);vertical-align:top}
thead th{background:var(--ink);color:#fff;font-weight:600;font-size:.92rem;letter-spacing:.01em}
thead th:first-child{border-top-left-radius:16px}
thead th:last-child{border-top-right-radius:16px}
tbody tr:last-child td{border-bottom:none}
td.feat{font-weight:700;color:var(--ink);width:24%}
td.old{color:var(--muted)}
td.old::before{content:"✕ ";color:#c4453b;font-weight:700}
td.new{color:#1c2733}
td.new::before{content:"✓ ";color:var(--green);font-weight:700}
tbody tr:nth-child(even){background:#fafbfe}
/* ---------- SPOTLIGHT ---------- */
.spot{display:grid;grid-template-columns:1.05fr .95fr;gap:38px;align-items:center}
.spot-card{
background:linear-gradient(160deg,var(--ink),var(--ink-2));
color:#fff;border-radius:22px;padding:34px;box-shadow:var(--shadow);
border:1px solid rgba(255,255,255,.07);
}
.spot-card h3{margin:0 0 14px;font-size:1.35rem}
.spot-card ul{margin:0;padding:0;list-style:none}
.spot-card li{position:relative;padding:9px 0 9px 30px;color:#cdd3df;border-bottom:1px dashed rgba(255,255,255,.09)}
.spot-card li:last-child{border-bottom:none}
.spot-card li::before{content:"→";position:absolute;left:0;color:var(--accent-2);font-weight:800}
.spot-text h2{margin-top:0}
.spot-text p{color:var(--muted)}
.chip{display:inline-block;font-size:.78rem;font-weight:600;color:var(--accent);background:rgba(86,165,50,.1);border:1px solid rgba(86,165,50,.22);padding:5px 12px;border-radius:100px;margin-bottom:14px}
/* ---------- KEY USER BOX ---------- */
.keyuser{background:var(--ink);color:#fff}
.keyuser .section-head p{color:#a7afbe}
.ku-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:20px}
.ku{
background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.09);
border-radius:16px;padding:24px;
}
.ku h3{margin:0 0 8px;font-size:1.05rem;color:#fff}
.ku h3 span{color:var(--accent-2)}
.ku p{margin:0;color:#a7afbe;font-size:.93rem}
/* ---------- EDITOR STEPS ---------- */
.steps{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:18px;margin-top:8px}
.step{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:22px 20px;box-shadow:0 10px 30px -20px rgba(18,36,63,.3)}
.step .n{font-size:.78rem;font-weight:800;color:var(--accent);letter-spacing:.08em}
.step h4{margin:6px 0 6px;font-size:1.04rem}
.step p{margin:0;color:var(--muted);font-size:.93rem}
/* ---------- SET-PRICE PANEL ---------- */
.setpanel{
margin-top:34px;border:1px solid var(--line);border-radius:22px;
background:linear-gradient(160deg,#ffffff,#eef4ea);
padding:34px 32px;box-shadow:var(--shadow);position:relative;overflow:hidden;
}
.setpanel::before{content:"";position:absolute;top:0;left:0;right:0;height:4px;background:linear-gradient(90deg,var(--blue),var(--accent))}
.setpanel > .ttl{display:flex;align-items:center;gap:12px;margin-bottom:6px}
.setpanel > .ttl .badge{font-size:1.4rem}
.setpanel h3{margin:0;font-size:1.35rem;font-weight:800;color:var(--blue)}
.setpanel > p{margin:8px 0 0;color:var(--muted);max-width:720px}
.setgrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:20px;margin-top:26px}
.setvar{background:#fff;border:1px solid var(--line);border-radius:16px;padding:24px 22px;box-shadow:0 12px 32px -22px rgba(18,36,63,.4);display:flex;flex-direction:column}
.setvar .num{
width:36px;height:36px;border-radius:11px;display:grid;place-items:center;
background:linear-gradient(150deg,var(--blue),var(--blue-2));color:#fff;font-weight:800;
font-size:1.05rem;margin-bottom:14px;
}
.setvar h4{margin:0 0 8px;font-size:1.06rem;line-height:1.3}
.setvar h4 small{display:block;font-size:.76rem;font-weight:600;color:var(--accent);letter-spacing:.04em;margin-top:3px}
.setvar p{margin:0 0 12px;color:var(--muted);font-size:.93rem}
.setvar .kv{margin-top:auto;font-size:.82rem;color:var(--blue);font-weight:600}
.pill{display:inline-block;font-size:.72rem;font-weight:700;padding:3px 10px;border-radius:100px;margin-top:10px}
.pill.rev{color:#8a6d3b;background:rgba(210,150,40,.14);border:1px solid rgba(210,150,40,.35)}
.pill.one{color:#a03d2e;background:rgba(196,69,59,.12);border:1px solid rgba(196,69,59,.3)}
.setnote{
margin-top:24px;padding:16px 20px;border-radius:14px;
background:rgba(27,67,121,.06);border:1px solid rgba(27,67,121,.16);
color:#2a3b52;font-size:.92rem;
}
.setnote b{color:var(--blue)}
/* ---------- FOOTER ---------- */
footer{padding:44px 0;text-align:center;color:var(--muted);font-size:.9rem;border-top:1px solid var(--line)}
footer b{color:var(--ink)}
@media(max-width:760px){
.spot{grid-template-columns:1fr}
section{padding:48px 0}
}
</style>
</head>
<body>
<header class="hero">
<div class="wrap">
<span class="eyebrow"><span class="dot"></span>Release-Übersicht · 2026</span>
<h1>Ihr Intranet wird<br><span class="grad">schneller, sicherer, transparenter.</span></h1>
<p class="lede">Die neue Generation des Fuchs Intranets bringt Live-Vorschau bei der Rechnungserstellung, Echtzeit-Rückmeldungen, extern validierte E-Rechnung (ZUGFeRD/DATEV und XRechnung) und eine durchgängig geprüfte Datenverarbeitung — ohne dass sich Ihr gewohnter Arbeitsablauf verändert.</p>
<div class="hero-meta">
<div><b>E-Rechnung</b><span>ZUGFeRD / XRechnung · extern validiert</span></div>
<div><b>Echtzeit</b><span>Live-Vorschau &amp; Benachrichtigungen</span></div>
<div><b>.NET 10</b><span>Moderne, geprüfte Plattform</span></div>
</div>
</div>
</header>
<!-- WAS NEU IST -->
<section>
<div class="wrap">
<div class="section-head">
<span class="kicker">Das Wichtigste auf einen Blick</span>
<h2>Die neuen Funktionen für Ihren Alltag</h2>
<p>Alle Neuerungen zielen auf dasselbe Ziel: weniger Fehler, mehr Überblick und Rechnungen, die auf Anhieb korrekt sind.</p>
</div>
<div class="grid">
<div class="card">
<div class="ico">👁️</div>
<h3>Live-Vorschau beim Bearbeiten</h3>
<p>Während Sie eine Rechnung bearbeiten, sehen Sie das fertige PDF sofort in Echtzeit — genau so, wie es der Kunde erhält. Kein Zwischenspeichern, kein Raten mehr.</p>
<span class="tag">Rechnungen &amp; Zahlungserinnerungen</span>
</div>
<div class="card">
<div class="ico">🧮</div>
<h3>Alle Beträge serverseitig berechnet</h3>
<p>Summen, Mehrwertsteuer, §13b-Umkehr und offene Beträge rechnet ab sofort der Server — geprüft und einheitlich. Der Bildschirm zeigt immer denselben Stand wie das PDF.</p>
<span class="tag">Keine Rechenfehler mehr</span>
</div>
<div class="card">
<div class="ico">🔔</div>
<h3>Benachrichtigungen in Echtzeit</h3>
<p>„Rechnung R2026-0001 wurde an den Kunden versandt." — Erfolg <em>und</em> Fehler erscheinen sofort als deutlich lesbare Meldung. Kein Nachschauen in Listen mehr.</p>
<span class="tag">Sofortiges Feedback</span>
</div>
<div class="card">
<div class="ico">🧾</div>
<h3>Extern geprüfte E-Rechnung</h3>
<p>Rechnungen werden automatisch als ZUGFeRD 2.4 / Factur-X (DATEV) ausgegeben — oder, sobald eine Leitweg-ID hinterlegt ist, als XRechnung 3.0 für den Behördenversand. Beide Formate sind bei einem externen Prüfdienst als fehlerfrei (0 Fehler) und PDF/A-3-konform bestätigt.</p>
<span class="tag">ZUGFeRD 2.4 · XRechnung 3.0 · Extern validiert</span>
</div>
<div class="card">
<div class="ico">🕓</div>
<h3>Änderungshistorie &amp; Verwerfen</h3>
<p>Jede Änderung an einem Entwurf wird protokolliert. Über „Änderungshistorie" sehen Sie, was passiert ist, und mit „Änderungen verwerfen" kehren Sie jederzeit zum gespeicherten Stand zurück.</p>
<span class="tag">Volle Nachvollziehbarkeit</span>
</div>
<div class="card">
<div class="ico">🏦</div>
<h3>Mehr Bankformate</h3>
<p>Kontoauszüge werden jetzt auch im modernen ISO-20022-Format (CAMT) automatisch erkannt und eingelesen — zusätzlich zum bewährten MT940. Das Format wird selbstständig erkannt.</p>
<span class="tag">CAMT + MT940</span>
</div>
</div>
</div>
</section>
<!-- SPOTLIGHT: Live-Editor -->
<section style="padding-top:12px">
<div class="wrap">
<div class="spot">
<div class="spot-text">
<span class="chip">Highlight</span>
<h2>Der Rechnungs-Editor, der mitdenkt</h2>
<p>Früher rechnete der Browser — heute ist der Server die einzige verbindliche Quelle. Das klingt technisch, bedeutet für Sie aber vor allem: Was Sie sehen, stimmt. Immer.</p>
<p>Gilt für <strong>alle Rechnungsarten</strong> (Regel-, Abschlags-, Schluss- und Stornorechnung) und <strong>alle Mahnstufen</strong> — das Online-Bild und das PDF sind garantiert identisch, bis hin zur Positionsnummerierung.</p>
</div>
<div class="spot-card">
<h3>Was der Editor jetzt automatisch tut</h3>
<ul>
<li>Rechnet Netto, MwSt. und Brutto sofort korrekt neu</li>
<li>Prüft E-Mail, Adresse, Positionen und Steuersätze live</li>
<li>Erzeugt die PDF-Vorschau direkt aus dem aktuellen Stand</li>
<li>Nummeriert Positionen auch nach Umsortieren korrekt durch</li>
<li>Warnt rechtzeitig, bevor ein Entwurf abläuft</li>
<li>Speichert erst final, wenn Sie es bestätigen</li>
</ul>
</div>
</div>
</div>
</section>
<!-- ONLINE-EDITOR IM DETAIL -->
<section>
<div class="wrap">
<div class="section-head">
<span class="kicker">Der Online-Editor im Detail</span>
<h2>Rechnungen direkt im Browser erstellen</h2>
<p>Der neue Rechnungs-Editor führt Sie Schritt für Schritt zur fertigen Rechnung — komfortabel zu bedienen und dabei jederzeit rechnerisch abgesichert. Er gilt für alle Rechnungsarten (Regel-, Abschlags-, Schluss- und Stornorechnung).</p>
</div>
<div class="steps">
<div class="step"><div class="n">BEARBEITEN</div><h4>Direkt im Feld</h4><p>Texte und Positionen bearbeiten Sie direkt an Ort und Stelle — ein Klick genügt.</p></div>
<div class="step"><div class="n">EMPFÄNGER</div><h4>Adresse als Formular</h4><p>Die Rechnungsadresse erfassen Sie strukturiert (Name, Straße, PLZ, Ort, Land, optional USt-IdNr., bei Behörden die Leitweg-ID) — vorausgefüllt aus den Kundendaten, ergänzt um Leistungsdatum bzw. Leistungszeitraum. Ein Hinweis zeigt, ob alles für die DATEV-/E-Rechnung passt; ist eine Leitweg-ID gesetzt, wird automatisch als XRechnung statt ZUGFeRD ausgegeben. Für Privatpersonen bleibt die USt-IdNr. einfach leer.</p></div>
<div class="step"><div class="n">ORDNEN</div><h4>Blöcke &amp; Reihenfolge</h4><p>Positionen sind je Auftrag in Abschnitten gebündelt und lassen sich per Ziehen neu sortieren; die Nummerierung passt sich automatisch an.</p></div>
<div class="step"><div class="n">RECHNEN</div><h4>Summen &amp; Steuer live</h4><p>Netto, Mehrwertsteuer, Brutto und die §13b-Umkehr werden bei jeder Änderung sofort und geprüft neu berechnet.</p></div>
<div class="step"><div class="n">PRÜFEN</div><h4>Vorschau auf Knopfdruck</h4><p>Die PDF-Vorschau entsteht direkt aus dem aktuellen Stand — was Sie sehen, ist exakt das, was der Kunde erhält.</p></div>
</div>
<!-- SET-PREIS-VARIANTEN -->
<div class="setpanel">
<div class="ttl"><span class="badge">📦</span><h3>Set-Preis: drei Wege, Positionen zusammenzufassen</h3></div>
<p>Oft sollen mehrere Einzelpositionen zu <em>einem</em> Set-Preis zusammengefasst werden — etwa als Pauschale pro Auftrag. Dafür gibt es drei klar getrennte Funktionen. Bei allen bleibt die <strong>Rechnungssumme unverändert</strong>; die Set-Zeile trägt genau den Wert der zusammengefassten Positionen.</p>
<div class="setgrid">
<div class="setvar">
<div class="num">1</div>
<h4>Einzelne Set-Position zusammenfassen<small>Zeilen-Schaltfläche · direkt an der Position</small></h4>
<p>Für eine einzelne Set-Position: Der Set-Kopf übernimmt die Summe seiner zugehörigen Teilpositionen, deren Einzelpreise werden dann leer dargestellt (kein Preis, nicht 0,00 €).</p>
<span class="kv">Wirkt auf: eine markierte Set-Position</span>
<span class="pill one">Einmalig — nicht per Klick umkehrbar</span>
</div>
<div class="setvar">
<div class="num">2</div>
<h4>„Set mit Preis"<small>Menü · ganzer Auftragsblock</small></h4>
<p>Für jeden Auftragsblock wird oben eine hervorgehobene Set-Zeile mit dem Gesamtwert des Blocks eingefügt. Die bisherigen Einzelpositionen <strong>bleiben sichtbar</strong>, jedoch ohne Einzelpreis (leeres Preisfeld). Die eingefügte Set-Zeile ist eine echte, nachträglich editierbare Position.</p>
<span class="kv">Wirkt auf: jeden Auftragsblock · Positionen bleiben erhalten</span>
<span class="pill rev">Einmalige Umwandlung</span>
</div>
<div class="setvar">
<div class="num">3</div>
<h4>„Nur Set mit Preis"<small>Menü · ganzer Auftragsblock</small></h4>
<p>Wie „Set mit Preis" — aber die Einzelpositionen werden <strong>vollständig entfernt</strong>. Es bleibt allein die eine Set-Zeile mit dem Gesamtpreis des Blocks stehen. Ideal für eine schlanke Pauschal-Darstellung.</p>
<span class="kv">Wirkt auf: jeden Auftragsblock · Positionen werden entfernt</span>
<span class="pill rev">Einmalige Umwandlung</span>
</div>
</div>
<div class="setnote">
<b>Gut zu wissen:</b> Die beiden Menü-Varianten (2 &amp; 3) sind bewusste, <b>einmalige Umwandlungen</b> — es gibt keinen Umschalter zurück. Möchten Sie den Ausgangszustand wiederherstellen, verwerfen Sie einfach den Entwurf („Änderungen verwerfen"), oder passen Sie die entstandene Set-Zeile von Hand an. In jedem Fall gilt: die <b>Gesamtsumme ändert sich nicht</b>, und die PDF-Ausgabe zeigt exakt dasselbe wie der Online-Editor.
</div>
</div>
</div>
</section>
<!-- BEFORE / AFTER -->
<section class="compare">
<div class="wrap">
<div class="section-head">
<span class="kicker">Alt gegen Neu</span>
<h2>Was sich konkret verbessert hat</h2>
<p>Ein direkter Vergleich der bisherigen Lösung mit der neuen Implementierung.</p>
</div>
<div class="table-scroll">
<table>
<thead>
<tr><th>Bereich</th><th>Bisher (Legacy)</th><th>Neu</th></tr>
</thead>
<tbody>
<tr>
<td class="feat">Rechnungs­vorschau</td>
<td class="old">Kein Live-PDF — Ergebnis erst nach dem Speichern sichtbar</td>
<td class="new">Echtzeit-PDF-Vorschau schon während der Bearbeitung</td>
</tr>
<tr>
<td class="feat">Berechnung</td>
<td class="old">Beträge im Browser gerechnet — Abweichungen möglich</td>
<td class="new">Alle Werte serverseitig geprüft &amp; einheitlich berechnet</td>
</tr>
<tr>
<td class="feat">Rückmeldungen</td>
<td class="old">Keine aktive Meldung — Status nur durch Nachschauen</td>
<td class="new">Sofortige Erfolgs- und Fehlermeldungen in Echtzeit</td>
</tr>
<tr>
<td class="feat">Fehler im Hintergrund</td>
<td class="old">Nur im Protokoll — für den Nutzer unsichtbar</td>
<td class="new">Werden dem Nutzer verständlich angezeigt</td>
</tr>
<tr>
<td class="feat">Rechnungsformat</td>
<td class="old">Reines PDF</td>
<td class="new">Zusätzlich extern validierte E-Rechnung (ZUGFeRD/DATEV oder XRechnung für Behörden) — 0 Fehler, PDF/A-3 bestätigt</td>
</tr>
<tr>
<td class="feat">Änderungsverlauf</td>
<td class="old">Nicht vorhanden</td>
<td class="new">Vollständige Historie &amp; gezieltes Verwerfen je Entwurf</td>
</tr>
<tr>
<td class="feat">Kontoauszüge</td>
<td class="old">Nur MT940</td>
<td class="new">MT940 <em>und</em> CAMT (ISO 20022) mit Auto-Erkennung</td>
</tr>
<tr>
<td class="feat">System-Überblick</td>
<td class="old">Kein Einblick in den Systemzustand</td>
<td class="new">Admin-/Status-Modul mit Live-Prüfungen (für berechtigte Nutzer)</td>
</tr>
<tr>
<td class="feat">Plattform</td>
<td class="old">Ältere VB-Codebasis</td>
<td class="new">Modernes .NET 10 — schneller, gepflegt, umfangreich getestet</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- KEY USER -->
<section class="keyuser">
<div class="wrap">
<div class="section-head">
<span class="kicker" style="color:var(--accent-2)">Für den Key-User</span>
<h2>Mehr Kontrolle hinter den Kulissen</h2>
<p>Diese Punkte betreffen vor allem Sie als Key-User — sie sorgen dafür, dass der Betrieb stabil, nachvollziehbar und überprüfbar bleibt.</p>
</div>
<div class="ku-grid">
<div class="ku">
<h3><span></span> Admin- &amp; Status-Modul</h3>
<p>Ein eigenes Modul zeigt den Zustand des Systems: Server, Datenbank, Schlüsseltresor, Speicher und die ERP-Anbindung werden live geprüft. Inklusive Test-E-Mail-Funktion — sichtbar nur für berechtigte Nutzer.</p>
</div>
<div class="ku">
<h3><span></span> Durchgängige Nachvollziehbarkeit</h3>
<p>Jeder wichtige Geschäftsvorfall — erstellt, versandt, importiert, fehlgeschlagen — wird als Ereignis erfasst und in verständliche Meldungen übersetzt.</p>
</div>
<div class="ku">
<h3><span></span> Überwachung &amp; Diagnose</h3>
<p>Moderne Telemetrie (OpenTelemetry) misst Abläufe, Laufzeiten und Fehler. Probleme lassen sich damit früher erkennen und schneller eingrenzen.</p>
</div>
<div class="ku">
<h3><span></span> Automatischer ERP-Abgleich</h3>
<p>Der Abgleich mit dem ERP-System (mfr) läuft zuverlässig im Hintergrund direkt in der Anwendung — mit automatischer Wiederholung bei kurzzeitigen Störungen.</p>
</div>
<div class="ku">
<h3><span></span> Sichere Konfiguration</h3>
<p>Zugangsdaten liegen im zentralen Azure Key Vault. Eine Test-Schutzfunktion verhindert, dass in Test-Umgebungen versehentlich echte Kunden angeschrieben werden.</p>
</div>
<div class="ku">
<h3><span></span> Umfassend getestet</h3>
<p>Die Kernlogik ist durch eine breite, automatisierte Testabdeckung abgesichert — erfolgreiche wie fehlerhafte Abläufe werden geprüft, bevor Änderungen live gehen.</p>
</div>
</div>
</div>
</section>
<!-- UNVERÄNDERT / VERTRAUT -->
<section>
<div class="wrap">
<div class="section-head">
<span class="kicker">Vertraut geblieben</span>
<h2>Was sich für Sie <em>nicht</em> ändert</h2>
<p>Modernisiert wurde die Technik — nicht Ihre Arbeitsweise.</p>
</div>
<div class="grid">
<div class="card"><div class="ico">🗂️</div><h3>Gewohnte Module</h3><p>Rechnungen, Zahlungserinnerungen, Anfragen, Banking und Berichte finden Sie an denselben Stellen wie bisher.</p></div>
<div class="card"><div class="ico">📄</div><h3>Vertrautes Layout</h3><p>Briefkopf, Adressfenster und Rechnungslayout wurden 1:1 übernommen — Ihre Dokumente sehen aus wie gewohnt.</p></div>
<div class="card"><div class="ico">🔐</div><h3>Gleiche Anmeldung</h3><p>Login und Berechtigungen bleiben unverändert. Neue Funktionen erscheinen nur dort, wo Sie dafür berechtigt sind.</p></div>
</div>
</div>
</section>
<footer>
<div class="wrap">
<p><b>Fuchs Intranet</b> — Neue Implementierung · Stand Juli 2026<br>
Sebastian Fuchs Bad und Heizung GmbH &amp; Co. KG · Bereitgestellt von ProcessWeb</p>
</div>
</footer>
</body>
</html>
Binary file not shown.
+7 -10
View File
@@ -22,9 +22,9 @@
<ProjectReference Include="..\OCORE_web\OCORE_web\OCORE_web.csproj" />
<ProjectReference Include="..\OCORE_web_pdf\OCORE_web_pdf.csproj" />
<ProjectReference Include="..\CAMTParser\CAMTParser.csproj" />
<ProjectReference Include="..\eRechnungLib\src\eRechnungLib\eRechnungLib.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="code\7z.dll" CopyToOutputDirectory="PreserveNewest" />
<Content Include="Data\**" CopyToOutputDirectory="PreserveNewest" />
<Content Include="favicon.ico" />
</ItemGroup>
@@ -34,18 +34,17 @@
<PackageReference Include="HtmlAgilityPack" Version="1.12.4" />
<PackageReference Include="MailKit" Version="4.17.0" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.16.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.16.0" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.16.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.16.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.16.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.SqlClient" Version="1.16.0" />
<PackageReference Include="Portable.BouncyCastle" Version="1.9.0" />
<PackageReference Include="QRCoder" Version="1.8.0" />
<PackageReference Include="PDFsharp" Version="6.2.4" />
<PackageReference Include="PDFsharp-MigraDoc" Version="6.2.4" />
<PackageReference Include="Spire.PDF" Version="[8.10.5,8.10.5]" allowedVersions="[8.10.5,8.10.5]" />
<PackageReference Include="Squid-Box.SevenZipSharp" Version="1.6.2.24" />
<!-- Updated packages -->
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
<PackageReference Include="MimeKit" Version="4.17.0" />
@@ -53,8 +52,8 @@
<!-- New packages (needed for .NET 10) -->
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.2" />
<PackageReference Include="SixLabors.ImageSharp" Version="4.0.0" />
<PackageReference Include="System.Configuration.ConfigurationManager" Version="10.0.9" />
<PackageReference Include="System.Drawing.Common" Version="10.0.9" />
<PackageReference Include="System.Configuration.ConfigurationManager" Version="10.0.10" />
<PackageReference Include="System.Drawing.Common" Version="10.0.10" />
<PackageReference Include="Azure.Storage.Blobs" Version="12.29.1" />
</ItemGroup>
<ItemGroup>
@@ -66,14 +65,12 @@
<!-- UsingTask für plattformneutrales Zippen nach Publish -->
<UsingTask TaskName="ZipDir" TaskFactory="CodeTaskFactory" AssemblyName="Microsoft.Build.Tasks.Core">
<UsingTask TaskName="ZipDir" TaskFactory="RoslynCodeTaskFactory" AssemblyName="Microsoft.Build.Tasks.Core">
<ParameterGroup>
<Source ParameterType="System.String" Required="true" />
<Destination ParameterType="System.String" Required="true" />
</ParameterGroup>
<Task>
<Reference Include="System.IO.Compression" />
<Reference Include="System.IO.Compression.FileSystem" />
<Code Type="Fragment" Language="cs">
<![CDATA[
if (System.IO.File.Exists(Destination)) {
+3
View File
@@ -51,6 +51,9 @@ public static class FuchsTelemetry
Meter.CreateCounter<long>("fuchs.blobstorage.uploads", "{upload}", "Number of documents successfully archived to Azure Blob Storage.");
public static readonly Counter<long> BlobUploadsFailed =
Meter.CreateCounter<long>("fuchs.blobstorage.uploads.failed", "{upload}", "Number of documents that failed to archive to Azure Blob Storage.");
public static readonly Counter<long> SystemProbes =
Meter.CreateCounter<long>("fuchs.systemstatus.probes", "{probe}",
"Admin system-status connectivity probes executed, tagged by component and outcome.");
// ── Performance histograms (durations in milliseconds) ───────────────────
public static readonly Histogram<double> PdfRenderDuration =
+38
View File
@@ -59,6 +59,31 @@ public class Program
// FDS MFR singleton — ILogger<FdsMfr> and ILoggerFactory are supplied by the ASP.NET Core DI container
builder.Services.AddSingleton<fds.IFdsMfr, fds.FdsMfr>();
// In-process MFR ERP sync — formerly the standalone Fuchs_DataService Windows Service
// (Topshelf), now hosted here as a BackgroundService. Gated by Fds:SyncEnabled so
// developer machines (appsettings.Development.json sets it false) never poll the ERP;
// interval and debug verbosity come from the Fds config section.
if (builder.Configuration.GetValue("Fds:SyncEnabled", false))
{
builder.Services.AddHostedService(sp =>
{
var mfr = sp.GetRequiredService<fds.IFdsMfr>();
var interval = TimeSpan.FromMinutes(
builder.Configuration.GetValue("Fds:ExecutionFrequency_Minutes", 15d));
bool debug = builder.Configuration.GetValue("Fds:DebugDetails", false);
var jobs = new[]
{
new PeriodicJobDefinition("MfrSync", interval, async ct =>
{
await mfr.UpdateIfNecessary_async(debug, ct);
await mfr.UpdateRequested_async(debug, ct);
await mfr.GetInvoiceFiles_async(debug, ct);
})
};
return new PeriodicHostedService(jobs, sp.GetRequiredService<ILogger<PeriodicHostedService>>());
});
}
// MVC with Razor view support
builder.Services.AddControllersWithViews();
builder.Services.AddSignalR();
@@ -96,8 +121,17 @@ public class Program
// (see appsettings.Development.json) so real tenant-owners/end-customers are never emailed.
builder.Services.Configure<FuchsEmailSettings>(builder.Configuration.GetSection("Fuchs:Email"));
builder.Services.Configure<StartupSelfTestSettings>(builder.Configuration.GetSection("Fuchs:StartupChecks"));
// eRechnung (ZUGFeRD/Factur-X) output + external formal-conformance validation seam.
builder.Services.Configure<ERechnungSettings>(builder.Configuration.GetSection("Fuchs:ERechnung"));
builder.Services.AddSingleton<IERechnungService, ERechnungService>(); // stateless: maps + embeds ZUGFeRD
builder.Services.AddSingleton<IERechnungValidator, ProcessWebERechnungValidator>(); // external EN16931 + PDF/A-3 check
builder.Services.AddHttpClient(ProcessWebERechnungValidator.HttpClientName,
c => c.Timeout = TimeSpan.FromSeconds(90));
builder.Services.AddHttpClient("ProcessWebMailer");
builder.Services.AddScoped<IComService, ProcessWebComService>();
// Holds the one-shot startup self-test result for the lifetime of the process so the Admin
// module can display it as a non-refreshable widget (must be registered before the service).
builder.Services.AddSingleton<StartupCheckReporter>();
builder.Services.AddHostedService<StartupSelfTestService>();
// Business services (DI migration — replaces the static helper / Active-Record pattern)
@@ -110,6 +144,10 @@ public class Program
builder.Services.AddScoped<IReminderService, ReminderService>();
builder.Services.AddScoped<IEventService, EventService>();
// Read-only system diagnostics for the Admin module (config snapshot + connectivity probes
// + test-email). Restricted to fds_sys > 4 in IntranetController.Admin; see the concept doc.
builder.Services.AddScoped<ISystemStatusService, SystemStatusService>();
// Live, backend-authoritative invoice draft editing (ADR 0006): an in-memory
// draft cache (singleton), the scoped edit orchestrator, a targeted SignalR
// notifier over the dedicated DraftPreviewHub, and the idle-expiry monitor.
@@ -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>
+81
View File
@@ -89,6 +89,87 @@ public class AzureBlobStorageService : IBlobStorageService
}
}
public async Task<BlobConnectivity> CheckConnectivityAsync(CancellationToken cancellationToken = default)
{
if (!_settings.Enabled)
return new BlobConnectivity { Enabled = false, Configured = _client != null };
if (_client == null)
return new BlobConnectivity
{
Enabled = true,
Configured = false,
Detail = "ConnectionStrings:AzureBlobStorage_ConnectionString ist nicht konfiguriert.",
};
using var act = FuchsTelemetry.StartActivity("blobstorage.connectivity");
try
{
AccountInfo info = await _client.GetAccountInfoAsync(cancellationToken);
var containers = await CountContainersAsync(cancellationToken);
return new BlobConnectivity
{
Enabled = true,
Configured = true,
Reachable = true,
AccountName = _client.AccountName,
Detail = $"{info.AccountKind} / {info.SkuName}",
Containers = containers,
};
}
catch (Exception ex)
{
act?.SetStatus(ActivityStatusCode.Error, ex.Message);
_logger.LogWarning(ex, "Blob connectivity check failed for account {Account}.", _client.AccountName);
return new BlobConnectivity
{
Enabled = true,
Configured = true,
Reachable = false,
AccountName = _client.AccountName,
Detail = ex.Message,
};
}
}
/// <summary>
/// Counts the blobs in each distinct configured container (invoice + reminder). A container
/// that does not exist yet reports <see cref="BlobContainerCount.Exists"/> = false / count 0;
/// a container whose listing throws reports count -1. Never throws.
/// </summary>
private async Task<List<BlobContainerCount>> CountContainersAsync(CancellationToken cancellationToken)
{
var result = new List<BlobContainerCount>();
if (_client == null) return result;
var names = new[] { _settings.InvoiceContainer, _settings.ReminderContainer }
.Where(n => !string.IsNullOrWhiteSpace(n))
.Distinct(StringComparer.OrdinalIgnoreCase);
foreach (var name in names)
{
try
{
var container = _client.GetBlobContainerClient(name);
bool exists = await container.ExistsAsync(cancellationToken);
int count = 0;
if (exists)
{
await foreach (var _ in container.GetBlobsAsync(cancellationToken: cancellationToken))
count++;
}
result.Add(new BlobContainerCount { Name = name, Exists = exists, FileCount = count });
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Blob file count failed for container {Container}.", name);
result.Add(new BlobContainerCount { Name = name, Exists = false, FileCount = -1 });
}
}
return result;
}
private static string BuildBlobName(string documentId, string fileName) =>
$"{documentId}/{(string.IsNullOrWhiteSpace(fileName) ? $"{documentId}.pdf" : fileName)}";
+21 -1
View File
@@ -23,7 +23,7 @@ public static class DocumentMetadataBuilder
{
if (string.IsNullOrWhiteSpace(field)) continue;
if (!TryGetValue(row, field, out object? value)) continue;
metadata[field] = Stringify(value);
metadata[field] = ToAsciiHeaderValue(Stringify(value));
}
return metadata;
}
@@ -55,4 +55,24 @@ public static class DocumentMetadataBuilder
DateTime dt => dt.ToString("O"),
_ => value.ToString() ?? ""
};
/// <summary>
/// Azure Blob metadata is transported in HTTP headers, whose values must contain ASCII
/// characters only. Preserve readable, printable ASCII and percent-encode every other
/// UTF-8 byte so titles and document names containing umlauts cannot break the upload.
/// </summary>
private static string ToAsciiHeaderValue(string value)
{
if (value.All(c => c is >= ' ' and <= '~')) return value;
var result = new System.Text.StringBuilder(value.Length);
foreach (byte b in System.Text.Encoding.UTF8.GetBytes(value))
{
if (b is >= 0x20 and <= 0x7E)
result.Append((char)b);
else
result.Append('%').Append(b.ToString("X2"));
}
return result.ToString();
}
}
+307
View File
@@ -0,0 +1,307 @@
using System.Globalization;
using eRechnungLib;
using eRechnungLib.Model;
using eRechnungLib.Model.CodeLists;
using Fuchs.intranet;
using static OCORE.OCORE_dictionaries;
namespace Fuchs.Services;
/// <summary>
/// Maps a Fuchs <see cref="FdsInvoiceData"/> onto the strongly-typed EN 16931 invoice model of
/// <c>eRechnungLib</c>, so it can be emitted as a ZUGFeRD/Factur-X hybrid (see ADR 0005/0012).
/// </summary>
/// <remarks>
/// The seller (<c>BG-4</c>) is Fuchs itself; its master data comes from
/// <see cref="ERechnungSellerSettings"/> (<c>Fuchs:ERechnung:Seller</c> in appsettings, defaults
/// mirroring the FuchsPdf letterhead). The buyer (<c>BG-7</c>) comes from the structured
/// <see cref="FdsInvoiceData.RecipientAddress"/>; a private person (no VAT id) maps cleanly with
/// no buyer tax registration. Amounts/VAT are recomputed by the library from the mapped lines.
/// </remarks>
public static class ERechnungMapper
{
/// <summary>Builds an <see cref="EInvoice"/> (EN 16931 model) from the Fuchs invoice data.</summary>
/// <param name="invoice">The Fuchs invoice data.</param>
/// <param name="seller">Seller master data; defaults to the built-in Fuchs values when omitted.</param>
public static EInvoice BuildEInvoice(FdsInvoiceData invoice, ERechnungSellerSettings? seller = null)
{
ArgumentNullException.ThrowIfNull(invoice);
seller ??= new ERechnungSellerSettings();
var reg = invoice.InvoiceRegistration;
bool reverseCharge = (reg?.getString("InvoiceOptions") ?? "").Contains("§13b", StringComparison.Ordinal);
var model = new Invoice
{
InvoiceNumber = NonEmpty(reg?.getString("InvoiceId"), invoice.Id, "ENTWURF"),
IssueDate = IssueDate(reg),
CurrencyCode = CurrencyCode.Eur,
Seller = BuildSeller(seller),
Buyer = BuildBuyer(invoice),
Payment = BuildPayment(invoice, seller),
};
// B2G: a Leitweg-ID makes this an XRechnung (buyer reference BT-10 is then mandatory).
var addr = invoice.RecipientAddress;
if (addr is { IsPublicAuthority: true })
model.BuyerReference = addr.LeitwegId.Trim();
string title = reg?.getString("InvoiceTitle") ?? "";
if (!string.IsNullOrWhiteSpace(title))
model.Notes.Add(new InvoiceNote(title));
AddLines(model, invoice, reverseCharge);
if (reverseCharge)
model.VatExemptionReasons[VatCategoryCode.ReverseCharge] =
new VatExemptionReason("Steuerschuldnerschaft des Leistungsempfängers (§ 13b UStG)");
// Payment terms (BT-20) + due date (BT-9) — required by BR-CO-25 for a positive amount due.
model.PaymentTerms = BuildPaymentTerms(invoice, model.IssueDate);
// Service date / period (BT-72 or BG-14) from the free-text ProvisionPeriod — clears the
// XRechnung recommendation BR-DE-TMP-32 when a date/range can be parsed.
ApplyServicePeriod(model, reg?.getString("ProvisionPeriod"));
return EInvoice.CreateInvoice(model).Recalculate();
}
private static TradeParty BuildSeller(ERechnungSellerSettings s)
{
var seller = new TradeParty
{
Name = s.Name,
Address = new PostalAddress
{
Line1 = NullIfEmpty(s.Street),
PostalCode = NullIfEmpty(s.PostalCode),
City = NullIfEmpty(s.City),
Country = NormalizeCountry(s.CountryCode),
},
// Seller electronic address (BT-34) + contact (BG-6) — mandatory for XRechnung (BR-DE-5/6/7).
ElectronicAddress = new Identifier(s.Email, "EM"),
Contact = new TradeContact { Name = s.Name, Telephone = NullIfEmpty(s.Phone), Email = NullIfEmpty(s.Email) },
};
if (!string.IsNullOrWhiteSpace(s.LegalRegistrationId))
seller.LegalRegistrationId = new Identifier(s.LegalRegistrationId.Trim()); // BT-30
if (!string.IsNullOrWhiteSpace(s.TaxNumber))
seller.TaxRegistrations.Add(new TaxRegistration(s.TaxNumber, TaxRegistrationScheme.LocalTaxNumber)); // BT-32
if (!string.IsNullOrWhiteSpace(s.VatId))
seller.TaxRegistrations.Add(new TaxRegistration(s.VatId, TaxRegistrationScheme.Vat)); // BT-31
return seller;
}
private static TradeParty BuildBuyer(FdsInvoiceData invoice)
{
var addr = invoice.RecipientAddress;
if (addr is null)
{
// No structured address yet (older draft): best-effort from the free-text block so a
// model can still be produced. The country defaults to DE; refine once structured
// capture is in place. EN 16931 validation will flag any remaining gaps.
var lines = (invoice.InvoiceRegistration?.getString("SendToAddress") ?? "")
.Replace("\r\n", "\n").Split('\n').Select(l => l.Trim()).Where(l => l.Length > 0).ToArray();
return new TradeParty
{
Name = lines.Length > 0 ? lines[0] : "Rechnungsempfänger",
Address = new PostalAddress { Country = CountryCode.Germany },
};
}
var buyer = new TradeParty
{
Name = NonEmpty(addr.Name, "Rechnungsempfänger"),
Address = new PostalAddress
{
Line1 = NullIfEmpty(addr.Street),
Line2 = NullIfEmpty(addr.AddressLine2),
PostalCode = NullIfEmpty(addr.PostalCode),
City = NullIfEmpty(addr.City),
Country = NormalizeCountry(addr.CountryCode),
},
};
if (!string.IsNullOrWhiteSpace(addr.VatId))
buyer.TaxRegistrations.Add(new TaxRegistration(addr.VatId.Trim(), TaxRegistrationScheme.Vat));
// Buyer electronic address (BT-49) — mandatory for XRechnung; use the recipient email.
string email = NonEmpty(invoice.InvoiceRegistration?.getString("SendToEmail"), invoice.RawInvoiceEmail);
if (!string.IsNullOrWhiteSpace(email))
buyer.ElectronicAddress = new Identifier(email.Trim(), "EM");
return buyer;
}
private static PaymentInstructions BuildPayment(FdsInvoiceData invoice, ERechnungSellerSettings s)
{
var payment = new PaymentInstructions
{
MeansCode = PaymentMeansCode.SepaCreditTransfer,
RemittanceInformation = invoice.InvoiceRegistration?.getString("InvoiceId") is { Length: > 0 } inv ? inv : null,
};
if (!string.IsNullOrWhiteSpace(s.Iban))
payment.CreditTransfers.Add(new CreditTransferAccount
{
AccountId = s.Iban.Replace(" ", ""),
AccountName = NullIfEmpty(s.Name),
BankId = NullIfEmpty(s.Bic),
});
return payment;
}
/// <summary>
/// Parses the free-text service period (e.g. <c>"01.06.2026 - 30.06.2026"</c> or a single
/// <c>"18.06.2026"</c>) into the invoicing period (BG-14) or, for a single date, the actual
/// delivery date (BT-72). Unparseable text is ignored (the field is only a recommendation).
/// </summary>
private static void ApplyServicePeriod(Invoice model, string? provisionPeriod)
{
if (string.IsNullOrWhiteSpace(provisionPeriod)) return;
var dates = System.Text.RegularExpressions.Regex
.Matches(provisionPeriod, @"(\d{1,2})\.(\d{1,2})\.(\d{4})|(\d{4})-(\d{2})-(\d{2})")
.Select(m => ParseDate(m)).Where(d => d is not null).Select(d => d!.Value).OrderBy(d => d).ToList();
if (dates.Count == 0) return;
if (dates.Count == 1)
model.Delivery = new DeliveryInformation { DeliveryDate = dates[0] }; // BT-72
else
model.InvoicingPeriod = new Period(dates[0], dates[^1]); // BG-14
}
private static DateOnly? ParseDate(System.Text.RegularExpressions.Match m)
{
try
{
if (m.Groups[1].Success)
return new DateOnly(int.Parse(m.Groups[3].Value), int.Parse(m.Groups[2].Value), int.Parse(m.Groups[1].Value));
return new DateOnly(int.Parse(m.Groups[4].Value), int.Parse(m.Groups[5].Value), int.Parse(m.Groups[6].Value));
}
catch { return null; }
}
private static void AddLines(Invoice model, FdsInvoiceData invoice, bool reverseCharge)
{
int id = 0;
foreach (var item in invoice.InvoiceItems)
{
string name = FirstNonEmpty(Str(item, "title"), Str(item, "nme"), Str(item, "text"), Str(item, "t"));
string desc = Str(item, "desc");
if (string.IsNullOrWhiteSpace(name) && string.IsNullOrWhiteSpace(desc)) name = "Position";
decimal qty = Dec(item, "qty", "q", "Quantity") ?? 1m;
if (qty == 0m) qty = 1m;
decimal net = Dec(item, "total_net", "value_total", "vt") ?? 0m;
decimal? unit = Dec(item, "price_net", "value", "v");
decimal rate = Dec(item, "vat") ?? 0m;
id++;
var line = new InvoiceLine
{
Id = id.ToString(CultureInfo.InvariantCulture),
Quantity = qty,
UnitCode = UnitCode.One,
NetPrice = unit ?? (qty != 0 ? decimal.Round(net / qty, 2, MidpointRounding.AwayFromZero) : net),
NetAmount = net,
VatCategory = reverseCharge ? VatCategoryCode.ReverseCharge
: rate > 0 ? VatCategoryCode.StandardRate : VatCategoryCode.ZeroRatedGoods,
VatRate = reverseCharge ? 0m : rate,
Item = new TradeItem { Name = Truncate(name, 500), Description = NullIfEmpty(desc) },
};
model.Lines.Add(line);
}
// EN 16931 requires at least one line (BR-16); synthesise one from the invoice total when
// the item list is empty (e.g. a lump-sum invoice).
if (model.Lines.Count == 0)
{
decimal net = ParseInvariant(invoice.InvoiceRegistration?.getString("InvoiceBalance_net")) ?? 0m;
decimal rate = ParseInvariant(invoice.InvoiceRegistration?.getString("InvoiceVAT_1")) ?? 0m;
model.Lines.Add(new InvoiceLine
{
Id = "1",
Quantity = 1m,
UnitCode = UnitCode.One,
NetPrice = net,
NetAmount = net,
VatCategory = reverseCharge ? VatCategoryCode.ReverseCharge
: rate > 0 ? VatCategoryCode.StandardRate : VatCategoryCode.ZeroRatedGoods,
VatRate = reverseCharge ? 0m : rate,
Item = new TradeItem { Name = NonEmpty(invoice.InvoiceRegistration?.getString("InvoiceTitle"), "Leistung") },
});
}
}
/// <summary>
/// Builds payment terms (BT-20 description + BT-9 due date) from the Fuchs payment term token
/// (e.g. <c>"10wd"</c> = 10 Werktage, <c>"14d"</c> = 14 Tage). Always returns a term so BR-CO-25
/// holds when the amount due is positive.
/// </summary>
private static PaymentTerms BuildPaymentTerms(FdsInvoiceData invoice, DateOnly issueDate)
{
string term = NonEmpty(invoice.InvoiceRegistration?.getString("PaymentTerm"), invoice.PaymentTerms).Trim().ToLowerInvariant();
var m = System.Text.RegularExpressions.Regex.Match(term, @"^(\d+)\s*(wd|d)?$");
if (!m.Success || !int.TryParse(m.Groups[1].Value, out int n) || n <= 0)
return new PaymentTerms { Description = "Zahlbar sofort ohne Abzug", DueDate = issueDate };
bool workingDays = m.Groups[2].Value == "wd";
DateOnly due = issueDate;
if (workingDays)
{
int added = 0;
while (added < n)
{
due = due.AddDays(1);
if (due.DayOfWeek is not DayOfWeek.Saturday and not DayOfWeek.Sunday) added++;
}
}
else due = issueDate.AddDays(n);
string desc = workingDays
? $"Zahlbar innerhalb von {n} Werktagen ohne Abzug (bis {due:dd.MM.yyyy})"
: $"Zahlbar innerhalb von {n} Tagen ohne Abzug (bis {due:dd.MM.yyyy})";
return new PaymentTerms { Description = desc, DueDate = due };
}
// ── helpers ────────────────────────────────────────────────────────────────
private static DateOnly IssueDate(GenericObjectDictionary? reg)
{
foreach (var key in new[] { "DateFinalized", "DateCreated" })
if (reg?.getString(key) is { Length: > 0 } s && DateTime.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dt))
return DateOnly.FromDateTime(dt);
return DateOnly.FromDateTime(DateTime.Today);
}
/// <summary>Normalises a raw country value (ISO alpha-2, or a German/English name) to a country code.</summary>
internal static CountryCode NormalizeCountry(string? raw)
{
string s = (raw ?? "").Trim();
if (s.Length == 2 && s.All(char.IsLetter)) return new CountryCode(s);
return s.ToLowerInvariant() switch
{
"deutschland" or "germany" => CountryCode.Germany,
"österreich" or "oesterreich" or "austria" => CountryCode.Austria,
"schweiz" or "switzerland" or "suisse" => CountryCode.Switzerland,
"" => CountryCode.Germany,
_ => CountryCode.Germany,
};
}
private static string Str(IDictionary<string, object?> d, string key)
=> d.TryGetValue(key, out var v) && v != null ? Convert.ToString(v, CultureInfo.InvariantCulture)?.Trim() ?? "" : "";
private static decimal? Dec(IDictionary<string, object?> d, params string[] keys)
{
foreach (var k in keys)
if (d.TryGetValue(k, out var v) && v != null)
{
string s = Convert.ToString(v, CultureInfo.InvariantCulture)?.Replace("%", "").Trim() ?? "";
if (decimal.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var dec)) return dec;
if (decimal.TryParse(s.Replace(',', '.'), NumberStyles.Any, CultureInfo.InvariantCulture, out dec)) return dec;
}
return null;
}
private static decimal? ParseInvariant(string? s)
=> decimal.TryParse((s ?? "").Replace(',', '.'), NumberStyles.Any, CultureInfo.InvariantCulture, out var d) ? d : null;
private static string FirstNonEmpty(params string[] values) => values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v)) ?? "";
private static string NonEmpty(params string?[] values) => values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v)) ?? "";
private static string? NullIfEmpty(string? s) => string.IsNullOrWhiteSpace(s) ? null : s.Trim();
private static string Truncate(string s, int max) => s.Length <= max ? s : s[..max];
}
+122
View File
@@ -0,0 +1,122 @@
using System.Diagnostics;
using eRechnungLib;
using eRechnungLib.Profiles;
using Fuchs.intranet;
using Fuchs.Observability;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Fuchs.Services;
/// <summary>
/// Produces the ZUGFeRD/Factur-X hybrid PDF for a finalized invoice by mapping
/// <see cref="FdsInvoiceData"/> to the EN 16931 model and embedding the CII XML into the
/// FuchsPdf-rendered <b>visual</b> PDF (ADR 0012 — eRechnungLib owns the single PDF/A-3 layer).
/// </summary>
public interface IERechnungService
{
/// <summary>Whether eRechnung emission is switched on (<c>Fuchs:ERechnung:Enabled</c>).</summary>
bool Enabled { get; }
/// <summary>
/// Builds the hybrid PDF from the raw (non-PDF/A) visual PDF, or returns <see langword="null"/>
/// when disabled or on any failure — the caller then falls back to the plain PDF/A so
/// invoicing never breaks because of eRechnung production. When external validation is enabled
/// and reports a failure with <c>FailOnError</c>, the hybrid is withheld (null → fallback) too.
/// </summary>
Task<byte[]?> TryBuildHybridPdfAsync(FdsInvoiceData invoice, byte[] rawVisualPdf);
}
/// <inheritdoc cref="IERechnungService"/>
public sealed class ERechnungService : IERechnungService
{
private readonly ERechnungSettings _settings;
private readonly IERechnungValidator _validator;
private readonly ILogger<ERechnungService> _logger;
public ERechnungService(IOptions<ERechnungSettings> settings, IERechnungValidator validator,
ILogger<ERechnungService> logger)
{
_settings = settings.Value;
_validator = validator;
_logger = logger;
}
public bool Enabled => _settings.Enabled;
public async Task<byte[]?> TryBuildHybridPdfAsync(FdsInvoiceData invoice, byte[] rawVisualPdf)
{
ArgumentNullException.ThrowIfNull(invoice);
if (!_settings.Enabled) return null;
if (rawVisualPdf is null || rawVisualPdf.Length == 0) return null;
var sw = Stopwatch.StartNew();
using var act = FuchsTelemetry.StartActivity("invoice.erechnung");
act?.SetTag("fuchs.invoice.id", invoice.Id);
try
{
var einvoice = ERechnungMapper.BuildEInvoice(invoice, _settings.Seller);
// A Leitweg-ID (buyer reference) marks a B2G invoice → emit the XRechnung profile
// (the mandatory form for public authorities); otherwise the configured default (EN 16931).
bool isB2G = !string.IsNullOrWhiteSpace(einvoice.Model.BuyerReference);
var profile = isB2G ? ZugferdProfile.XRechnung : ParseProfile(_settings.Profile);
act?.SetTag("fuchs.erechnung.profile", profile.ToString());
var result = einvoice.ToZugferd(profile, rawVisualPdf);
if (!result.Success)
{
_logger.LogError("eRechnung: conversion produced no output for invoice {Id}. Findings: {Findings}",
invoice.Id, result.Validation);
return null;
}
if (!result.Validation.IsValid)
_logger.LogWarning("eRechnung: invoice {Id} produced with library validation findings: {Findings}",
invoice.Id, result.Validation);
byte[] hybrid = result.Value!;
act?.SetTag("fuchs.erechnung.bytes", hybrid.Length);
_logger.LogInformation("eRechnung: hybrid PDF/A-3 built for invoice {Id} ({Profile}, {Bytes} bytes, {Ms} ms)",
invoice.Id, profile, hybrid.Length, sw.ElapsedMilliseconds);
// External formal verification (EN 16931 XML + PDF/A-3 via the ProcessWeb eInvoice service).
if (_validator.Enabled)
{
var v = await _validator.ValidatePdfAsync(hybrid);
act?.SetTag("fuchs.erechnung.valid", v.IsValid);
act?.SetTag("fuchs.erechnung.pdfa", v.PdfACompliant);
if (v.IsValid)
_logger.LogInformation("eRechnung: invoice {Id} externally validated OK ({Summary})", invoice.Id, v.Summary);
else if (v.HasHardError)
{
_logger.LogWarning("eRechnung: invoice {Id} has validation errors (pdfA={PdfA}, xmlErrors={Errors}): {Summary}",
invoice.Id, v.PdfACompliant, v.XmlErrorCount, v.Summary);
if (_settings.Validation.FailOnError)
{
_logger.LogError("eRechnung: withholding hybrid for invoice {Id} (FailOnError); falling back to plain PDF/A", invoice.Id);
return null;
}
}
else
// Reached but not strictly valid without hard errors — e.g. the validator's XML
// scenario set does not cover our EN 16931 ZUGFeRD. Informational only.
_logger.LogInformation("eRechnung: invoice {Id} — PDF/A ok; XML scenario not matched by validator (no errors). {Summary}",
invoice.Id, v.Summary);
}
return hybrid;
}
catch (Exception ex)
{
act?.SetStatus(ActivityStatusCode.Error, ex.Message);
_logger.LogError(ex, "eRechnung: hybrid build failed for invoice {Id}; falling back to plain PDF/A", invoice.Id);
return null;
}
}
private static ZugferdProfile ParseProfile(string? profile) => (profile ?? "").Trim().ToUpperInvariant() switch
{
"EXTENDED" => ZugferdProfile.Extended,
"BASIC" => ZugferdProfile.Basic,
"XRECHNUNG" => ZugferdProfile.XRechnung,
_ => ZugferdProfile.EN16931,
};
}
+97
View File
@@ -0,0 +1,97 @@
namespace Fuchs.Services;
/// <summary>
/// eRechnung (ZUGFeRD/Factur-X) output settings, bound from appsettings.json → "Fuchs:ERechnung".
/// </summary>
/// <remarks>
/// Per ADR 0005 the invoice visual PDF is produced by <c>FuchsPdf</c> (render-only, no Spire
/// PDF/A) and handed to <c>eRechnungLib.ToZugferd(...)</c>, which owns the single PDF/A-3 layer
/// and embeds the CII XML. This is the gate that turns that emission on and configures it.
/// </remarks>
public sealed class ERechnungSettings
{
/// <summary>
/// When <see langword="true"/>, finalized invoices are emitted as a ZUGFeRD/Factur-X hybrid
/// PDF/A-3. Kept <see langword="false"/> until the <c>FdsInvoiceData</c> → model mapping is
/// wired, so the app keeps producing the plain PDF/A until then.
/// </summary>
public bool Enabled { get; set; }
/// <summary>The ZUGFeRD profile to emit. Defaults to <c>EN16931</c> (the minimum DATEV accepts
/// for full booking; MINIMUM/BASIC WL are intentionally not offered).</summary>
public string Profile { get; set; } = "EN16931";
/// <summary>Seller (Fuchs) master data mapped into every eRechnung (BG-4).</summary>
public ERechnungSellerSettings Seller { get; set; } = new();
/// <summary>Formal-conformance validation settings (PDF/A-3 via veraPDF, ZUGFeRD rules).</summary>
public ERechnungValidationSettings Validation { get; set; } = new();
}
/// <summary>
/// The seller (Fuchs) master data written into every eRechnung, bound from
/// <c>Fuchs:ERechnung:Seller</c>. Defaults mirror the FuchsPdf letterhead so the mapping keeps
/// working if the section is absent; override in <c>appsettings.json</c>.
/// </summary>
public sealed class ERechnungSellerSettings
{
/// <summary>Legal / trading name (BT-27).</summary>
public string Name { get; set; } = "Sebastian Fuchs GmbH & Co. KG";
/// <summary>Street and house number (BT-35).</summary>
public string Street { get; set; } = "Germaniastraße 15";
/// <summary>Post code (BT-38).</summary>
public string PostalCode { get; set; } = "40223";
/// <summary>City (BT-37).</summary>
public string City { get; set; } = "Düsseldorf";
/// <summary>ISO 3166-1 alpha-2 country code (BT-40).</summary>
public string CountryCode { get; set; } = "DE";
/// <summary>Steuernummer / local tax number (BT-32).</summary>
public string TaxNumber { get; set; } = "106/5849/2962";
/// <summary>USt-IdNr / VAT identifier (BT-31) — required by BR-CO-26.</summary>
public string VatId { get; set; } = "DE286366012";
/// <summary>Legal registration id / Handelsregisternummer (BT-30). Optional.</summary>
public string LegalRegistrationId { get; set; } = "";
/// <summary>Payment IBAN (BT-84).</summary>
public string Iban { get; set; } = "DE76300501100045014800";
/// <summary>Payment BIC (BT-86).</summary>
public string Bic { get; set; } = "DUSSDEDDXXX";
/// <summary>Contact / electronic-address e-mail (BT-34 / BT-43).</summary>
public string Email { get; set; } = "info@sanitaerfuchs.de";
/// <summary>Contact telephone (BT-42).</summary>
public string Phone { get; set; } = "0211 - 31 07 222";
}
/// <summary>
/// Settings for the external eRechnung validation service (veraPDF for PDF/A-3 and a
/// ZUGFeRD/EN 16931 validator), bound from "Fuchs:ERechnung:Validation".
/// </summary>
/// <remarks>
/// The concrete service URL/contract is provided by an online service that is not yet available;
/// this is the configurable seam so only the URL has to be supplied later. While
/// <see cref="ServiceUrl"/> is empty, verification is reported as "not configured / skipped".
/// </remarks>
public sealed class ERechnungValidationSettings
{
/// <summary>Whether to call the external validation service after producing a hybrid PDF.</summary>
public bool Enabled { get; set; }
/// <summary>Base URL of the online veraPDF/ZUGFeRD validation service (empty until provisioned).</summary>
public string ServiceUrl { get; set; } = "";
/// <summary>
/// When <see langword="true"/>, a validation error withholds the invoice output rather than
/// shipping it with findings. Defaults to <see langword="false"/> (report-only).
/// </summary>
public bool FailOnError { get; set; }
}
+126
View File
@@ -0,0 +1,126 @@
using System.Net.Http;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Fuchs.Services;
/// <summary>The outcome of an external eRechnung validation (ZUGFeRD/EN 16931 XML + PDF/A-3).</summary>
/// <param name="Configured">False when no validation service URL is set (validation skipped).</param>
/// <param name="Reached">False when the service could not be contacted / returned an error.</param>
/// <param name="XmlValid">Whether the embedded XML matched a scenario and passed its rules.</param>
/// <param name="PdfACompliant">Whether the PDF/A-3 layer passed (veraPDF).</param>
/// <param name="XmlErrorCount">Number of hard XML rule errors reported.</param>
/// <param name="ScenarioMatched">Whether the validator had a scenario for this document type.</param>
/// <param name="Summary">Short human-readable summary from the service.</param>
public sealed record ERechnungValidationResult(
bool Configured, bool Reached, bool XmlValid, bool PdfACompliant,
int XmlErrorCount, bool ScenarioMatched, string Summary)
{
/// <summary>True only when the service was reached and both the XML and the PDF/A-3 passed.</summary>
public bool IsValid => Reached && XmlValid && PdfACompliant;
/// <summary>
/// A hard defect: an unreachable service is <b>not</b> one, but a non-compliant PDF/A-3 or an
/// XML with actual rule errors is. A pure <see cref="ScenarioMatched"/>=false with zero errors
/// (e.g. an EN 16931 ZUGFeRD checked by an XRechnung-only scenario set) is <b>not</b> a hard
/// error — it is a validator scope limitation, so it never withholds the invoice.
/// </summary>
public bool HasHardError => Reached && (!PdfACompliant || XmlErrorCount > 0);
public static ERechnungValidationResult NotConfigured { get; } =
new(false, false, false, false, 0, false, "Validierungsdienst nicht konfiguriert (übersprungen).");
}
/// <summary>
/// Validates a produced ZUGFeRD/Factur-X hybrid against the external ProcessWeb eInvoice service
/// (<c>POST {ServiceUrl}/validatepdf</c>, raw <c>application/pdf</c> body — checks the embedded
/// EN 16931 XML <b>and</b> PDF/A-3 in one call). Configured via <c>Fuchs:ERechnung:Validation</c>.
/// </summary>
public interface IERechnungValidator
{
/// <summary>Whether external validation is switched on and a service URL is configured.</summary>
bool Enabled { get; }
/// <summary>Validates the hybrid PDF; never throws (failures are reported in the result).</summary>
Task<ERechnungValidationResult> ValidatePdfAsync(byte[] hybridPdf, CancellationToken ct = default);
}
/// <inheritdoc cref="IERechnungValidator"/>
public sealed class ProcessWebERechnungValidator : IERechnungValidator
{
/// <summary>Named <see cref="HttpClient"/> registered in <c>Program.cs</c>.</summary>
public const string HttpClientName = "eInvoiceValidator";
private readonly IHttpClientFactory _httpFactory;
private readonly ERechnungValidationSettings _settings;
private readonly ILogger<ProcessWebERechnungValidator> _logger;
public ProcessWebERechnungValidator(IHttpClientFactory httpFactory,
IOptions<ERechnungSettings> settings, ILogger<ProcessWebERechnungValidator> logger)
{
_httpFactory = httpFactory;
_settings = settings.Value.Validation;
_logger = logger;
}
public bool Enabled => _settings.Enabled && !string.IsNullOrWhiteSpace(_settings.ServiceUrl);
public async Task<ERechnungValidationResult> ValidatePdfAsync(byte[] hybridPdf, CancellationToken ct = default)
{
if (!Enabled) return ERechnungValidationResult.NotConfigured;
if (hybridPdf is null || hybridPdf.Length == 0)
return new ERechnungValidationResult(true, false, false, false, 0, false, "Leeres PDF nicht validiert.");
var url = _settings.ServiceUrl.TrimEnd('/') + "/validatepdf";
try
{
using var content = new ByteArrayContent(hybridPdf);
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/pdf");
using var client = _httpFactory.CreateClient(HttpClientName);
using var response = await client.PostAsync(url, content, ct);
var body = await response.Content.ReadAsStringAsync(ct);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("eRechnung validation: {Url} returned {Status}: {Body}", url, (int)response.StatusCode, Truncate(body));
return new ERechnungValidationResult(true, false, false, false, 0, false, $"HTTP {(int)response.StatusCode} vom Validierungsdienst.");
}
return Parse(body);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "eRechnung validation: could not reach {Url}", url);
return new ERechnungValidationResult(true, false, false, false, 0, false, "Validierungsdienst nicht erreichbar.");
}
}
/// <summary>Parses the <c>validatepdf</c> response (<c>{ isValid, summary, xml:{…}, pdfa:{ isCompliant } }</c>).</summary>
private static ERechnungValidationResult Parse(string json)
{
try
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
root.TryGetProperty("xml", out var xml);
bool xmlValid = xml.ValueKind == JsonValueKind.Object && GetBool(xml, "isValid");
bool scenarioMatched = xml.ValueKind == JsonValueKind.Object && GetBool(xml, "scenarioMatched");
int errorCount = xml.ValueKind == JsonValueKind.Object && xml.TryGetProperty("errorCount", out var ec)
&& ec.ValueKind == JsonValueKind.Number ? ec.GetInt32() : 0;
bool pdfa = root.TryGetProperty("pdfa", out var p) && GetBool(p, "isCompliant");
string summary = root.TryGetProperty("summary", out var s) && s.ValueKind == JsonValueKind.String
? s.GetString() ?? "" : "";
return new ERechnungValidationResult(true, true, xmlValid, pdfa, errorCount, scenarioMatched, summary);
}
catch (JsonException)
{
return new ERechnungValidationResult(true, false, false, false, 0, false, "Antwort des Validierungsdienstes nicht lesbar.");
}
}
private static bool GetBool(JsonElement obj, string name)
=> obj.TryGetProperty(name, out var v) && (v.ValueKind == JsonValueKind.True
|| (v.ValueKind == JsonValueKind.String && bool.TryParse(v.GetString(), out var b) && b));
private static string Truncate(string s) => s.Length <= 300 ? s : s[..300];
}
+23
View File
@@ -143,6 +143,29 @@ public class FuchsPdfService : IPdfService
}
}
public byte[] DocToPdfBytesRaw(Document doc)
{
var sw = Stopwatch.StartNew();
using var act = FuchsTelemetry.StartActivity("pdf.render.raw");
try
{
byte[] bytes = FuchsPdf.DocToPdfBytesRaw(doc);
sw.Stop();
FuchsTelemetry.PdfRenderDuration.Record(sw.Elapsed.TotalMilliseconds,
new KeyValuePair<string, object?>("operation", "pdf-raw"));
act?.SetTag("fuchs.pdf.bytes", bytes.Length);
_logger.LogDebug("DocToPdfBytesRaw rendered {Bytes} bytes in {Ms} ms", bytes.Length, sw.ElapsedMilliseconds);
return bytes;
}
catch (Exception ex)
{
sw.Stop();
act?.SetStatus(ActivityStatusCode.Error, ex.Message);
_logger.LogError(ex, "DocToPdfBytesRaw failed after {Ms} ms", sw.ElapsedMilliseconds);
throw;
}
}
public async Task<OCORE.pdf._pdf.ImageCollection> DocToImageCollectionAsync(Document doc)
{
var sw = Stopwatch.StartNew();
+45
View File
@@ -44,4 +44,49 @@ public interface IBlobStorageService
/// </summary>
Task<bool> ExistsAsync(string containerName, string documentId, string fileName,
CancellationToken cancellationToken = default);
/// <summary>
/// Verifies connectivity and access to the configured storage account by requesting its
/// account information. Read-only, never mutates, and never throws — any failure is reported
/// via <see cref="BlobConnectivity.Reachable"/>/<see cref="BlobConnectivity.Detail"/>. Used by
/// the Admin module's system-status view.
/// </summary>
Task<BlobConnectivity> CheckConnectivityAsync(CancellationToken cancellationToken = default);
}
/// <summary>Result of <see cref="IBlobStorageService.CheckConnectivityAsync"/> — no secrets.</summary>
public sealed class BlobConnectivity
{
/// <summary><c>Fuchs:AzureStorage:Enabled</c>.</summary>
public bool Enabled { get; init; }
/// <summary>A storage client could be constructed (connection string present + valid form).</summary>
public bool Configured { get; init; }
/// <summary>The account responded to an authenticated request.</summary>
public bool Reachable { get; init; }
/// <summary>Storage account name (never the key/connection string).</summary>
public string? AccountName { get; init; }
/// <summary>Non-sensitive detail: account kind/SKU on success, error summary on failure.</summary>
public string? Detail { get; init; }
/// <summary>
/// Per-configured-container blob (file) counts, populated when the account was reachable.
/// <c>null</c> when connectivity failed.
/// </summary>
public IReadOnlyList<BlobContainerCount>? Containers { get; init; }
}
/// <summary>Blob (file) count for one configured container.</summary>
public sealed class BlobContainerCount
{
public string Name { get; init; } = "";
/// <summary><c>false</c> when the container does not exist yet (created lazily on first upload).</summary>
public bool Exists { get; init; }
/// <summary>Number of blobs in the container; <c>-1</c> when the count could not be determined.</summary>
public int FileCount { get; init; }
}
+1 -1
View File
@@ -2,7 +2,7 @@
/// <summary>
/// Abstraction for outbound communication: email and SMS.
/// Backed by the ProcessWeb Mailer API (POST /api/mailer?fn=push_com).
/// Backed by the ProcessWeb Mailer API (POST {BaseUrl}/mailer/push_com).
/// </summary>
public interface IComService
{
+6
View File
@@ -20,6 +20,12 @@ public interface IPdfService
/// <summary>Renders a MigraDoc Document to a PDF/A byte array.</summary>
byte[] DocToPdfBytes(Document doc);
/// <summary>
/// Renders a MigraDoc Document to a plain (non-PDF/A) PDF byte array — the visual PDF used as
/// input to eRechnung (ZUGFeRD) conversion, which owns the PDF/A-3 layer itself.
/// </summary>
byte[] DocToPdfBytesRaw(Document doc);
/// <summary>Renders a MigraDoc Document to an image collection for preview.</summary>
Task<OCORE.pdf._pdf.ImageCollection> DocToImageCollectionAsync(Document doc);
+28
View File
@@ -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);
}
+259 -4
View File
@@ -1,4 +1,4 @@
using System.Globalization;
using System.Globalization;
using System.Text.RegularExpressions;
using Fuchs.intranet;
using Microsoft.Extensions.Logging;
@@ -95,7 +95,12 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
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 "address":
// Structured dialog form posts a JSON object; a plain string keeps the legacy
// free-text path working (backward compatibility).
return d.Value is JObject
? SetSendToAddress(s, d, ref oldValue, ref newValue)
: SetNewText(s, "invoiceaddress", d, ref oldValue, ref newValue);
case "title": return SetNewText(s, "invoicetitle", d, ref oldValue, ref newValue);
case "provisionperiod": return SetNewText(s, "provisionperiod", d, ref oldValue, ref newValue);
case "provisionlocation":
@@ -116,6 +121,9 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
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;
}
}
@@ -154,6 +162,26 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
private static string ContactLabel(string name, string email) =>
string.IsNullOrEmpty(name) ? email : string.IsNullOrEmpty(email) ? name : $"{name} <{email}>";
/// <summary>
/// Applies a structured recipient address: stores it as JSON inside the <c>CustomValues</c>
/// blob (interim persistence, no schema change) and composes the multi-line free-text block
/// into <c>invoiceaddress</c> so the PDF and the <c>SendToAddress</c> column are unchanged.
/// </summary>
private static bool SetSendToAddress(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
{
if (d.Value is not JObject vo) return false;
var addr = InvoiceRecipientAddress.FromJson(vo);
oldValue = Str(s.New["invoiceaddress"]);
JObject cvo = (JObject)TryParseObject(Str(s.New["CustomValues"])).DeepClone();
cvo[InvoiceRecipientAddress.CustomValuesKey] = addr.ToJson();
s.New["CustomValues"] = cvo.ToString(Newtonsoft.Json.Formatting.None);
newValue = addr.Compose();
s.New["invoiceaddress"] = newValue;
return true;
}
/// <summary>Replaces (or inserts) a whole block — the editor re-emits an edited block's line arrays as one delta.</summary>
private static bool ReplaceBlock(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
{
@@ -185,6 +213,183 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
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
@@ -247,10 +452,55 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
block_net = session.Sums.NetByBlock
},
validation = session.ValidationMessages.Select(v => new { field = v.Field, severity = v.Severity, message = v.Message }),
historyCount = session.History.Count
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>();
@@ -288,6 +538,7 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
// ── Internals ──────────────────────────────────────────────────────────────
private static void Refresh(InvoiceDraftSession session)
{
InvoiceDraftCalculator.RecomputeLineValues(session);
InvoiceDraftCalculator.RecomputeTotals(session);
InvoiceDraftCalculator.RecomputePositions(session);
InvoiceDraftCalculator.Validate(session);
@@ -364,6 +615,8 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
["InvoiceBalance"] = session.Sums.TotalGross,
["InvoiceBalance_net"] = session.Sums.TotalNet,
["CustomValues"] = Str(session.New["CustomValues"]),
["SendToAddressJson"] = InvoiceRecipientAddress.FromCustomValues(Str(session.New["CustomValues"]))?
.ToJson().ToString(Newtonsoft.Json.Formatting.None) ?? "",
["InvoiceOptions"] = BuildInvoiceOptions(session),
["DateCreated"] = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
};
@@ -385,7 +638,9 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
var tokens = new List<string>();
if (AsBool(session.Admin["p13b"])) tokens.Add("§13b");
string setmode = Str(session.Admin["setmode"]).Trim().ToLowerInvariant();
if (setmode is "itemprices" or "setonly") tokens.Add("setmode:" + setmode);
// 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);
}
+141
View File
@@ -0,0 +1,141 @@
using System.Text;
using Newtonsoft.Json.Linq;
namespace Fuchs.Services;
/// <summary>
/// Structured invoice-recipient (buyer) address held in the draft cache and persisted as JSON
/// (interim: inside the <c>CustomValues</c> blob under <c>sendToAddress</c>; a dedicated
/// <c>SendToAddressJson</c> column is planned — see ADR 0012 / the SQL plan). It replaces the
/// former free-text <c>invoiceaddress</c> as the source of truth, while a composed free-text
/// block (<see cref="Compose"/>) keeps the existing PDF/<c>SendToAddress</c> path unchanged.
/// </summary>
/// <remarks>
/// The field set targets EN 16931 / DATEV: <see cref="Name"/> (BT-44), <see cref="Contact"/>
/// (z.Hd.), <see cref="AddressLine2"/> (BT-51), <see cref="Street"/> (BT-50),
/// <see cref="PostalCode"/> (BT-53), <see cref="City"/> (BT-52), <see cref="CountryCode"/>
/// (BT-55, mandatory), <see cref="VatId"/> (BT-48, optional — empty for a private person, which
/// must stay effortless: B2C invoices need no VAT id and are still EN 16931-valid).
/// </remarks>
public sealed class InvoiceRecipientAddress
{
/// <summary>The JSON key under which the structured address rides inside <c>CustomValues</c>.</summary>
public const string CustomValuesKey = "sendToAddress";
public string Name { get; set; } = "";
public string Contact { get; set; } = "";
public string AddressLine2 { get; set; } = "";
public string Street { get; set; } = "";
public string PostalCode { get; set; } = "";
public string City { get; set; } = "";
/// <summary>ISO 3166-1 alpha-2 country code (BT-55). Defaults to <c>DE</c>.</summary>
public string CountryCode { get; set; } = "DE";
/// <summary>Buyer VAT identifier (BT-48). Empty for a private person (B2C).</summary>
public string VatId { get; set; } = "";
/// <summary>
/// Leitweg-ID / buyer reference (BT-10). When set, the invoice is treated as B2G and emitted
/// as <b>XRechnung</b> (the mandatory form for German public authorities); empty for B2B/B2C.
/// </summary>
public string LeitwegId { get; set; } = "";
/// <summary>True when no VAT id is set — a private person / B2C recipient.</summary>
public bool IsPrivatePerson => string.IsNullOrWhiteSpace(VatId);
/// <summary>True when a Leitweg-ID is present — a B2G recipient (→ XRechnung).</summary>
public bool IsPublicAuthority => !string.IsNullOrWhiteSpace(LeitwegId);
/// <summary>Reads a structured address from a JSON object (tolerant of missing keys).</summary>
public static InvoiceRecipientAddress FromJson(JObject? o)
{
o ??= new JObject();
return new InvoiceRecipientAddress
{
Name = S(o, "name"),
Contact = S(o, "contact"),
AddressLine2 = S(o, "line2", "addressLine2"),
Street = S(o, "street"),
PostalCode = S(o, "postalCode", "zip", "plz"),
City = S(o, "city", "ort"),
CountryCode = S(o, "countryCode", "country").ToUpperInvariant() is { Length: > 0 } cc ? cc : "DE",
VatId = S(o, "vatId", "ustid"),
LeitwegId = S(o, "leitwegId", "leitweg", "buyerReference"),
};
}
/// <summary>Parses the structured address out of a <c>CustomValues</c> JSON blob, if present.</summary>
public static InvoiceRecipientAddress? FromCustomValues(string? customValuesJson)
{
if (string.IsNullOrWhiteSpace(customValuesJson)) return null;
try
{
var cv = JObject.Parse(customValuesJson);
return cv[CustomValuesKey] is JObject a ? FromJson(a) : null;
}
catch { return null; }
}
/// <summary>Serialises to a JSON object for the cache / <c>CustomValues</c> blob.</summary>
public JObject ToJson() => new()
{
["name"] = Name,
["contact"] = Contact,
["line2"] = AddressLine2,
["street"] = Street,
["postalCode"] = PostalCode,
["city"] = City,
["countryCode"] = CountryCode,
["vatId"] = VatId,
["leitwegId"] = LeitwegId,
};
/// <summary>
/// Composes the multi-line free-text postal block (for the PDF and the legacy
/// <c>SendToAddress</c> column). The VAT id is intentionally excluded — it is not part of the
/// postal address, only of the structured eRechnung data (BT-48). Foreign countries append
/// the country code line; domestic (DE) omits it, matching the current letter layout.
/// </summary>
public string Compose()
{
var sb = new StringBuilder();
void Line(string? v) { if (!string.IsNullOrWhiteSpace(v)) sb.Append(sb.Length > 0 ? "\n" : "").Append(v!.Trim()); }
Line(Name);
if (!string.IsNullOrWhiteSpace(Contact)) Line($"z.Hd. {Contact.Trim()}");
Line(AddressLine2);
Line(Street);
string cityLine = $"{PostalCode} {City}".Trim();
Line(cityLine);
if (!string.IsNullOrWhiteSpace(CountryCode) && !CountryCode.Equals("DE", StringComparison.OrdinalIgnoreCase))
Line(CountryCode.ToUpperInvariant());
return sb.ToString();
}
/// <summary>
/// The EN 16931 fields still missing for a formally valid buyer (BG-7). Buyer name (BT-44)
/// and country code (BT-55) are mandatory; a postal address needs a city or post code. VAT id
/// is <b>not</b> required (a private person is valid without one). An empty result means the
/// recipient is EN 16931 / DATEV conformant — the editor surfaces this as a hint.
/// </summary>
public IReadOnlyList<string> MissingForEn16931()
{
var missing = new List<string>();
if (string.IsNullOrWhiteSpace(Name)) missing.Add("Name");
if (string.IsNullOrWhiteSpace(CountryCode)) missing.Add("Land");
if (string.IsNullOrWhiteSpace(City) && string.IsNullOrWhiteSpace(PostalCode)) missing.Add("Ort/PLZ");
return missing;
}
/// <summary>True when the recipient satisfies the EN 16931 mandatory buyer fields.</summary>
public bool IsEn16931Conformant => MissingForEn16931().Count == 0;
private static string S(JObject o, params string[] keys)
{
foreach (var k in keys)
if (o[k] is { } t && t.Type != JTokenType.Null)
return t.Type == JTokenType.String ? t.Value<string>()?.Trim() ?? "" : t.ToString().Trim();
return "";
}
}
+18 -5
View File
@@ -26,15 +26,17 @@ public class InvoiceService : IInvoiceService
private readonly IPdfService _pdf;
private readonly IBlobStorageService _blobStorage;
private readonly IEventService _events;
private readonly IERechnungService _erechnung;
private readonly ILogger<InvoiceService> _logger;
public InvoiceService(Fuchs_intranet intranet, IPdfService pdf, IBlobStorageService blobStorage,
IEventService events, ILogger<InvoiceService> logger)
IEventService events, IERechnungService erechnung, ILogger<InvoiceService> logger)
{
_intranet = intranet;
_pdf = pdf;
_blobStorage = blobStorage;
_events = events;
_erechnung = erechnung;
_logger = logger;
}
@@ -133,13 +135,13 @@ public class InvoiceService : IInvoiceService
var sqlParts = new List<string> { "DECLARE @Id varchar(10);" };
if (!change)
{
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice] @InvoiceType, @InvoiceTitle, @InvoiceBalance, @InvoiceBalance_net, @InvoiceVAT_net1, @InvoiceVAT_1, @PaymentTerm, @CustomerId, @SendToAddress, @SendToEmail, @ProvisionPeriod, @CustomValues, @authuser, @Id OUTPUT;");
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice] @InvoiceType, @InvoiceTitle, @InvoiceBalance, @InvoiceBalance_net, @InvoiceVAT_net1, @InvoiceVAT_1, @PaymentTerm, @CustomerId, @SendToAddress, @SendToEmail, @ProvisionPeriod, @CustomValues, @authuser, @Id OUTPUT, @SendToAddressJson;");
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice_Details] @Id, @InvoiceService_net, @InvoiceService_VAT, @InvoiceOptions, @authuser;");
}
else
{
pl.Add(SQL_VarChar("@InvId", invId));
sqlParts.Add("EXECUTE [dbo].[fds__setInvoice] @InvId, @InvoiceType, @InvoiceTitle, @InvoiceBalance, @InvoiceBalance_net, @InvoiceVAT_net1, @InvoiceVAT_1, @PaymentTerm, @CustomerId, @SendToAddress, @SendToEmail, @ProvisionPeriod, @CustomValues, @authuser, @Id OUTPUT;");
sqlParts.Add("EXECUTE [dbo].[fds__setInvoice] @InvId, @InvoiceType, @InvoiceTitle, @InvoiceBalance, @InvoiceBalance_net, @InvoiceVAT_net1, @InvoiceVAT_1, @PaymentTerm, @CustomerId, @SendToAddress, @SendToEmail, @ProvisionPeriod, @CustomValues, @authuser, @Id OUTPUT, @SendToAddressJson;");
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice_Details] @Id, @InvoiceService_net, @InvoiceService_VAT, @InvoiceOptions, @authuser;");
}
if (invoice.RawProvisionLocation.Length > 0)
@@ -282,8 +284,19 @@ public class InvoiceService : IInvoiceService
return doc;
}
public Task<byte[]> RenderInvoicePdfBytesAsync(FdsInvoiceData invoice, bool draft)
=> Task.FromResult(_pdf.DocToPdfBytes(GenerateInvoicePdf(invoice, draft)));
public async Task<byte[]> RenderInvoicePdfBytesAsync(FdsInvoiceData invoice, bool draft)
{
var doc = GenerateInvoicePdf(invoice, draft);
// Finalized invoices are emitted as a ZUGFeRD/Factur-X hybrid when eRechnung is enabled:
// eRechnungLib embeds the CII XML into the render-only visual PDF and owns the single
// PDF/A-3 layer (ADR 0012). Drafts/previews keep the plain PDF/A. Any failure falls back.
if (!draft && _erechnung.Enabled)
{
var hybrid = await _erechnung.TryBuildHybridPdfAsync(invoice, _pdf.DocToPdfBytesRaw(doc));
if (hybrid is { Length: > 0 }) return hybrid;
}
return _pdf.DocToPdfBytes(doc);
}
public async Task<byte[]> StoreInvoiceDocumentFileAsync(FdsInvoiceData invoice, bool draft,
string userAccountId, DatabaseSecurity dbSec)
@@ -1,7 +1,7 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace fds;
namespace Fuchs.Services;
/// <summary>
/// Defines a named job with its own execution schedule for use with <see cref="PeriodicHostedService"/>.
@@ -13,6 +13,8 @@ public sealed record PeriodicJobDefinition(
/// <summary>
/// A <see cref="BackgroundService"/> that runs multiple independent jobs, each on its own <see cref="PeriodicTimer"/>.
/// Hosts the MFR ERP sync that formerly ran as the standalone <c>Fuchs_DataService</c> Windows Service
/// (Topshelf); it is now registered in-process from <c>Program.cs</c>, gated by <c>Fds:SyncEnabled</c>.
/// </summary>
public sealed class PeriodicHostedService : BackgroundService
{
+61 -26
View File
@@ -14,7 +14,7 @@ namespace Fuchs.Services;
/// <summary>
/// Outbound communication service backed by the ProcessWeb Mailer API
/// (POST https://api.processweb.de/api/mailer?fn=push_com).
/// (POST https://api.processweb.de/mailer/push_com).
/// When <c>ProcessWebComSettings.Enabled</c> is <c>false</c> the service
/// only logs the intended communication without calling the API.
/// </summary>
@@ -71,6 +71,9 @@ public class ProcessWebComService : IComService
_logger.LogWarning("SendEmailAsync: invalid email address '{Email}' for ref {Reference}", email, reference);
FuchsTelemetry.EmailsFailed.Add(1, new KeyValuePair<string, object?>("reason", "invalid-email"));
act?.SetStatus(ActivityStatusCode.Error, "invalid-email");
// Every attempt is audit-logged, including this early rejection (previously it returned unlogged).
await WriteAuditLogAsync(reference, "", "", default, false,
[$"Invalid recipient email address: '{email}'"]);
return false;
}
@@ -105,10 +108,9 @@ public class ProcessWebComService : IComService
})
.ToArray();
var payload = new
var communication = new
{
comType = "email",
recipient = email,
to = email,
subject,
body,
attachments = attachmentPayload
@@ -117,7 +119,7 @@ public class ProcessWebComService : IComService
_logger.LogDebug("SendEmailAsync ref={Reference} to={Email} attachments={Count}",
reference, email, attachmentPayload.Length);
var (ok, responseBody) = await PostToApiAsync("push_com", payload);
var (ok, status, responseBody) = await PostToApiAsync(reference, "e", communication);
if (ok)
{
success = true;
@@ -125,13 +127,17 @@ public class ProcessWebComService : IComService
}
else
{
errors.Add($"API error: {responseBody}");
_logger.LogWarning("SendEmailAsync API error for {Reference}: {Body}", reference, responseBody);
// Persist the mailer's raw response so an unsuccessful send is diagnosable from the log.
errors.Add($"API error {(int)status} ({status}): {responseBody}");
_logger.LogWarning("SendEmailAsync API error for {Reference}: {Status} {Body}",
reference, (int)status, responseBody);
}
}
catch (Exception ex)
{
errors.Add("Beim Versenden ist ein Fehler aufgetreten.");
// Capture the exception detail in the audit log (not just a generic message) so a
// transport/serialization failure is diagnosable after the fact.
errors.Add($"Exception while sending: {ex.GetType().Name}: {ex.Message}");
act?.SetStatus(ActivityStatusCode.Error, ex.Message);
_logger.LogError(ex, "SendEmailAsync failed for {Reference}", reference);
}
@@ -166,14 +172,13 @@ public class ProcessWebComService : IComService
try
{
var payload = new
var communication = new
{
comType = "sms",
recipient = mobile,
to = mobile,
body = message
};
var (ok, responseBody) = await PostToApiAsync("push_com", payload);
var (ok, status, responseBody) = await PostToApiAsync(mobile, "s", communication);
if (ok)
{
FuchsTelemetry.SmsSent.Add(1);
@@ -181,7 +186,7 @@ public class ProcessWebComService : IComService
return true;
}
_logger.LogWarning("SendSmsAsync API error for {Mobile}: {Body}", mobile, responseBody);
_logger.LogWarning("SendSmsAsync API error for {Mobile}: {Status} {Body}", mobile, (int)status, responseBody);
return false;
}
catch (Exception ex)
@@ -193,9 +198,19 @@ public class ProcessWebComService : IComService
// ── Private helpers ────────────────────────────────────────────────────────
private async Task<(bool ok, string body)> PostToApiAsync(string fn, object payload)
private async Task<(bool ok, System.Net.HttpStatusCode status, string body)> PostToApiAsync(string reference, string comtype, object communication)
{
var client = _httpClientFactory.CreateClient("ProcessWebMailer");
// The Mailer API expects a fixed envelope: { serverid, comtype, communication, reference }.
// "communication" carries the actual channel-specific payload (to/subject/body/attachments).
var payload = new
{
serverid = _settings.ServerId,
comtype,
communication,
reference
};
var json = JsonConvert.SerializeObject(payload);
var content = new StringContent(json, Encoding.UTF8, "application/json");
@@ -204,25 +219,23 @@ public class ProcessWebComService : IComService
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Basic", credentials);
var response = await client.PostAsync($"{_settings.BaseUrl}/api/mailer?fn={fn}", content);
var response = await client.PostAsync($"{_settings.BaseUrl}/mailer/push_com", content);
string responseBody = await response.Content.ReadAsStringAsync();
return (response.IsSuccessStatusCode, responseBody);
return (response.IsSuccessStatusCode, response.StatusCode, responseBody);
}
private async Task WriteAuditLogAsync(string reference, string guid, string config,
/// <summary>
/// Persists one communication attempt to <c>[dbo].[fds__logEmail]</c>. Called on every path —
/// success, API error (with the mailer's raw response in <paramref name="errors"/>), exceptions,
/// invalid address and the disabled-service case — so the email log is always complete. Wrapped
/// in try/catch so a logging failure never breaks (or masks) the send itself. Overridable for tests.
/// </summary>
protected virtual async Task WriteAuditLogAsync(string reference, string guid, string config,
DateTime sent, bool success, IEnumerable<string> errors)
{
try
{
var pl = new List<SqlParameter>
{
SQL_VarChar("@Ref", reference),
SQL_VarChar("@guid", guid),
SQL_DateTime("@DateSent", sent == default ? DBNull.Value : (object)sent),
SQL_NVarChar("@config", config, dbNull_IfEmpty: true),
SQL_Bit("@success", success),
SQL_NVarChar("@log", JsonConvert.SerializeObject(errors.ToList()))
};
var pl = BuildAuditLogParameters(reference, guid, config, sent, success, errors);
await setSQLValue_async(
"EXECUTE [dbo].[fds__logEmail] @Ref, @guid, @DateSent, @config, @success, @log;",
_intranet.Intranet__SQLConnectionString, pl,
@@ -234,6 +247,28 @@ public class ProcessWebComService : IComService
}
}
/// <summary>
/// Builds the parameter list for <c>[dbo].[fds__logEmail]</c>. <c>fds__emaillog</c> declares
/// <c>config</c> and <c>DateSent</c> as NOT NULL, so neither may be sent as NULL:
/// <list type="bullet">
/// <item><c>@config</c> is stored as an empty string (never NULL) when unset.</item>
/// <item><c>@DateSent</c> falls back to the current (attempt) time when the send did not
/// complete — the <c>success</c> flag distinguishes a real send-time from a failed attempt.</item>
/// </list>
/// Pure/side-effect-free so the NOT NULL contract can be unit-tested without a database.
/// </summary>
internal static List<SqlParameter> BuildAuditLogParameters(string reference, string guid,
string config, DateTime sent, bool success, IEnumerable<string> errors) =>
new()
{
SQL_VarChar("@Ref", reference),
SQL_VarChar("@guid", guid ?? ""),
SQL_DateTime("@DateSent", sent == default ? DateTime.UtcNow : sent),
SQL_NVarChar("@config", config ?? ""),
SQL_Bit("@success", success),
SQL_NVarChar("@log", JsonConvert.SerializeObject(errors.ToList()))
};
private string BuildSignature()
{
string sigPath = Path.Combine(AppContext.BaseDirectory,
+3
View File
@@ -14,6 +14,9 @@ public class ProcessWebComSettings
/// <summary>API token used for HTTP Basic authentication.</summary>
public string Token { get; set; } = "";
/// <summary>Server identifier assigned by ProcessWeb, sent as "serverid" in every push_com call.</summary>
public string ServerId { get; set; } = "";
/// <summary>
/// When <c>false</c> (default) the service is disabled and only logs the
/// intended communication without actually calling the API.
+18 -2
View File
@@ -1,4 +1,4 @@
using System.Globalization;
using System.Globalization;
using Fuchs.intranet;
using Microsoft.Extensions.Logging;
using MigraDoc.DocumentObjectModel;
@@ -159,10 +159,24 @@ public sealed class ReminderDraftEditService : IReminderDraftService
amount_open = session.Sums.AmountOpen
},
validation = session.ValidationMessages.Select(v => new { field = v.Field, severity = v.Severity, message = v.Message }),
historyCount = session.History.Count
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>();
@@ -250,6 +264,8 @@ public sealed class ReminderDraftEditService : IReminderDraftService
["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,
+46
View File
@@ -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 -1
View File
@@ -20,17 +20,20 @@ public class StartupSelfTestService : BackgroundService
private readonly IConfiguration _configuration;
private readonly StartupSelfTestSettings _settings;
private readonly ILogger<StartupSelfTestService> _logger;
private readonly StartupCheckReporter? _reporter;
public StartupSelfTestService(
IServiceProvider serviceProvider,
IConfiguration configuration,
IOptions<StartupSelfTestSettings> settings,
ILogger<StartupSelfTestService> logger)
ILogger<StartupSelfTestService> logger,
StartupCheckReporter? reporter = null)
{
_serviceProvider = serviceProvider;
_configuration = configuration;
_settings = settings.Value;
_logger = logger;
_reporter = reporter;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -41,6 +44,7 @@ public class StartupSelfTestService : BackgroundService
if (!_settings.Enabled)
{
_logger.LogDebug("StartupSelfTestService skipped - Fuchs:StartupChecks:Enabled is false.");
_reporter?.Set(new StartupCheckReport { Ran = false, MachineName = Environment.MachineName });
return;
}
@@ -124,6 +128,21 @@ public class StartupSelfTestService : BackgroundService
mfrOk,
mailerOk,
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)
{
+132
View File
@@ -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; }
}
+415
View File
@@ -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,
};
}
}
+13
View File
@@ -19,6 +19,10 @@
string appName = ViewData["AppName"] as string ?? "Fuchs Intranet";
string fullName = ViewData["FullName"] as string ?? "";
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;
if (HostEnvironment.IsDevelopment())
{
@@ -56,6 +60,15 @@
@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">
$ocms.auth = @Html.Raw(JsonConvert.SerializeObject(authinfo));
</script>
+5 -2
View File
@@ -30,15 +30,18 @@
"Enabled": true
},
"Email": {
"OverrideRecipient": "service@emails.processweb.de"
"OverrideRecipient": "info@processweb.de"
},
"AzureStorage": {
"Enabled": false,
"Enabled": true,
"InvoiceContainer": "dev-fuchs-invoices",
"ReminderContainer": "dev-fuchs-reminders"
}
},
"Fds": {
"SyncEnabled": false,
"ExecutionFrequency_Minutes": 15,
"DebugDetails": true,
"MFR_host": "portal.mobilefieldreport.com",
"MFR_UserName": "system@sebastian-fuchs---bad-und-heizung-gmbh-und-co-kg.com",
"MFR_Password": "0oT4G3H2"
+33 -5
View File
@@ -32,6 +32,7 @@
"AzureBlobStorage_ConnectionString": "MANAGED_BY_KEYVAULT"
},
"Fuchs": {
"IsTestDeployment": true,
"ocms_guid": "00094b8f-a822-4e9c-b627-87802f93fca8",
"ocms_valid_locales_csv": "de,en",
"ocms_default_locale": "de",
@@ -44,10 +45,11 @@
"BaseUrl": "https://api.processweb.de",
"AccountId": "82d87114-c8c3-4d33-95e5-4c781a9229ab",
"Token": "MANAGED_BY_KEYVAULT",
"Enabled": false
"ServerId": "87AAB12E-29E0-47AB-8DD1-5F5D49B4AC2E",
"Enabled": true
},
"Email": {
"OverrideRecipient": ""
"OverrideRecipient": "info@processweb.de"
},
"StartupChecks": {
"Enabled": false,
@@ -59,17 +61,43 @@
"StartupEmailRecipientName": "Startup Monitor"
},
"AzureStorage": {
"Enabled": false,
"InvoiceContainer": "fuchs-invoices",
"ReminderContainer": "fuchs-reminders",
"Enabled": true,
"InvoiceContainer": "dev-fuchs-invoices",
"ReminderContainer": "dev-fuchs-reminders",
"MetadataFields": [ "Id", "Version", "InvoiceId", "InvoiceTitle", "InvId", "DocumentName", "file_guid" ]
},
"Telemetry": {
"Enabled": true,
"OtlpEndpoint": ""
},
"ERechnung": {
"Enabled": false,
"Profile": "EN16931",
"Seller": {
"Name": "Sebastian Fuchs GmbH & Co. KG",
"Street": "Germaniastraße 15",
"PostalCode": "40223",
"City": "Düsseldorf",
"CountryCode": "DE",
"TaxNumber": "106/5849/2962",
"VatId": "DE286366012",
"LegalRegistrationId": "",
"Iban": "DE76300501100045014800",
"Bic": "DUSSDEDDXXX",
"Email": "info@sanitaerfuchs.de",
"Phone": "0211 - 31 07 222"
},
"Validation": {
"Enabled": true,
"ServiceUrl": "https://api.processweb.de/api/eInvoice",
"FailOnError": false
}
}
},
"Fds": {
"SyncEnabled": true,
"ExecutionFrequency_Minutes": 15,
"DebugDetails": false,
"MFR_host": "portal.mobilefieldreport.com",
"MFR_UserName": "MANAGED_BY_KEYVAULT",
"MFR_Password": "MANAGED_BY_KEYVAULT"
+19
View File
@@ -151,5 +151,24 @@
"js/intranet/modules/fis.bam.scss"
],
"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 }
}
]
BIN
View File
Binary file not shown.
+35 -2
View File
@@ -1,4 +1,4 @@
using System.Globalization;
using System.Globalization;
using Microsoft.Data.SqlClient;
using Newtonsoft.Json.Linq;
using static OCORE.OCORE_dictionaries;
@@ -83,6 +83,34 @@ public class FdsInvoiceData
}
}
/// <summary>
/// Structured recipient (buyer) address for eRechnung (EN 16931) mapping, parsed from the
/// <c>CustomValues</c> JSON (interim persistence — see ADR 0012). Null for invoices that
/// predate structured capture; callers fall back to the free-text <c>SendToAddress</c> block.
/// </summary>
public Fuchs.Services.InvoiceRecipientAddress? RecipientAddress
{
get
{
// Prefer the dedicated SendToAddressJson column; fall back to the CustomValues blob
// (interim persistence and rows written before the column existed).
string col = InvoiceRegistration?.getString("SendToAddressJson") ?? "";
if (col.Length > 0)
{
try { return Fuchs.Services.InvoiceRecipientAddress.FromJson(JObject.Parse(col)); }
catch { /* fall through to CustomValues */ }
}
return Fuchs.Services.InvoiceRecipientAddress.FromCustomValues(
InvoiceRegistration?.getString("CustomValues") is { Length: > 0 } cv ? cv : RawCustomValues);
}
}
/// <summary>The structured recipient address serialised for the <c>SendToAddressJson</c> column
/// (from the posted CustomValues blob), or empty when the invoice has no structured address.</summary>
private string SendToAddressJsonForPersist() =>
Fuchs.Services.InvoiceRecipientAddress.FromCustomValues(RawCustomValues)?.ToJson()
.ToString(Newtonsoft.Json.Formatting.None) ?? "";
/// <summary>VAT rows keyed by percentage string (e.g. "19"), from InvoiceRegistration.</summary>
public Dictionary<string, Dictionary<string, object?>> VatRows
{
@@ -153,6 +181,7 @@ public class FdsInvoiceData
SQL_NVarChar("@SendToEmail", RawInvoiceEmail),
SQL_NVarChar("@ProvisionPeriod", RawProvisionPeriod, dbNull_IfEmpty: true),
SQL_NVarChar("@CustomValues", RawCustomValues, dbNull_IfEmpty: true),
SQL_NVarChar("@SendToAddressJson", SendToAddressJsonForPersist(), dbNull_IfEmpty: true),
SQL_Float("@InvoiceService_net", stringvalue: Sms?.nz("tscn") ?? "0"),
SQL_Float("@InvoiceService_VAT", stringvalue: Sms?.nz("tscvat") ?? "0"),
SQL_VarChar("@InvoiceOptions", BuildInvoiceOptions(), dbNull_IfEmpty: true)
@@ -171,7 +200,11 @@ public class FdsInvoiceData
var tokens = new List<string>();
if (Admin?.no("p13b", false) is true) tokens.Add("§13b");
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);
}
+264 -171
View File
@@ -112,21 +112,40 @@ public static class FuchsPdf
"Kreissparkasse D\u00fcsseldorf", "IBAN: DE52 3015 0200 0002 0914 78", "BIC: WELADED1KSD",
"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()
{
["f"] = new[] {
"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,",
"trotz unserer Ersten Mahnung haben wir bis heute noch keinen Zahlungseingang feststellen k\u00f6nnen. " +
"Wir bitten Sie, den offenen Betrag inkl. der Mahnzinsen und Mahnkosten umgehend auf unser Konto zu \u00fcberweisen."
"nun m\u00fcssen wir Sie noch einmal anschreiben.",
"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 ───────────────────────────────────────────────
public static string Currency(object? input, string returnobject = "?")
{
@@ -149,6 +168,9 @@ public static class FuchsPdf
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", ""),
@@ -156,7 +178,8 @@ public static class FuchsPdf
Qty = i.nz("qty", ""),
PriceNet = price,
TotalNet = total,
ShowPrice = !isText,
ShowPrice = !isText && hasPrice,
Numbered = !isText, // numbered like the editor: every line except free-text/heading, even price-blanked set members
IsSetHeader = type == "set"
};
}
@@ -529,7 +552,7 @@ public static class FuchsPdf
ParagraphFormat ClonePf()
{
var pf = doc.Styles["Normal"]!.ParagraphFormat.Clone();
pf.Borders.Distance = 3;
pf.Borders.Distance = 0;
pf.Alignment = ParagraphAlignment.Left;
return pf;
}
@@ -557,145 +580,21 @@ public static class FuchsPdf
AddStyle(doc, "InvoiceNotes_ucb", "InvoiceNotes", s => s.Font = new Font(tgtFont, 9) { Bold = true });
}
// ── ApplyInvoice ──────────────────────────────────────────────────────────
public static void ApplyInvoice(Document doc, FdsTextBlocks tb, FdsInvoiceData inv, bool draft = false)
/// <summary>
/// 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;
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).
// Per the product decision the PDF must mirror the online editor exactly: each section
// prints its heading, every position shows its own price, and positions are numbered the
// same way the editor numbers them (every line except free-text/heading lines, including a
// set header). Set-display collapsing is honoured only for the explicit SetOnly mode; every
// other mode renders the items flat, so nothing is silently blanked or renumbered.
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);
}
var lines = setMode == SetDisplayMode.SetOnly && InvoiceSetPricing.ContainsSets(block.Items)
? InvoiceSetPricing.Build(block.Items, SetDisplayMode.SetOnly) // only this mode collapses members
: block.Items.Select(MapItemToLine).ToList(); // flat: faithful mirror of the editor
foreach (var line in lines)
{
bool numbered = line.IsSetHeader || line.ShowPrice; // free-text/heading lines carry no number
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) ──────────
var reg = inv.InvoiceRegistration;
var notes = new List<PdfNote>();
void Note(string text, string style = "InvoiceNotes")
{
if (string.IsNullOrEmpty(text)) return;
var np = sec.AddParagraph(); np.Style = style; np.AddText(text);
if (!string.IsNullOrEmpty(text)) notes.Add(new PdfNote(text, style));
}
var reg = inv.InvoiceRegistration;
ParseDec(reg?.getItem("InvoiceService"), out decimal invService);
string serviceGross = Currency(reg?.getItem("InvoiceService"));
@@ -772,8 +671,182 @@ public static class FuchsPdf
"die Umwelt zu schützen. Deshalb konzentrieren wir uns auf die Aufforstung von Flächen.");
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)
ParseDec(reg?.getItem("InvoiceBalance"), out decimal payAmount);
ParseDec(inv.InvoiceRegistration?.getItem("InvoiceBalance"), out decimal payAmount);
if (!inv.IsDraft && payAmount > 0 && !string.IsNullOrWhiteSpace(inv.InvoiceId))
AddGirocode(sec, payAmount, $"{inv.InvoiceTitle.ne("Rechnung")} {inv.InvoiceId}");
}
@@ -795,31 +868,37 @@ public static class FuchsPdf
p.AddText(rem.ReminderTitle.ne("Zahlungserinnerung"));
}
var (introLines, closingLines) = BuildReminderNotes(tb, rem);
// Opening text
if (tb.ReminderTexts_before.TryGetValue(rtype, out var intro))
if (introLines.Length > 0)
{
var p = sec.AddParagraph(); p.Style = "BodyText";
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();
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;
tbl.Format.SpaceBefore = mm(4);
double pageW = sec.PageSetup.PageWidth.Centimeter
- sec.PageSetup.LeftMargin.Centimeter
- sec.PageSetup.RightMargin.Centimeter;
tbl.AddColumn(cm(pageW * 0.20));
tbl.AddColumn(cm(pageW * 0.15));
tbl.AddColumn(cm(pageW * 0.45));
tbl.AddColumn(cm(pageW * 0.20));
tbl.AddColumn(cm(pageW * 0.20));
tbl.AddColumn(cm(pageW * 0.22));
tbl.AddColumn(cm(pageW * 0.21));
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();
for (int i = 0; i < headers.Length; i++)
{
@@ -828,33 +907,39 @@ public static class FuchsPdf
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);
ParseDec(rem.ReminderRegistration?.getItem("amount_open"), out decimal openTotal);
string invoiceDate = rem.ReminderRegistration?.getString("InvoiceDate") is { Length: > 0 } d
&& DateTime.TryParse(d, out var dd) ? dd.ToString("dd.MM.yy") : "";
var row = tbl.AddRow(); row.HeightRule = RowHeightRule.Auto;
row.Cells[0].AddParagraph(itm.nz("InvoiceDate", ""));
row.Cells[1].AddParagraph(itm.nz("DocumentName", "").ne(itm.nz("InvoiceTitle", "")));
row.Cells[2].AddParagraph(Currency(itm.no("InvoiceBalance", 0)));
row.Cells[3].AddParagraph(Currency(itm.no("amount_open", 0)));
row.Cells[2].Format.Alignment = ParagraphAlignment.Right;
row.Cells[3].Format.Alignment = ParagraphAlignment.Right;
}
row.Cells[0].AddParagraph(rem.InvoiceId);
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
ParseDec(rem.ReminderRegistration?.getItem("amount_open"), out decimal openTotal);
var sumRow = tbl.AddRow(); sumRow.Shading = LightShading();
sumRow.Borders.Top.Width = 0.5;
sumRow.Cells[2].AddParagraph().AddFormattedText("Offener Betrag:", TextFormat.Bold);
var sv = sumRow.Cells[3].AddParagraph();
sumRow.Cells[3].AddParagraph().AddFormattedText("Offener Betrag:", TextFormat.Bold);
var sv = sumRow.Cells[4].AddParagraph();
sv.AddFormattedText(Currency(openTotal), TextFormat.Bold);
sumRow.Cells[3].Format.Alignment = ParagraphAlignment.Right;
sumRow.Cells[4].Format.Alignment = ParagraphAlignment.Right;
// Closing text + QR code
if (closingLines.Length > 0)
{
var closing = sec.AddParagraph(); closing.Style = "BodyText";
closing.Format.SpaceBefore = cm(0.8);
string terms = TranslatePaymentTerm(rem.ReminderRegistration?.getString("PaymentTerm") ?? "");
closing.AddText(
$"Bitte \u00fcberweisen 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\u00fcsseldorf)");
for (int i = 0; i < closingLines.Length; i++)
{
if (i > 0) closing.AddLineBreak();
closing.AddText(closingLines[i]);
}
}
// GiroCode payment QR (only on finalized reminders with a positive open amount)
if (!rem.IsDraft && openTotal > 0 && !string.IsNullOrWhiteSpace(rem.InvoiceId))
@@ -894,18 +979,26 @@ public static class FuchsPdf
}
}
/// <summary>Renders a MigraDoc Document to a PDF/A byte array.</summary>
public static byte[] DocToPdfBytes(Document doc)
/// <summary>
/// Renders a MigraDoc Document to a plain (non-PDF/A) PDF byte array. Fonts are embedded via
/// the <c>OCOREFontResolver</c>, but no PDF/A post-processing is applied. This is the visual
/// PDF fed into <c>eRechnungLib.ToZugferd(...)</c>, which owns the single PDF/A-3 conversion
/// for eRechnung output (avoiding a conflicting second PDF/A layer from Spire). See ADR 0005.
/// </summary>
public static byte[] DocToPdfBytesRaw(Document doc)
{
EnsureFontResolver();
var renderer = new PdfDocumentRenderer() { Document = doc };
renderer.RenderDocument();
using var ms = new MemoryStream();
renderer.PdfDocument.Save(ms, closeStream: false);
ms.Position = 0;
return OCORE.pdf._pdf.pdfAFileContent(ms.ToArray());
return ms.ToArray();
}
/// <summary>Renders a MigraDoc Document to a PDF/A byte array (Spire post-processing).</summary>
public static byte[] DocToPdfBytes(Document doc)
=> OCORE.pdf._pdf.pdfAFileContent(DocToPdfBytesRaw(doc));
/// <summary>Renders a MigraDoc Document to an ImageCollection for preview.</summary>
public static async Task<OCORE.pdf._pdf.ImageCollection> DocToImageCollection(Document doc)
{
+40 -1
View File
@@ -1,4 +1,4 @@
using System.Globalization;
using System.Globalization;
using Newtonsoft.Json.Linq;
namespace Fuchs.intranet;
@@ -16,6 +16,45 @@ namespace Fuchs.intranet;
/// </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).
+53 -23
View File
@@ -1,4 +1,4 @@
using static OCORE.commons;
using static OCORE.commons;
using static OCORE.OCORE_dictionaries;
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>
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>
SetOnly
}
@@ -22,6 +20,9 @@ public enum SetDisplayMode
/// <summary>A resolved invoice display line (after applying the set display mode).</summary>
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 Desc { get; init; } = "";
public string Qty { get; init; } = "1";
@@ -29,6 +30,14 @@ public sealed class InvoiceSetLine
public decimal TotalNet { get; init; }
/// <summary>When false, the price/total cells are rendered blank (e.g. set members in SetPrice mode).</summary>
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>
public bool IsSetHeader { get; init; }
}
@@ -40,15 +49,22 @@ public sealed class InvoiceSetLine
/// 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
/// lines, so switching modes is purely presentational and never changes the
/// invoice sum — set price always equals the sum of its members.
/// lines, so calling <see cref="Build"/> is a pure, non-mutating transformation
/// 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>
public static class InvoiceSetPricing
{
public static SetDisplayMode ParseMode(string? raw) =>
(raw ?? "").Trim().ToLowerInvariant() switch
{
"itemprices" or "items" or "item" => SetDisplayMode.ItemPrices,
"setonly" or "set_only" => SetDisplayMode.SetOnly,
_ => SetDisplayMode.SetPrice
};
@@ -68,9 +84,21 @@ public static class InvoiceSetPricing
public static bool ContainsSets(IEnumerable<Dictionary<string, object?>> items) =>
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>
/// 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>
public static List<InvoiceSetLine> Build(IReadOnlyList<Dictionary<string, object?>> items, SetDisplayMode mode)
{
@@ -89,20 +117,27 @@ public static class InvoiceSetPricing
{
string setId = HeaderIdOf(item);
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)
{
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));
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:
result.Add(HeaderLine(item, setTot, showPrice: true));
result.Add(HeaderLine(item, headerOwnTotal, showPrice: true));
break;
}
}
@@ -145,23 +180,16 @@ public static class InvoiceSetPricing
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()
{
Id = i.nz("id", ""),
Title = i.nz("title", ""),
Desc = i.nz("desc", ""),
Qty = i.nz("qty", "1"),
PriceNet = setTotal,
TotalNet = setTotal,
ShowPrice = showPrice,
Numbered = true, // a set header is always numbered
IsSetHeader = true
};
@@ -171,12 +199,14 @@ public static class InvoiceSetPricing
FuchsPdf.ParseDec(i.no("total_net", 0), out decimal total);
return new InvoiceSetLine
{
Id = i.nz("id", ""),
Title = i.nz("title", ""),
Desc = i.nz("desc", ""),
Qty = i.nz("qty", "1"),
PriceNet = price,
TotalNet = total,
ShowPrice = showPrice && !IsNoPriceLine(i), // headings/free text print no price
Numbered = !IsNoPriceLine(i), // …but are still numbered unless heading/free-text
IsSetHeader = false
};
}
+13
View File
@@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "10.0.10",
"commands": [
"dotnet-ef"
],
"rollForward": false
}
}
}
+1
View File
@@ -2,4 +2,5 @@
$fis.notifications.init();
$fis.draft.init();
$fis.ov();
$fis.addAdminMenuIfAuthorized();
});
+17
View File
@@ -10,3 +10,20 @@
//, { lbl: $t.m_efa, id: 'm_efa', fnc: 'init:efa' }
]);
})();
/* The Administration module is restricted to users whose fds_sys authorization exceeds 4.
The button is added and only then does clicking it load the module script after the
asynchronous auth check resolves, so lower-privileged users never see it or fetch the code.
Called from fis_main_go.js once the DOM (and the initial main menu) is ready. */
$fis.addAdminMenuIfAuthorized = function () {
if ($('#m_admin').length > 0) { return; } // idempotent
$fis.getAuth('fds_sys').then(function (auth) {
if ((auth || -1) > 4 && $('#m_admin').length === 0) {
$ocms.ocmsmenu.push({ lbl: $t.m_admin, id: 'm_admin', fnc: 'init:admin', ico: 'glyphicon glyphicon-cog' });
/* Re-render the main menu cleanly: drop the previously generated menu list
(never the .nav-right settings dropdown) and rebuild it from the array. */
$('#mainmenu').children('ul:not(.nav-right)').remove();
$('#mainmenu').ocmsmenu($ocms.ocmsmenu);
}
}).catch(function () { /* auth lookup failed → leave the menu unchanged */ });
};
+1
View File
@@ -4,6 +4,7 @@
, m_rep: 'Berichte'
, m_todo: 'ToDos'
, m_bcd: 'BankBuchungen'
, m_admin: 'Administration'
, rsp: 'Passwort ändern'
, pnm: 'Die Passwörter stimmen nicht überein'
, cps: 'Das neue Passwort wurde gespeichert.'
+245
View File
@@ -0,0 +1,245 @@
let $adm = {
init2: function () {
// No external libraries needed — go straight to render.
$ocms.getScript([], function () { $adm.init3(); });
},
init3: function () {
$fis.cf(true);
$fis.lf(true);
$('#activemodule').text($adt.mdl);
$adm.topbar();
let frm = $$.dc('admfrm', $('#contentframe')).ldng(1);
$adm.load(frm);
},
topbar: function () {
return $('#topbar').ocmsmenu([
{ lbl: $adt.refreshAll, id: 'adm_refresh_all', ico: 'glyphicon glyphicon-refresh', fnc: function () { $adm.init3(); } }
]);
},
load: function (frm) {
$ocms.postXT({
url: $ocms.url('admin/status'), success: function (r) {
frm.empty();
$adm.render(frm, r.info || {}, r.probes || []);
}, error: function () {
frm.empty();
$$.dc('adm_err', frm).text($adt.loadfail);
}, complete: function () { frm.ldng(0); }
});
},
render: function (frm, info, probes) {
let pmap = {};
$.each(probes, function (i, p) { pmap[p.component] = p; });
$$.dc('adm_sec', frm).text($adt.sub_info);
$adm.sysCard(frm, info);
$adm.startupCard(frm, info.startupChecks);
$$.dc('adm_sec', frm).text($adt.sub_status);
let grid = $$.dc('adm_grid', frm);
$adm.dbCard(grid, info.database || {}, pmap.database);
$adm.mailCard(grid, info.email || {});
$adm.blobCard(grid, info.blob || {}, pmap.blob);
$adm.mfrCard(grid, info.mfr || {}, pmap.mfr);
$adm.kvCard(grid, info.keyVault || {}, pmap.keyvault);
},
/* ── generic building blocks ─────────────────────────────────────────── */
kvTable: function (parent, rows) {
let t = $$.tbl().addClass('adm_kv');
$.each(rows || [], function (i, r) {
if (!r) { return true; }
let tr = $$.tr(t);
$$.td(tr).addClass('k').text(r[0] == null ? '' : String(r[0]));
let v = $$.td(tr).addClass('v');
if (r[1] instanceof jQuery) { v.append(r[1]); } else { v.text(r[1] == null ? '' : String(r[1])); }
});
if (parent instanceof jQuery) { parent.append(t); }
return t;
},
yesNo: function (b) {
return $$.sc('adm_bool adm_bool_' + (b ? 'y' : 'n'), b ? $adt.yes : $adt.no);
},
setPill: function (pill, probe) {
probe = probe || {};
let st = probe.status || 'unconfigured';
let lbl = {
ok: $adt.st_ok, error: $adt.st_error, disabled: $adt.st_disabled,
unconfigured: $adt.st_unconfigured, checking: $adt.st_checking
}[st] || st;
pill.attr('class', 'adm_pill adm_pill_' + st).text(lbl);
return pill;
},
/* Connectivity card with a live status pill + per-card refresh button. */
probeCard: function (grid, title, component, cfgRows, probe) {
let card = $$.dc('adm_card', grid);
let hd = $$.dc('adm_card_hd', card);
$$.s(title).appendTo(hd);
let pill = $$.sc('adm_pill').appendTo(hd);
let rbtn = $$.bbtn($adt.refresh, 'adm_btn').appendTo(hd);
let bd = $$.dc('adm_card_bd', card);
$adm.kvTable(bd, cfgRows);
let pf = $$.dc('adm_probe', bd);
let msg = $$.dc('adm_probe_msg', pf);
let met = $$.dc('adm_probe_metrics', pf);
let det = $$.dc('adm_probe_det', pf);
let dur = $$.dc('adm_probe_dur', pf);
let apply = function (p) {
p = p || {};
$adm.setPill(pill, p);
msg.text(p.message || '');
met.empty();
$.each(p.metrics || [], function (i, m) {
let row = $$.dc('adm_metric', met);
$$.sc('adm_metric_k').text(m.label || '').appendTo(row);
$$.sc('adm_metric_v').text(m.value == null ? '' : String(m.value)).appendTo(row);
});
det.text(p.detail || '');
dur.text(p.durationMs != null ? (p.durationMs + ' ms') : '');
};
apply(probe);
rbtn.click(function () {
$adm.setPill(pill, { status: 'checking' });
msg.text(''); met.empty(); det.text(''); dur.text('');
$ocms.postXT({
url: $ocms.url('admin/probe/' + component), success: function (r) {
apply(r.probe);
}, error: function () {
$adm.setPill(pill, { status: 'error' });
msg.text($adt.st_error);
}
});
});
return card;
},
/* ── individual cards ────────────────────────────────────────────────── */
sysCard: function (frm, info) {
let card = $$.dc('adm_card adm_card_wide', frm);
let hd = $$.dc('adm_card_hd', card);
$$.s($adt.host).appendTo(hd);
let env = $$.sc('adm_env').text(info.environment || '');
if (info.isTestDeployment === true) { env.aC('adm_env_test'); }
env.appendTo(hd);
let bd = $$.dc('adm_card_bd', card);
$adm.kvTable(bd, [
[$adt.machine, info.machineName],
[$adt.os, info.osDescription],
[$adt.framework, info.frameworkDescription],
[$adt.env, info.environment],
[$adt.testdep, $adm.yesNo(info.isTestDeployment === true)],
[$adt.pid, info.processId],
[$adt.since, info.processStartUtc ? fdt(info.processStartUtc) : ''],
[$adt.uptime, (info.uptimeHours != null ? (info.uptimeHours + ' ' + $adt.hours) : '')]
]);
},
/* Non-refreshable widget: the one-shot startup self-test result captured at boot. */
startupCard: function (frm, sc) {
let card = $$.dc('adm_card adm_card_wide', frm);
let hd = $$.dc('adm_card_hd', card);
$$.s($adt.startup).appendTo(hd);
let bd = $$.dc('adm_card_bd', card);
if (!sc || sc.ran !== true) {
$adm.setPill($$.sc('adm_pill').appendTo(hd), { status: 'disabled' });
$$.dc('adm_note', bd).text($adt.startup_notrun);
return;
}
let items = sc.items || [];
let anyFail = false;
$.each(items, function (i, it) { if (it.enabled === true && it.ok !== true) { anyFail = true; } });
$adm.setPill($$.sc('adm_pill').appendTo(hd), { status: anyFail ? 'error' : 'ok' });
$adm.kvTable(bd, [
[$adt.startup_on, sc.machineName],
[$adt.startup_at, sc.completedUtc ? fdt(sc.completedUtc) : '']
]);
let list = $$.dc('adm_checks', bd);
$.each(items, function (i, it) {
let row = $$.dc('adm_check', list);
$$.sc('adm_check_k').text($adt['chk_' + it.name] || it.name).appendTo(row);
let stt = it.enabled !== true ? 'skipped' : (it.ok === true ? 'ok' : 'fail');
let lbl = { ok: $adt.chk_ok, fail: $adt.chk_fail, skipped: $adt.chk_skipped }[stt];
$$.sc('adm_check_v adm_check_' + stt).text(lbl).appendTo(row);
});
},
dbCard: function (grid, d, probe) {
$adm.probeCard(grid, $adt.db, 'database', [
[$adt.configured, $adm.yesNo(d.configured === true)],
[$adt.server, d.server],
[$adt.catalog, d.catalog],
[$adt.login, d.userId]
], probe);
},
mailCard: function (grid, e) {
let card = $$.dc('adm_card', grid);
let hd = $$.dc('adm_card_hd', card);
$$.s($adt.mail).appendTo(hd);
$adm.setPill($$.sc('adm_pill').appendTo(hd), { status: e.mailerEnabled === true ? 'ok' : 'disabled' });
let bd = $$.dc('adm_card_bd', card);
let rows = [
[$adt.mailer, $adm.yesNo(e.mailerEnabled === true)],
[$adt.baseurl, e.baseUrl],
[$adt.account, e.accountId],
[$adt.serverid, e.serverId],
[$adt.token, $adm.yesNo(e.tokenConfigured === true)],
[$adt.overrideActive, $adm.yesNo(e.overrideActive === true)]
];
if (e.overrideActive === true) { rows.push([$adt.override, e.overrideRecipient]); }
$adm.kvTable(bd, rows);
let ft = $$.dc('adm_card_ft', card);
$$.bbtn($adt.testmail_btn, 'adm_btn adm_btn_primary').appendTo(ft).click($adm.testMailDlg);
},
blobCard: function (grid, b, probe) {
$adm.probeCard(grid, $adt.blob, 'blob', [
[$adt.enabled, $adm.yesNo(b.enabled === true)],
[$adt.configured, $adm.yesNo(b.configured === true)],
[$adt.invContainer, b.invoiceContainer],
[$adt.remContainer, b.reminderContainer]
], probe);
},
mfrCard: function (grid, m, probe) {
$adm.probeCard(grid, $adt.mfr, 'mfr', [
[$adt.host_l, m.host],
[$adt.creds, $adm.yesNo(m.credentialsConfigured === true)],
[$adt.sync, $adm.yesNo(m.syncEnabled === true)]
], probe);
},
kvCard: function (grid, k, probe) {
$adm.probeCard(grid, $adt.kv, 'keyvault', [
[$adt.vault, k.vaultUri],
[$adt.appname, k.appName],
[$adt.seccount, k.managedSecretCount],
[$adt.clientreg, $adm.yesNo(k.clientRegistered === true)]
], probe);
},
/* ── test email ──────────────────────────────────────────────────────── */
testMailDlg: function () {
$ocms.dlgform([
{ name: 'to', label: $adt.tm_to, type: 'email', required: true, value: ($ocms.auth.email || '') },
{ name: 'subject', label: $adt.tm_subject, type: 'string', required: true, value: $adt.tm_default_subject },
{ name: 'body', label: $adt.tm_body, type: 'text', required: true, value: $adt.tm_default_body }
], {
title: $adt.testmail,
button: $adt.testmail_btn,
size: [420, 560],
addcontent: $$.dc('adm_note').text($adt.tm_hint),
url: $ocms.url('admin/testmail'),
typedvalues: true,
/* Runs inside jQuery's success dispatch it must never throw, or postXT's
`complete` (which clears the page loading indicator) is skipped. */
success: function (r) {
let res = (r && r.result) || {};
alert(res.message || (res.sent === true ? $adt.tm_sent : $adt.tm_failed));
}
});
}
};
let $$adm = { init2: $adm.init2, auth: {} };
export default $$adm;
+200
View File
@@ -0,0 +1,200 @@
.admfrm {
padding: 1.5rem;
.adm_sec {
font-size: 1.2rem;
font-weight: bold;
color: $fuchs_blau;
margin: 1.5rem 0 0.75rem;
border-bottom: 2px solid $fuchs_blau;
padding-bottom: 0.25rem;
&:first-child { margin-top: 0; }
}
.adm_err {
padding: 1rem;
color: #b00020;
font-weight: bold;
}
.adm_grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(22rem, 1fr));
gap: 1rem;
align-items: start;
}
.adm_card {
border: 1px solid #d5d5d5;
border-radius: 0.4rem;
background: #fff;
box-shadow: 0 1px 3px rgba(50, 50, 50, 0.12);
overflow: hidden;
&.adm_card_wide { grid-column: 1 / -1; }
}
.adm_card_hd {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 0.85rem;
background: $fuchs_blau;
color: #fff;
font-weight: bold;
> span:first-child { flex: 1 1 auto; }
}
.adm_card_bd {
padding: 0.6rem 0.85rem;
}
.adm_card_ft {
padding: 0.5rem 0.85rem 0.75rem;
text-align: right;
}
table.adm_kv {
border-collapse: collapse;
width: 100%;
td {
padding: 0.25rem 0.35rem;
border-bottom: 1px solid #eee;
vertical-align: top;
font-size: 0.92rem;
&.k { color: #666; white-space: nowrap; width: 40%; }
&.v { word-break: break-word; }
}
tr:last-child td { border-bottom: none; }
}
.adm_probe {
margin-top: 0.6rem;
padding-top: 0.5rem;
border-top: 1px dashed #ddd;
font-size: 0.88rem;
.adm_probe_msg { font-weight: 600; }
.adm_probe_det { color: #555; word-break: break-word; margin-top: 0.15rem; }
.adm_probe_dur { color: #999; margin-top: 0.15rem; font-size: 0.8rem; }
.adm_probe_metrics {
margin-top: 0.35rem;
.adm_metric {
display: flex;
justify-content: space-between;
gap: 0.5rem;
padding: 0.1rem 0;
border-bottom: 1px dotted #eee;
&:last-child { border-bottom: none; }
.adm_metric_k { color: #555; word-break: break-word; }
.adm_metric_v { font-weight: 600; white-space: nowrap; }
}
}
}
/* startup checks list */
.adm_checks {
margin-top: 0.5rem;
.adm_check {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0;
border-bottom: 1px solid #eee;
&:last-child { border-bottom: none; }
.adm_check_k { color: #333; }
.adm_check_v {
font-weight: bold;
font-size: 0.8rem;
padding: 0.05rem 0.5rem;
border-radius: 0.25rem;
white-space: nowrap;
}
.adm_check_ok { background: #e2f3da; color: #2f6d18; }
.adm_check_fail { background: #f3ded9; color: #c0341d; }
.adm_check_skipped { background: #ececec; color: #777; }
}
}
/* status pill */
.adm_pill {
display: inline-block;
padding: 0.1rem 0.55rem;
border-radius: 1rem;
font-size: 0.78rem;
font-weight: bold;
color: #fff;
background: #999;
white-space: nowrap;
}
.adm_pill_ok { background: $fuchs_akzent; }
.adm_pill_error { background: #c0341d; }
.adm_pill_disabled { background: #888; }
.adm_pill_unconfigured { background: #d69100; }
.adm_pill_checking { background: #2a72c4; }
/* boolean chips */
.adm_bool {
display: inline-block;
padding: 0 0.4rem;
border-radius: 0.25rem;
font-size: 0.8rem;
font-weight: bold;
}
.adm_bool_y { background: #e2f3da; color: #2f6d18; }
.adm_bool_n { background: #f3e0dd; color: #9c2a17; }
.adm_env {
font-size: 0.8rem;
font-weight: normal;
padding: 0.1rem 0.5rem;
border-radius: 0.25rem;
background: rgba(255, 255, 255, 0.25);
&.adm_env_test { background: #13a143; }
}
.adm_btn {
cursor: pointer;
border-radius: 0.28rem;
padding: 0.2rem 0.6rem;
font-size: 0.85rem;
border: 1px solid #ababab;
background-color: #fff;
color: #333;
&.adm_btn_primary {
background-color: $fuchs_akzent;
border-color: darken($fuchs_akzent, 8%);
color: #fff;
}
}
.adm_card_hd .adm_btn {
background-color: rgba(255, 255, 255, 0.15);
color: #fff;
border-color: rgba(255, 255, 255, 0.4);
}
}
.adm_note {
margin-top: 0.75rem;
padding: 0.5rem 0.6rem;
background: #f3f6fb;
border-left: 3px solid $fuchs_blau;
font-size: 0.85rem;
color: #444;
}
@@ -0,0 +1,84 @@
let $adt = {
mdl: 'Administration'
, sub_info: 'System'
, sub_status: 'Status & Konnektivität'
, sub_tests: 'Tests'
// startup checks widget (non-refreshable)
, startup: 'Startup-Prüfungen'
, startup_notrun: 'Die Startup-Prüfungen sind deaktiviert oder wurden noch nicht ausgeführt (Fuchs:StartupChecks:Enabled).'
, startup_on: 'Host'
, startup_at: 'Ausgeführt (UTC)'
, chk_ok: 'OK'
, chk_fail: 'Fehler'
, chk_skipped: 'übersprungen'
, chk_KeyVault: 'Azure Key Vault'
, chk_Database: 'SQL-Datenbank'
, chk_MFR: 'MFR (ERP)'
, chk_PdfLicense: 'Spire.PDF-Lizenz'
, chk_Mailer: 'E-Mail (Startup-Probe)'
// system info
, host: 'Host / Server'
, machine: 'Hostname'
, os: 'Betriebssystem'
, framework: 'Framework'
, env: 'Umgebung'
, testdep: 'Test-Deployment'
, pid: 'Prozess-ID'
, uptime: 'Laufzeit'
, since: 'Start (UTC)'
, hours: 'Std.'
// status cards
, db: 'SQL-Datenbank'
, mail: 'E-Mail-Dienst'
, blob: 'Azure Blob Storage'
, mfr: 'MFR (ERP)'
, kv: 'Azure Key Vault'
// labels
, server: 'Server'
, catalog: 'Datenbank'
, login: 'Login'
, mailer: 'Mailer aktiv'
, baseurl: 'Base-URL'
, account: 'Account'
, serverid: 'Server-ID'
, token: 'Token hinterlegt'
, override: 'OverrideRecipient'
, overrideActive: 'Umleitung aktiv'
, enabled: 'Aktiviert'
, configured: 'Konfiguriert'
, invContainer: 'Rechnungs-Container'
, remContainer: 'Mahnungs-Container'
, host_l: 'Host'
, creds: 'Zugangsdaten hinterlegt'
, sync: 'Sync aktiv'
, vault: 'Vault-URI'
, appname: 'App-Präfix'
, seccount: 'Verwaltete Secrets'
, clientreg: 'Client registriert'
, duration: 'Dauer'
, checked: 'Geprüft'
, detail: 'Detail'
// status values
, st_ok: 'OK'
, st_error: 'Fehler'
, st_disabled: 'Deaktiviert'
, st_unconfigured: 'Nicht konfiguriert'
, st_checking: 'Prüfe…'
// actions
, refresh: 'Aktualisieren'
, refreshAll: 'Alle prüfen'
, yes: 'Ja'
, no: 'Nein'
// test email
, testmail: 'Test-E-Mail'
, testmail_btn: 'Test-E-Mail senden'
, tm_to: 'Empfänger'
, tm_subject: 'Betreff'
, tm_body: 'Nachricht'
, tm_hint: 'Der Versand nutzt die normale E-Mail-Pipeline; bei gesetztem OverrideRecipient wird die Nachricht dorthin umgeleitet.'
, tm_sent: 'Test-E-Mail übergeben.'
, tm_failed: 'Test-E-Mail konnte nicht gesendet werden.'
, tm_default_subject: 'Fuchs Intranet Test-E-Mail'
, tm_default_body: 'Dies ist eine Test-E-Mail aus dem Administrationsbereich des Fuchs Intranet.'
, loadfail: 'Der Systemstatus konnte nicht geladen werden.'
};
+31
View File
@@ -36,11 +36,16 @@ let $bam = {
let fds = bool(ix.fds, false), id = ix.taID, m = [];
m.push({ lbl: $bct.smd, fnc: () => { $bam.smd(id); } });
m.push({ lbl: $bct.ati, fnc: () => { $bam.ati(id); } });
if (bool(ix.IsManual, false) === true) {
m.push({ lbl: $bct.mbc, fnc: () => { $bam.mbc(id); } });
m.push({ lbl: $bct.mbd, fnc: () => { $bam.mbd(id); } });
}
return $('#topbar').ocmsmenu(m);
}, eM : (r, re, opt) => {
let m = [];
if ($fis.isAuth('fds_bam', 2) === true) {
m.push({ lbl: $bct.upl, fnc: $bam.ubs});
m.push({ lbl: $bct.mbe, fnc: $bam.mbe});
}
if (bool(r, false) === true) {
m.push({ lbl: $bct.rel, fnc: $bam.renderLst });
@@ -312,6 +317,32 @@ let $bam = {
}
}
});
}, mbe: () => {
$bam.manForm({});
}, mbc: (taid) => {
$ocms.postXT({
url: $ocms.url('bam/mget'), data: { taid: taid }, success: (dta) => {
$bam.manForm(dta || {});
}
});
}, mbd: (taid) => {
if (confirm($bct.mbdc)) {
$ocms.postXT({
url: $ocms.url('bam/mdel'), data: { taid: taid }, success: () => {
$bam.renderLst();
}
});
}
}, manForm: (dta) => {
let flds = $bcol.man.clone().applyValues(dta || {});
$ocms.dlgform(flds, {
title: (ne(dta.taID, '') !== '') ? $bct.mbc : $bct.mbe,
url: $ocms.url('bam/man'),
typedvalues: true,
success: () => {
$bam.renderLst();
}
});
}
}
+12 -1
View File
@@ -18,10 +18,21 @@
},
in: 'Rechnungsnummer',
bt: 'Buchungstexte',
bv: 'Buchungswert (volle €)'
bv: 'Buchungswert (volle €)',
mbe: 'Manuelle Buchung erfassen',
mbc: 'Manuelle Buchung ändern',
mbd: 'Manuelle Buchung löschen',
mbdc: 'Manuelle Buchung wirklich löschen?\nDieser Vorgang kann nicht rückgängig gemacht werden.'
};
let $bcol = {
man: new fields_definition('Manuelle Buchung', 'Manuelle Buchungen', [
{ name: 'taID', type: 'hidden' },
{ name: 'ValueDate', label: 'Valuta', type: 'date', required: true },
{ name: 'Amount', label: 'Betrag', type: 'number', precision: '0.01', required: true },
{ name: 'NameOfPayer', label: 'Name', type: 'string' },
{ name: 'SepaRemittanceInformation', label: 'Verwendungszweck', type: 'string' }
]),
qtl: new fields_definition('Kontobericht', 'Kontoberichte', [
{ name: 'InvoiceId', label: 'RechnungsNr', type: 'string' },
{ name: 'ValueDate', label: 'Valuta', type: 'date' },
+381 -132
View File
@@ -82,6 +82,11 @@ $inv.iMn = (ix) => {
}
return $('#topbar').ocmsmenu(m);
}
/* Menu option CSV builder: 'setm' (Set-Preisanzeige) is only offered while the invoice has not
yet been explicitly switched to a set-display mode (admin.setmode empty/undefined) once set,
changing it again is not supported (the user edits the line items directly instead); it
reappears only if 'Änderungen verwerfen' reverts to a DB state that was never switched. */
$inv.emOpt = (admin) => 'iss,p13b,ctp' + (((admin || {}).setmode || '') === '' ? ',setm' : '');
$inv.eM = (r, re, opt) => {
let m = [];
if (booln(r, false) === true || booln(re, false) === true) {
@@ -125,10 +130,13 @@ $inv.d = {
return h;
},
/* Seed the authoritative server session from the assembled editor payload. Called
once from invSumUpdate on open; thereafter the backend is the source of truth. */
once from invSumUpdate on open; thereafter the backend is the source of truth.
The in-flight ajax promise is stashed on 'dseedpromise' so a delta sent (e.g. via
a context-menu action) before seeding has actually completed can wait for the token
instead of silently no-op'ing (see $inv.d.sync). */
seed: function (payload) {
let l = $inv.d.layout(); l.aC('freeze');
$ocms.postXT({
let p = $ocms.postXT({
url: $ocms.url('inv/dopen'), data: { payload: JSON.stringify(payload) }, success: (r) => {
$inv.d.tbl().data('dtoken', r.token).data('dver', r.version).data('dhashes', $inv.d.hashes()).data('dorder', $inv.d.order());
$fis.draft.bind(r.token, {
@@ -137,8 +145,10 @@ $inv.d = {
onClosed: (reason) => $inv.d.closed(reason)
});
$inv.d.refresh();
}, error: () => { l.rC('freeze'); }, complete: () => { $inv.d.tbl().removeData('dseeding'); }
}, error: () => { l.rC('freeze'); }, complete: () => { $inv.d.tbl().removeData('dseeding').removeData('dseedpromise'); }
});
$inv.d.tbl().data('dseedpromise', p);
return p;
},
/* Re-fetch the authoritative state and render the totals footer + validation from it. */
refresh: function (cb) {
@@ -152,26 +162,122 @@ $inv.d = {
},
applyState: function (state) {
let tbl = $inv.d.tbl(); if (tbl.length < 1) { return; }
/* Concurrent refreshes (one per flushed block plus one for a follow-up delta such as
item.setprice, and SignalR draftReady on top) can have their dstate responses arrive
out of order. Applying a response older than the version already rendered would
silently roll back a just-applied server-side mutation (e.g. a set-price conversion
reverting to its pre-conversion 0/individually-priced state) so stale responses are
dropped here instead of rendered. */
let curVer = tbl.data('dver');
if (typeof curVer === 'number' && typeof state.version === 'number' && state.version < curVer) {
return;
}
tbl.data('dver', state.version).data('serverSums', state.sums);
$inv.d.footer(tbl, state.sums || {}, state.admin || {});
$inv.d.notes(state.notes || []);
$inv.d.validation(state.validation || []);
$inv.d.applyPositions(tbl, state.req || []);
$inv.d.applyItems(tbl, state.req || []);
$inv.d.applySetDisplay(tbl, state.setDisplay || {});
/* Keep the menu's "Set-Preisanzeige" entry in sync with the authoritative admin.setmode:
hides it right after a mode switch, and restores it if a discard reverted the draft to
a DB state that was never switched (ADR 0006 backend-authoritative refresh). */
$inv.eM(false, true, $inv.emOpt(state.admin || {}));
},
/* Renders the backend-authoritative notice paragraphs (ADR: notice texts are
backend-authoritative) exactly as FuchsPdf.BuildInvoiceNotes emits them for the PDF,
so the online editor always shows precisely what will be printed. */
notes: function (notes) {
let frm = $inv.d.layout(); if (frm.length < 1) { return; }
let box = frm.children('.dnotes');
if (box.length < 1) { box = $$.dc('dnotes'); frm.append(box); }
box.empty();
(notes || []).forEach((n) => $$.dc('dnote').aC(n.style || 'InvoiceNotes').text(n.text));
},
/* Push the server's authoritative position numbers back onto the rendered rows so the online
editor and the PDF preview always agree (the server numbers priced lines continuously; the
browser must not keep its own numbering). Only the position cell is touched no re-render. */
applyPositions: function (tbl, req) {
(req || []).forEach((b) => (b && b.itm || []).forEach((co) => {
if (!co || (co.id || '') === '') { return; }
let cell = tbl.find('#itm' + co.id + ' td.keep').first();
if (cell.length) { cell.text(co.p != null ? co.p : ''); }
}));
/* Reconciles each block's rendered rows against the authoritative server line list
(`req[].itm`, the co shape). The server may not only change a line's values but also
ADD lines (the block set row inserted by "Set mit Preis"/block.setprice) and REMOVE lines
("Nur Set mit Preis"/block.setonly), and reorder them the browser never computes any of
that (ADR 0006/0008/0009), it just mirrors the server. For each server line: patch/insert
the matching row, preserving null price fields verbatim (ADR 0009: null = empty cell, kept
distinct from 0). Rows the server no longer lists are removed; order follows the server.
Position numbers, net/VAT and the set-row emphasis all come from rrw off the mirrored data. */
applyItems: function (tbl, req) {
let val = (x) => (x === undefined ? null : x); // JSON null stays null; absent -> null
(req || []).forEach((b) => {
if (!b) { return; }
let bid = (b.Id || '').toString(); if (bid === '') { return; }
let bdy = tbl.children('tbody').filter((i, e) => (($(e).data() || {}).Id || '').toString() === bid).first();
if (bdy.length < 1) { return; }
let itms = b.itm || [], serverIds = {};
let title = bdy.find('tr.title').first();
let prev = title.length ? title : null;
itms.forEach((co) => {
let id = (co.id || '').toString(); if (id === '') { return; }
serverIds[id] = true;
let rw = bdy.find('#itm' + id).first(), isNew = rw.length < 1;
if (isNew) { rw = $$.tr(bdy, { id: 'itm' + id, class: 'itm' }); }
let dta = rw.data() || {};
let changed = isNew || (dta.Type || '') !== (co.typ || '')
|| dta.net !== val(co.v) || dta.net_val !== val(co.vt) || dta.vat_val !== val(co.vv)
|| dta.svcnet_val !== val(co.vs) || dta.svcvat_val !== val(co.vsv)
|| (dta.position || '') !== (co.p != null ? co.p : '');
$.extend(dta, {
Id: id, Type: co.typ,
net: val(co.v), net_val: val(co.vt), vat_val: val(co.vv),
svcnet_val: val(co.vs), svcvat_val: val(co.vsv),
vat: co.vat, position: (co.p != null ? co.p : ''),
SetItmId: (co.SetItmId != null ? co.SetItmId : null)
});
if (isNew) { $.extend(dta, { htmltext: co.t, quantity: co.q }); } // text only for server-created rows (e.g. the set row)
rw.data(dta);
if (prev) { if (rw.prev()[0] !== prev[0]) { rw.insertAfter(prev); } } // keep server order, but don't churn already-ordered rows
else if (bdy.children().first()[0] !== rw[0]) { bdy.prepend(rw); }
prev = rw;
if (changed) { $inv.rrw.call(rw); }
});
bdy.find('tr.itm').each((i, tr) => {
let id = (($(tr).data() || {}).Id || '').toString();
if (id !== '' && !serverIds[id]) { $(tr).remove(); } // dropped server-side (block.setonly)
});
});
},
/* Send one change to the server; the draftReady signal and this success both refresh. */
/* Applies the backend-authoritative set-pricing display flags (see BuildSetDisplay /
InvoiceSetPricing) onto the rendered rows: blanks the price/total cells for lines the
server says should show no price (set members in SetPrice/SetOnly mode), and emphasises
the set header line so the online editor always shows exactly what the PDF will print,
without duplicating the pricing rules client-side. Rows not present in the map (no set in
that block, or standalone items) are left untouched. */
applySetDisplay: function (tbl, setDisplay) {
tbl.find('tr.itm').each((i, tr) => {
let rw = $(tr), dta = rw.data() || {}, id = (dta.Id || '').toString();
if (id === '' || !Object.prototype.hasOwnProperty.call(setDisplay, id)) { return; }
let flags = setDisplay[id] || {};
rw.tC('sethdr', bool(flags.h, false));
if (bool(flags.p, true) === false) {
rw.find('td.currency').text('');
}
});
},
/* Send one change to the server; the draftReady signal and this success both refresh.
Returns the underlying ajax promise so callers that must not race a follow-up delta
against this one (e.g. $inv.toSetPrice after a flush) can chain on it.
If the initial seeding (inv/dopen) is still in flight (no token yet, but a
'dseedpromise' is pending), the delta previously vanished silently the user saw no
network activity at all when clicking a context-menu action right after opening the
editor. Now the delta waits for the seed to finish and retries against the freshly
issued token instead of being dropped. */
sync: function (delta) {
let t = $inv.d.token(); if (t === '') { return; }
let t = $inv.d.token();
if (t === '') {
let seeding = $inv.d.tbl().data('dseedpromise');
if (seeding) { return $.when(seeding).then(() => $inv.d.sync(delta)); }
return $.when();
}
$inv.d.layout().aC('freeze');
$ocms.postXT({
return $ocms.postXT({
url: $ocms.url('inv/dpatch'), data: { token: t, delta: JSON.stringify(delta) },
success: () => { $inv.d.refresh(); },
error: (xhr) => { $inv.d.layout().rC('freeze'); if (xhr && xhr.status === 410) { $inv.d.closed('expired'); } }
@@ -182,22 +288,29 @@ $inv.d = {
/* Diff the freshly-rebuilt blocks against the last-synced state and send only the
changed/removed blocks as granular block.replace / block.remove deltas. A pure section
reorder (same blocks, new sequence) changes no block hash, so it is sent separately as a
block.order delta; the server reorders the cache, renumbers positions and pushes them back. */
block.order delta; the server reorders the cache, renumbers positions and pushes them back.
No early return on an empty token here: sync() itself waits for an in-flight seed
(dseedpromise) instead of silently dropping the delta, so edits made in the brief window
before seeding completes are not lost. */
syncChanged: function (tbl) {
if (($inv.d.token()) === '') { return; }
let bai = tbl.data('bai') || [], prev = tbl.data('dhashes') || {}, next = {}, changed = [], removed = [];
let bai = tbl.data('bai') || [], prev = tbl.data('dhashes') || {}, next = {}, changed = [], removed = [], pending = [];
$.each(bai, (i, b) => { let id = (b.Id || '').toString(), h = JSON.stringify(b); next[id] = h; if (prev[id] !== h) { changed.push(b); } });
$.each(prev, (id) => { if (next[id] === undefined) { removed.push(id); } });
let order = $inv.d.order(), prevOrder = tbl.data('dorder') || [];
tbl.data('dhashes', next).data('dorder', order);
changed.forEach((b) => $inv.d.sync({ Target: 'block.replace', Ref: (b.Id || '').toString(), Value: b }));
removed.forEach((id) => $inv.d.sync({ Target: 'block.remove', Ref: id }));
changed.forEach((b) => pending.push($inv.d.sync({ Target: 'block.replace', Ref: (b.Id || '').toString(), Value: b })));
removed.forEach((id) => pending.push($inv.d.sync({ Target: 'block.remove', Ref: id })));
let sameSet = prevOrder.length === order.length && prevOrder.slice().sort().join(',') === order.slice().sort().join(',');
if (sameSet && prevOrder.join(',') !== order.join(',')) { $inv.d.sync({ Target: 'block.order', Value: order }); }
if (sameSet && prevOrder.join(',') !== order.join(',')) { pending.push($inv.d.sync({ Target: 'block.order', Value: order })); }
/* $.when(...pending) so a caller (e.g. toSetPrice) can wait until every flushed block
has actually reached the server before sending a further delta that depends on it
otherwise a delayed block.replace carrying pre-conversion values can land after and
silently overwrite a just-applied server-side mutation (e.g. item.setprice). */
return $.when.apply($, pending).promise();
},
/* Map an inline recipient field to its delta target and send it. */
/* Map an inline recipient field to its delta target and send it. No token-guard here for the
same reason as syncChanged: sync() waits for an in-flight seed instead of dropping the edit. */
syncField: function (nme, val) {
if ($inv.d.token() === '') { return; }
let map = { invoicetitle: 'title', invoiceaddress: 'address', invoiceemail: 'email', loc: 'provisionlocation', provisionlocation: 'provisionlocation', provisionperiod: 'provisionperiod' };
let target = map[nme]; if (!target) { return; }
$inv.d.sync({ Target: target, Value: val });
@@ -352,10 +465,31 @@ $inv.rd = {
},
applyState: function (state) {
let tbl = $inv.rd.tbl(); if (tbl.length < 1) { return; }
/* See $inv.d.applyState drop stale/out-of-order dstate responses so an older refresh
can never roll back a newer one's totals/validation. */
let curVer = tbl.data('rdver');
if (typeof curVer === 'number' && typeof state.version === 'number' && state.version < curVer) {
return;
}
tbl.data('rdver', state.version).data('serverSums', state.sums).data('remid', state.remid || '');
$inv.rd.footer(tbl, state.sums || {});
$inv.rd.notes(state.notes || {});
$inv.rd.validation(state.validation || []);
},
/* Renders the backend-authoritative intro/closing notice paragraphs (ADR: notice texts
are backend-authoritative) exactly as FuchsPdf.BuildReminderNotes emits them for the
PDF, so the online editor always shows precisely what will be printed. */
notes: function (notes) {
let frm = $inv.rd.layout(); if (frm.length < 1) { return; }
let rq = frm.find('.srq'); if (rq.length < 1) { return; }
rq.children('.dnote').remove();
let intro = (notes || {}).intro || [], first = rq.children().first();
intro.slice().reverse().forEach((t) => {
let p = $$[0]('p').aC('dnote intro').rwText(t);
if (first.length) { p.insertBefore(first); } else { p.appendTo(rq); }
});
((notes || {}).closing || []).forEach((t) => $$[0]('p').aC('dnote closing').rwText(t).appendTo(rq));
},
/* Send one change to the server; the draftReady signal and this success both refresh. */
sync: function (delta) {
let t = $inv.rd.token(); if (t === '') { return; }
@@ -379,10 +513,13 @@ $inv.rd = {
$inv.rd.sync({ Target: 'amount', Value: (amount != null ? amount : 0).toString() });
$inv.rd.sync({ Target: 'amount_payed', Value: (amount_payed != null ? amount_payed : 0).toString() });
},
/* Render the open-amount footer from the server sums. */
/* Render the open-amount footer from the server sums. Detail rows ($inv.rRemRw) have
6 columns (aux, invoiceid, date, amount, amount_payed, open); span the label over the
4 columns between aux and the open-amount column so the total lands in the same last
column as the per-invoice open amounts. */
footer: function (tbl, sums) {
let ft = tbl.children('tfoot').empty();
let tr = $$.tr(ft, { class: 'tsum' }).append([$$.tdc('aux'), $$.td({ colspan: 3 }).text('Offener Betrag')]);
let tr = $$.tr(ft, { class: 'tsum' }).append([$$.tdc('aux'), $$.td({ colspan: 4 }).text('Offener Betrag')]);
$$.tdc('currency', tr, fnum(sums.amount_open || 0, $rct.cst));
},
validation: function (msgs) {
@@ -625,7 +762,7 @@ $inv.ccInv = function (ev) { //normale rechnung
rif.tbl.children('tbody').each($inv.bdysort);
rif.tbl.trigger('fds.inv'); /* trigger calculations */
$inv.eM(false, true, 'iss,p13b,setm,ctp');
$inv.eM(false, true, $inv.emOpt(admin));
}, complete: () => {
o.c.trigger('modal_close');
}
@@ -770,7 +907,7 @@ $inv.cntInv = function (data) { //invoice continuation
rif.tbl.children('tbody').each($inv.bdysort);
rif.tbl.trigger('fds.inv'); /* trigger calculations */
$inv.eM(false, true, 'iss,p13b,setm,ctp');
$inv.eM(false, true, $inv.emOpt(response.admin));
}, complete: () => {
o.c.trigger('modal_close');
}
@@ -843,7 +980,121 @@ $inv.cSt = function (data) {
}
});
};
/* Country choices for the structured recipient-address dialog (ISO 3166-1 alpha-2, BT-55). */
$inv.adrCountries = [['DE', 'Deutschland'], ['AT', 'Österreich'], ['CH', 'Schweiz'], ['FR', 'Frankreich'],
['NL', 'Niederlande'], ['BE', 'Belgien'], ['LU', 'Luxemburg'], ['IT', 'Italien'], ['ES', 'Spanien'],
['PL', 'Polen'], ['DK', 'Dänemark'], ['CZ', 'Tschechien'], ['GB', 'Großbritannien'], ['US', 'USA']];
/* Compose the free-text postal block (for the inline display) from the structured address
mirrors the backend InvoiceRecipientAddress.Compose (the VAT id is never part of the block). */
$inv.composeAddress = function (a) {
a = a || {}; let lines = [];
let push = (v) => { v = ('' + (v || '')).trim(); if (v !== '') lines.push(v); };
push(a.name);
if (('' + (a.contact || '')).trim() !== '') push('z.Hd. ' + ('' + a.contact).trim());
push(a.line2 || a.addressLine2);
push(a.street);
push((('' + (a.postalCode || '')) + ' ' + ('' + (a.city || ''))).trim());
let cc = ('' + (a.countryCode || '')).toUpperCase();
if (cc !== '' && cc !== 'DE') push(cc);
return lines.join('\n');
};
/* The structured recipient address currently in effect: the backend keeps it in
CustomValues.sendToAddress (survives a draft refresh); new invoices seed it from
fds__prepInvoice's invoiceaddressData; otherwise start empty. */
$inv.adrCurrent = function (tbl) {
let cv = jObj((tbl.data('new') || {}).CustomValues, 'sendToAddress');
if (typeof cv === 'string' && cv.trim().charAt(0) === '{') { try { cv = JSON.parse(cv); } catch (e) { cv = null; } }
if (cv && typeof cv === 'object') return cv;
let ad = (tbl.data('admin') || {}).invoiceaddressData;
if (typeof ad === 'string' && ad.trim().charAt(0) === '{') { try { return JSON.parse(ad); } catch (e) { } }
else if (ad && typeof ad === 'object') { return ad; }
return {};
};
/* Structured recipient-address dialog (EN 16931 / DATEV). Posts the address delta as a JSON
object; the backend stores it, composes SendToAddress and keeps the PDF unchanged (ADR 0012).
B2C stays effortless: the USt-IdNr. is optional (empty = private person). */
$inv.eAddress = function (ev) {
let tbl = $inv.d.tbl(), cur = $inv.adrCurrent(tbl);
let flds = [
{ name: 'name', label: 'Name / Firma', type: 'text', value: cur.name || '', required: true },
{ name: 'contact', label: 'z.Hd. (optional)', type: 'text', value: cur.contact || '' },
{ name: 'street', label: 'Straße + Nr.', type: 'text', value: cur.street || '' },
{ name: 'line2', label: 'Adresszusatz (optional)', type: 'text', value: cur.line2 || cur.addressLine2 || '' },
{ name: 'postalCode', label: 'PLZ', type: 'text', value: cur.postalCode || '' },
{ name: 'city', label: 'Ort', type: 'text', value: cur.city || '' },
{ name: 'countryCode', label: 'Land', type: 'select', url: $inv.adrCountries, value: (cur.countryCode || 'DE') },
{ name: 'vatId', label: 'USt-IdNr. (nur bei Firmen)', type: 'text', value: cur.vatId || '' },
{ name: 'leitwegId', label: 'Leitweg-ID (nur Behörden / B2G)', type: 'text', value: cur.leitwegId || '' }
];
let hint = $$.dc('adr-conformity').append([
$$.dc('hd').text('Hinweis zur DATEV-/eRechnungs-Konformität'),
$$.dc('tx').text('Pflicht: Name, Straße, PLZ, Ort und Land. Die USt-IdNr. nur bei Firmen angeben — '
+ 'für eine Rechnung an eine Privatperson einfach leer lassen. Eine Leitweg-ID nur bei '
+ 'Behörden (B2G) — dann wird die Rechnung automatisch als XRechnung erzeugt.')
]);
$ocms.dlgform(flds, {
title: 'Rechnungsempfänger',
addcontent: hint,
success: function (res) {
let a = {
name: res.name || '', contact: res.contact || '', line2: res.line2 || '',
street: res.street || '', postalCode: res.postalCode || '', city: res.city || '',
countryCode: (res.countryCode || 'DE'), vatId: ('' + (res.vatId || '')).trim(),
leitwegId: ('' + (res.leitwegId || '')).trim()
};
let txt = $inv.composeAddress(a);
if (ev.data && ev.data.t) { ev.data.t.rwText(txt); }
if (typeof (ev.data || {}).change === 'function') { ev.data.change(txt); }
/* Structured delta — the invoice draft backend accepts a JSON object for 'address'. */
$inv.d.sync({ Target: 'address', Value: a });
}
});
};
/* Leistungsdatum / -zeitraum: structured German-date entry only (no free text). A single date =
Leistungsdatum (BT-72); a from+to date = Leistungszeitraum (BG-14). The composed value is a
German dd.MM.yyyy string ("18.06.2026" or "01.06.2026 - 30.06.2026") that the PDF shows and the
backend maps to the eRechnung service date/period. */
$inv.provToIso = function (dmy) {
let m = ('' + (dmy || '')).match(/(\d{1,2})\.(\d{1,2})\.(\d{4})/);
return m ? (m[3] + '-' + ('0' + m[2]).slice(-2) + '-' + ('0' + m[1]).slice(-2)) : '';
};
$inv.eProvisionPeriod = function (ev) {
let cur = ('' + (ev.data && ev.data.t ? ev.data.t.text() : '')).trim();
let dates = cur.match(/\d{1,2}\.\d{1,2}\.\d{4}/g) || [];
let flds = [
{ name: 'von', label: 'Leistungsdatum', type: 'date', value: $inv.provToIso(dates[0] || '') },
{ name: 'bis', label: 'Leistungszeitraum bis (optional)', type: 'date', value: $inv.provToIso(dates[1] || '') }
];
let hint = $$.dc('adr-conformity').append([
$$.dc('hd').text('Leistungsdatum / -zeitraum'),
$$.dc('tx').text('Für ein einzelnes Datum nur „Leistungsdatum" ausfüllen; für einen Zeitraum '
+ 'zusätzlich das Bis-Datum. Nur strukturierte Datumsauswahl — für die E-Rechnung empfohlen.')
]);
$ocms.dlgform(flds, {
title: 'Leistungsdatum / -zeitraum',
addcontent: hint,
success: function (res) {
let v = ('' + (res.von || '')).trim(), b = ('' + (res.bis || '')).trim();
let txt = v === '' ? '' : ((b === '' || b === v) ? v : (v + ' - ' + b));
if (ev.data && ev.data.t) { ev.data.t.rwText(txt); }
if (typeof (ev.data || {}).change === 'function') { ev.data.change(txt); }
$inv.d.sync({ Target: 'provisionperiod', Value: txt });
}
});
};
$inv.eHtml = function (ev) {
/* The recipient address and the service date/period are edited through structured dialogs
whenever an invoice draft is active; reminders (no invoice draft token) keep the plain
free-text editor. */
if (ev.data && !(ev.data instanceof jQuery) && $inv.d && typeof $inv.d.token === 'function' && $inv.d.token() !== '') {
if (ev.data.nme === 'invoiceaddress') { return $inv.eAddress.call(this, ev); }
if (ev.data.nme === 'provisionperiod') { return $inv.eProvisionPeriod.call(this, ev); }
}
let t = $(this), frmct = ev.data instanceof jQuery ? ev.data : ev.data.t;
/* Single-line fields must stay plain text the TinyMCE/html editor wraps the value in <p>
tags, which used to get posted and persisted verbatim (e.g. <p>18.06.2026</p> in the
@@ -893,6 +1144,8 @@ $inv.eHtml = function (ev) {
}
$ocms.dlgform(flds, sets);
};
/* No client-side arithmetic (ADR 0006/0008): only the chosen VAT rate is stored on each row;
the server (InvoiceDraftCalculator.RecomputeLineValues) recomputes vat_val/svcvat_val from it. */
$inv.setVat = function (ev) {
let t = $(this), thisrow = ev.data;
let vat = prompt($rct.rqV);
@@ -905,12 +1158,6 @@ $inv.setVat = function (ev) {
thisrow.siblings('.itm').each(function () {
let rwi = $(this), dta = rwi.data();
dta.vat = fnum(vat, { style: 'percent' }).replace(' ', '');
if ((dta.net_val || 0) > 0) {
dta.vat_val = dta.net_val * vat;
}
if ((dta.svcnet_val || 0) > 0) {
dta.svcvat_val = dta.svcnet_val * vat;
}
});
$inv.t_fds_inv();
}
@@ -970,6 +1217,16 @@ $inv.rrw = function () {
if (ph === false) {
bc.unshift($$.dc('ibtn edit', { title: $rct.cP }).append(gi('pencil')).click(rw, $inv.eRow));
bc.push($$.dc('ibtn del', { title: $rct.dR }).append(gi('trash')).click(function (e) { if (confirm($rct.cD)) { rw.remove(); $inv.t_fds_inv(); } }));
/* "Auf Setpreis umstellen": offered on a Set header item that does not yet carry a
price. fds__prepInvoice's [SetItmId] now anchors on the still-unconverted (price 0)
header, so the header row's own SetItmId self-references its own id rather than being
null the real signal for showing this button is still the header's own price/net_val
being 0 (once converted it carries a nonzero price and the button disappears).
Irreversible: the actual sum/clear mutation happens server-side (backend-authoritative,
see InvoiceDraftEditService.ApplyItemSetPrice) via the "item.setprice" patch target. */
if ((dta.Type || '').toLowerCase() === 'set' && (dta.net_val || 0) === 0) {
bc.push($$.dc('ibtn tosetp', { title: $rct.toSetP }).append(gi('scale')).click(rw, $inv.toSetPrice));
}
}
let axf = $$.dc('axf').append(bc);
if (ph === true) {
@@ -977,7 +1234,7 @@ $inv.rrw = function () {
} else if (rw.is('.itm.osum') === true) {
co = { invrqid: dta.InvRqId, id: 'osum' + rw.index(), typ: 'osum', p: '', q: null, t: oHtml(dta.tbl.tbl), tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: false };
} else {
co = { invrqid: dta.InvRqId, id: dta.Id || '', typ: dta.Type || 'other', p: '', q: null, t: '', tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: (dta.Note || '') !== '' && hn === false };
co = { invrqid: dta.InvRqId, id: dta.Id || '', typ: dta.Type || 'other', p: '', q: null, t: '', tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: (dta.Note || '') !== '' && hn === false, SetItmId: dta.SetItmId || null };
$$.dc('ibtn ico move', axf, { title: $rct.mR }); /* should be the last added */
co.p = dta.position || (dta.SortOrder || '');
@@ -987,7 +1244,7 @@ $inv.rrw = function () {
co.t = dta.htmltext || (((dta.NameOrNumber || '').substr(0, 1) !== '#' ? oHtml($$[0]('p').text(dta.NameOrNumber)) : '') + (dta.Note || ''));
} else {
co.tt = co.det ? '' : $$.s(dta.Note || '').text();
co.q = dta.quantity || (fnum(dta.quantityhours) + ' ' + (dta.UnitString || ''));
co.q = dta.quantity || ((dta.quantityhours || 0) !== 0 ? (fnum(dta.quantityhours) + ' ' + (dta.UnitString || '')) : ''); /* guard: no quantityhours -> blank, not fnum(undefined)="NaN" (e.g. the block set row) */
co.t = dta.htmltext ||(co.det ? (oHtml($$.s(dta.NameOrNumber || '')) + oHtml($$.dc('desc').html(dta.Note))) : oHtml($$.s(dta.NameOrNumber || '')));
co.v = dta.net
co.vt = dta.net_val
@@ -1006,45 +1263,62 @@ $inv.rrw = function () {
tda.push($$.td(rw, { colspan: 4 }).append(co.t));
} else {
Array.prototype.push.apply(tda, co.q ? [$$.tdc('keep').text(co.q)] : []);
/* ADR 0009: a null/undefined price renders an EMPTY cell (block-mode "Set mit Preis"
members, and the set row's own unit-price), distinct from a real 0,00 . */
let cur = (x) => (x == null ? '' : fnum(x, $rct.cst));
Array.prototype.push.apply(tda, [
$$.tdc('txt', { colspan: !co.q ? 2 : 1, title: co.tt }).append(co.t),
$$.tdc('currency').text(fnum(co.v, $rct.cst)),
$$.tdc('currency inetval').text(fnum(co.vt, $rct.cst)).attr('title', $rct.svcPart + ': ' + fnum(co.vs, $rct.cst))
$$.tdc('currency').text(cur(co.v)),
$$.tdc('currency inetval').text(cur(co.vt)).attr('title', $rct.svcPart + ': ' + cur(co.vs))
]);
}
rw.empty().attr('class', ph ? 'placeholder' : 'itm').aC(co.Typ).tC('hidenote', hn).append(tda);
rw.empty().attr('class', ph ? 'placeholder' : 'itm').aC(co.Typ)
.tC('sethdr', (co.typ || '').toString().toLowerCase() === 'set') /* emphasise set rows (fn1 header + block set row) */
.tC('hidenote', hn).append(tda);
dta.co = co;
};
/* "Auf Setpreis umstellen" (see $inv.rrw): backend-authoritative (ADR 0006) the browser only
posts the target set-header item id; the server (InvoiceDraftEditService.ApplyItemSetPrice)
sums the member rows (same block, [SetItmId] equal to this row's id), writes that sum onto
the set header and clears each member's price, then the normal draftReady refresh re-renders
both the header and its members from the authoritative session state. This mirrors the PDF's
read of the same cached session, so the online editor and PDF can never drift apart. */
$inv.toSetPrice = function (ev) {
let rw = ev.data, dta = rw.data(), id = (dta.Id || '').toString();
if (id === '') { return; }
if (confirm($rct.toSetPc) === false) { return; }
/* Wait for the flush of any pending local edits to actually reach the server before sending
the conversion delta sending both in parallel let a delayed block.replace (still carrying
the pre-conversion, zero member/header values) land after item.setprice and silently
overwrite the just-converted totals back to 0. */
$.when($inv.t_fds_inv()).done(() => { $inv.d.sync({ Target: 'item.setprice', Ref: id }); });
};
/* No client-side totals/footer/tax computation (ADR 0006/0008): this only re-assembles each
block's row contract array (needed to post the "req" shape to the server) and, on the very
first pass, seeds the authoritative backend session. Footer, VAT breakdown, service-refund
note figures and the per-block sum cell are never computed here they are rendered exclusively
from the server's `dstate` response by $inv.d.footer / $inv.d.applyState once the draft has a
token. Before a token exists the footer/notes/isum cells are simply left empty; there is no
local approximation to display. */
$inv.invSumUpdate = function () {
let tbl = $(this), ft = tbl.children('tfoot').empty(), p13b = bool((tbl.data().admin || {}).p13b || '', false);
tbl.nextAll('.fnote').remove();
let sms = { ttn: 0, ttb: 0, ttvat: 0, tscn: 0, tscvat: 0, vat: {}, itmnet: {} }, ba = [];
let rwcy = (lbl, val, cls) => $$.tdc('currency', $$.tr(ft, { class: cls || 'tsum' }).append([$$.tdc('aux'), $$.td({ colspan: 4 }).text(lbl)]), fnum(val, $rct.cst)), fn = (t) => $$.dc('fnote').insertAfter(tbl).rwText(t);
let csms = function (rrx, sms, sid) {
sms.tscn += (rrx.svcnet_val || 0);
sms.tscvat += (rrx.svcvat_val || 0);
sms.ttn += (rrx.net_val || 0);
sms.ttvat += (rrx.vat_val || 0);
sms.ttb += ((rrx.net_val || 0) + (rrx.vat_val || 0));
if ((rrx.vat || '') !== '') {
sms.vat[rrx.vat] = (sms.vat[rrx.vat] || 0) + (rrx.vat_val || 0);
}
//sms.itmnet[sid] = (sms.itmnet[sid] || 0) + (rrx.net_val || 0);
};
let tbl = $(this);
let ba = [];
let bds = tbl.children('tbody');
bds.each((bi, bdy) => {
let b = $(bdy), rx = b.data() || {}, i = [], citems = [], cset = null, bnet = 0, itm = b.find('tr.itm'), iso = 0, ipos = 0;
let b = $(bdy), rx = b.data() || {}, i = [], citems = [], itm = b.find('tr.itm'), iso = 0, ipos = 0;
b.tC('empty', itm.length < 1);
itm.each((ti, tx) => {
let rrx = $(tx).data() || {}; csms(rrx, sms, rx.Id); bnet += (rrx.net_val || 0); i.push(rrx.co);
//console.debug('rrx %o', rrx);
let rrx = $(tx).data() || {};
i.push(rrx.co);
/* backend item contract (title/desc/qty/price_net/total_net + set flags), see InvoiceSetPricing.
Set grouping: an item of Type 'set' is a header that claims the following items in this
block as its members until the next set header (mfr__items has no explicit member link). */
Set grouping: fds__prepInvoice computes [SetItmId] per item, anchored on the still-unconverted
(price 0) Set header that owns it this is the authoritative membership signal (not row order),
so items not tagged with a SetItmId by the server are never swept into a preceding set. The
header row's own SetItmId self-references its own id, but it is never its own member (excluded
by the id !== '' && !== citem.id check below). */
let citem = $inv.itemToContract(rrx);
if (citem.type === 'set' && citem.id !== '') { cset = citem.id; }
else if (cset !== null && (citem.id || '') !== '') { citem.setId = cset; }
let sid = (rrx.SetItmId || '').toString();
if (citem.type !== 'set' && sid !== '' && sid !== citem.id) { citem.setId = sid; }
citems.push(citem);
if (((typeof rrx.SortOrder === 'undefined' || rrx.SortOrder === null) ? -1 : rrx.SortOrder) > -1) {
if (['text', 'title'].includes((rrx.Type || 'other').toLowerCase()) === false) { ipos++; }
@@ -1053,41 +1327,10 @@ $inv.invSumUpdate = function () {
$inv.rrw.call(tx);
}
});
//console.debug('%o', {
// f: b.find('tr.isum > td.isumval'), t: fnum(bnet, $rct.cst), n: bnet
//});
b.find('tr.isum > td.isumval').text(fnum(bnet, $rct.cst));
ba.push({ Id: rx.Id, nme: rx.Name, text: rx.text, itm: i, items: citems, netval: bnet });
ba.push({ Id: rx.Id, nme: rx.Name, text: rx.text, itm: i, items: citems });
});
let nonempty = tbl.find('tbody:not(.empty)').length;
bds.find('tr.isum').tC('hidden', nonempty < 2);
//let fnet = $$.tdc('currency', $$.tr(ft, { class: 'tsum' }).append([$$.tdc('aux'), $$.td({ colspan: 4 }).text('Netto')]), fnum(sms.ttn, $rct.cst));
rwcy('Netto', sms.ttn);
if (p13b === false) {
$.each(sms.vat, (vi, vx) => {
//$$.tdc('currency vat', $$.tr(ft, { class: 'tvat' }).append([$$.tdc('aux'), $$.td({ colspan: 4 }).text('Umsatzsteuer ' + vi)]), fnum(vx, $rct.cst));
rwcy($rct.vat + ' ' + vi, vx, 'tvat');
});
} else {
sms.ttb = sms.ttn;
}
//let fsum = $$.tdc('currency', $$.tr(ft, { class: 'tsum' }).append([$$.tdc('aux'), $$.td({ colspan: 4 }).text('Summe')]), fnum(sms.ttb, $rct.cst));
rwcy('Summe', sms.ttb);
let itype = tbl.data().admin.type;
if (itype === 'i') {
fn($rct.note2);
fn($rct.note4);
} else if (itype === 'c') {
fn($rct.note2);
} else {
fn(string($rct.note3, [fnum((sms.tscn + sms.tscvat) * (tbl.data().admin.tax_servicerefund || 0), $rct.cst)])).aC('ntax');
fn($rct.note2);
fn(string($rct.note1, [fnum(sms.tscn + sms.tscvat, $rct.cst), fnum(sms.tscn, $rct.cst), fnum(sms.tscvat, $rct.cst)]));
}
if (p13b === true) {
fn($rct.note13b);
}
tbl.data('sms', sms);
tbl.data('bai', ba);
/* Backend-authoritative seeding (ADR 0006): on the first calculation of an invoice
draft (invSumUpdate is only bound for invoices, never reminders), hand the assembled
@@ -1187,8 +1430,16 @@ $inv.t_fds_inv = () => {
let tbl = $('div.invoice_layout table.invi');
tbl.trigger('fds.inv');
/* After any local item mutation, push the changed block(s) to the authoritative
server session as granular deltas (invoice drafts only reminders have no token). */
if ((tbl.data('dtoken') || '') !== '') { $inv.d.syncChanged(tbl); }
server session as granular deltas (invoice drafts only reminders have no token,
nor a 'dseedpromise', so they correctly fall through to the no-op). While the initial
seed (inv/dopen) is still in flight there is no token yet either, but a 'dseedpromise'
is pending wait for it instead of skipping the flush, otherwise an edit made in that
brief window is silently never sent. Returns the flush promise so callers that must
sequence a follow-up server delta after this flush (e.g. $inv.toSetPrice) can wait for
it instead of racing it. */
if ((tbl.data('dtoken') || '') !== '') { return $inv.d.syncChanged(tbl); }
let seeding = tbl.data('dseedpromise');
return seeding ? $.when(seeding).then(() => $inv.d.syncChanged(tbl)) : $.when();
};
$inv.sedit = () => {
$inv.sprev(true);
@@ -1255,7 +1506,10 @@ $inv.itemToContract = function (rrx) {
rrx = rrx || {};
let oHtml = (e) => $$.d().append(e).html();
let type = (rrx.Type || '').toString().toLowerCase();
let ci = { id: (rrx.Id || '').toString(), type: type, title: '', desc: '', qty: '', price_net: '', total_net: (rrx.net_val || 0), vat: rrx.vat || '' };
/* ADR 0009: preserve null prices verbatim (null = empty cell, kept distinct from a real 0) so
block-mode nulled members and the set row round-trip unchanged when the block is re-posted. */
let nz = (x) => (x == null ? null : x);
let ci = { id: (rrx.Id || '').toString(), type: type, title: '', desc: '', qty: '', price_net: '', total_net: nz(rrx.net_val), vat: rrx.vat || '' };
if (rrx.co && rrx.co.typ === 'osum') {
/* combined single-sum line — the on-screen "title" is an HTML sub-table; render it as desc */
ci.desc = rrx.co.t || '';
@@ -1276,30 +1530,35 @@ $inv.itemToContract = function (rrx) {
ci.desc = rrx.Note || '';
}
ci.qty = rrx.quantity || ((rrx.quantityhours || 0) !== 0 ? (fnum(rrx.quantityhours) + (rrx.UnitString ? ' ' + rrx.UnitString : '')) : '');
ci.price_net = (rrx.net || 0);
ci.total_net = (rrx.net_val || 0);
ci.price_net = nz(rrx.net);
ci.total_net = nz(rrx.net_val);
}
return ci;
};
/* 3-way set-pricing display switch. Mirrors §13b: writes the choice onto admin.setmode,
which BuildInvoiceParams turns into the "setmode:<mode>" InvoiceOptions token the PDF reads. */
/* 2-way set-pricing display switch (SetPrice / SetOnly). Mirrors §13b: writes the choice onto
admin.setmode, which BuildInvoiceParams turns into the "setmode:<mode>" InvoiceOptions token
the PDF reads. ItemPrices ("Positionen mit Preis Set als Überschrift") was removed: it was
always just the implicit pre-conversion default and is not a state the user can switch back to
once a set has been converted via the "Auf Setpreis umstellen" item switch (see $inv.toSetPrice /
InvoiceDraftEditService.ApplyItemSetPrice) that conversion is one-way. */
$inv.ssetmode = () => {
let l = $('div.invoice_layout'), tbl = l.find('table.invi'), d = tbl.data();
d.admin = d.admin || {};
let cur = (d.admin.setmode || 'setprice'), o;
let btn = (mode) => $$.dc('btn', $ict.setmo[mode]).tC('selected', cur === mode).click(() => { o.c.trigger('modal_close'); $inv.setSetmode(mode); });
let fr = $$.dc('choicefrm').append([btn('setprice'), btn('itemprices'), btn('setonly')]);
let fr = $$.dc('choicefrm').append([btn('setprice'), btn('setonly')]);
o = $ocms.dlg(fr, { width: 800 });
};
/* "Set mit Preis" / "Nur Set mit Preis" (ADR 0009): NOT a display toggle an irreversible,
backend-authoritative mutation grouped by service-request block. The server inserts a dedicated
set row per block (block sum) and either nulls each member's price (setprice) or removes the
members (setonly); the normal draftReady refresh re-renders both via $inv.d.applyItems. The
browser only posts the chosen mode. Flush pending local edits first so a delayed block.replace
can't land after the conversion and overwrite it (same race guard as $inv.toSetPrice). */
$inv.setSetmode = (mode) => {
let l = $('div.invoice_layout'), tbl = l.find('table.invi'), d = tbl.data();
d.admin = d.admin || {};
d.admin.setmode = mode; /* posted in admin -> BuildInvoiceParams writes setmode: into InvoiceOptions */
d.inv = d.inv || {}; /* keep a local InvoiceOptions reflection in sync (cosmetic) */
let opts = (d.inv.InvoiceOptions || '').split(',').filter(x => x !== '' && x.indexOf('setmode:') !== 0);
if (mode && mode !== 'setprice') { opts.push('setmode:' + mode); }
d.inv.InvoiceOptions = opts.join(',');
$inv.d.sync({ Target: 'setmode', Value: mode });
if (confirm($rct.toSetMc || $rct.toSetPc) === false) { return; }
let target = (mode === 'setonly') ? 'block.setonly' : 'block.setprice';
$.when($inv.t_fds_inv()).done(() => { $inv.d.sync({ Target: target }); });
};
$inv.sctp = () => {
let flds = $invcol.ctp;
@@ -1322,24 +1581,21 @@ $inv.sctp = () => {
});
};
/* Normalises the editor's working model into the exact field names the C# backend
(FdsInvoiceData.BuildInvoiceParams) reads, then returns the `invc` payload:
- balances/service sums come from `sms` (ttn/ttb), exposed on `new` as total_net/total_gross;
- every VAT rate's net amount is exposed as new.vat_<rate>_net (the backend reads the highest);
(FdsInvoiceData.BuildInvoiceParams) reads, then returns the `invc` payload. No totals/VAT
are computed here (ADR 0006/0008: the online editor performs no arithmetic at all) the
backend session computes total_net/total_gross/vat itself (InvoiceDraftCalculator) once
opened from this payload, and BuildFdsData/BuildInvoiceParams reads them from there:
- new.invoicetitle -> new.title, new.loc -> new.provisionlocation, admin.paymentterms ->
new.paymentterm, admin.CustomerId -> admin.customerid.
Originals are kept alongside; the source objects are not mutated. */
$inv.invcPayload = function (d) {
d = d || {};
let sms = d.sms || {}, nw = $.extend({}, d.new), adm = $.extend({}, d.admin);
nw.total_net = sms.ttn || 0;
nw.total_gross = sms.ttb || 0;
/* VAT (rate + amount) is taken by the backend straight from the posted sms.vat map
(FdsInvoiceData.HighestVat), so no per-rate new.vat_* keys are needed here. */
let nw = $.extend({}, d.new), adm = $.extend({}, d.admin);
nw.title = (nw.invoicetitle != null ? nw.invoicetitle : (nw.title || ''));
nw.provisionlocation = (nw.loc != null ? nw.loc : (nw.provisionlocation || ''));
nw.paymentterm = (adm.paymentterms != null ? adm.paymentterms : (nw.paymentterm || ''));
adm.customerid = (adm.customerid != null ? adm.customerid : adm.CustomerId);
return { admin: adm, req: d.bai, sms: d.sms, new: nw };
return { admin: adm, req: d.bai, new: nw };
};
/* Zwischenspeichern and preview now run against the backend-authoritative session
(ADR 0006): no full-invoice re-upload the cached draft is flushed / rendered by
@@ -1353,20 +1609,11 @@ $inv.rReload = () => {
$inv.cInv2({ id: s.search });
} catch (e) { }
};
$inv.quantChange = function (i) {
//console.debug({ t: this, i: i });
let t = $(this), f = t.closest('form'), fi = {}, pf = (i) => parseFloat(i.toString().replace('%', '').replace(',', '.')), rtp = (num) => num.toFixed(2);
f.find(':input').each((i, e) => { fi[$(e).attr('name')] = $(e); });
let qv = parseInt(fi.quantityhours.val() || '0'), nv = pf(fi.net.val() || '0'), vat = pf(fi.vat.val()) * 0.01;
if (qv > 0 && nv > 0) {
fi.net_val.val(rtp(qv * nv));
fi.vat_val.val(rtp(qv * nv * vat));
if (['Service'].includes(fi.Type.val())) {
fi.svcnet_val.val(rtp(qv * nv));
fi.svcvat_val.val(rtp(qv * nv * vat));
}
}
};
/* No client-side arithmetic (ADR 0006/0008): quantity/price/VAT edits are posted as raw values
and the server (InvoiceDraftCalculator.RecomputeLineValues) computes net_val/vat_val/
svcnet_val/svcvat_val. The fields are left as entered here; the authoritative values are
pushed back onto the row by $inv.d.applyItems once the change reaches the backend. */
$inv.quantChange = function (i) { };
$inv.storno = function (id, fds) {
let o, fr = $$.dc('choicefrm').append([
$$.dc('btn', 'Storno ohne Details').click({ id: id, mode: 'simple' }, (ev) => { o.c.trigger('modal_close'); $inv.cSt(ev.data); })
@@ -1541,7 +1788,10 @@ $inv.ccRem_s2 = function (id, sets) { //reminder creation
//console.debug(response);
let rem = response.rm || {};
let rq = $$.dc('srq', frm);
$ict.remt[rem.type].forEach(r => $$[0]('p').rwText(r).appendTo(rq));
/* Intro/closing notice paragraphs are backend-authoritative (see $inv.rd.notes,
populated from FuchsPdf.BuildReminderNotes via rem/dstate after seeding below)
they are appended/prepended into this same .srq container once the draft session
is open, so no client-side wording is constructed here. */
let rif = $$.tblset({ class: 'invi' }, rq);
rif.ft = $$[0]('tfoot');
rif.tbl.data($.extend({ invid: rem.invid, new: {} }, response));
@@ -1549,7 +1799,6 @@ $inv.ccRem_s2 = function (id, sets) { //reminder creation
$ict.remHR.forEach(h => $$.th(thr, h));
$inv.rRemRw.call($$.tr(rif.bdy), rif.tbl.data());
rif.ft.appendTo(rif.tbl);
$ict.remt2[rem.type].forEach(r => $$[0]('p').rwText(r).appendTo(rq));
let fm = (cls, txt, nme, hd, dta) => {
let fr = $$.dc('inpfrm', frm).aC(cls).append(typeof hd === 'string' ? $$.dc('ahd', hd) : (hd > 0 ? $$.dc('ahd', $rct.frm[nme]) : null)), fc = $$.dc('content', fr).rwText(txt);
@@ -62,6 +62,18 @@
height: auto;
cursor: pointer;
.content {
color: #000;
}
.content:empty::before {
content: '-';
min-width: 5rem;
height: 1rem;
display: block;
position: absolute;
}
&:hover {
div.axf {
display: block;
@@ -232,6 +244,12 @@
border-color: #FFF;
border-top: 1.5px solid #000;
}
/* Set-pricing display (see InvoiceSetPricing / $inv.d.applySetDisplay): the set
header line is emphasised the same way the PDF renders it (bold title). */
&.sethdr td.txt {
font-weight: bold;
}
}
> tfoot > tr {
@@ -456,3 +474,22 @@
}
}
/* Conformity hint shown inside the structured recipient-address dialog (EN 16931 / DATEV). */
.adr-conformity {
margin-top: 0.75rem;
padding: 0.6rem 0.75rem;
background: #f3f7fb;
border: 1px solid #cfe0f0;
border-radius: 4px;
font-size: 0.85rem;
line-height: 1.35;
.hd {
font-weight: 600;
margin-bottom: 0.2rem;
}
.tx {
color: #445;
}
}
+4 -11
View File
@@ -43,7 +43,6 @@
setm: 'Set-Preisanzeige',
setmo: {
setprice: 'Set mit Preis Positionen ohne Preis',
itemprices: 'Positionen mit Preis Set als Überschrift',
setonly: 'Nur Set mit Preis Positionen ausgeblendet'
},
ctp: 'Ansprechpartner festlegen',
@@ -63,16 +62,10 @@
rovlh: 'Übersicht der bisherigen Mahnungen',
rovl: ['Betreff', 'Betrag', 'Betrag gezahlt', 'fertiggestellt am'],
remHR: ['Rechnung', 'vom', 'Rechnungsbetrag', 'bereits bezahlt', 'noch offen'],
remt: {
f: ['Sehr geehrte Damen und Herren,', 'ein Mahnschreiben sollte kurz, freundlich und erfolgreich sein. Kurz ist es, freundlich sowieso; ob es auch erfolgreich ist, hängt von Ihnen ab.']
, m: ['Sehr geehrte Damen und Herren,', 'nun müssen wir Sie noch einmal anschreiben.', 'Wahrscheinlich haben Sie triftige Gründe dafür, warum Sie die Zahlung unserer Forderung nicht vornehmen und auch nicht auf unsere Mahnung reagieren. Sollten wir darüber nicht einmal sprechen?', 'Bitte nehmen Sie umgehend in dieser Sache mit uns Kontakt auf.']
, l: ['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ächsten 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öffnen wir das gerichtliche Mahnverfahren. Sollten Sie die Rechnung inzwischen beglichen haben, so betrachten Sie bitte dieses Schreiben als gegenstandslos.']
},
remt2: {
f: ['Wir bitten Sie, den noch offenen Rechnungsbetrag innerhalb einer Woche auf unser Konto zu überweisen.', 'Sollten Sie den Betrag bereits überwiesen haben, so bitten wir Sie, diese Zahlungserinnerung als gegenstandslos zu betrachten.']
, m: ['Um Ihnen zusätzliche Kosten für weitere Mahnungen zu ersparen, bitten wir Sie nunmehr um die Überweisung des noch zu zahlenden Gesamtbetrages inklusive der ggf. bereits fälligen Mahnzinsen und Mahngebühren innerhalb von einer Woche.']
, l: []
}, payi: { account: 'Konto', name: 'Zahler', text: 'Verw.Zweck', InvoiceID: 'Rechnung', amount: 'Betrag', date: 'Datum', manual: 'Typ'}
// Reminder intro/closing notice texts are backend-authoritative (see FuchsPdf.BuildReminderNotes /
// $inv.rd.notes) — no client-side copies here.
translatePaymentTerm: (pt) => (pt || '').replace('wd', ' Werktagen').replace('d', ' Tagen').replace('wk', ' Wochen') || '10 Tagen'
, payi: { account: 'Konto', name: 'Zahler', text: 'Verw.Zweck', InvoiceID: 'Rechnung', amount: 'Betrag', date: 'Datum', manual: 'Typ'}
}, $invcol = {
datev: new fields_definition('Rechnung', 'Rechnungen', [
{ name: 'Umsatz (ohne Soll/Haben-Kz)', label: 'Umsatz (ohne Soll/Haben-Kz)', type: 'string' },

Some files were not shown because too many files have changed in this diff Show More