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>
67 lines
4.2 KiB
Markdown
67 lines
4.2 KiB
Markdown
---
|
|
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.
|