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:
+23
-13
@@ -162,6 +162,9 @@ public static class FuchsPdf
|
||||
/// (type <c>text</c>/<c>title</c>) show neither a price nor a position number. This is the flat
|
||||
/// (non-collapsing) rendering used for every set mode except the explicit <c>setonly</c>.
|
||||
/// </summary>
|
||||
/// <summary>Indent per nesting level of a set member on the invoice (mirrors the editor's per-level padding).</summary>
|
||||
internal const double SetLevelIndentMm = 4;
|
||||
|
||||
private static InvoiceSetLine MapItemToLine(Dictionary<string, object?> i)
|
||||
{
|
||||
string type = i.nz("type", "").ToLowerInvariant();
|
||||
@@ -180,7 +183,8 @@ public static class FuchsPdf
|
||||
TotalNet = total,
|
||||
ShowPrice = !isText && hasPrice,
|
||||
Numbered = !isText, // numbered like the editor: every line except free-text/heading, even price-blanked set members
|
||||
IsSetHeader = type == "set"
|
||||
IsSetHeader = type == "set",
|
||||
Level = int.TryParse(i.nz("level", "0"), out int lvl) && lvl > 0 ? lvl : 0 // ADR 0016 nesting depth → indent
|
||||
};
|
||||
}
|
||||
|
||||
@@ -772,9 +776,22 @@ public static class FuchsPdf
|
||||
// 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;
|
||||
foreach (var block in inv.InvoiceBlocks)
|
||||
|
||||
// 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 blockLines = inv.InvoiceBlocks
|
||||
.Select(block => InvoiceSetPricing.HasSetMembers(block.Items)
|
||||
? InvoiceSetPricing.Build(block.Items, setMode)
|
||||
: block.Items.Select(MapItemToLine).ToList())
|
||||
.ToList();
|
||||
// Position labels are hierarchical ("1", "1.1", "1.2", "2", …, ADR 0017) and span the
|
||||
// whole invoice, so they must be computed once across every block, not per block.
|
||||
InvoiceSetPricing.AssignPositionLabels(blockLines);
|
||||
|
||||
for (int bi = 0; bi < inv.InvoiceBlocks.Count; bi++)
|
||||
{
|
||||
var block = inv.InvoiceBlocks[bi];
|
||||
if (!string.IsNullOrWhiteSpace(block.Heading))
|
||||
{
|
||||
var hr = tbl.AddRow();
|
||||
@@ -783,19 +800,12 @@ public static class FuchsPdf
|
||||
hr.Cells[1].AddParagraph().WithStyle("TblCell_RTitle").AddFormattedText(block.Heading, TextFormat.Bold);
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
foreach (var line in lines)
|
||||
foreach (var line in blockLines[bi])
|
||||
{
|
||||
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";
|
||||
row.Cells[0].AddParagraph(line.PositionLabel).Style = "TblCell_Base";
|
||||
if (line.Level > 0) row.Cells[1].Format.LeftIndent = Unit.FromMillimeter(SetLevelIndentMm * line.Level); // nested set member (ADR 0016) — indented like the editor
|
||||
if (!string.IsNullOrEmpty(line.Title)) // skip the empty paragraph that added a blank line before free text
|
||||
{
|
||||
var titleCell = row.Cells[1].AddParagraph();
|
||||
|
||||
@@ -109,27 +109,35 @@ public static class InvoiceDraftCalculator
|
||||
|
||||
/// <summary>
|
||||
/// Renumbers the visible line positions authoritatively (the port of the client-side
|
||||
/// numbering in <c>invSumUpdate</c>): priced lines are numbered sequentially across the whole
|
||||
/// invoice — matching the PDF's <c>Pos.</c> column — while heading/free-text lines
|
||||
/// (<c>typ</c> = "text"/"title") carry no number. The result is written onto each line's
|
||||
/// <c>p</c> field so it flows back to the browser (via the view state) and into the PDF; this
|
||||
/// keeps the online editor and the PDF preview showing the same position numbers, including
|
||||
/// after a reorder.
|
||||
/// numbering in <c>invSumUpdate</c>): priced lines are numbered hierarchically across the
|
||||
/// whole invoice — a set header gets a number at its own level, its members get subsidiary
|
||||
/// numbers one level deeper (e.g. "3", "3.1", "3.2") — matching the PDF's <c>Pos.</c> column
|
||||
/// (ADR 0017), while heading/free-text lines (<c>typ</c> = "text"/"title") carry no number.
|
||||
/// Levels come from <c>lvl</c> (written by <see cref="InvoiceSetStructure"/>, default 0). The
|
||||
/// result is written onto each line's <c>p</c> field (a string, since "3.1" isn't numeric) so
|
||||
/// it flows back to the browser (via the view state) and into the PDF; this keeps the online
|
||||
/// editor and the PDF preview showing the same position labels, including after a reorder.
|
||||
/// </summary>
|
||||
public static void RecomputePositions(InvoiceDraftSession session)
|
||||
{
|
||||
int pos = 0;
|
||||
var lines = new List<JObject>();
|
||||
var input = new List<(bool Numbered, int Level)>();
|
||||
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 (blockTok is not JObject block || block["itm"] is not JArray itms) continue;
|
||||
foreach (var lineTok in itms)
|
||||
{
|
||||
if (lineTok is not JObject co) continue;
|
||||
string typ = Str(co["typ"]).Trim().ToLowerInvariant();
|
||||
bool numbered = typ is not ("text" or "title"); // only headings/free-text carry no number (mirrors invSumUpdate)
|
||||
co["p"] = numbered ? (JToken)(++pos) : (JToken)"";
|
||||
int level = (int?)co[InvoiceSetStructure.LevelKey] ?? 0;
|
||||
lines.Add(co);
|
||||
input.Add((numbered, level));
|
||||
}
|
||||
}
|
||||
var labels = InvoiceLineNumbering.Compute(input);
|
||||
for (int i = 0; i < lines.Count; i++)
|
||||
lines[i]["p"] = labels[i];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -149,6 +157,10 @@ public static class InvoiceDraftCalculator
|
||||
else if (!IsValidEmail(email))
|
||||
Add("email", "error", "Die E-Mail-Adresse ist ungültig.");
|
||||
|
||||
string emailCc = Str(session.New["invoiceemailcc"]).Trim(); // "Zusatzempfänger" — optional, may stay empty
|
||||
if (emailCc.Length > 0 && !IsValidEmail(emailCc))
|
||||
Add("emailcc", "error", "Die E-Mail-Adresse des Zusatzempfängers ist ungültig.");
|
||||
|
||||
if (Str(session.New["invoiceaddress"]).Trim().Length == 0)
|
||||
Add("address", "warning", "Es ist keine Rechnungsanschrift hinterlegt.");
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using static OCORE.commons;
|
||||
using System.Linq;
|
||||
using static OCORE.commons;
|
||||
using static OCORE.OCORE_dictionaries;
|
||||
|
||||
namespace Fuchs.intranet;
|
||||
@@ -40,13 +41,24 @@ public sealed class InvoiceSetLine
|
||||
public bool Numbered { get; init; } = true;
|
||||
/// <summary>True for the set header line (rendered emphasised).</summary>
|
||||
public bool IsSetHeader { get; init; }
|
||||
/// <summary>Nesting depth (0 = top level; a member of a top-level set is 1, a member of a set inside it is 2 …) — rendered as an indent (ADR 0016).</summary>
|
||||
public int Level { get; init; }
|
||||
/// <summary>
|
||||
/// The hierarchical position label ("1", "1.1", "1.2", "2", …) as printed in the PDF's
|
||||
/// <c>Pos.</c> column (ADR 0017), or "" for unnumbered lines. Assigned via
|
||||
/// <see cref="InvoiceSetPricing.AssignPositionLabels"/> across the whole invoice, since a
|
||||
/// single <see cref="Build"/> call only sees one block.
|
||||
/// </summary>
|
||||
public string PositionLabel { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Transforms raw invoice line items into display lines according to a
|
||||
/// <see cref="SetDisplayMode"/>. A "set" item is identified by
|
||||
/// <c>type == "set"</c>; its members carry <c>setId</c> equal to the set
|
||||
/// header's <c>id</c>. Items that belong to no set pass through unchanged.
|
||||
/// header's <c>id</c> — a nested set is itself a member of its parent set
|
||||
/// (ADR 0016), and every line reports its depth as <see cref="InvoiceSetLine.Level"/>.
|
||||
/// Items that belong to no set pass through unchanged.
|
||||
///
|
||||
/// The invoice total is taken from the registration balance, not from these
|
||||
/// lines, so calling <see cref="Build"/> is a pure, non-mutating transformation
|
||||
@@ -94,65 +106,81 @@ public static class InvoiceSetPricing
|
||||
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. 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"/>.
|
||||
/// Produces the display lines for the given items and mode, <b>preserving the input order</b>
|
||||
/// (the draft service already linearises each block depth-first — set, then its members,
|
||||
/// recursively — so editor and PDF print the very same sequence, ADR 0008/0016).
|
||||
/// 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"/>.
|
||||
/// Sets may nest: a line is governed by its <i>nearest converted ancestor</i> — inside one, it
|
||||
/// is price-blanked (SetPrice) or dropped (SetOnly); a nested set that is itself converted
|
||||
/// but sits inside an unconverted one behaves like a converted top-level set for its own subtree.
|
||||
/// </summary>
|
||||
public static List<InvoiceSetLine> Build(IReadOnlyList<Dictionary<string, object?>> items, SetDisplayMode mode)
|
||||
{
|
||||
var result = new List<InvoiceSetLine>();
|
||||
if (items.Count == 0) return result;
|
||||
|
||||
// Group members by their set id.
|
||||
var membersBySet = items
|
||||
.Where(i => !IsSetHeader(i) && !string.IsNullOrEmpty(SetIdOf(i)))
|
||||
.GroupBy(SetIdOf)
|
||||
.ToDictionary(g => g.Key!, g => g.ToList());
|
||||
var headers = new Dictionary<string, Dictionary<string, object?>>();
|
||||
foreach (var i in items)
|
||||
if (IsSetHeader(i) && i.nz("id", "").Length > 0) headers.TryAdd(i.nz("id", ""), i);
|
||||
|
||||
static bool Converted(Dictionary<string, object?> h)
|
||||
{
|
||||
FuchsPdf.ParseDec(h.no("total_net", 0), out decimal own);
|
||||
return own != 0;
|
||||
}
|
||||
|
||||
// Walks the parent chain (setId → header) and reports depth + whether any ancestor is converted.
|
||||
(int level, bool insideConverted) Ancestry(Dictionary<string, object?> i)
|
||||
{
|
||||
int level = 0; bool inside = false;
|
||||
var seen = new HashSet<string> { i.nz("id", "") };
|
||||
string? pid = ParentIdOf(i, headers);
|
||||
while (pid != null && seen.Add(pid))
|
||||
{
|
||||
level++;
|
||||
var h = headers[pid];
|
||||
if (Converted(h)) inside = true;
|
||||
pid = ParentIdOf(h, headers);
|
||||
}
|
||||
return (level, inside);
|
||||
}
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
var (level, insideConverted) = Ancestry(item);
|
||||
if (insideConverted && mode == SetDisplayMode.SetOnly) continue; // collapsed into its set's single line
|
||||
|
||||
if (IsSetHeader(item))
|
||||
{
|
||||
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, headerOwnTotal, showPrice: true));
|
||||
foreach (var mem in members) result.Add(MemberLine(mem, showPrice: false));
|
||||
break;
|
||||
case SetDisplayMode.SetOnly:
|
||||
result.Add(HeaderLine(item, headerOwnTotal, showPrice: true));
|
||||
break;
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(SetIdOf(item)))
|
||||
{
|
||||
// Member — already emitted next to its header above; skip standalone emission.
|
||||
// Unconverted (still zero-priced) header: blank price, members keep their own price.
|
||||
// Converted: priced line; members blank (SetPrice) — or dropped above (SetOnly).
|
||||
bool show = headerOwnTotal != 0 && !insideConverted;
|
||||
result.Add(HeaderLine(item, show ? headerOwnTotal : 0, showPrice: show, level));
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Add(MemberLine(item, showPrice: true)); // standalone item
|
||||
result.Add(MemberLine(item, showPrice: !insideConverted, level));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes and assigns <see cref="InvoiceSetLine.PositionLabel"/> across every block's lines
|
||||
/// (in the order the blocks are printed), so numbering spans the whole invoice exactly like
|
||||
/// <c>InvoiceDraftCalculator.RecomputePositions</c> does for the online editor (ADR 0017).
|
||||
/// </summary>
|
||||
public static void AssignPositionLabels(IEnumerable<IReadOnlyList<InvoiceSetLine>> blocks)
|
||||
{
|
||||
var lines = blocks.SelectMany(b => b).ToList();
|
||||
var labels = InvoiceLineNumbering.Compute(lines.Select(l => (l.Numbered, l.Level)).ToList());
|
||||
for (int i = 0; i < lines.Count; i++)
|
||||
lines[i].PositionLabel = labels[i];
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────
|
||||
private static bool IsSetHeader(Dictionary<string, object?> i) =>
|
||||
string.Equals(i.nz("type", ""), "set", StringComparison.OrdinalIgnoreCase);
|
||||
@@ -174,13 +202,14 @@ public static class InvoiceSetPricing
|
||||
return string.IsNullOrEmpty(s) ? null : s;
|
||||
}
|
||||
|
||||
private static string HeaderIdOf(Dictionary<string, object?> i)
|
||||
/// <summary>The owning set header's id: <c>setId</c> when it names another known header; null for top-level lines and self-references.</summary>
|
||||
private static string? ParentIdOf(Dictionary<string, object?> i, Dictionary<string, Dictionary<string, object?>> headers)
|
||||
{
|
||||
string s = i.nz("setId", "");
|
||||
return string.IsNullOrEmpty(s) ? i.nz("id", "") : s;
|
||||
string s = SetIdOf(i) ?? "";
|
||||
return s.Length > 0 && s != i.nz("id", "") && headers.ContainsKey(s) ? s : null;
|
||||
}
|
||||
|
||||
private static InvoiceSetLine HeaderLine(Dictionary<string, object?> i, decimal setTotal, bool showPrice) => new()
|
||||
private static InvoiceSetLine HeaderLine(Dictionary<string, object?> i, decimal setTotal, bool showPrice, int level) => new()
|
||||
{
|
||||
Id = i.nz("id", ""),
|
||||
Title = i.nz("title", ""),
|
||||
@@ -190,10 +219,11 @@ public static class InvoiceSetPricing
|
||||
TotalNet = setTotal,
|
||||
ShowPrice = showPrice,
|
||||
Numbered = true, // a set header is always numbered
|
||||
IsSetHeader = true
|
||||
IsSetHeader = true,
|
||||
Level = level
|
||||
};
|
||||
|
||||
private static InvoiceSetLine MemberLine(Dictionary<string, object?> i, bool showPrice)
|
||||
private static InvoiceSetLine MemberLine(Dictionary<string, object?> i, bool showPrice, int level)
|
||||
{
|
||||
FuchsPdf.ParseDec(i.no("price_net", 0), out decimal price);
|
||||
FuchsPdf.ParseDec(i.no("total_net", 0), out decimal total);
|
||||
@@ -207,7 +237,8 @@ public static class InvoiceSetPricing
|
||||
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
|
||||
IsSetHeader = false,
|
||||
Level = level
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace Fuchs.intranet;
|
||||
|
||||
/// <summary>
|
||||
/// The explicit, user-editable <b>set structure</b> of an invoice draft (ADR 0016): which line
|
||||
/// belongs to which set, and how deep it sits. Every line in a block's <c>itm</c> array carries a
|
||||
/// parent link in <c>SetItmId</c> (the id of the <c>typ == "set"</c> line that owns it, or
|
||||
/// null/self for a top-level line). Sets may contain sets, so the links form a forest per block;
|
||||
/// the depth of each line is published as <c>lvl</c> (0 = top level) and mirrored onto the
|
||||
/// <c>items</c> contract shape as <c>setId</c>/<c>level</c>.
|
||||
/// <para>
|
||||
/// The implicit derivation (<c>fds__prepInvoice</c> tags every item following a zero-priced Set
|
||||
/// header with that header's id) is kept as the <b>initial</b> structure; this class only applies
|
||||
/// the user's explicit changes on top of it — assigning a line to a set, pulling it out, changing
|
||||
/// its level, or creating a new set/text line — and then re-linearises the block depth-first so
|
||||
/// every set is immediately followed by its members, recursively. Pure and static, so it is
|
||||
/// exhaustively unit-tested (<c>InvoiceSetStructureTests</c>); the draft service merely delegates.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class InvoiceSetStructure
|
||||
{
|
||||
/// <summary>Parent link on the editor <c>co</c> line shape.</summary>
|
||||
public const string ParentKey = "SetItmId";
|
||||
/// <summary>Depth (0 = top level) on the editor <c>co</c> line shape.</summary>
|
||||
public const string LevelKey = "lvl";
|
||||
|
||||
// ── Structural queries ───────────────────────────────────────────────────
|
||||
internal static string Id(JObject co) => Str(co["id"]);
|
||||
|
||||
internal static bool IsSet(JObject co) =>
|
||||
string.Equals(Str(co["typ"]), "set", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// The effective parent set id of a line: its <c>SetItmId</c> when that names <i>another</i>
|
||||
/// set line of the same block; null for top-level lines, a self-reference (the shape
|
||||
/// <c>fds__prepInvoice</c> delivers for a header), an unknown id or a non-set target.
|
||||
/// </summary>
|
||||
internal static string? ParentOf(JObject co, IReadOnlyDictionary<string, JObject> byId)
|
||||
{
|
||||
string p = Str(co[ParentKey]);
|
||||
if (p.Length == 0 || p == Id(co)) return null;
|
||||
return byId.TryGetValue(p, out var parent) && IsSet(parent) ? p : null;
|
||||
}
|
||||
|
||||
/// <summary>The block's line objects in their current order (non-object entries skipped).</summary>
|
||||
internal static List<JObject> Lines(JObject block) =>
|
||||
block["itm"] is JArray arr ? arr.OfType<JObject>().ToList() : new List<JObject>();
|
||||
|
||||
internal static Dictionary<string, JObject> ById(IEnumerable<JObject> lines)
|
||||
{
|
||||
var d = new Dictionary<string, JObject>();
|
||||
foreach (var l in lines) { string id = Id(l); if (id.Length > 0) d.TryAdd(id, l); }
|
||||
return d;
|
||||
}
|
||||
|
||||
/// <summary>Depth of every line (0 = top level). A cyclic chain is cut at its first repeat, so it degrades to a root.</summary>
|
||||
internal static Dictionary<string, int> Levels(List<JObject> lines)
|
||||
{
|
||||
var byId = ById(lines);
|
||||
var result = new Dictionary<string, int>();
|
||||
foreach (var line in lines)
|
||||
{
|
||||
int depth = 0;
|
||||
var seen = new HashSet<string> { Id(line) };
|
||||
var cur = line;
|
||||
while (ParentOf(cur, byId) is { } pid)
|
||||
{
|
||||
if (!seen.Add(pid)) { depth = 0; break; } // cyclic chain — degrade to top level (Linearize emits it as a root, too)
|
||||
depth++;
|
||||
cur = byId[pid];
|
||||
}
|
||||
result[Id(line)] = depth;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>All lines transitively owned by the set <paramref name="setId"/> (never the set itself), in current order.</summary>
|
||||
public static List<JObject> Descendants(List<JObject> lines, string setId)
|
||||
{
|
||||
var byId = ById(lines);
|
||||
var children = new Dictionary<string, List<JObject>>();
|
||||
foreach (var l in lines)
|
||||
if (ParentOf(l, byId) is { } p)
|
||||
(children.TryGetValue(p, out var lst) ? lst : children[p] = new List<JObject>()).Add(l);
|
||||
|
||||
var result = new List<JObject>();
|
||||
var seen = new HashSet<string> { setId };
|
||||
var queue = new Queue<string>();
|
||||
queue.Enqueue(setId);
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
string cur = queue.Dequeue();
|
||||
if (!children.TryGetValue(cur, out var kids)) continue;
|
||||
foreach (var k in kids)
|
||||
{
|
||||
string kid = Id(k);
|
||||
if (!seen.Add(kid)) continue;
|
||||
result.Add(k);
|
||||
queue.Enqueue(kid);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>The id of the top-level ancestor of a line (itself when it is top level).</summary>
|
||||
private static string RootOf(JObject line, IReadOnlyDictionary<string, JObject> byId)
|
||||
{
|
||||
var seen = new HashSet<string> { Id(line) };
|
||||
var cur = line;
|
||||
while (ParentOf(cur, byId) is { } pid && seen.Add(pid)) cur = byId[pid];
|
||||
return Id(cur);
|
||||
}
|
||||
|
||||
// ── Recompute / linearise ────────────────────────────────────────────────
|
||||
/// <summary>
|
||||
/// Publishes the depth of every line (<c>lvl</c>) and mirrors parent + depth onto the
|
||||
/// <c>items</c> contract shape (<c>setId</c>/<c>level</c>) for every block — without
|
||||
/// reordering anything. Runs on every refresh so the view state and the PDF read one truth.
|
||||
/// </summary>
|
||||
public static void RecomputeLevels(InvoiceDraftSession session)
|
||||
{
|
||||
foreach (var blockTok in session.Req)
|
||||
if (blockTok is JObject block) ApplyLevels(block);
|
||||
}
|
||||
|
||||
private static void ApplyLevels(JObject block)
|
||||
{
|
||||
var lines = Lines(block);
|
||||
if (lines.Count == 0) return;
|
||||
var byId = ById(lines);
|
||||
var levels = Levels(lines);
|
||||
foreach (var line in lines) line[LevelKey] = levels[Id(line)];
|
||||
if (block["items"] is not JArray items) return;
|
||||
foreach (var io in items.OfType<JObject>())
|
||||
{
|
||||
string id = Str(io["id"]);
|
||||
if (!byId.TryGetValue(id, out var co)) continue; // e.g. combined-sum rows use differing ids — leave untouched
|
||||
io["level"] = levels[id];
|
||||
io["setId"] = ParentOf(co, byId) is { } p ? p : JValue.CreateNull();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reorders a block's lines depth-first — each set immediately followed by its members,
|
||||
/// recursively, siblings keeping their current relative order — then republishes levels and
|
||||
/// mirrors the order onto <c>items</c>. Lines caught in a cyclic chain are emitted as roots.
|
||||
/// </summary>
|
||||
public static void Linearize(JObject block)
|
||||
{
|
||||
var lines = Lines(block);
|
||||
if (lines.Count == 0) return;
|
||||
var byId = ById(lines);
|
||||
var children = new Dictionary<string, List<JObject>>();
|
||||
var roots = new List<JObject>();
|
||||
foreach (var l in lines)
|
||||
{
|
||||
if (ParentOf(l, byId) is { } p)
|
||||
(children.TryGetValue(p, out var lst) ? lst : children[p] = new List<JObject>()).Add(l);
|
||||
else roots.Add(l);
|
||||
}
|
||||
|
||||
var ordered = new List<JObject>();
|
||||
var seen = new HashSet<string>();
|
||||
void Visit(JObject l)
|
||||
{
|
||||
if (!seen.Add(Id(l))) return;
|
||||
ordered.Add(l);
|
||||
if (children.TryGetValue(Id(l), out var kids)) foreach (var k in kids) Visit(k);
|
||||
}
|
||||
foreach (var r in roots) Visit(r);
|
||||
foreach (var l in lines) Visit(l); // anything unreachable (cycle) — keep, as a root
|
||||
|
||||
var itm = (JArray)block["itm"]!;
|
||||
itm.Clear();
|
||||
foreach (var l in ordered) itm.Add(l);
|
||||
|
||||
if (block["items"] is JArray items)
|
||||
{
|
||||
var itemsById = new Dictionary<string, JObject>();
|
||||
var rest = new List<JObject>();
|
||||
foreach (var io in items.OfType<JObject>())
|
||||
{
|
||||
string id = Str(io["id"]);
|
||||
if (id.Length > 0 && byId.ContainsKey(id) && itemsById.TryAdd(id, io)) continue;
|
||||
rest.Add(io);
|
||||
}
|
||||
items.Clear();
|
||||
foreach (var l in ordered) if (itemsById.TryGetValue(Id(l), out var io)) items.Add(io);
|
||||
foreach (var io in rest) items.Add(io);
|
||||
}
|
||||
ApplyLevels(block);
|
||||
}
|
||||
|
||||
// ── Mutations ────────────────────────────────────────────────────────────
|
||||
/// <summary>
|
||||
/// Assigns line <paramref name="itemId"/> to the set <paramref name="parentId"/> ("" = pull it
|
||||
/// out to the top level). The line's own descendants move with it (one level deeper/shallower).
|
||||
/// A line dropped onto a set becomes its last member; a line pulled out is placed right after
|
||||
/// the subtree it came from. No-op (false) when the item is unknown, the target is not a set
|
||||
/// line of this block, the target is the item itself or one of its descendants (cycle), or
|
||||
/// the parent is unchanged.
|
||||
/// </summary>
|
||||
public static bool Assign(JObject block, string itemId, string parentId, out string oldParent)
|
||||
{
|
||||
oldParent = "";
|
||||
var lines = Lines(block);
|
||||
var byId = ById(lines);
|
||||
if (!byId.TryGetValue(itemId, out var item)) return false;
|
||||
string? current = ParentOf(item, byId);
|
||||
oldParent = current ?? "";
|
||||
string? target = parentId.Length == 0 ? null : parentId;
|
||||
if (target == current) return false;
|
||||
if (target != null)
|
||||
{
|
||||
if (target == itemId || !byId.TryGetValue(target, out var parent) || !IsSet(parent)) return false;
|
||||
if (Descendants(lines, itemId).Any(d => Id(d) == target)) return false; // would create a cycle
|
||||
}
|
||||
|
||||
var itm = (JArray)block["itm"]!;
|
||||
if (target != null)
|
||||
{
|
||||
item.Remove();
|
||||
itm.Add(item); // last member of the target set
|
||||
}
|
||||
else
|
||||
{
|
||||
string root = RootOf(item, byId);
|
||||
var subtree = new HashSet<string>(Descendants(lines, root).Select(Id)) { root };
|
||||
var own = new HashSet<string>(Descendants(lines, itemId).Select(Id)) { itemId };
|
||||
var last = lines.Where(l => subtree.Contains(Id(l)) && !own.Contains(Id(l))).LastOrDefault();
|
||||
item.Remove();
|
||||
if (last != null) last.AddAfterSelf(item); else itm.Add(item);
|
||||
}
|
||||
item[ParentKey] = ParentToken(target);
|
||||
Linearize(block);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Moves a line one level deeper (<paramref name="delta"/> > 0: it becomes the last member of
|
||||
/// the nearest preceding sibling set) or one level up (<paramref name="delta"/> < 0: it joins
|
||||
/// its grandparent right after its former parent's subtree). Returns false when no such move
|
||||
/// exists (no preceding sibling set / already top level) or the item is unknown.
|
||||
/// </summary>
|
||||
public static bool ChangeLevel(JObject block, string itemId, int delta, out int oldLevel, out int newLevel)
|
||||
{
|
||||
oldLevel = newLevel = 0;
|
||||
if (delta == 0) return false;
|
||||
Linearize(block); // guarantees contiguous subtrees + fresh levels
|
||||
var lines = Lines(block);
|
||||
var byId = ById(lines);
|
||||
if (!byId.TryGetValue(itemId, out var item)) return false;
|
||||
oldLevel = newLevel = Levels(lines)[itemId];
|
||||
string? parent = ParentOf(item, byId);
|
||||
|
||||
if (delta > 0)
|
||||
{
|
||||
JObject? sibling = null;
|
||||
foreach (var l in lines)
|
||||
{
|
||||
if (Id(l) == itemId) break;
|
||||
if (ParentOf(l, byId) == parent && IsSet(l)) sibling = l; // nearest preceding sibling set
|
||||
}
|
||||
if (sibling == null) return false;
|
||||
if (!Assign(block, itemId, Id(sibling), out _)) return false;
|
||||
newLevel = Levels(Lines(block))[itemId];
|
||||
return true;
|
||||
}
|
||||
|
||||
if (parent == null) return false;
|
||||
string grand = ParentOf(byId[parent], byId) ?? "";
|
||||
// Place the line (with its own subtree) right after its former parent's subtree.
|
||||
var parentSubtree = new HashSet<string>(Descendants(lines, parent).Select(Id));
|
||||
var own = new HashSet<string>(Descendants(lines, itemId).Select(Id)) { itemId };
|
||||
var anchor = lines.Where(l => (parentSubtree.Contains(Id(l)) || Id(l) == parent) && !own.Contains(Id(l))).Last();
|
||||
var moving = lines.Where(l => own.Contains(Id(l))).ToList();
|
||||
foreach (var l in moving) l.Remove();
|
||||
JToken after = anchor;
|
||||
foreach (var l in moving) { after.AddAfterSelf(l); after = l; }
|
||||
item[ParentKey] = ParentToken(grand.Length > 0 ? grand : null);
|
||||
Linearize(block);
|
||||
newLevel = Levels(Lines(block))[itemId];
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Places line <paramref name="itemId"/> (with its whole subtree) immediately <b>before</b> the
|
||||
/// anchor line or <b>after</b> the anchor's subtree, adopting the anchor's parent — i.e. it
|
||||
/// becomes the anchor's sibling. This is the reorder primitive behind drag-between-rows and
|
||||
/// the ▲/▼ buttons. Rejected (false) when the item or anchor is unknown, they are the same
|
||||
/// line, or the anchor sits inside the item's own subtree.
|
||||
/// </summary>
|
||||
public static bool Place(JObject block, string itemId, string anchorId, bool after, out string oldParent, out string newParent)
|
||||
{
|
||||
oldParent = newParent = "";
|
||||
if (itemId == anchorId) return false;
|
||||
Linearize(block); // contiguous subtrees
|
||||
var lines = Lines(block);
|
||||
var byId = ById(lines);
|
||||
if (!byId.TryGetValue(itemId, out var item) || !byId.TryGetValue(anchorId, out var anchor)) return false;
|
||||
var own = new HashSet<string>(Descendants(lines, itemId).Select(Id)) { itemId };
|
||||
if (own.Contains(anchorId)) return false; // cannot place a set relative to its own member
|
||||
oldParent = ParentOf(item, byId) ?? "";
|
||||
newParent = ParentOf(anchor, byId) ?? "";
|
||||
|
||||
var moving = lines.Where(l => own.Contains(Id(l))).ToList();
|
||||
foreach (var l in moving) l.Remove();
|
||||
JToken slot = after
|
||||
? lines.Where(l => !own.Contains(Id(l)) && (Id(l) == anchorId || Descendants(lines, anchorId).Any(d => Id(d) == Id(l)))).Last()
|
||||
: anchor;
|
||||
if (after) { foreach (var l in moving) { slot.AddAfterSelf(l); slot = l; } }
|
||||
else { foreach (var l in moving) slot.AddBeforeSelf(l); }
|
||||
item[ParentKey] = ParentToken(newParent.Length > 0 ? newParent : null);
|
||||
Linearize(block);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Moves a line one step up (<paramref name="delta"/> < 0) or down (> 0) among its
|
||||
/// siblings (same parent); its subtree travels with it. False at the first/last position or
|
||||
/// for an unknown line. Reports the 0-based sibling index before and after.
|
||||
/// </summary>
|
||||
public static bool MoveSibling(JObject block, string itemId, int delta, out int oldIndex, out int newIndex)
|
||||
{
|
||||
oldIndex = newIndex = -1;
|
||||
if (delta == 0) return false;
|
||||
Linearize(block);
|
||||
var lines = Lines(block);
|
||||
var byId = ById(lines);
|
||||
if (!byId.TryGetValue(itemId, out var item)) return false;
|
||||
string? parent = ParentOf(item, byId);
|
||||
var siblings = lines.Where(l => ParentOf(l, byId) == parent).ToList();
|
||||
oldIndex = siblings.FindIndex(l => Id(l) == itemId);
|
||||
int target = oldIndex + Math.Sign(delta);
|
||||
if (target < 0 || target >= siblings.Count) return false;
|
||||
if (!Place(block, itemId, Id(siblings[target]), after: delta > 0, out _, out _)) return false;
|
||||
newIndex = target;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new line of <paramref name="typ"/> "set" (an unconverted, zero-priced set header
|
||||
/// that can later be switched to a set price) or "text" (a free-text line) titled
|
||||
/// <paramref name="title"/>, as the last member of <paramref name="parentId"/> ("" = end of the
|
||||
/// block). Both line shapes (<c>itm</c> + <c>items</c>) are written. Returns the new id, or
|
||||
/// null when the type is unknown, the title is blank or the parent is not a set of this block.
|
||||
/// </summary>
|
||||
public static string? Create(JObject block, string typ, string title, string parentId)
|
||||
{
|
||||
string kind = (typ ?? "").Trim().ToLowerInvariant();
|
||||
title = (title ?? "").Trim();
|
||||
if (kind is not ("set" or "text") || title.Length == 0) return null;
|
||||
var lines = Lines(block);
|
||||
var byId = ById(lines);
|
||||
string? parent = parentId.Length == 0 ? null : parentId;
|
||||
if (parent != null && (!byId.TryGetValue(parent, out var p) || !IsSet(p))) return null;
|
||||
|
||||
string id = "sN" + Guid.NewGuid().ToString("N")[..8];
|
||||
string rate = lines.Select(l => Str(l["vat"])).FirstOrDefault(r => r.Length > 0) ?? "19%";
|
||||
JObject co, io;
|
||||
if (kind == "set")
|
||||
{
|
||||
co = new JObject
|
||||
{
|
||||
["id"] = id, ["typ"] = "set", ["p"] = "", ["q"] = "", ["t"] = title, ["tt"] = "",
|
||||
["v"] = 0, ["vt"] = 0, ["vv"] = 0, ["vs"] = 0, ["vsv"] = 0, ["vat"] = rate, ["det"] = false,
|
||||
[ParentKey] = ParentToken(parent)
|
||||
};
|
||||
io = new JObject
|
||||
{
|
||||
["id"] = id, ["type"] = "set", ["title"] = title, ["desc"] = "", ["qty"] = "",
|
||||
["price_net"] = 0, ["total_net"] = 0, ["vat"] = rate, ["setId"] = ParentToken(parent)
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
string html = "<p>" + System.Net.WebUtility.HtmlEncode(title) + "</p>";
|
||||
co = new JObject
|
||||
{
|
||||
["id"] = id, ["typ"] = "Text", ["p"] = "", ["q"] = JValue.CreateNull(), ["t"] = html, ["tt"] = JValue.CreateNull(),
|
||||
["v"] = JValue.CreateNull(), ["vt"] = JValue.CreateNull(), ["vv"] = JValue.CreateNull(), // free text: no price at all (empty cell, ADR 0009 null semantics)
|
||||
["vs"] = JValue.CreateNull(), ["vsv"] = JValue.CreateNull(), ["vat"] = "", ["det"] = false,
|
||||
[ParentKey] = ParentToken(parent)
|
||||
};
|
||||
io = new JObject
|
||||
{
|
||||
["id"] = id, ["type"] = "text", ["title"] = "", ["desc"] = html, ["qty"] = "",
|
||||
["price_net"] = "", ["total_net"] = "", ["vat"] = "", ["setId"] = ParentToken(parent)
|
||||
};
|
||||
}
|
||||
if (block["itm"] is not JArray itm) { itm = new JArray(); block["itm"] = itm; }
|
||||
itm.Add(co);
|
||||
if (block["items"] is JArray items) items.Add(io); else block["items"] = new JArray { io };
|
||||
Linearize(block);
|
||||
return id;
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────
|
||||
private static JToken ParentToken(string? parent) => parent != null ? new JValue(parent) : JValue.CreateNull();
|
||||
|
||||
internal static string Str(JToken? t) =>
|
||||
t == null || t.Type == JTokenType.Null ? "" : t.Type == JTokenType.String ? t.Value<string>() ?? "" : t.ToString();
|
||||
}
|
||||
Reference in New Issue
Block a user