Add unit tests for Fuchs_DataService and related components

- Introduced comprehensive unit tests for the Fuchs_DataService library, covering DATEV header formatting, CSV/XML generation, and FdsMfrClient construction.
- Implemented tests for FdsMfr.UpdateNeed parsing and FdsShared utility helpers, ensuring correct functionality and stability.
- Added tests for FdsConfig and FdsMfrClient to validate configuration resolution and client construction.

Document decisions on backend-authoritative invoice and reminder handling

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

Define irreversible mutations for set-price modes in invoices

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

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

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

Add publish profile and periodic hosted service for job scheduling

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

Add dotnet-tools.json for EF Core CLI tools

- Included dotnet-tools.json to manage the version of dotnet-ef for Entity Framework Core migrations and commands.
This commit is contained in:
Stefan
2026-07-16 13:34:23 +02:00
parent f724b9b59d
commit 49e3ed2673
102 changed files with 2501 additions and 8438 deletions
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -175,7 +175,7 @@ public class FdsInvoiceData
// explicit user choice of the default is indistinguishable from an invoice that was never
// switched to set-pricing at all (needed so the editor can hide the "Set-Preisanzeige"
// menu entry once a mode has been chosen; see InvoiceDraftEditService.BuildInvoiceOptions).
if (setmode is "setprice" or "itemprices" or "setonly") tokens.Add("setmode:" + setmode);
if (setmode is "setprice" or "setonly") tokens.Add("setmode:" + setmode);
return string.Join(",", tokens);
}
+13 -6
View File
@@ -168,6 +168,9 @@ public static class FuchsPdf
bool isText = type is "text" or "title";
ParseDec(i.no("price_net", 0), out decimal price);
ParseDec(i.no("total_net", 0), out decimal total);
// ADR 0009: a null/absent total_net means "no price" — render an empty cell (not 0,00 €),
// distinct from a genuine 0. This is how block-mode (Set mit Preis) nulled members print.
bool hasPrice = i.TryGetValue("total_net", out var tv) && tv != null;
return new InvoiceSetLine
{
Title = i.nz("title", ""),
@@ -175,7 +178,8 @@ public static class FuchsPdf
Qty = i.nz("qty", ""),
PriceNet = price,
TotalNet = total,
ShowPrice = !isText,
ShowPrice = !isText && hasPrice,
Numbered = !isText, // numbered like the editor: every line except free-text/heading, even price-blanked set members
IsSetHeader = type == "set"
};
}
@@ -764,8 +768,8 @@ public static class FuchsPdf
// Each section prints its heading; positions are numbered the same way the editor numbers
// them (every line except free-text/heading lines, including a set header). The chosen
// set-display mode (see INVOICE_SET_PRICING.md) governs how "set" items and their members
// are shown: SetPrice (default) prices the set, blanks members; ItemPrices prices the
// members, blanks the set heading; SetOnly prices the set and drops the members entirely.
// are shown: SetPrice (default) prices the set, blanks members; SetOnly prices the set
// and drops the members entirely.
// Blocks without any set items are unaffected and always render flat.
var setMode = InvoiceSetPricing.ModeFromInvoiceOptions(inv.InvoiceRegistration?.getString("InvoiceOptions"));
int pos = 0;
@@ -779,13 +783,16 @@ public static class FuchsPdf
hr.Cells[1].AddParagraph().WithStyle("TblCell_RTitle").AddFormattedText(block.Heading, TextFormat.Bold);
}
var lines = InvoiceSetPricing.ContainsSets(block.Items)
// Function-1 (SetItmId-linked) sets still collapse via Build; block-mode sets (ADR 0009)
// and plain blocks render flat — their set row is emphasised on its own and nulled
// members map to blank cells (see MapItemToLine).
var lines = InvoiceSetPricing.HasSetMembers(block.Items)
? InvoiceSetPricing.Build(block.Items, setMode)
: block.Items.Select(MapItemToLine).ToList(); // no sets: flat, faithful mirror of the editor
: block.Items.Select(MapItemToLine).ToList();
foreach (var line in lines)
{
bool numbered = line.IsSetHeader || line.ShowPrice; // free-text/heading lines carry no number
bool numbered = line.Numbered; // every line except free-text/heading (mirrors the editor's RecomputePositions), even price-blanked set members
var row = tbl.AddRow();
row.HeightRule = RowHeightRule.Auto;
row.Cells[0].AddParagraph(numbered ? (++pos).ToString() : "").Style = "TblCell_Base";
+40 -1
View File
@@ -1,4 +1,4 @@
using System.Globalization;
using System.Globalization;
using Newtonsoft.Json.Linq;
namespace Fuchs.intranet;
@@ -16,6 +16,45 @@ namespace Fuchs.intranet;
/// </summary>
public static class InvoiceDraftCalculator
{
/// <summary>
/// Recomputes every line's own net/VAT/service values from its raw quantity (<c>qn</c>),
/// unit price (<c>v</c>) and VAT rate (<c>vat</c>) — the authoritative, server-side port of
/// the former client-side <c>quantChange</c>/<c>setVat</c> multiplication (ADR 0006/0008: the
/// online editor performs no arithmetic at all, not even a single line's net = qty × price).
/// Only lines that actually carry a raw quantity and a positive unit price are recomputed
/// (mirrors <c>quantChange</c>'s own guard); lines without both (headings, free text,
/// combined-sum rows, and set members that have been zeroed by
/// <see cref="Fuchs.Services.InvoiceDraftEditService"/>'s "Auf Setpreis umstellen" conversion)
/// keep whatever value they already carry, so a set header's synthesised sum is never
/// clobbered by a subsequent recompute.
/// </summary>
public static void RecomputeLineValues(InvoiceDraftSession session)
{
foreach (var blockTok in session.Req)
{
if (blockTok is not JObject block || block["itm"] is not JArray lines) continue;
foreach (var lineTok in lines)
{
if (lineTok is not JObject co) continue;
decimal qty = Dec(co["qn"]);
decimal price = Dec(co["v"]);
if (qty <= 0 || price <= 0) continue; // no raw qty/price posted -> leave the value as delivered
string rate = NormalizeRate(Str(co["vat"]));
decimal vatFactor = rate.Length > 0 && decimal.TryParse(rate, NumberStyles.Any, CultureInfo.InvariantCulture, out decimal r)
? r / 100m : 0m;
decimal netVal = Math.Round(qty * price, 2, MidpointRounding.AwayFromZero);
decimal vatVal = Math.Round(netVal * vatFactor, 2, MidpointRounding.AwayFromZero);
co["vt"] = netVal;
co["vv"] = vatVal;
bool isService = string.Equals(Str(co["typ"]), "Service", StringComparison.OrdinalIgnoreCase);
co["vs"] = isService ? netVal : 0;
co["vsv"] = isService ? vatVal : 0;
}
}
}
/// <summary>
/// Aggregates every block's line values into the draft's totals — the port of
/// <c>invSumUpdate</c>'s <c>csms</c> accumulation plus §13b (VAT suppressed → gross = net).
+51 -26
View File
@@ -13,8 +13,6 @@ public enum SetDisplayMode
{
/// <summary>Default: show the set as one priced line; member items listed without price.</summary>
SetPrice,
/// <summary>Show each member item with its own price; the set line is a header without price.</summary>
ItemPrices,
/// <summary>Show only the set as one priced line; member items are removed entirely.</summary>
SetOnly
}
@@ -32,6 +30,14 @@ public sealed class InvoiceSetLine
public decimal TotalNet { get; init; }
/// <summary>When false, the price/total cells are rendered blank (e.g. set members in SetPrice mode).</summary>
public bool ShowPrice { get; init; } = true;
/// <summary>
/// Whether this line carries a position number. Mirrors the editor's numbering
/// (<c>InvoiceDraftCalculator.RecomputePositions</c>): every line is numbered <b>except</b>
/// free-text/heading lines — independent of whether a price is shown, so a price-blanked set
/// member (SetPrice mode / block-mode nulled member) is still numbered exactly like in the
/// online editor.
/// </summary>
public bool Numbered { get; init; } = true;
/// <summary>True for the set header line (rendered emphasised).</summary>
public bool IsSetHeader { get; init; }
}
@@ -43,17 +49,24 @@ public sealed class InvoiceSetLine
/// header's <c>id</c>. Items that belong to no set pass through unchanged.
///
/// The invoice total is taken from the registration balance, not from these
/// lines, so switching modes is purely presentational and never changes the
/// invoice sum — set price always equals the sum of its members.
/// lines, so calling <see cref="Build"/> is a pure, non-mutating transformation
/// of the items it is given and never changes the invoice sum. A set header
/// only adopts the chosen <see cref="SetDisplayMode"/> once it has actually been
/// converted (its own <c>total_net</c> is non-zero, via the one-way set-item
/// switch, <c>InvoiceDraftEditService.ApplyItemSetPrice</c>) — until then the
/// header renders blank and each member keeps its own individual price, exactly
/// like a non-set item. (The choice of which mode to apply is itself a
/// persisted, effectively one-way decision on the draft session — see
/// <c>Fuchs/Docs/INVOICE_SET_PRICING.md</c> — but that persistence lives outside
/// this class.)
/// </summary>
public static class InvoiceSetPricing
{
public static SetDisplayMode ParseMode(string? raw) =>
(raw ?? "").Trim().ToLowerInvariant() switch
{
"itemprices" or "items" or "item" => SetDisplayMode.ItemPrices,
"setonly" or "set_only" => SetDisplayMode.SetOnly,
_ => SetDisplayMode.SetPrice
"setonly" or "set_only" => SetDisplayMode.SetOnly,
_ => SetDisplayMode.SetPrice
};
/// <summary>Reads the set mode from an InvoiceOptions CSV token like "setmode:itemprices".</summary>
@@ -71,9 +84,21 @@ public static class InvoiceSetPricing
public static bool ContainsSets(IEnumerable<Dictionary<string, object?>> items) =>
items.Any(IsSetHeader) || items.Any(i => !string.IsNullOrEmpty(SetIdOf(i)));
/// <summary>
/// True if the block carries a <c>SetItmId</c>-linked set (a member points at a header via
/// <c>setId</c>) — i.e. the function-1 "set-item switch" grouping. This is the only case that
/// still routes through <see cref="Build"/>; the two menu modes (ADR 0009) clear <c>setId</c>
/// and render flat (their inserted set row is emphasised on its own, members null out to blank).
/// </summary>
public static bool HasSetMembers(IEnumerable<Dictionary<string, object?>> items) =>
items.Any(i => !string.IsNullOrEmpty(SetIdOf(i)));
/// <summary>
/// Produces the ordered display lines for the given items and mode.
/// Standalone items are always shown with their price.
/// Standalone items are always shown with their price. A set header whose
/// own <c>total_net</c> is still zero (not yet converted via the set-item
/// switch) is rendered blank with its members individually priced,
/// regardless of <paramref name="mode"/>.
/// </summary>
public static List<InvoiceSetLine> Build(IReadOnlyList<Dictionary<string, object?>> items, SetDisplayMode mode)
{
@@ -90,22 +115,29 @@ public static class InvoiceSetPricing
{
if (IsSetHeader(item))
{
string setId = HeaderIdOf(item);
var members = membersBySet.TryGetValue(setId, out var m) ? m : new List<Dictionary<string, object?>>();
decimal setTot = HeaderTotal(item, members);
string setId = HeaderIdOf(item);
var members = membersBySet.TryGetValue(setId, out var m) ? m : new List<Dictionary<string, object?>>();
FuchsPdf.ParseDec(item.no("total_net", 0), out decimal headerOwnTotal);
if (headerOwnTotal == 0)
{
// Not yet converted (the set-item switch, ApplyItemSetPrice, hasn't run): the
// header still carries its as-delivered zero price, so the chosen display mode
// does not apply yet — show the header blank and each member with its own,
// individual price, exactly as if there were no set grouping at all.
result.Add(HeaderLine(item, 0, showPrice: false));
foreach (var mem in members) result.Add(MemberLine(mem, showPrice: true));
continue;
}
switch (mode)
{
case SetDisplayMode.SetPrice:
result.Add(HeaderLine(item, setTot, showPrice: true));
result.Add(HeaderLine(item, headerOwnTotal, showPrice: true));
foreach (var mem in members) result.Add(MemberLine(mem, showPrice: false));
break;
case SetDisplayMode.ItemPrices:
result.Add(HeaderLine(item, setTot, showPrice: false)); // grouping title, no price (avoid double count)
foreach (var mem in members) result.Add(MemberLine(mem, showPrice: true));
break;
case SetDisplayMode.SetOnly:
result.Add(HeaderLine(item, setTot, showPrice: true));
result.Add(HeaderLine(item, headerOwnTotal, showPrice: true));
break;
}
}
@@ -148,15 +180,6 @@ public static class InvoiceSetPricing
return string.IsNullOrEmpty(s) ? i.nz("id", "") : s;
}
private static decimal HeaderTotal(Dictionary<string, object?> header, List<Dictionary<string, object?>> members)
{
FuchsPdf.ParseDec(header.no("total_net", 0), out decimal headerTot);
if (headerTot != 0) return headerTot;
decimal sum = 0;
foreach (var m in members) { FuchsPdf.ParseDec(m.no("total_net", 0), out decimal t); sum += t; }
return sum;
}
private static InvoiceSetLine HeaderLine(Dictionary<string, object?> i, decimal setTotal, bool showPrice) => new()
{
Id = i.nz("id", ""),
@@ -166,6 +189,7 @@ public static class InvoiceSetPricing
PriceNet = setTotal,
TotalNet = setTotal,
ShowPrice = showPrice,
Numbered = true, // a set header is always numbered
IsSetHeader = true
};
@@ -182,6 +206,7 @@ public static class InvoiceSetPricing
PriceNet = price,
TotalNet = total,
ShowPrice = showPrice && !IsNoPriceLine(i), // headings/free text print no price
Numbered = !IsNoPriceLine(i), // …but are still numbered unless heading/free-text
IsSetHeader = false
};
}