Add hierarchical invoice position numbering (ADR 0017)

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 09:09:43 +02:00
co-authored by Claude Sonnet 5
parent 94aa7cbfe4
commit d16d168eee
4 changed files with 166 additions and 1 deletions
+1 -1
View File
@@ -1186,7 +1186,7 @@ public class InvoiceDraftServiceTests
Assert.Equal("B", (string)LineOf(s2!, "x")["SetItmId"]!); Assert.Equal("B", (string)LineOf(s2!, "x")["SetItmId"]!);
Assert.Equal("", s2!.History[0].OldValue); Assert.Equal("", s2!.History[0].OldValue);
Assert.StartsWith("before:m1", s2.History[0].NewValue); Assert.StartsWith("before:m1", s2.History[0].NewValue);
Assert.Equal(3, LineOf(s2, "x")["p"]!.Value<int>()); Assert.Equal("1.1.1", (string)LineOf(s2, "x")["p"]!); // hierarchical label (ADR 0017): lvl2 under B (1.1)
} }
[Theory] [Theory]
+71
View File
@@ -0,0 +1,71 @@
using System.Collections.Generic;
using Fuchs.intranet;
using Xunit;
namespace Fuchs.Tests;
/// <summary>Pure unit tests for the hierarchical position-numbering algorithm (ADR 0017).</summary>
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));
}
}
@@ -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.
+28
View File
@@ -0,0 +1,28 @@
namespace Fuchs.intranet;
/// <summary>
/// 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 — <see cref="InvoiceSetStructure.Linearize"/> and
/// <see cref="InvoiceSetPricing.Build"/> both already guarantee that.
/// </summary>
public static class InvoiceLineNumbering
{
public static IReadOnlyList<string> Compute(IReadOnlyList<(bool Numbered, int Level)> lines)
{
var result = new List<string>(lines.Count);
var counters = new List<int>();
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;
}
}