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; + } +}