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