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:
Binary file not shown.
@@ -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
@@ -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";
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user