From d16d168eee80a4a7da4a8bdafe012a4c242c16b8 Mon Sep 17 00:00:00 2001 From: Stefan Date: Thu, 10 Sep 2026 09:09:43 +0200 Subject: [PATCH] Add hierarchical invoice position numbering (ADR 0017) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the InvoiceLineNumbering class that InvoiceDraftCalculator. RecomputePositions, InvoiceSetPricing.AssignPositionLabels and their callers already depend on (this dependency was pushed in ecc74cf without the class itself, breaking the build) — position labels are now hierarchical ("1", "1.1", "1.2", …) matching a line's ADR 0016 nesting level, computed by one pure, shared algorithm for both the online editor and the PDF so they can never disagree (ADR 0008). Also fixes an InvoiceDraftServiceTests assertion that still expected the old flat integer position field; it is a string label now. Co-Authored-By: Claude Sonnet 5 --- Fuchs.Tests/InvoiceDraftServiceTests.cs | 2 +- Fuchs.Tests/InvoiceLineNumberingTests.cs | 71 +++++++++++++++++++ ...hierarchical-invoice-position-numbering.md | 66 +++++++++++++++++ Fuchs/code/InvoiceLineNumbering.cs | 28 ++++++++ 4 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 Fuchs.Tests/InvoiceLineNumberingTests.cs create mode 100644 Fuchs/Docs/Decisions/0017-hierarchical-invoice-position-numbering.md create mode 100644 Fuchs/code/InvoiceLineNumbering.cs diff --git a/Fuchs.Tests/InvoiceDraftServiceTests.cs b/Fuchs.Tests/InvoiceDraftServiceTests.cs index e9bbf42..32ed20e 100644 --- a/Fuchs.Tests/InvoiceDraftServiceTests.cs +++ b/Fuchs.Tests/InvoiceDraftServiceTests.cs @@ -1186,7 +1186,7 @@ public class InvoiceDraftServiceTests Assert.Equal("B", (string)LineOf(s2!, "x")["SetItmId"]!); Assert.Equal("", s2!.History[0].OldValue); Assert.StartsWith("before:m1", s2.History[0].NewValue); - Assert.Equal(3, LineOf(s2, "x")["p"]!.Value()); + Assert.Equal("1.1.1", (string)LineOf(s2, "x")["p"]!); // hierarchical label (ADR 0017): lvl2 under B (1.1) } [Theory] diff --git a/Fuchs.Tests/InvoiceLineNumberingTests.cs b/Fuchs.Tests/InvoiceLineNumberingTests.cs new file mode 100644 index 0000000..c37d007 --- /dev/null +++ b/Fuchs.Tests/InvoiceLineNumberingTests.cs @@ -0,0 +1,71 @@ +using System.Collections.Generic; +using Fuchs.intranet; +using Xunit; + +namespace Fuchs.Tests; + +/// Pure unit tests for the hierarchical position-numbering algorithm (ADR 0017). +public class InvoiceLineNumberingTests +{ + [Fact] + public void Compute_EmptyInput_ReturnsEmpty() + { + Assert.Empty(InvoiceLineNumbering.Compute(new List<(bool, int)>())); + } + + [Fact] + public void Compute_FlatSequence_NumbersSequentially() + { + var input = new List<(bool Numbered, int Level)> { (true, 0), (true, 0), (true, 0) }; + Assert.Equal(new[] { "1", "2", "3" }, InvoiceLineNumbering.Compute(input)); + } + + [Fact] + public void Compute_AllUnnumbered_ReturnsAllBlank() + { + var input = new List<(bool Numbered, int Level)> { (false, 0), (false, 0) }; + Assert.Equal(new[] { "", "" }, InvoiceLineNumbering.Compute(input)); + } + + [Fact] + public void Compute_OneLevelOfNesting_ProducesSubsidiaryNumbers() + { + // header, member, member, top-level item + var input = new List<(bool Numbered, int Level)> { (true, 0), (true, 1), (true, 1), (true, 0) }; + Assert.Equal(new[] { "1", "1.1", "1.2", "2" }, InvoiceLineNumbering.Compute(input)); + } + + [Fact] + public void Compute_TwoLevelsOfNesting_ProducesDeepLabels() + { + // A(0), B(1, nested set), m1(2), m2(2), m3(1), x(0) + var input = new List<(bool Numbered, int Level)> + { + (true, 0), (true, 1), (true, 2), (true, 2), (true, 1), (true, 0) + }; + Assert.Equal(new[] { "1", "1.1", "1.1.1", "1.1.2", "1.2", "2" }, InvoiceLineNumbering.Compute(input)); + } + + [Fact] + public void Compute_TextLinesInterleaved_GetNoLabelAndDoNotConsumeACounterSlot() + { + var input = new List<(bool Numbered, int Level)> { (true, 0), (false, 0), (true, 1), (false, 1), (true, 0) }; + Assert.Equal(new[] { "1", "", "1.1", "", "2" }, InvoiceLineNumbering.Compute(input)); + } + + [Fact] + public void Compute_LevelJumpWithoutIntermediateLevelPresent_StillProducesAStableLabel() + { + // A level-2 line appears with no level-1 ancestor ever seen (defensive — shouldn't happen + // given InvoiceSetStructure's guarantees, but the algorithm must not throw or misbehave). + var input = new List<(bool Numbered, int Level)> { (true, 0), (true, 2) }; + Assert.Equal(new[] { "1", "1.0.1" }, InvoiceLineNumbering.Compute(input)); + } + + [Fact] + public void Compute_ReturningToAShallowerLevel_TruncatesDeeperCounters() + { + var input = new List<(bool Numbered, int Level)> { (true, 0), (true, 1), (true, 2), (true, 1) }; + Assert.Equal(new[] { "1", "1.1", "1.1.1", "1.2" }, InvoiceLineNumbering.Compute(input)); + } +} diff --git a/Fuchs/Docs/Decisions/0017-hierarchical-invoice-position-numbering.md b/Fuchs/Docs/Decisions/0017-hierarchical-invoice-position-numbering.md new file mode 100644 index 0000000..c799fa1 --- /dev/null +++ b/Fuchs/Docs/Decisions/0017-hierarchical-invoice-position-numbering.md @@ -0,0 +1,66 @@ +--- +status: Accepted +date: 2026-09-10 +applyTo: + - "Fuchs/code/InvoiceLineNumbering.cs" + - "Fuchs/code/InvoiceDraftCalculator.cs" + - "Fuchs/code/InvoiceSetPricing.cs" + - "Fuchs/code/FuchsPdf.cs" + - "Fuchs/js/intranet/modules/fis.inv_shared.js" +supersededBy: "" +--- + +# 0017 — Invoice position numbers are hierarchical labels ("1", "1.1", "1.2", …), computed by one shared algorithm + +## Context +ADR [0016](0016-user-defined-set-structure.md) made set nesting explicit and gave every line a +depth (`lvl`/`level`). With a set able to contain a set able to contain plain items, a flat, +sequential position number ("1", "2", "3", …) across the whole invoice no longer communicates the +structure a set-price customer sees: nothing in the printed `Pos.` column distinguished a set's +own line from its members, nor showed how deep a member sat. + +## Decision +1. **Position labels are hierarchical**, matching the line's nesting level: a top-level line gets + "1", "2", …; a member of set "3" gets "3.1", "3.2", …; a member nested two sets deep gets + "3.1.1", "3.1.2", … Text/free-text lines are unnumbered (`""`) and consume no slot at any level, + exactly as before. +2. **One pure algorithm, two call sites.** `InvoiceLineNumbering.Compute` takes an ordered + sequence of `(Numbered, Level)` pairs and returns the label per line — it knows nothing about + invoices, sets or JSON. It is called from: + - `InvoiceDraftCalculator.RecomputePositions` (the online editor / cached draft session) — + labels are written onto each line's `p` field, which is now a **string**, not an int. + - `InvoiceSetPricing.AssignPositionLabels` (the PDF) — called once across **every** block's + `Build` output (`InvoiceSetLine.PositionLabel`), so numbering spans the whole document the + same way the editor's `RecomputePositions` does across the whole session, not per block. +3. **Levels come from the ADR 0016 structure**, not re-derived: the editor reads each line's + `lvl` (written by `InvoiceSetStructure`), the PDF reads each line's `Level` (written by + `InvoiceSetPricing.Build`). Both are already order-preserving/depth-first, so a straight + left-to-right pass produces correct labels without knowing about sets at all. +4. **The algorithm is defensive**, not invoice-specific: a level jump with no shallower ancestor + ever seen still produces a stable label (padding with "0" — e.g. `1.0.1`) instead of throwing, + and returning to a shallower level truncates the deeper counters. This keeps it a pure, + independently testable utility (`InvoiceLineNumberingTests`) with no invoice-shaped + preconditions to violate. + +## Consequences +- Everywhere a line's position was read as a number (persistence params, sort keys, display), + it must be treated as an opaque string label instead. The editor (`fis.inv_shared.js`) renders + `co.p`/`ix.position` as text; nothing sorts or does arithmetic on it. +- The PDF and the online editor compute labels via the **same underlying algorithm** but two + separate call sites (one owns the whole draft session, the other owns the whole rendered + document) — keeping them in one algorithm is what guarantees they can never drift apart + (ADR 0008). +- `InvoiceSetPricing.Build` no longer assigns its own line numbers per block; a caller that wants + correct labels must call `AssignPositionLabels` across the full ordered set of blocks. + +## Alternatives considered +- **Keep flat sequential numbers, show nesting only via indentation**: rejected — indentation + alone does not survive being read out loud or referenced ("Position 3.1") the way a printed + invoice is used in practice, and does not withstand copy/paste into an email or a phone call. +- **Number only top-level lines, leave members entirely unnumbered**: rejected — a "Set mit + Preis"-adjacent workflow (ADR 0009) still wants every priced member individually referenceable + before/after a set-price conversion. +- **Re-derive nesting from `SetItmId` at numbering time instead of reading the already-published + `lvl`/`Level`**: rejected — `InvoiceSetStructure`/`InvoiceSetPricing.Build` already guarantee a + depth-first, level-tagged order; recomputing it a second time would duplicate that logic and + risk disagreeing with it. diff --git a/Fuchs/code/InvoiceLineNumbering.cs b/Fuchs/code/InvoiceLineNumbering.cs new file mode 100644 index 0000000..70b5a88 --- /dev/null +++ b/Fuchs/code/InvoiceLineNumbering.cs @@ -0,0 +1,28 @@ +namespace Fuchs.intranet; + +/// +/// Computes hierarchical invoice position labels ("1", "1.1", "1.2", "2", …) from a +/// depth-first-ordered sequence of lines (ADR 0017). A set header gets a number at its own +/// level; its members get subsidiary numbers one level deeper. Text/Title lines get no label +/// and do not consume a counter slot at any level. Callers must supply lines already in final +/// document order — and +/// both already guarantee that. +/// +public static class InvoiceLineNumbering +{ + public static IReadOnlyList Compute(IReadOnlyList<(bool Numbered, int Level)> lines) + { + var result = new List(lines.Count); + var counters = new List(); + foreach (var (numbered, level) in lines) + { + if (!numbered) { result.Add(""); continue; } + int lvl = Math.Max(level, 0); + while (counters.Count <= lvl) counters.Add(0); + if (counters.Count > lvl + 1) counters.RemoveRange(lvl + 1, counters.Count - lvl - 1); + counters[lvl]++; + result.Add(string.Join(".", counters)); + } + return result; + } +}