Compare commits
2
Commits
5ccd85c38f
...
d94974ce06
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d94974ce06 | ||
|
|
628802db19 |
@@ -0,0 +1,123 @@
|
|||||||
|
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)
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
["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);
|
||||||
|
}
|
||||||
@@ -84,6 +84,37 @@ public class InvoiceDraftServiceTests
|
|||||||
Assert.Equal(1, h.Version);
|
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]
|
[Fact]
|
||||||
public void ApplyPatch_BlockReplace_RecomputesTotals()
|
public void ApplyPatch_BlockReplace_RecomputesTotals()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -168,7 +168,12 @@ OCORE_Charting (standalone — referenced by solution but no direct project ref
|
|||||||
|
|
||||||
### 4.3 Service Layer (Dependency Injection)
|
### 4.3 Service Layer (Dependency Injection)
|
||||||
Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces, injected into `IntranetController`:
|
Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces, injected into `IntranetController`:
|
||||||
`IComService`, `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`, `ISystemStatusService`.
|
`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`).
|
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).
|
The **Admin** module (`Do_Process_Admin`, `ISystemStatusService`) surfaces a live system-status/diagnostics page (host, SQL/Key Vault/blob/MFR connectivity, email config, test-email) restricted to `fds_sys` > 4 — see ADR [0011](Decisions/0011-admin-module-system-status.md) and the [concept doc](Concepts/admin-system-status.md).
|
||||||
`FdsInvoiceData` / `FdsReminderData` are now **pure data holders** (parse + properties); loading, persistence and PDF generation live in the services (fully async — no `Task.Run(...).Wait()`).
|
`FdsInvoiceData` / `FdsReminderData` are now **pure data holders** (parse + properties); loading, persistence and PDF generation live in the services (fully async — no `Task.Run(...).Wait()`).
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
|
(constants mirroring the FuchsPdf letterhead — name, Steuernummer BT-32, IBAN/BIC), buyer =
|
||||||
|
the structured recipient, lines from the invoice items, §13b → reverse charge (category AE +
|
||||||
|
exemption reason). VAT breakdown and totals are recomputed by the library.
|
||||||
|
- **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:ServiceUrl` is the seam for an external
|
||||||
|
online veraPDF (PDF/A-3) + ZUGFeRD validator; until the URL is provisioned, verification is
|
||||||
|
reported as not-configured.
|
||||||
|
|
||||||
|
## Key files
|
||||||
|
- `Fuchs/Services/InvoiceRecipientAddress.cs` — structured buyer address, composition, conformity.
|
||||||
|
- `Fuchs/Services/ERechnungMapper.cs` — `FdsInvoiceData` → `eRechnungLib.Model.Invoice`.
|
||||||
|
- `Fuchs/Services/ERechnungService.cs` / `ERechnungSettings.cs` — hybrid production + config.
|
||||||
|
- `Fuchs/Services/InvoiceService.cs` — wiring in `RenderInvoicePdfBytesAsync`.
|
||||||
|
- `Fuchs/code/FuchsPdf.cs` — `DocToPdfBytesRaw` (render-only visual PDF).
|
||||||
|
- `Fuchs/js/intranet/modules/fis.inv_shared.js` — `$inv.eAddress` structured dialog.
|
||||||
|
- `Fuchs_Database` — `fds__invoices.SendToAddressJson`, `fds__getCompanyAddressJson`,
|
||||||
|
`fds__prepInvoice.invoiceaddressData`, `fds__createInvoice`/`setInvoice`/`getInvoice`.
|
||||||
|
- `eRechnungLib/**` — CII/UBL serialization, EN 16931 validation, `FacturXPdfBuilder` (PDF/A-3).
|
||||||
|
|
||||||
|
## Related decisions
|
||||||
|
- [`0005-pdf-generation-and-erechnung.md`](../Decisions/0005-pdf-generation-and-erechnung.md)
|
||||||
|
- [`0012-erechnung-single-pdfa-engine-pipeline.md`](../Decisions/0012-erechnung-single-pdfa-engine-pipeline.md)
|
||||||
@@ -0,0 +1,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.
|
||||||
@@ -90,8 +90,12 @@ payload; see `EVAL_live_invoice_editing.md` for the rationale.
|
|||||||
### 4.1 What the user can change
|
### 4.1 What the user can change
|
||||||
- **Line items** — quantities, prices, notes, combine into one sum
|
- **Line items** — quantities, prices, notes, combine into one sum
|
||||||
(`$inv.rendersrq`, `$inv.quantChange`).
|
(`$inv.rendersrq`, `$inv.quantChange`).
|
||||||
- **Recipient fields** — invoice title, address, email, provision
|
- **Recipient fields** — invoice title, email, provision location/period (inline
|
||||||
location/period (inline edit fields, `fm(...)` helper in `fis.inv_shared.js`).
|
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)).
|
||||||
- **§13b reverse-charge** toggle (`$inv.sp13b`) — suppresses VAT lines/columns.
|
- **§13b reverse-charge** toggle (`$inv.sp13b`) — suppresses VAT lines/columns.
|
||||||
- **Set-pricing display mode** (`$inv.ssetmode` / `setSetmode`) — `SetPrice`
|
- **Set-pricing display mode** (`$inv.ssetmode` / `setSetmode`) — `SetPrice`
|
||||||
(default) / `SetOnly`; see `INVOICE_SET_PRICING.md`. Purely presentational
|
(default) / `SetOnly`; see `INVOICE_SET_PRICING.md`. Purely presentational
|
||||||
@@ -360,6 +364,10 @@ flowchart TD
|
|||||||
- **Draft vs. final changes the rendered PDF**: draft = watermark overlay, no
|
- **Draft vs. final changes the rendered PDF**: draft = watermark overlay, no
|
||||||
GiroCode; final = no watermark, GiroCode payment QR added when there's a
|
GiroCode; final = no watermark, GiroCode payment QR added when there's a
|
||||||
positive balance.
|
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`
|
- **Email is best-effort and tracked**: `IsSent` is only set `true`
|
||||||
automatically after a *successful* send; a failed send still leaves a
|
automatically after a *successful* send; a failed send still leaves a
|
||||||
correctly finalised, stored invoice that staff can resend or mark sent
|
correctly finalised, stored invoice that staff can resend or mark sent
|
||||||
|
|||||||
@@ -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, gesetzeskonforme E-Rechnung 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 inklusive</span></div>
|
||||||
|
<div><b>Echtzeit</b><span>Live-Vorschau & Benachrichtigungen</span></div>
|
||||||
|
<div><b>.NET 10</b><span>Moderne, geprüfte Plattform</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- WAS NEU IST -->
|
||||||
|
<section>
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="kicker">Das Wichtigste auf einen Blick</span>
|
||||||
|
<h2>Die neuen Funktionen für Ihren Alltag</h2>
|
||||||
|
<p>Alle Neuerungen zielen auf dasselbe Ziel: weniger Fehler, mehr Überblick und Rechnungen, die auf Anhieb korrekt sind.</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid">
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="ico">👁️</div>
|
||||||
|
<h3>Live-Vorschau beim Bearbeiten</h3>
|
||||||
|
<p>Während Sie eine Rechnung bearbeiten, sehen Sie das fertige PDF sofort in Echtzeit — genau so, wie es der Kunde erhält. Kein Zwischenspeichern, kein Raten mehr.</p>
|
||||||
|
<span class="tag">Rechnungen & Zahlungserinnerungen</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="ico">🧮</div>
|
||||||
|
<h3>Alle Beträge serverseitig berechnet</h3>
|
||||||
|
<p>Summen, Mehrwertsteuer, §13b-Umkehr und offene Beträge rechnet ab sofort der Server — geprüft und einheitlich. Der Bildschirm zeigt immer denselben Stand wie das PDF.</p>
|
||||||
|
<span class="tag">Keine Rechenfehler mehr</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="ico">🔔</div>
|
||||||
|
<h3>Benachrichtigungen in Echtzeit</h3>
|
||||||
|
<p>„Rechnung R2026-0001 wurde an den Kunden versandt." — Erfolg <em>und</em> Fehler erscheinen sofort als deutlich lesbare Meldung. Kein Nachschauen in Listen mehr.</p>
|
||||||
|
<span class="tag">Sofortiges Feedback</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="ico">🧾</div>
|
||||||
|
<h3>Gesetzeskonforme E-Rechnung</h3>
|
||||||
|
<p>Rechnungen werden als strukturierte E-Rechnung (ZUGFeRD 2.4 / Factur-X & XRechnung) in einem formal geprüften PDF/A-3 ausgegeben — DATEV-tauglich und die verpflichtende Form für den B2B- und Behördenversand.</p>
|
||||||
|
<span class="tag">ZUGFeRD 2.4 · PDF/A-3 · DATEV</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="ico">🕓</div>
|
||||||
|
<h3>Änderungshistorie & Verwerfen</h3>
|
||||||
|
<p>Jede Änderung an einem Entwurf wird protokolliert. Über „Änderungshistorie" sehen Sie, was passiert ist, und mit „Änderungen verwerfen" kehren Sie jederzeit zum gespeicherten Stand zurück.</p>
|
||||||
|
<span class="tag">Volle Nachvollziehbarkeit</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="ico">🏦</div>
|
||||||
|
<h3>Mehr Bankformate</h3>
|
||||||
|
<p>Kontoauszüge werden jetzt auch im modernen ISO-20022-Format (CAMT) automatisch erkannt und eingelesen — zusätzlich zum bewährten MT940. Das Format wird selbstständig erkannt.</p>
|
||||||
|
<span class="tag">CAMT + MT940</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- SPOTLIGHT: Live-Editor -->
|
||||||
|
<section style="padding-top:12px">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="spot">
|
||||||
|
<div class="spot-text">
|
||||||
|
<span class="chip">Highlight</span>
|
||||||
|
<h2>Der Rechnungs-Editor, der mitdenkt</h2>
|
||||||
|
<p>Früher rechnete der Browser — heute ist der Server die einzige verbindliche Quelle. Das klingt technisch, bedeutet für Sie aber vor allem: Was Sie sehen, stimmt. Immer.</p>
|
||||||
|
<p>Gilt für <strong>alle Rechnungsarten</strong> (Regel-, Abschlags-, Schluss- und Stornorechnung) und <strong>alle Mahnstufen</strong> — das Online-Bild und das PDF sind garantiert identisch, bis hin zur Positionsnummerierung.</p>
|
||||||
|
</div>
|
||||||
|
<div class="spot-card">
|
||||||
|
<h3>Was der Editor jetzt automatisch tut</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Rechnet Netto, MwSt. und Brutto sofort korrekt neu</li>
|
||||||
|
<li>Prüft E-Mail, Adresse, Positionen und Steuersätze live</li>
|
||||||
|
<li>Erzeugt die PDF-Vorschau direkt aus dem aktuellen Stand</li>
|
||||||
|
<li>Nummeriert Positionen auch nach Umsortieren korrekt durch</li>
|
||||||
|
<li>Warnt rechtzeitig, bevor ein Entwurf abläuft</li>
|
||||||
|
<li>Speichert erst final, wenn Sie es bestätigen</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ONLINE-EDITOR IM DETAIL -->
|
||||||
|
<section>
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="kicker">Der Online-Editor im Detail</span>
|
||||||
|
<h2>Rechnungen direkt im Browser erstellen</h2>
|
||||||
|
<p>Der neue Rechnungs-Editor führt Sie Schritt für Schritt zur fertigen Rechnung — komfortabel zu bedienen und dabei jederzeit rechnerisch abgesichert. Er gilt für alle Rechnungsarten (Regel-, Abschlags-, Schluss- und Stornorechnung).</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="steps">
|
||||||
|
<div class="step"><div class="n">BEARBEITEN</div><h4>Direkt im Feld</h4><p>Texte und Positionen bearbeiten Sie direkt an Ort und Stelle — ein Klick genügt.</p></div>
|
||||||
|
<div class="step"><div class="n">EMPFÄNGER</div><h4>Adresse als Formular</h4><p>Die Rechnungsadresse erfassen Sie strukturiert (Name, Straße, PLZ, Ort, Land, optional USt-IdNr.) — vorausgefüllt aus den Kundendaten. Ein Hinweis zeigt, ob alles für die DATEV-/E-Rechnung passt; für Privatpersonen bleibt die USt-IdNr. einfach leer.</p></div>
|
||||||
|
<div class="step"><div class="n">ORDNEN</div><h4>Blöcke & Reihenfolge</h4><p>Positionen sind je Auftrag in Abschnitten gebündelt und lassen sich per Ziehen neu sortieren; die Nummerierung passt sich automatisch an.</p></div>
|
||||||
|
<div class="step"><div class="n">RECHNEN</div><h4>Summen & Steuer live</h4><p>Netto, Mehrwertsteuer, Brutto und die §13b-Umkehr werden bei jeder Änderung sofort und geprüft neu berechnet.</p></div>
|
||||||
|
<div class="step"><div class="n">PRÜFEN</div><h4>Vorschau auf Knopfdruck</h4><p>Die PDF-Vorschau entsteht direkt aus dem aktuellen Stand — was Sie sehen, ist exakt das, was der Kunde erhält.</p></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SET-PREIS-VARIANTEN -->
|
||||||
|
<div class="setpanel">
|
||||||
|
<div class="ttl"><span class="badge">📦</span><h3>Set-Preis: drei Wege, Positionen zusammenzufassen</h3></div>
|
||||||
|
<p>Oft sollen mehrere Einzelpositionen zu <em>einem</em> Set-Preis zusammengefasst werden — etwa als Pauschale pro Auftrag. Dafür gibt es drei klar getrennte Funktionen. Bei allen bleibt die <strong>Rechnungssumme unverändert</strong>; die Set-Zeile trägt genau den Wert der zusammengefassten Positionen.</p>
|
||||||
|
|
||||||
|
<div class="setgrid">
|
||||||
|
|
||||||
|
<div class="setvar">
|
||||||
|
<div class="num">1</div>
|
||||||
|
<h4>Einzelne Set-Position zusammenfassen<small>Zeilen-Schaltfläche · direkt an der Position</small></h4>
|
||||||
|
<p>Für eine einzelne Set-Position: Der Set-Kopf übernimmt die Summe seiner zugehörigen Teilpositionen, deren Einzelpreise werden dann leer dargestellt (kein Preis, nicht 0,00 €).</p>
|
||||||
|
<span class="kv">Wirkt auf: eine markierte Set-Position</span>
|
||||||
|
<span class="pill one">Einmalig — nicht per Klick umkehrbar</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setvar">
|
||||||
|
<div class="num">2</div>
|
||||||
|
<h4>„Set mit Preis"<small>Menü · ganzer Auftragsblock</small></h4>
|
||||||
|
<p>Für jeden Auftragsblock wird oben eine hervorgehobene Set-Zeile mit dem Gesamtwert des Blocks eingefügt. Die bisherigen Einzelpositionen <strong>bleiben sichtbar</strong>, jedoch ohne Einzelpreis (leeres Preisfeld). Die eingefügte Set-Zeile ist eine echte, nachträglich editierbare Position.</p>
|
||||||
|
<span class="kv">Wirkt auf: jeden Auftragsblock · Positionen bleiben erhalten</span>
|
||||||
|
<span class="pill rev">Einmalige Umwandlung</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setvar">
|
||||||
|
<div class="num">3</div>
|
||||||
|
<h4>„Nur Set mit Preis"<small>Menü · ganzer Auftragsblock</small></h4>
|
||||||
|
<p>Wie „Set mit Preis" — aber die Einzelpositionen werden <strong>vollständig entfernt</strong>. Es bleibt allein die eine Set-Zeile mit dem Gesamtpreis des Blocks stehen. Ideal für eine schlanke Pauschal-Darstellung.</p>
|
||||||
|
<span class="kv">Wirkt auf: jeden Auftragsblock · Positionen werden entfernt</span>
|
||||||
|
<span class="pill rev">Einmalige Umwandlung</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setnote">
|
||||||
|
<b>Gut zu wissen:</b> Die beiden Menü-Varianten (2 & 3) sind bewusste, <b>einmalige Umwandlungen</b> — es gibt keinen Umschalter zurück. Möchten Sie den Ausgangszustand wiederherstellen, verwerfen Sie einfach den Entwurf („Änderungen verwerfen"), oder passen Sie die entstandene Set-Zeile von Hand an. In jedem Fall gilt: die <b>Gesamtsumme ändert sich nicht</b>, und die PDF-Ausgabe zeigt exakt dasselbe wie der Online-Editor.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- BEFORE / AFTER -->
|
||||||
|
<section class="compare">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="kicker">Alt gegen Neu</span>
|
||||||
|
<h2>Was sich konkret verbessert hat</h2>
|
||||||
|
<p>Ein direkter Vergleich der bisherigen Lösung mit der neuen Implementierung.</p>
|
||||||
|
</div>
|
||||||
|
<div class="table-scroll">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Bereich</th><th>Bisher (Legacy)</th><th>Neu</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="feat">Rechnungsvorschau</td>
|
||||||
|
<td class="old">Kein Live-PDF — Ergebnis erst nach dem Speichern sichtbar</td>
|
||||||
|
<td class="new">Echtzeit-PDF-Vorschau schon während der Bearbeitung</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="feat">Berechnung</td>
|
||||||
|
<td class="old">Beträge im Browser gerechnet — Abweichungen möglich</td>
|
||||||
|
<td class="new">Alle Werte serverseitig geprüft & einheitlich berechnet</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="feat">Rückmeldungen</td>
|
||||||
|
<td class="old">Keine aktive Meldung — Status nur durch Nachschauen</td>
|
||||||
|
<td class="new">Sofortige Erfolgs- und Fehlermeldungen in Echtzeit</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="feat">Fehler im Hintergrund</td>
|
||||||
|
<td class="old">Nur im Protokoll — für den Nutzer unsichtbar</td>
|
||||||
|
<td class="new">Werden dem Nutzer verständlich angezeigt</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="feat">Rechnungsformat</td>
|
||||||
|
<td class="old">Reines PDF</td>
|
||||||
|
<td class="new">Zusätzlich gesetzeskonforme E-Rechnung (ZUGFeRD / XRechnung)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="feat">Änderungsverlauf</td>
|
||||||
|
<td class="old">Nicht vorhanden</td>
|
||||||
|
<td class="new">Vollständige Historie & gezieltes Verwerfen je Entwurf</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="feat">Kontoauszüge</td>
|
||||||
|
<td class="old">Nur MT940</td>
|
||||||
|
<td class="new">MT940 <em>und</em> CAMT (ISO 20022) mit Auto-Erkennung</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="feat">System-Überblick</td>
|
||||||
|
<td class="old">Kein Einblick in den Systemzustand</td>
|
||||||
|
<td class="new">Admin-/Status-Modul mit Live-Prüfungen (für berechtigte Nutzer)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="feat">Plattform</td>
|
||||||
|
<td class="old">Ältere VB-Codebasis</td>
|
||||||
|
<td class="new">Modernes .NET 10 — schneller, gepflegt, umfangreich getestet</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- KEY USER -->
|
||||||
|
<section class="keyuser">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="kicker" style="color:var(--accent-2)">Für den Key-User</span>
|
||||||
|
<h2>Mehr Kontrolle hinter den Kulissen</h2>
|
||||||
|
<p>Diese Punkte betreffen vor allem Sie als Key-User — sie sorgen dafür, dass der Betrieb stabil, nachvollziehbar und überprüfbar bleibt.</p>
|
||||||
|
</div>
|
||||||
|
<div class="ku-grid">
|
||||||
|
<div class="ku">
|
||||||
|
<h3><span>◆</span> Admin- & Status-Modul</h3>
|
||||||
|
<p>Ein eigenes Modul zeigt den Zustand des Systems: Server, Datenbank, Schlüsseltresor, Speicher und die ERP-Anbindung werden live geprüft. Inklusive Test-E-Mail-Funktion — sichtbar nur für berechtigte Nutzer.</p>
|
||||||
|
</div>
|
||||||
|
<div class="ku">
|
||||||
|
<h3><span>◆</span> Durchgängige Nachvollziehbarkeit</h3>
|
||||||
|
<p>Jeder wichtige Geschäftsvorfall — erstellt, versandt, importiert, fehlgeschlagen — wird als Ereignis erfasst und in verständliche Meldungen übersetzt.</p>
|
||||||
|
</div>
|
||||||
|
<div class="ku">
|
||||||
|
<h3><span>◆</span> Überwachung & Diagnose</h3>
|
||||||
|
<p>Moderne Telemetrie (OpenTelemetry) misst Abläufe, Laufzeiten und Fehler. Probleme lassen sich damit früher erkennen und schneller eingrenzen.</p>
|
||||||
|
</div>
|
||||||
|
<div class="ku">
|
||||||
|
<h3><span>◆</span> Automatischer ERP-Abgleich</h3>
|
||||||
|
<p>Der Abgleich mit dem ERP-System (mfr) läuft zuverlässig im Hintergrund direkt in der Anwendung — mit automatischer Wiederholung bei kurzzeitigen Störungen.</p>
|
||||||
|
</div>
|
||||||
|
<div class="ku">
|
||||||
|
<h3><span>◆</span> Sichere Konfiguration</h3>
|
||||||
|
<p>Zugangsdaten liegen im zentralen Azure Key Vault. Eine Test-Schutzfunktion verhindert, dass in Test-Umgebungen versehentlich echte Kunden angeschrieben werden.</p>
|
||||||
|
</div>
|
||||||
|
<div class="ku">
|
||||||
|
<h3><span>◆</span> Umfassend getestet</h3>
|
||||||
|
<p>Die Kernlogik ist durch eine breite, automatisierte Testabdeckung abgesichert — erfolgreiche wie fehlerhafte Abläufe werden geprüft, bevor Änderungen live gehen.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- UNVERÄNDERT / VERTRAUT -->
|
||||||
|
<section>
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="kicker">Vertraut geblieben</span>
|
||||||
|
<h2>Was sich für Sie <em>nicht</em> ändert</h2>
|
||||||
|
<p>Modernisiert wurde die Technik — nicht Ihre Arbeitsweise.</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card"><div class="ico">🗂️</div><h3>Gewohnte Module</h3><p>Rechnungen, Zahlungserinnerungen, Anfragen, Banking und Berichte finden Sie an denselben Stellen wie bisher.</p></div>
|
||||||
|
<div class="card"><div class="ico">📄</div><h3>Vertrautes Layout</h3><p>Briefkopf, Adressfenster und Rechnungslayout wurden 1:1 übernommen — Ihre Dokumente sehen aus wie gewohnt.</p></div>
|
||||||
|
<div class="card"><div class="ico">🔐</div><h3>Gleiche Anmeldung</h3><p>Login und Berechtigungen bleiben unverändert. Neue Funktionen erscheinen nur dort, wo Sie dafür berechtigt sind.</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<div class="wrap">
|
||||||
|
<p><b>Fuchs Intranet</b> — Neue Implementierung · Stand Juli 2026<br>
|
||||||
|
Sebastian Fuchs Bad und Heizung GmbH & Co. KG · Bereitgestellt von ProcessWeb</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Binary file not shown.
@@ -22,6 +22,7 @@
|
|||||||
<ProjectReference Include="..\OCORE_web\OCORE_web\OCORE_web.csproj" />
|
<ProjectReference Include="..\OCORE_web\OCORE_web\OCORE_web.csproj" />
|
||||||
<ProjectReference Include="..\OCORE_web_pdf\OCORE_web_pdf.csproj" />
|
<ProjectReference Include="..\OCORE_web_pdf\OCORE_web_pdf.csproj" />
|
||||||
<ProjectReference Include="..\CAMTParser\CAMTParser.csproj" />
|
<ProjectReference Include="..\CAMTParser\CAMTParser.csproj" />
|
||||||
|
<ProjectReference Include="..\eRechnungLib\src\eRechnungLib\eRechnungLib.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Content Include="Data\**" CopyToOutputDirectory="PreserveNewest" />
|
<Content Include="Data\**" CopyToOutputDirectory="PreserveNewest" />
|
||||||
|
|||||||
@@ -121,6 +121,9 @@ public class Program
|
|||||||
// (see appsettings.Development.json) so real tenant-owners/end-customers are never emailed.
|
// (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<FuchsEmailSettings>(builder.Configuration.GetSection("Fuchs:Email"));
|
||||||
builder.Services.Configure<StartupSelfTestSettings>(builder.Configuration.GetSection("Fuchs:StartupChecks"));
|
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.AddHttpClient("ProcessWebMailer");
|
builder.Services.AddHttpClient("ProcessWebMailer");
|
||||||
builder.Services.AddScoped<IComService, ProcessWebComService>();
|
builder.Services.AddScoped<IComService, ProcessWebComService>();
|
||||||
// Holds the one-shot startup self-test result for the lifetime of the process so the Admin
|
// Holds the one-shot startup self-test result for the lifetime of the process so the Admin
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
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 currently lives as constants in
|
||||||
|
/// <c>FuchsPdf</c> (letterhead, Steuernummer, bank) and is mirrored here — a future change should
|
||||||
|
/// lift both into shared configuration. 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
|
||||||
|
{
|
||||||
|
// Seller master data — mirrors the FuchsPdf letterhead/footer constants (ADR 0005).
|
||||||
|
private const string SellerName = "Sebastian Fuchs GmbH & Co. KG";
|
||||||
|
private const string SellerStreet = "Germaniastraße 15";
|
||||||
|
private const string SellerPostalCode = "40223";
|
||||||
|
private const string SellerCity = "Düsseldorf";
|
||||||
|
private const string SellerTaxNumber = "106/5849/2962"; // Steuernummer (BT-32)
|
||||||
|
private const string SellerIban = "DE76300501100045014800"; // Stadtsparkasse Düsseldorf (GiroCode account)
|
||||||
|
private const string SellerBic = "DUSSDEDDXXX";
|
||||||
|
|
||||||
|
/// <summary>Builds an <see cref="EInvoice"/> (EN 16931 model) from the Fuchs invoice data.</summary>
|
||||||
|
public static EInvoice BuildEInvoice(FdsInvoiceData invoice)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(invoice);
|
||||||
|
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(),
|
||||||
|
Buyer = BuildBuyer(invoice),
|
||||||
|
Payment = BuildPayment(invoice),
|
||||||
|
};
|
||||||
|
|
||||||
|
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)");
|
||||||
|
|
||||||
|
return EInvoice.CreateInvoice(model).Recalculate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TradeParty BuildSeller()
|
||||||
|
{
|
||||||
|
var seller = new TradeParty
|
||||||
|
{
|
||||||
|
Name = SellerName,
|
||||||
|
Address = new PostalAddress
|
||||||
|
{
|
||||||
|
Line1 = SellerStreet,
|
||||||
|
PostalCode = SellerPostalCode,
|
||||||
|
City = SellerCity,
|
||||||
|
Country = CountryCode.Germany,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
seller.TaxRegistrations.Add(new TaxRegistration(SellerTaxNumber, TaxRegistrationScheme.LocalTaxNumber));
|
||||||
|
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));
|
||||||
|
return buyer;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PaymentInstructions BuildPayment(FdsInvoiceData invoice)
|
||||||
|
{
|
||||||
|
var payment = new PaymentInstructions
|
||||||
|
{
|
||||||
|
MeansCode = PaymentMeansCode.SepaCreditTransfer,
|
||||||
|
RemittanceInformation = invoice.InvoiceRegistration?.getString("InvoiceId") is { Length: > 0 } inv ? inv : null,
|
||||||
|
};
|
||||||
|
payment.CreditTransfers.Add(new CreditTransferAccount
|
||||||
|
{
|
||||||
|
AccountId = SellerIban,
|
||||||
|
AccountName = SellerName,
|
||||||
|
BankId = SellerBic,
|
||||||
|
});
|
||||||
|
return payment;
|
||||||
|
}
|
||||||
|
|
||||||
|
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") },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── helpers ────────────────────────────────────────────────────────────────
|
||||||
|
private static DateOnly IssueDate(GenericObjectDictionary? reg)
|
||||||
|
{
|
||||||
|
foreach (var key in new[] { "DateFinalized", "DateCreated" })
|
||||||
|
if (reg?.getString(key) is { Length: > 0 } s && DateTime.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dt))
|
||||||
|
return DateOnly.FromDateTime(dt);
|
||||||
|
return DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Normalises a raw country value (ISO alpha-2, or a German/English name) to a country code.</summary>
|
||||||
|
internal static CountryCode NormalizeCountry(string? raw)
|
||||||
|
{
|
||||||
|
string s = (raw ?? "").Trim();
|
||||||
|
if (s.Length == 2 && s.All(char.IsLetter)) return new CountryCode(s);
|
||||||
|
return s.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"deutschland" or "germany" => CountryCode.Germany,
|
||||||
|
"österreich" or "oesterreich" or "austria" => CountryCode.Austria,
|
||||||
|
"schweiz" or "switzerland" or "suisse" => CountryCode.Switzerland,
|
||||||
|
"" => CountryCode.Germany,
|
||||||
|
_ => CountryCode.Germany,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Str(IDictionary<string, object?> d, string key)
|
||||||
|
=> d.TryGetValue(key, out var v) && v != null ? Convert.ToString(v, CultureInfo.InvariantCulture)?.Trim() ?? "" : "";
|
||||||
|
|
||||||
|
private static decimal? Dec(IDictionary<string, object?> d, params string[] keys)
|
||||||
|
{
|
||||||
|
foreach (var k in keys)
|
||||||
|
if (d.TryGetValue(k, out var v) && v != null)
|
||||||
|
{
|
||||||
|
string s = Convert.ToString(v, CultureInfo.InvariantCulture)?.Replace("%", "").Trim() ?? "";
|
||||||
|
if (decimal.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var dec)) return dec;
|
||||||
|
if (decimal.TryParse(s.Replace(',', '.'), NumberStyles.Any, CultureInfo.InvariantCulture, out dec)) return dec;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static decimal? ParseInvariant(string? s)
|
||||||
|
=> decimal.TryParse((s ?? "").Replace(',', '.'), NumberStyles.Any, CultureInfo.InvariantCulture, out var d) ? d : null;
|
||||||
|
|
||||||
|
private static string FirstNonEmpty(params string[] values) => values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v)) ?? "";
|
||||||
|
private static string NonEmpty(params string?[] values) => values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v)) ?? "";
|
||||||
|
private static string? NullIfEmpty(string? s) => string.IsNullOrWhiteSpace(s) ? null : s.Trim();
|
||||||
|
private static string Truncate(string s, int max) => s.Length <= max ? s : s[..max];
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
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.
|
||||||
|
/// </summary>
|
||||||
|
byte[]? TryBuildHybridPdf(FdsInvoiceData invoice, byte[] rawVisualPdf);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc cref="IERechnungService"/>
|
||||||
|
public sealed class ERechnungService : IERechnungService
|
||||||
|
{
|
||||||
|
private readonly ERechnungSettings _settings;
|
||||||
|
private readonly ILogger<ERechnungService> _logger;
|
||||||
|
|
||||||
|
public ERechnungService(IOptions<ERechnungSettings> settings, ILogger<ERechnungService> logger)
|
||||||
|
{
|
||||||
|
_settings = settings.Value;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Enabled => _settings.Enabled;
|
||||||
|
|
||||||
|
public byte[]? TryBuildHybridPdf(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 profile = ParseProfile(_settings.Profile);
|
||||||
|
var einvoice = ERechnungMapper.BuildEInvoice(invoice);
|
||||||
|
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 validation findings: {Findings}",
|
||||||
|
invoice.Id, result.Validation);
|
||||||
|
|
||||||
|
act?.SetTag("fuchs.erechnung.bytes", result.Value!.Length);
|
||||||
|
_logger.LogInformation("eRechnung: hybrid PDF/A-3 built for invoice {Id} ({Profile}, {Bytes} bytes, {Ms} ms)",
|
||||||
|
invoice.Id, profile, result.Value.Length, sw.ElapsedMilliseconds);
|
||||||
|
return result.Value;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
act?.SetStatus(ActivityStatusCode.Error, ex.Message);
|
||||||
|
_logger.LogError(ex, "eRechnung: hybrid build failed for invoice {Id}; falling back to plain PDF/A", invoice.Id);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ZugferdProfile ParseProfile(string? profile) => (profile ?? "").Trim().ToUpperInvariant() switch
|
||||||
|
{
|
||||||
|
"EXTENDED" => ZugferdProfile.Extended,
|
||||||
|
"BASIC" => ZugferdProfile.Basic,
|
||||||
|
"XRECHNUNG" => ZugferdProfile.XRechnung,
|
||||||
|
_ => ZugferdProfile.EN16931,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
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>Formal-conformance validation settings (PDF/A-3 via veraPDF, ZUGFeRD rules).</summary>
|
||||||
|
public ERechnungValidationSettings Validation { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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; }
|
||||||
|
}
|
||||||
@@ -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)
|
public async Task<OCORE.pdf._pdf.ImageCollection> DocToImageCollectionAsync(Document doc)
|
||||||
{
|
{
|
||||||
var sw = Stopwatch.StartNew();
|
var sw = Stopwatch.StartNew();
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ public interface IPdfService
|
|||||||
/// <summary>Renders a MigraDoc Document to a PDF/A byte array.</summary>
|
/// <summary>Renders a MigraDoc Document to a PDF/A byte array.</summary>
|
||||||
byte[] DocToPdfBytes(Document doc);
|
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>
|
/// <summary>Renders a MigraDoc Document to an image collection for preview.</summary>
|
||||||
Task<OCORE.pdf._pdf.ImageCollection> DocToImageCollectionAsync(Document doc);
|
Task<OCORE.pdf._pdf.ImageCollection> DocToImageCollectionAsync(Document doc);
|
||||||
|
|
||||||
|
|||||||
@@ -95,7 +95,12 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
|
|||||||
switch (d.Target)
|
switch (d.Target)
|
||||||
{
|
{
|
||||||
case "email": return SetNewText(s, "invoiceemail", d, ref oldValue, ref newValue);
|
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 "title": return SetNewText(s, "invoicetitle", d, ref oldValue, ref newValue);
|
||||||
case "provisionperiod": return SetNewText(s, "provisionperiod", d, ref oldValue, ref newValue);
|
case "provisionperiod": return SetNewText(s, "provisionperiod", d, ref oldValue, ref newValue);
|
||||||
case "provisionlocation":
|
case "provisionlocation":
|
||||||
@@ -157,6 +162,26 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
|
|||||||
private static string ContactLabel(string name, string email) =>
|
private static string ContactLabel(string name, string email) =>
|
||||||
string.IsNullOrEmpty(name) ? email : string.IsNullOrEmpty(email) ? name : $"{name} <{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>
|
/// <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)
|
private static bool ReplaceBlock(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
|
||||||
{
|
{
|
||||||
@@ -590,6 +615,8 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
|
|||||||
["InvoiceBalance"] = session.Sums.TotalGross,
|
["InvoiceBalance"] = session.Sums.TotalGross,
|
||||||
["InvoiceBalance_net"] = session.Sums.TotalNet,
|
["InvoiceBalance_net"] = session.Sums.TotalNet,
|
||||||
["CustomValues"] = Str(session.New["CustomValues"]),
|
["CustomValues"] = Str(session.New["CustomValues"]),
|
||||||
|
["SendToAddressJson"] = InvoiceRecipientAddress.FromCustomValues(Str(session.New["CustomValues"]))?
|
||||||
|
.ToJson().ToString(Newtonsoft.Json.Formatting.None) ?? "",
|
||||||
["InvoiceOptions"] = BuildInvoiceOptions(session),
|
["InvoiceOptions"] = BuildInvoiceOptions(session),
|
||||||
["DateCreated"] = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
|
["DateCreated"] = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
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>True when no VAT id is set — a private person / B2C recipient.</summary>
|
||||||
|
public bool IsPrivatePerson => string.IsNullOrWhiteSpace(VatId);
|
||||||
|
|
||||||
|
/// <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"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Composes the multi-line free-text postal block (for the PDF and the legacy
|
||||||
|
/// <c>SendToAddress</c> column). The VAT id is intentionally excluded — it is not part of the
|
||||||
|
/// postal address, only of the structured eRechnung data (BT-48). Foreign countries append
|
||||||
|
/// the country code line; domestic (DE) omits it, matching the current letter layout.
|
||||||
|
/// </summary>
|
||||||
|
public string Compose()
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
void Line(string? v) { if (!string.IsNullOrWhiteSpace(v)) sb.Append(sb.Length > 0 ? "\n" : "").Append(v!.Trim()); }
|
||||||
|
|
||||||
|
Line(Name);
|
||||||
|
if (!string.IsNullOrWhiteSpace(Contact)) Line($"z.Hd. {Contact.Trim()}");
|
||||||
|
Line(AddressLine2);
|
||||||
|
Line(Street);
|
||||||
|
string cityLine = $"{PostalCode} {City}".Trim();
|
||||||
|
Line(cityLine);
|
||||||
|
if (!string.IsNullOrWhiteSpace(CountryCode) && !CountryCode.Equals("DE", StringComparison.OrdinalIgnoreCase))
|
||||||
|
Line(CountryCode.ToUpperInvariant());
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The EN 16931 fields still missing for a formally valid buyer (BG-7). Buyer name (BT-44)
|
||||||
|
/// and country code (BT-55) are mandatory; a postal address needs a city or post code. VAT id
|
||||||
|
/// is <b>not</b> required (a private person is valid without one). An empty result means the
|
||||||
|
/// recipient is EN 16931 / DATEV conformant — the editor surfaces this as a hint.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<string> MissingForEn16931()
|
||||||
|
{
|
||||||
|
var missing = new List<string>();
|
||||||
|
if (string.IsNullOrWhiteSpace(Name)) missing.Add("Name");
|
||||||
|
if (string.IsNullOrWhiteSpace(CountryCode)) missing.Add("Land");
|
||||||
|
if (string.IsNullOrWhiteSpace(City) && string.IsNullOrWhiteSpace(PostalCode)) missing.Add("Ort/PLZ");
|
||||||
|
return missing;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>True when the recipient satisfies the EN 16931 mandatory buyer fields.</summary>
|
||||||
|
public bool IsEn16931Conformant => MissingForEn16931().Count == 0;
|
||||||
|
|
||||||
|
private static string S(JObject o, params string[] keys)
|
||||||
|
{
|
||||||
|
foreach (var k in keys)
|
||||||
|
if (o[k] is { } t && t.Type != JTokenType.Null)
|
||||||
|
return t.Type == JTokenType.String ? t.Value<string>()?.Trim() ?? "" : t.ToString().Trim();
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,15 +26,17 @@ public class InvoiceService : IInvoiceService
|
|||||||
private readonly IPdfService _pdf;
|
private readonly IPdfService _pdf;
|
||||||
private readonly IBlobStorageService _blobStorage;
|
private readonly IBlobStorageService _blobStorage;
|
||||||
private readonly IEventService _events;
|
private readonly IEventService _events;
|
||||||
|
private readonly IERechnungService _erechnung;
|
||||||
private readonly ILogger<InvoiceService> _logger;
|
private readonly ILogger<InvoiceService> _logger;
|
||||||
|
|
||||||
public InvoiceService(Fuchs_intranet intranet, IPdfService pdf, IBlobStorageService blobStorage,
|
public InvoiceService(Fuchs_intranet intranet, IPdfService pdf, IBlobStorageService blobStorage,
|
||||||
IEventService events, ILogger<InvoiceService> logger)
|
IEventService events, IERechnungService erechnung, ILogger<InvoiceService> logger)
|
||||||
{
|
{
|
||||||
_intranet = intranet;
|
_intranet = intranet;
|
||||||
_pdf = pdf;
|
_pdf = pdf;
|
||||||
_blobStorage = blobStorage;
|
_blobStorage = blobStorage;
|
||||||
_events = events;
|
_events = events;
|
||||||
|
_erechnung = erechnung;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,13 +135,13 @@ public class InvoiceService : IInvoiceService
|
|||||||
var sqlParts = new List<string> { "DECLARE @Id varchar(10);" };
|
var sqlParts = new List<string> { "DECLARE @Id varchar(10);" };
|
||||||
if (!change)
|
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;");
|
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice_Details] @Id, @InvoiceService_net, @InvoiceService_VAT, @InvoiceOptions, @authuser;");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
pl.Add(SQL_VarChar("@InvId", invId));
|
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;");
|
sqlParts.Add("EXECUTE [dbo].[fds__createInvoice_Details] @Id, @InvoiceService_net, @InvoiceService_VAT, @InvoiceOptions, @authuser;");
|
||||||
}
|
}
|
||||||
if (invoice.RawProvisionLocation.Length > 0)
|
if (invoice.RawProvisionLocation.Length > 0)
|
||||||
@@ -283,7 +285,18 @@ public class InvoiceService : IInvoiceService
|
|||||||
}
|
}
|
||||||
|
|
||||||
public Task<byte[]> RenderInvoicePdfBytesAsync(FdsInvoiceData invoice, bool draft)
|
public Task<byte[]> RenderInvoicePdfBytesAsync(FdsInvoiceData invoice, bool draft)
|
||||||
=> Task.FromResult(_pdf.DocToPdfBytes(GenerateInvoicePdf(invoice, 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 = _erechnung.TryBuildHybridPdf(invoice, _pdf.DocToPdfBytesRaw(doc));
|
||||||
|
if (hybrid is { Length: > 0 }) return Task.FromResult(hybrid);
|
||||||
|
}
|
||||||
|
return Task.FromResult(_pdf.DocToPdfBytes(doc));
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<byte[]> StoreInvoiceDocumentFileAsync(FdsInvoiceData invoice, bool draft,
|
public async Task<byte[]> StoreInvoiceDocumentFileAsync(FdsInvoiceData invoice, bool draft,
|
||||||
string userAccountId, DatabaseSecurity dbSec)
|
string userAccountId, DatabaseSecurity dbSec)
|
||||||
|
|||||||
@@ -69,6 +69,15 @@
|
|||||||
"Telemetry": {
|
"Telemetry": {
|
||||||
"Enabled": true,
|
"Enabled": true,
|
||||||
"OtlpEndpoint": ""
|
"OtlpEndpoint": ""
|
||||||
|
},
|
||||||
|
"ERechnung": {
|
||||||
|
"Enabled": false,
|
||||||
|
"Profile": "EN16931",
|
||||||
|
"Validation": {
|
||||||
|
"Enabled": false,
|
||||||
|
"ServiceUrl": "",
|
||||||
|
"FailOnError": false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Fds": {
|
"Fds": {
|
||||||
|
|||||||
@@ -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>
|
/// <summary>VAT rows keyed by percentage string (e.g. "19"), from InvoiceRegistration.</summary>
|
||||||
public Dictionary<string, Dictionary<string, object?>> VatRows
|
public Dictionary<string, Dictionary<string, object?>> VatRows
|
||||||
{
|
{
|
||||||
@@ -153,6 +181,7 @@ public class FdsInvoiceData
|
|||||||
SQL_NVarChar("@SendToEmail", RawInvoiceEmail),
|
SQL_NVarChar("@SendToEmail", RawInvoiceEmail),
|
||||||
SQL_NVarChar("@ProvisionPeriod", RawProvisionPeriod, dbNull_IfEmpty: true),
|
SQL_NVarChar("@ProvisionPeriod", RawProvisionPeriod, dbNull_IfEmpty: true),
|
||||||
SQL_NVarChar("@CustomValues", RawCustomValues, 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_net", stringvalue: Sms?.nz("tscn") ?? "0"),
|
||||||
SQL_Float("@InvoiceService_VAT", stringvalue: Sms?.nz("tscvat") ?? "0"),
|
SQL_Float("@InvoiceService_VAT", stringvalue: Sms?.nz("tscvat") ?? "0"),
|
||||||
SQL_VarChar("@InvoiceOptions", BuildInvoiceOptions(), dbNull_IfEmpty: true)
|
SQL_VarChar("@InvoiceOptions", BuildInvoiceOptions(), dbNull_IfEmpty: true)
|
||||||
|
|||||||
+12
-4
@@ -979,18 +979,26 @@ public static class FuchsPdf
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Renders a MigraDoc Document to a PDF/A byte array.</summary>
|
/// <summary>
|
||||||
public static byte[] DocToPdfBytes(Document doc)
|
/// 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();
|
EnsureFontResolver();
|
||||||
var renderer = new PdfDocumentRenderer() { Document = doc };
|
var renderer = new PdfDocumentRenderer() { Document = doc };
|
||||||
renderer.RenderDocument();
|
renderer.RenderDocument();
|
||||||
using var ms = new MemoryStream();
|
using var ms = new MemoryStream();
|
||||||
renderer.PdfDocument.Save(ms, closeStream: false);
|
renderer.PdfDocument.Save(ms, closeStream: false);
|
||||||
ms.Position = 0;
|
return ms.ToArray();
|
||||||
return OCORE.pdf._pdf.pdfAFileContent(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>
|
/// <summary>Renders a MigraDoc Document to an ImageCollection for preview.</summary>
|
||||||
public static async Task<OCORE.pdf._pdf.ImageCollection> DocToImageCollection(Document doc)
|
public static async Task<OCORE.pdf._pdf.ImageCollection> DocToImageCollection(Document doc)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -980,7 +980,84 @@ $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 || '' }
|
||||||
|
];
|
||||||
|
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.')
|
||||||
|
]);
|
||||||
|
$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()
|
||||||
|
};
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
$inv.eHtml = function (ev) {
|
$inv.eHtml = function (ev) {
|
||||||
|
/* The recipient address is edited through the structured dialog whenever an invoice draft is
|
||||||
|
active; reminders (no invoice draft token) keep the plain free-text editor. */
|
||||||
|
if (ev.data && !(ev.data instanceof jQuery) && ev.data.nme === 'invoiceaddress'
|
||||||
|
&& $inv.d && typeof $inv.d.token === 'function' && $inv.d.token() !== '') {
|
||||||
|
return $inv.eAddress.call(this, ev);
|
||||||
|
}
|
||||||
let t = $(this), frmct = ev.data instanceof jQuery ? ev.data : ev.data.t;
|
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>
|
/* 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
|
tags, which used to get posted and persisted verbatim (e.g. <p>18.06.2026</p> in the
|
||||||
|
|||||||
@@ -474,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -492,4 +492,22 @@ table.if td.num {
|
|||||||
padding: 0.2rem;
|
padding: 0.2rem;
|
||||||
border-top-left-radius: inherit;
|
border-top-left-radius: inherit;
|
||||||
border-top-right-radius: inherit;
|
border-top-right-radius: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
.adr-conformity .hd {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
.adr-conformity .tx {
|
||||||
|
color: #445;
|
||||||
}
|
}
|
||||||
@@ -1523,7 +1523,84 @@ $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 || '' }
|
||||||
|
];
|
||||||
|
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.')
|
||||||
|
]);
|
||||||
|
$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()
|
||||||
|
};
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
$inv.eHtml = function (ev) {
|
$inv.eHtml = function (ev) {
|
||||||
|
/* The recipient address is edited through the structured dialog whenever an invoice draft is
|
||||||
|
active; reminders (no invoice draft token) keep the plain free-text editor. */
|
||||||
|
if (ev.data && !(ev.data instanceof jQuery) && ev.data.nme === 'invoiceaddress'
|
||||||
|
&& $inv.d && typeof $inv.d.token === 'function' && $inv.d.token() !== '') {
|
||||||
|
return $inv.eAddress.call(this, ev);
|
||||||
|
}
|
||||||
let t = $(this), frmct = ev.data instanceof jQuery ? ev.data : ev.data.t;
|
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>
|
/* 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
|
tags, which used to get posted and persisted verbatim (e.g. <p>18.06.2026</p> in the
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -629,4 +629,22 @@ table.if th.keep, table.if td.keep {
|
|||||||
padding: 0.2rem;
|
padding: 0.2rem;
|
||||||
border-top-left-radius: inherit;
|
border-top-left-radius: inherit;
|
||||||
border-top-right-radius: inherit;
|
border-top-right-radius: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
.adr-conformity .hd {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
.adr-conformity .tx {
|
||||||
|
color: #445;
|
||||||
}
|
}
|
||||||
@@ -1504,7 +1504,84 @@ $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 || '' }
|
||||||
|
];
|
||||||
|
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.')
|
||||||
|
]);
|
||||||
|
$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()
|
||||||
|
};
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
$inv.eHtml = function (ev) {
|
$inv.eHtml = function (ev) {
|
||||||
|
/* The recipient address is edited through the structured dialog whenever an invoice draft is
|
||||||
|
active; reminders (no invoice draft token) keep the plain free-text editor. */
|
||||||
|
if (ev.data && !(ev.data instanceof jQuery) && ev.data.nme === 'invoiceaddress'
|
||||||
|
&& $inv.d && typeof $inv.d.token === 'function' && $inv.d.token() !== '') {
|
||||||
|
return $inv.eAddress.call(this, ev);
|
||||||
|
}
|
||||||
let t = $(this), frmct = ev.data instanceof jQuery ? ev.data : ev.data.t;
|
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>
|
/* 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
|
tags, which used to get posted and persisted verbatim (e.g. <p>18.06.2026</p> in the
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,41 @@
|
|||||||
|
-- =============================================
|
||||||
|
-- Returns the recipient company's postal address as a structured JSON object
|
||||||
|
-- ({name, street, postalCode, city, state, country}) for the invoice editor's
|
||||||
|
-- structured address dialog (EN 16931 / eRechnung). Mirrors the location
|
||||||
|
-- resolution of [fds__getCompanyNameAddress] but keeps the fields separate
|
||||||
|
-- instead of composing them into one free-text string. See ADR 0012.
|
||||||
|
-- =============================================
|
||||||
|
CREATE FUNCTION [dbo].[fds__getCompanyAddressJson]
|
||||||
|
(
|
||||||
|
@companyid bigint
|
||||||
|
)
|
||||||
|
RETURNS nvarchar(max)
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
DECLARE @locationid bigint, @name nvarchar(255);
|
||||||
|
DECLARE @street nvarchar(255), @postal nvarchar(255), @city nvarchar(255), @state nvarchar(255), @country varchar(15);
|
||||||
|
|
||||||
|
SELECT TOP(1) @locationid = cy.[Location#ID], @name = cy.[name]
|
||||||
|
FROM [dbo].[mfr__companies] as cy WHERE cy.[id] = @companyid;
|
||||||
|
|
||||||
|
IF @locationid IS NULL
|
||||||
|
SELECT TOP(1) @locationid = l.[ID]
|
||||||
|
FROM [dbo].[mfr__#locations] as l
|
||||||
|
JOIN [dbo].[mfr__companies] as cy ON l.[Property] = 'Company:Location' AND l.[EntityId] = cy.[Id]
|
||||||
|
WHERE cy.[id] = @companyid;
|
||||||
|
|
||||||
|
SELECT TOP(1) @street = loc.[AddressString], @postal = loc.[Postal], @city = loc.[City],
|
||||||
|
@state = loc.[State], @country = loc.[Country]
|
||||||
|
FROM [dbo].[mfr__#locations] as loc WHERE loc.[id] = @locationid;
|
||||||
|
|
||||||
|
RETURN (
|
||||||
|
SELECT
|
||||||
|
ISNULL(@name, '') AS [name]
|
||||||
|
, ISNULL(@street, '') AS [street]
|
||||||
|
, ISNULL(@postal, '') AS [postalCode]
|
||||||
|
, ISNULL(@city, '') AS [city]
|
||||||
|
, ISNULL(@state, '') AS [state]
|
||||||
|
, ISNULL(@country, '') AS [country]
|
||||||
|
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
|
||||||
|
);
|
||||||
|
END
|
||||||
@@ -17,7 +17,8 @@ CREATE PROCEDURE [dbo].[fds__createInvoice]
|
|||||||
@ProvisionPeriod varchar(50),
|
@ProvisionPeriod varchar(50),
|
||||||
@CustomValues nvarchar(max),
|
@CustomValues nvarchar(max),
|
||||||
@authuser varchar(25),
|
@authuser varchar(25),
|
||||||
@Id varchar(10) OUT
|
@Id varchar(10) OUT,
|
||||||
|
@SendToAddressJson nvarchar(max) = NULL
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
SET NOCOUNT ON;
|
SET NOCOUNT ON;
|
||||||
@@ -64,6 +65,7 @@ BEGIN
|
|||||||
[IsCanceled] [bit] NULL,
|
[IsCanceled] [bit] NULL,
|
||||||
[Replaces_InvId] [varchar](10) NULL,
|
[Replaces_InvId] [varchar](10) NULL,
|
||||||
[CustomValues] [nvarchar](max) NULL,
|
[CustomValues] [nvarchar](max) NULL,
|
||||||
|
[SendToAddressJson] [nvarchar](max) NULL,
|
||||||
[DateSent] [datetime] NULL,
|
[DateSent] [datetime] NULL,
|
||||||
[UserSent] [varchar](25) NULL,
|
[UserSent] [varchar](25) NULL,
|
||||||
[DateFinalized] [datetime] NULL,
|
[DateFinalized] [datetime] NULL,
|
||||||
@@ -104,6 +106,7 @@ BEGIN
|
|||||||
,[IsPayed]
|
,[IsPayed]
|
||||||
,[IsSent]
|
,[IsSent]
|
||||||
,[CustomValues]
|
,[CustomValues]
|
||||||
|
,[SendToAddressJson]
|
||||||
,[DateSent]
|
,[DateSent]
|
||||||
,[UserSent]
|
,[UserSent]
|
||||||
,[DateFinalized]
|
,[DateFinalized]
|
||||||
@@ -143,6 +146,7 @@ BEGIN
|
|||||||
,0 --<IsPayed, bit,>
|
,0 --<IsPayed, bit,>
|
||||||
,0 --<IsSent, bit,>
|
,0 --<IsSent, bit,>
|
||||||
,@CustomValues
|
,@CustomValues
|
||||||
|
,@SendToAddressJson
|
||||||
, NULL --[DateSent]
|
, NULL --[DateSent]
|
||||||
, NULL --[UserSent]
|
, NULL --[UserSent]
|
||||||
,NULL --<DateFinalized, datetime,>
|
,NULL --<DateFinalized, datetime,>
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ BEGIN
|
|||||||
,[IsCanceled]
|
,[IsCanceled]
|
||||||
,[Replaces_InvId]
|
,[Replaces_InvId]
|
||||||
,[CustomValues]
|
,[CustomValues]
|
||||||
|
,[SendToAddressJson]
|
||||||
,[DateSent]
|
,[DateSent]
|
||||||
,[UserSent]
|
,[UserSent]
|
||||||
,[DateFinalized]
|
,[DateFinalized]
|
||||||
|
|||||||
@@ -356,6 +356,7 @@ BEGIN
|
|||||||
, [paymentterms] = N'10wd'
|
, [paymentterms] = N'10wd'
|
||||||
, [invoiceemail] = (SELECT TOP(1) [SupportMail] FROM @company where IsEmailInvoicingActive = 1)
|
, [invoiceemail] = (SELECT TOP(1) [SupportMail] FROM @company where IsEmailInvoicingActive = 1)
|
||||||
, [invoiceaddress] = (SELECT TOP(1) CONCAT([name], CHAR(10), [address]) FROM @company ORDER BY IsEmailInvoicingActive DESC)
|
, [invoiceaddress] = (SELECT TOP(1) CONCAT([name], CHAR(10), [address]) FROM @company ORDER BY IsEmailInvoicingActive DESC)
|
||||||
|
, [invoiceaddressData] = [dbo].[fds__getCompanyAddressJson]((SELECT TOP(1) [id] FROM @company ORDER BY IsEmailInvoicingActive DESC))
|
||||||
, [tax_servicerefund] = 0.2
|
, [tax_servicerefund] = 0.2
|
||||||
, [CustomerId] = [CustomerId]
|
, [CustomerId] = [CustomerId]
|
||||||
, [invoicetitle] = CASE WHEN @type = 'i' THEN (CASE WHEN @NUM_of_int_Invoices > 0 THEN CAST((@NUM_of_int_Invoices + 1) as varchar(3)) + '. ' ELSE '' END) + 'Abschlagsrechnung'
|
, [invoicetitle] = CASE WHEN @type = 'i' THEN (CASE WHEN @NUM_of_int_Invoices > 0 THEN CAST((@NUM_of_int_Invoices + 1) as varchar(3)) + '. ' ELSE '' END) + 'Abschlagsrechnung'
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ CREATE PROCEDURE [dbo].[fds__setInvoice]
|
|||||||
@ProvisionPeriod varchar(50),
|
@ProvisionPeriod varchar(50),
|
||||||
@CustomValues nvarchar(max),
|
@CustomValues nvarchar(max),
|
||||||
@authuser varchar(25),
|
@authuser varchar(25),
|
||||||
@Id varchar(10) OUT
|
@Id varchar(10) OUT,
|
||||||
|
@SendToAddressJson nvarchar(max) = NULL
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
SET NOCOUNT ON;
|
SET NOCOUNT ON;
|
||||||
@@ -65,6 +66,7 @@ BEGIN
|
|||||||
[IsCanceled] [bit] NULL,
|
[IsCanceled] [bit] NULL,
|
||||||
[Replaces_InvId] [varchar](50) NULL,
|
[Replaces_InvId] [varchar](50) NULL,
|
||||||
[CustomValues] [nvarchar](max) NULL,
|
[CustomValues] [nvarchar](max) NULL,
|
||||||
|
[SendToAddressJson] [nvarchar](max) NULL,
|
||||||
[DateSent] [datetime] NULL,
|
[DateSent] [datetime] NULL,
|
||||||
[UserSent] [varchar](25) NULL,
|
[UserSent] [varchar](25) NULL,
|
||||||
[DateFinalized] [datetime] NULL,
|
[DateFinalized] [datetime] NULL,
|
||||||
@@ -102,6 +104,7 @@ BEGIN
|
|||||||
,0 --<IsPayed, bit,>
|
,0 --<IsPayed, bit,>
|
||||||
,0 --<IsSent, bit,>
|
,0 --<IsSent, bit,>
|
||||||
,@CustomValues
|
,@CustomValues
|
||||||
|
,@SendToAddressJson
|
||||||
, NULL --[DateSent]
|
, NULL --[DateSent]
|
||||||
, NULL --[UserSent] [varchar](25) NULL,
|
, NULL --[UserSent] [varchar](25) NULL,
|
||||||
,NULL --<DateFinalized, datetime,>
|
,NULL --<DateFinalized, datetime,>
|
||||||
@@ -133,6 +136,7 @@ BEGIN
|
|||||||
,[IsPayed]
|
,[IsPayed]
|
||||||
,[IsSent]
|
,[IsSent]
|
||||||
,[CustomValues]
|
,[CustomValues]
|
||||||
|
,[SendToAddressJson]
|
||||||
,[DateSent]
|
,[DateSent]
|
||||||
,[UserSent]
|
,[UserSent]
|
||||||
,[DateFinalized]
|
,[DateFinalized]
|
||||||
@@ -163,6 +167,7 @@ BEGIN
|
|||||||
,[ProvisionPeriod] = SOURCE.[ProvisionPeriod]
|
,[ProvisionPeriod] = SOURCE.[ProvisionPeriod]
|
||||||
,[ProvisionLocation] = SOURCE.[ProvisionLocation]
|
,[ProvisionLocation] = SOURCE.[ProvisionLocation]
|
||||||
,[CustomValues] = SOURCE.[CustomValues]
|
,[CustomValues] = SOURCE.[CustomValues]
|
||||||
|
,[SendToAddressJson] = SOURCE.[SendToAddressJson]
|
||||||
,[DateModified] = SOURCE.[DateModified]
|
,[DateModified] = SOURCE.[DateModified]
|
||||||
,[UserModified] = SOURCE.[UserModified]
|
,[UserModified] = SOURCE.[UserModified]
|
||||||
OUTPUT inserted.*
|
OUTPUT inserted.*
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
[IsCanceled] AS (CONVERT([bit],case when [DateCancelled] IS NULL then (0) else (1) end)),
|
[IsCanceled] AS (CONVERT([bit],case when [DateCancelled] IS NULL then (0) else (1) end)),
|
||||||
[Replaces_InvId] VARCHAR (50) NULL,
|
[Replaces_InvId] VARCHAR (50) NULL,
|
||||||
[CustomValues] NVARCHAR (MAX) NULL,
|
[CustomValues] NVARCHAR (MAX) NULL,
|
||||||
|
[SendToAddressJson] NVARCHAR (MAX) NULL,
|
||||||
[DateSent] DATETIME NULL,
|
[DateSent] DATETIME NULL,
|
||||||
[UserSent] VARCHAR (25) NULL,
|
[UserSent] VARCHAR (25) NULL,
|
||||||
[DateFinalized] DATETIME NULL,
|
[DateFinalized] DATETIME NULL,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
[IsCanceled] BIT NULL,
|
[IsCanceled] BIT NULL,
|
||||||
[Replaces_InvId] VARCHAR (50) NULL,
|
[Replaces_InvId] VARCHAR (50) NULL,
|
||||||
[CustomValues] NVARCHAR (MAX) NULL,
|
[CustomValues] NVARCHAR (MAX) NULL,
|
||||||
|
[SendToAddressJson] NVARCHAR (MAX) NULL,
|
||||||
[DateSent] DATETIME NULL,
|
[DateSent] DATETIME NULL,
|
||||||
[UserSent] VARCHAR (25) NULL,
|
[UserSent] VARCHAR (25) NULL,
|
||||||
[DateFinalized] DATETIME NULL,
|
[DateFinalized] DATETIME NULL,
|
||||||
|
|||||||
+1
-1
Submodule OCORE updated: 91eb660610...d760efc077
+1
-1
Submodule OCORE_Charting updated: 69e71570a4...fcb8f090d4
+1
-1
Submodule OCORE_web updated: 552ceca7bf...6ee60c848c
+1
-1
Submodule OCORE_web_pdf updated: cd8214ed7b...926f6e1f3e
+1
-1
Submodule eRechnungLib updated: 3f0bd93a62...14f73a527a
Reference in New Issue
Block a user