Files
Fuchs_Intranet/Fuchs/Docs/Decisions/0017-hierarchical-invoice-position-numbering.md
StefanandClaude Sonnet 5 d16d168eee 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>
2026-09-10 09:09:43 +02:00

4.2 KiB

status, date, applyTo, supersededBy
status date applyTo supersededBy
Accepted 2026-09-10
Fuchs/code/InvoiceLineNumbering.cs
Fuchs/code/InvoiceDraftCalculator.cs
Fuchs/code/InvoiceSetPricing.cs
Fuchs/code/FuchsPdf.cs
Fuchs/js/intranet/modules/fis.inv_shared.js

0017 — Invoice position numbers are hierarchical labels ("1", "1.1", "1.2", …), computed by one shared algorithm

Context

ADR 0016 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.