Add user-defined invoice set structure dialog (ADR 0016)
Playwright Tests / test (push) Canceled after 0s

Lets a back-office user correct/define which invoice line belongs to
which "set" and how deep it is nested, instead of relying only on the
implicit "everything after a Set header until the next Set" grouping
fds__prepInvoice derives. Sets may contain sets; every change applies
immediately to the cached draft session and is reflected in the editor
and PDF (ADR 0006/0008).

Backend:
- InvoiceSetStructure: pure parent-link forest (assign, change level,
  create, place/move-sibling, depth-first linearise + level recompute).
- InvoiceDraftSetStructureDeltas: new inv/dpatch targets set.assign,
  set.level, set.create, set.move.
- InvoiceSetPricing.Build is now order-preserving and nesting-aware
  (governed by the nearest converted ancestor); the set-item switch
  sums a header's transitive descendants so nested conversions never
  double-count.
- FuchsPdf indents nested set members per level, mirroring the editor.

Frontend:
- New fis.inv_sets.js: "Set-Struktur bearbeiten" dialog — per-block
  table of all lines with drag & drop reorder (before/after/into),
  ▲▼ order and ◀▶ level buttons, fold/unfold per set (view-only), and
  a create bar for new Set/Text lines. Posts one delta per action; the
  dialog is a pure view of the server state (ADR 0006).
- _Layout.cshtml / fis_main.js: cache-bust the dynamically imported
  per-module bundles (web/fis.<mdl>.<locale>.js/.css) with an asset
  version derived from the newest bundle write time, so a gulp rebuild
  is picked up without a stale cached module.

Docs: ADR 0016, live-draft-editing concept, INVOICE_SET_PRICING.md,
USER_GUIDE.md, ARCHITECTURE.md updated. Tests: InvoiceSetStructureTests
(new) + additions to InvoiceDraftServiceTests/InvoiceSetPricingTests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 08:55:05 +02:00
co-authored by Claude Sonnet 5
parent c1936a327e
commit ecc74cf3c6
31 changed files with 3003 additions and 147 deletions
+18 -12
View File
@@ -95,6 +95,7 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
switch (d.Target)
{
case "email": return SetNewText(s, "invoiceemail", d, ref oldValue, ref newValue);
case "emailcc": return SetNewText(s, "invoiceemailcc", d, ref oldValue, ref newValue); // "Zusatzempfänger" — Cc recipient
case "address":
// Structured dialog form posts a JSON object; a plain string keeps the legacy
// free-text path working (backward compatibility).
@@ -124,6 +125,10 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
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);
case "set.assign":
case "set.level":
case "set.create":
case "set.move": return InvoiceDraftSetStructureDeltas.Apply(s, d, ref oldValue, ref newValue); // ADR 0016: user-defined set structure
default: return false;
}
}
@@ -226,9 +231,12 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
/// 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.
/// <c>null</c>) once <c>fds__prepInvoice</c> stopped special-casing it; membership is resolved
/// by <see cref="InvoiceSetStructure.Descendants"/>, which never yields the header itself.
/// Because sets may contain sets (ADR 0016), the members are the header's <b>transitive</b>
/// descendants: a nested, still-unconverted set contributes its members' prices, a nested
/// already-converted set contributes its own set price — either way every descendant line is
/// price-nulled afterwards, so the total is conserved and nothing is counted twice.
/// </para>
/// </summary>
private static bool ApplyItemSetPrice(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
@@ -236,17 +244,12 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
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 (blockTok is not JObject block || block["itm"] is not JArray) continue;
var lines = InvoiceSetStructure.Lines(block);
var header = lines.FirstOrDefault(co => Str(co["id"]) == d.Ref);
if (header == null) continue;
if (!string.Equals(Str(header["typ"]), "set", StringComparison.OrdinalIgnoreCase)) return false;
var members = InvoiceSetStructure.Descendants(lines, d.Ref);
oldValue = InvoiceDraftCalculator.Dec(header["vt"]).ToString(CultureInfo.InvariantCulture);
decimal sumNet = 0, sumVat = 0, sumSvcNet = 0, sumSvcVat = 0;
@@ -256,6 +259,7 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
sumVat += InvoiceDraftCalculator.Dec(m["vv"]);
sumSvcNet += InvoiceDraftCalculator.Dec(m["vs"]);
sumSvcVat += InvoiceDraftCalculator.Dec(m["vsv"]);
if (Str(header["vat"]).Length == 0 && Str(m["vat"]).Length > 0) header["vat"] = Str(m["vat"]); // a user-created set adopts its members' rate
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;
@@ -540,6 +544,7 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
{
InvoiceDraftCalculator.RecomputeLineValues(session);
InvoiceDraftCalculator.RecomputeTotals(session);
InvoiceSetStructure.RecomputeLevels(session); // ADR 0016: publish lvl + mirror setId/level onto the items contract — must run before RecomputePositions, which reads lvl (ADR 0017)
InvoiceDraftCalculator.RecomputePositions(session);
InvoiceDraftCalculator.Validate(session);
}
@@ -609,6 +614,7 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
["InvoiceTitle"] = title,
["SendToAddress"] = Str(session.New["invoiceaddress"]),
["SendToEmail"] = Str(session.New["invoiceemail"]),
["SendToEmailCc"] = Str(session.New["invoiceemailcc"]),
["ProvisionLocation"] = loc,
["ProvisionPeriod"] = Str(session.New["provisionperiod"]),
["PaymentTerm"] = Str(session.Admin["paymentterms"]).ne(Str(session.New["paymentterm"])),
@@ -0,0 +1,120 @@
using System.Globalization;
using Fuchs.intranet;
using Newtonsoft.Json.Linq;
namespace Fuchs.Services;
/// <summary>
/// The <c>set.*</c> patch targets of <c>inv/dpatch</c> (ADR 0016) — the user-defined set structure
/// dialog's edits, applied to the authoritative cached session. Each handler locates the block,
/// delegates the actual tree mutation to the pure <see cref="InvoiceSetStructure"/> and reports the
/// old/new values for the change history. The browser never restructures anything itself: it posts
/// one delta, the server rewrites the block (parent links, depth-first order, levels) and the normal
/// <c>draftReady</c> refresh re-renders both the editor rows and the open structure dialog.
/// <list type="bullet">
/// <item><c>set.assign</c> — <c>Ref</c> = item id, <c>Value</c> = { parent: set id | "" }: drop a line onto a set / pull it out to the top level.</item>
/// <item><c>set.level</c> — <c>Ref</c> = item id, <c>Value</c> = +1 | -1: indent under the preceding sibling set / outdent to the grandparent.</item>
/// <item><c>set.create</c> — <c>Ref</c> = block id, <c>Value</c> = { typ: "set" | "text", title, parent: set id | "" }: add a new set header or free-text line.</item>
/// <item><c>set.move</c> — <c>Ref</c> = item id, <c>Value</c> = -1 | +1 (one step up/down among its siblings) or { anchor: item id, where: "before" | "after" } (drag between rows: become the anchor's sibling, before it or after its subtree). The item's own subtree travels with it.</item>
/// </list>
/// </summary>
internal static class InvoiceDraftSetStructureDeltas
{
public static bool Apply(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue) =>
d.Target switch
{
"set.assign" => Assign(s, d, ref oldValue, ref newValue),
"set.level" => Level(s, d, ref oldValue, ref newValue),
"set.create" => Create(s, d, ref oldValue, ref newValue),
"set.move" => Move(s, d, ref oldValue, ref newValue),
_ => false
};
private static bool Assign(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
{
if (string.IsNullOrEmpty(d.Ref)) return false;
var block = BlockOfItem(s, d.Ref);
if (block == null) return false;
string parent = d.Value is JObject vo ? InvoiceSetStructure.Str(vo["parent"]) : d.ValueString;
if (!InvoiceSetStructure.Assign(block, d.Ref, parent.Trim(), out string oldParent)) return false;
oldValue = oldParent;
newValue = parent.Trim();
return true;
}
private static bool Level(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
{
if (string.IsNullOrEmpty(d.Ref)) return false;
var block = BlockOfItem(s, d.Ref);
if (block == null) return false;
int delta = ParseDelta(d.Value);
if (!InvoiceSetStructure.ChangeLevel(block, d.Ref, delta, out int oldLevel, out int newLevel)) return false;
oldValue = oldLevel.ToString(CultureInfo.InvariantCulture);
newValue = newLevel.ToString(CultureInfo.InvariantCulture);
return true;
}
private static bool Create(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
{
if (d.Value is not JObject vo) return false;
var block = FindBlock(s, d.Ref);
if (block == null) return false;
string typ = InvoiceSetStructure.Str(vo["typ"]);
string title = InvoiceSetStructure.Str(vo["title"]);
string parent = InvoiceSetStructure.Str(vo["parent"]).Trim();
string? id = InvoiceSetStructure.Create(block, typ, title, parent);
if (id == null) return false;
oldValue = "";
newValue = typ.Trim().ToLowerInvariant() + ":" + title.Trim();
return true;
}
private static bool Move(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
{
if (string.IsNullOrEmpty(d.Ref)) return false;
var block = BlockOfItem(s, d.Ref);
if (block == null) return false;
if (d.Value is JObject vo)
{
string anchor = InvoiceSetStructure.Str(vo["anchor"]).Trim();
string where = InvoiceSetStructure.Str(vo["where"]).Trim().ToLowerInvariant();
if (anchor.Length == 0 || where is not ("before" or "after")) return false;
if (!InvoiceSetStructure.Place(block, d.Ref, anchor, after: where == "after", out string oldParent, out string newParent)) return false;
oldValue = oldParent;
newValue = where + ":" + anchor + (newParent.Length > 0 ? " (" + newParent + ")" : "");
return true;
}
int delta = ParseDelta(d.Value);
if (!InvoiceSetStructure.MoveSibling(block, d.Ref, delta, out int oldIndex, out int newIndex)) return false;
oldValue = oldIndex.ToString(CultureInfo.InvariantCulture);
newValue = newIndex.ToString(CultureInfo.InvariantCulture);
return true;
}
/// <summary>Accepts a JSON number, a numeric string, or the words "in"/"out" (indent/outdent), "up"/"down". 0 when unparseable.</summary>
private static int ParseDelta(JToken? value)
{
if (value == null || value.Type == JTokenType.Null) return 0;
if (value.Type is JTokenType.Integer or JTokenType.Float) return Math.Sign(value.Value<double>());
string s = InvoiceSetStructure.Str(value).Trim().ToLowerInvariant();
if (s is "in" or "indent" or "+" or "down") return 1;
if (s is "out" or "outdent" or "-" or "up") return -1;
return int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out int n) ? Math.Sign(n) : 0;
}
private static JObject? FindBlock(InvoiceDraftSession s, string blockId)
{
if (string.IsNullOrEmpty(blockId)) return null;
foreach (var b in s.Req)
if (b is JObject bo && InvoiceSetStructure.Str(bo["Id"]) == blockId) return bo;
return null;
}
/// <summary>The block whose <c>itm</c> array contains the line <paramref name="itemId"/>.</summary>
private static JObject? BlockOfItem(InvoiceDraftSession s, string itemId)
{
foreach (var b in s.Req)
if (b is JObject bo && InvoiceSetStructure.Lines(bo).Any(l => InvoiceSetStructure.Id(l) == itemId)) return bo;
return null;
}
}