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:
@@ -116,6 +116,9 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
|
||||
case "block.replace": return ReplaceBlock(s, d, ref oldValue, ref newValue);
|
||||
case "block.remove": return RemoveBlock(s, d, ref oldValue, ref newValue);
|
||||
case "block.order": return ReorderBlocks(s, d, ref oldValue, ref newValue);
|
||||
case "item.setprice": return ApplyItemSetPrice(s, d, ref oldValue, ref newValue);
|
||||
case "block.setprice": return ApplyBlockSetPricing(s, removeMembers: false, ref oldValue, ref newValue);
|
||||
case "block.setonly": return ApplyBlockSetPricing(s, removeMembers: true, ref oldValue, ref newValue);
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
@@ -185,6 +188,183 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// "Auf Setpreis umstellen" (backend-authoritative, ADR 0006): irreversibly converts a Set
|
||||
/// header item (<c>Ref</c> = the header's item id) from individually-priced members to a
|
||||
/// single set price. Sums the <c>net</c>/<c>net_val</c>/<c>vat_val</c>/<c>svcnet_val</c>/
|
||||
/// <c>svcvat_val</c> of every member line in the same block whose <c>SetItmId</c> equals the
|
||||
/// header id (the grouping <c>fds__prepInvoice</c> computes, anchored on the still-unconverted,
|
||||
/// zero-priced Set header — see the <c>[SetItmID]</c> window function there), writes that sum
|
||||
/// onto the header line and zeroes each member's price fields — matching the visual
|
||||
/// <c>SetPrice</c> display mode (<see cref="InvoiceSetPricing"/>), but as an actual, irreversible
|
||||
/// data change rather than a display toggle. The user can still change the resulting header price
|
||||
/// manually afterwards (ordinary item edit), which is why this is one-way.
|
||||
/// <para>
|
||||
/// The header row's own <c>SetItmId</c> now self-references its own id (rather than being
|
||||
/// <c>null</c>) once <c>fds__prepInvoice</c> stopped special-casing it, so membership is
|
||||
/// determined by <c>id == Ref</c> first (captured as <paramref name="header"/> below and
|
||||
/// <c>continue</c>d past) — a header is never mistaken for its own member.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private static bool ApplyItemSetPrice(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
|
||||
{
|
||||
if (string.IsNullOrEmpty(d.Ref)) return false;
|
||||
foreach (var blockTok in s.Req)
|
||||
{
|
||||
if (blockTok is not JObject block || block["itm"] is not JArray lines) continue;
|
||||
JObject? header = null;
|
||||
var members = new List<JObject>();
|
||||
foreach (var lineTok in lines)
|
||||
{
|
||||
if (lineTok is not JObject co) continue;
|
||||
if (Str(co["id"]) == d.Ref) { header = co; continue; } // the header itself is never a member, even if its SetItmId self-references
|
||||
if (Str(co["SetItmId"]) == d.Ref) members.Add(co);
|
||||
}
|
||||
if (header == null) continue;
|
||||
if (!string.Equals(Str(header["typ"]), "set", StringComparison.OrdinalIgnoreCase)) return false;
|
||||
|
||||
oldValue = InvoiceDraftCalculator.Dec(header["vt"]).ToString(CultureInfo.InvariantCulture);
|
||||
decimal sumNet = 0, sumVat = 0, sumSvcNet = 0, sumSvcVat = 0;
|
||||
foreach (var m in members)
|
||||
{
|
||||
sumNet += InvoiceDraftCalculator.Dec(m["vt"]);
|
||||
sumVat += InvoiceDraftCalculator.Dec(m["vv"]);
|
||||
sumSvcNet += InvoiceDraftCalculator.Dec(m["vs"]);
|
||||
sumSvcVat += InvoiceDraftCalculator.Dec(m["vsv"]);
|
||||
NullLinePrice(m); // ADR 0009: null (empty cell), not 0 — excluded from the sum, distinct from a real 0,00 €
|
||||
}
|
||||
header["v"] = sumNet; header["vt"] = sumNet; header["vv"] = sumVat; header["vs"] = sumSvcNet; header["vsv"] = sumSvcVat;
|
||||
newValue = sumNet.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
// Mirror the conversion onto the "items" contract shape (InvoiceSetPricing/BuildSetDisplay
|
||||
// read total_net from here, not from "itm") so the online editor's set-display immediately
|
||||
// reflects that the set is now converted, instead of waiting for a reload.
|
||||
if (block["items"] is JArray items)
|
||||
{
|
||||
foreach (var itemTok in items)
|
||||
{
|
||||
if (itemTok is not JObject io) continue;
|
||||
string id = Str(io["id"]);
|
||||
if (id == d.Ref) { io["total_net"] = sumNet; }
|
||||
else if (members.Any(m => Str(m["id"]) == id)) { NullItemPrice(io); }
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The two menu set-price modes (ADR 0009), grouped by service-request <b>block</b>
|
||||
/// (not <c>SetItmId</c>) and applied server-side as an <b>irreversible mutation</b> of the
|
||||
/// cached session — never a display flag. For every block that carries any priced line:
|
||||
/// a dedicated, emphasised set row (<c>typ == "set"</c>) is inserted at the top carrying the
|
||||
/// block's aggregated value (net + VAT + service-net/-VAT splits); then the block's original
|
||||
/// items are either price-<b>nulled</b> (<paramref name="removeMembers"/> = false → "Set mit
|
||||
/// Preis": empty price cells, kept) or <b>removed</b> (<paramref name="removeMembers"/> = true →
|
||||
/// "Nur Set mit Preis"). <c>null</c> (not <c>0</c>) marks "no price" — an empty cell excluded
|
||||
/// from the sum, distinct from a genuine 0,00 €. Any pre-existing set membership is neutralised
|
||||
/// (existing set rows demoted, <c>setId</c>/<c>SetItmId</c> cleared) so the block becomes one
|
||||
/// flat set and the two groupings (this vs. the <c>SetItmId</c>-based item switch) never collide.
|
||||
/// The invoice total is conserved: the set row's value equals the sum of the members it blanks
|
||||
/// or removes.
|
||||
/// </summary>
|
||||
private static bool ApplyBlockSetPricing(InvoiceDraftSession s, bool removeMembers, ref string oldValue, ref string newValue)
|
||||
{
|
||||
bool mutated = false;
|
||||
int converted = 0;
|
||||
foreach (var blockTok in s.Req)
|
||||
{
|
||||
if (blockTok is not JObject block || block["itm"] is not JArray lines || lines.Count == 0) continue;
|
||||
|
||||
// Aggregate the block from its current line values (nulled/absent = 0 contribution).
|
||||
decimal net = 0, vat = 0, svcNet = 0, svcVat = 0;
|
||||
string rate = "";
|
||||
foreach (var lineTok in lines)
|
||||
{
|
||||
if (lineTok is not JObject co) continue;
|
||||
net += InvoiceDraftCalculator.Dec(co["vt"]);
|
||||
vat += InvoiceDraftCalculator.Dec(co["vv"]);
|
||||
svcNet += InvoiceDraftCalculator.Dec(co["vs"]);
|
||||
svcVat += InvoiceDraftCalculator.Dec(co["vsv"]);
|
||||
string r = InvoiceDraftCalculator.NormalizeRate(Str(co["vat"]));
|
||||
if (r.Length > 0) rate = r; // collapsed set line carries the block's (highest/last) rate
|
||||
}
|
||||
if (net == 0 && vat == 0 && svcNet == 0 && svcVat == 0) continue; // nothing priced -> skip block
|
||||
|
||||
string blockId = Str(block["Id"]);
|
||||
string setId = "bset_" + (blockId.Length > 0 ? blockId : Guid.NewGuid().ToString("N")[..8]);
|
||||
const string title = "Gesamtumfang pauschal"; // neutral label for the block set row (ADR 0009) — not the service-request title
|
||||
|
||||
// The dedicated, emphasised set row, in both line shapes (itm = editor co; items = contract).
|
||||
var setItm = new JObject
|
||||
{
|
||||
["id"] = setId, ["typ"] = "set", ["p"] = "", ["q"] = "", ["t"] = title, ["tt"] = "",
|
||||
["v"] = JValue.CreateNull(), ["vt"] = net, ["vv"] = vat, ["vs"] = svcNet, ["vsv"] = svcVat,
|
||||
["vat"] = rate, ["det"] = false
|
||||
};
|
||||
var setItem = new JObject
|
||||
{
|
||||
["id"] = setId, ["type"] = "set", ["title"] = title, ["desc"] = "", ["qty"] = "",
|
||||
["price_net"] = net, ["total_net"] = net, ["vat"] = rate, ["setId"] = JValue.CreateNull()
|
||||
};
|
||||
|
||||
// Mutate the itm array in place (avoids Newtonsoft re-parenting): drop members (setonly)
|
||||
// or null their prices + neutralise set membership (setprice), then prepend the set row.
|
||||
if (removeMembers) lines.Clear();
|
||||
else
|
||||
foreach (var lineTok in lines)
|
||||
{
|
||||
if (lineTok is not JObject co) continue;
|
||||
DemoteSet(co, "typ"); co["SetItmId"] = JValue.CreateNull();
|
||||
NullLinePrice(co);
|
||||
}
|
||||
lines.Insert(0, setItm);
|
||||
|
||||
if (block["items"] is JArray items)
|
||||
{
|
||||
if (removeMembers) items.Clear();
|
||||
else
|
||||
foreach (var itemTok in items)
|
||||
{
|
||||
if (itemTok is not JObject io) continue;
|
||||
DemoteSet(io, "type"); io["setId"] = JValue.CreateNull();
|
||||
NullItemPrice(io);
|
||||
}
|
||||
items.Insert(0, setItem);
|
||||
}
|
||||
else
|
||||
{
|
||||
block["items"] = new JArray { setItem };
|
||||
}
|
||||
|
||||
mutated = true; converted++;
|
||||
}
|
||||
oldValue = "";
|
||||
newValue = (removeMembers ? "setonly" : "setprice") + ":" + converted;
|
||||
return mutated;
|
||||
}
|
||||
|
||||
/// <summary>Demotes a pre-existing set row to a plain line so only the freshly inserted block set row stays "set".</summary>
|
||||
private static void DemoteSet(JObject line, string typeKey)
|
||||
{
|
||||
if (line[typeKey] is { } t && string.Equals(Str(t), "set", StringComparison.OrdinalIgnoreCase))
|
||||
line[typeKey] = "other";
|
||||
}
|
||||
|
||||
/// <summary>Sets the editor <c>co</c> price fields to JSON null (empty cell, excluded from sums — ADR 0009).</summary>
|
||||
private static void NullLinePrice(JObject co)
|
||||
{
|
||||
co["v"] = JValue.CreateNull(); co["vt"] = JValue.CreateNull(); co["vv"] = JValue.CreateNull();
|
||||
co["vs"] = JValue.CreateNull(); co["vsv"] = JValue.CreateNull();
|
||||
}
|
||||
|
||||
/// <summary>Sets the items-contract price fields to JSON null (empty cell — ADR 0009).</summary>
|
||||
private static void NullItemPrice(JObject io)
|
||||
{
|
||||
io["price_net"] = JValue.CreateNull(); io["total_net"] = JValue.CreateNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reorders the service-request blocks to the id sequence the editor posts after a section
|
||||
/// drag (<c>Value</c> = ["id",…]). Named ids move into the given order; any not named are kept
|
||||
@@ -269,7 +449,9 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
|
||||
{
|
||||
if (blockTok is not JObject block || block["items"] is not JArray itemsArr) continue;
|
||||
List<Dictionary<string, object?>>? items = itemsArr.ToObject<List<Dictionary<string, object?>>>();
|
||||
if (items == null || !InvoiceSetPricing.ContainsSets(items)) continue;
|
||||
// Only function-1 (SetItmId-linked) sets get display flags; block-mode sets (ADR 0009)
|
||||
// clear setId and render flat, so BuildSetDisplay must not sweep them into Build here.
|
||||
if (items == null || !InvoiceSetPricing.HasSetMembers(items)) continue;
|
||||
|
||||
foreach (var line in InvoiceSetPricing.Build(items, mode))
|
||||
{
|
||||
@@ -331,6 +513,7 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
|
||||
// ── Internals ──────────────────────────────────────────────────────────────
|
||||
private static void Refresh(InvoiceDraftSession session)
|
||||
{
|
||||
InvoiceDraftCalculator.RecomputeLineValues(session);
|
||||
InvoiceDraftCalculator.RecomputeTotals(session);
|
||||
InvoiceDraftCalculator.RecomputePositions(session);
|
||||
InvoiceDraftCalculator.Validate(session);
|
||||
@@ -430,7 +613,7 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
|
||||
string setmode = Str(session.Admin["setmode"]).Trim().ToLowerInvariant();
|
||||
// Mirrors FdsInvoiceData.BuildInvoiceOptions: persist any explicitly-chosen mode
|
||||
// (including the default "setprice") so it is distinguishable from "never touched".
|
||||
if (setmode is "setprice" or "itemprices" or "setonly") tokens.Add("setmode:" + setmode);
|
||||
if (setmode is "setprice" or "setonly") tokens.Add("setmode:" + setmode);
|
||||
return string.Join(",", tokens);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a named job with its own execution schedule for use with <see cref="PeriodicHostedService"/>.
|
||||
/// </summary>
|
||||
public sealed record PeriodicJobDefinition(
|
||||
string Name,
|
||||
TimeSpan Interval,
|
||||
Func<CancellationToken, Task> Execute);
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="BackgroundService"/> that runs multiple independent jobs, each on its own <see cref="PeriodicTimer"/>.
|
||||
/// Hosts the MFR ERP sync that formerly ran as the standalone <c>Fuchs_DataService</c> Windows Service
|
||||
/// (Topshelf); it is now registered in-process from <c>Program.cs</c>, gated by <c>Fds:SyncEnabled</c>.
|
||||
/// </summary>
|
||||
public sealed class PeriodicHostedService : BackgroundService
|
||||
{
|
||||
private readonly IReadOnlyList<PeriodicJobDefinition> _jobs;
|
||||
private readonly ILogger<PeriodicHostedService> _logger;
|
||||
|
||||
public PeriodicHostedService(IEnumerable<PeriodicJobDefinition> jobs, ILogger<PeriodicHostedService> logger)
|
||||
{
|
||||
_jobs = jobs.ToList();
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var jobTasks = _jobs.Select(job => RunJobAsync(job, stoppingToken)).ToList();
|
||||
await Task.WhenAll(jobTasks);
|
||||
}
|
||||
|
||||
private async Task RunJobAsync(PeriodicJobDefinition job, CancellationToken stoppingToken)
|
||||
{
|
||||
using var timer = new PeriodicTimer(job.Interval);
|
||||
_logger.LogInformation("Job '{Name}' scheduled with interval {Interval}.", job.Name, job.Interval);
|
||||
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken))
|
||||
{
|
||||
_logger.LogDebug("Job '{Name}' starting.", job.Name);
|
||||
try
|
||||
{
|
||||
await job.Execute(stoppingToken);
|
||||
_logger.LogDebug("Job '{Name}' completed.", job.Name);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogError(ex, "Job '{Name}' failed.", job.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user