Add user-defined invoice set structure dialog (ADR 0016)
Playwright Tests / test (push) Canceled after 0s
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user