Skip to content

Konzept: Erweiterte Preisverwaltung in calServer V2

Stand: 2026-08-03 · Status: Konzept / Planung · Scope: Preisliste (prices), Preisgruppen (price_category), Katalogartikel (standard_article) und die Preisfindung für Auftragspositionen

Kurzfassung: Preise lassen sich in V2 heute nicht anlegen und nicht ändern. Das ist keine API-Lücke, sondern eine reine Frontend-Lücke: POST /prices, PUT /prices/{id} und POST /prices/{id}/duplicate existieren samt Rechten und Tests, es ruft sie nur niemand auf. Dieses Dokument schließt zuerst diese Lücke, macht dann die Preisgruppe zum tragenden Element der Preisbearbeitung (Matrix-Ansicht, Gruppe duplizieren, Massenanpassung) und plant zuletzt die fachlichen Erweiterungen (Gültigkeit statt Überschreiben, Staffelpreise, Zuschläge, Währung). Unterwegs sind sechs konkrete Defekte aufgefallen, darunter ein treiberabhängiges Preisergebnis in der Preisfindung (Abschnitt 2.5).

Nachträge 2026-08-03: Abschnitt 3.5 ergänzt die Leistungsachse (ein Preis ist Leistung × Gerätetyp × Preisgruppe × Gültigkeit, AP 10), Abschnitt 3.6 die fehlende Vererbungsstufe der Geräteachse (AP 11).

Nachtrag 2026-08-04: Diese Stufe ist die Preiskategorie als Container (ADR-032). Sie nimmt Gerätetypen und Prozeduren auf, und eine Prozedur reicht die ihr zugeordneten Typen weiter. Damit wird der Container bündelweise befüllt statt typweise, was den Kosteneinwand aus ADR-029 auflöst. Typen ohne Prozedur, etwa extern kalibrierte Geräte, hängen direkt am Container. Dazu kommt die Vererbung zwischen Preisgruppen (prozentual oder fixer Aufschlag), die den in Abschnitt 6.3 beschriebenen stillen 0,00-Fallstrick beseitigt. Beide Nachträge gehen auf additive V2-Tabellen; prices und price_category bleiben unangetastet.


1. Auslöser

„Preise können nicht bearbeitet werden." Der Befund stimmt, die Ursache liegt enger als erwartet:

Ebene Anlegen Ändern Duplizieren Löschen
API (routes/api.php:1028-1042)
RBAC (OperationCatalog, AppSetup) price_edit price_edit price_delete
Tests (PriceControllerTest, 16 Fälle)
Frontend pages/price/

pages/price/index.vue rendert PageHeader plus TableMaster ohne #actions-Slot, ohne rowActions, ohne CrudModal. pages/price/[id].vue übergibt der DetailActionBar nur :danger, nie :primary. price_edit kommt im gesamten Frontend nicht vor. Es gibt keinen TODO-Kommentar und keine Historie dazu, die Auslassung ist undokumentiert.

Der Vorschlag aus dem Auftrag, „Preisgruppen könnten Teil der Preisbearbeitung werden", trifft den wunden Punkt: Die Preisgruppe ist der zweite Schlüssel jeder Preiszeile, hat aber heute eine eigene, unverbundene Seite. Preise pflegen heißt derzeit, sich Gerätetyp und Preisgruppe im Kopf zu merken.

2. Ist-Stand

2.1 Datenmodell (V1-Erbe)

Drei Tabellen, alle aus V1 übernommen und schemagleich in V2 migriert (2026_02_28_600000_create_price_tables.php, 2026_02_28_500006_create_standard_article_table.php):

  • price_category (PK uID): Preisgruppe als Kundensegment. name (unique), description. Kunden zeigen über customers.price_category_id darauf.
  • prices (PK pID): eine Preiszeile je Gerätetyp × Preisgruppe, zusätzlich abgestuft nach complexity (V1-Auswahlliste ISO#DAKKS) und befristet über start_date/end_date. Werte: price, price_min, price_max, costs, quantity, unit (Default EA), discount, tax (Default 19). Seit 2026_07_03_000002 zusätzlich custom_fields.
  • standard_article (PK uID): freier Katalogartikel mit article_name, price, discount, delivery_costs, tax, unit. Für Pauschalen, Dienstleistungen ohne Gerätebezug und Versand.

Auftragspositionen (article) kopieren die gefundenen Werte. Es gibt keinen Rückverweis auf die Preiszeile, die den Wert erzeugt hat: article_ID trägt je nach article_type die Geräte-MTAG, die standard_article.uID oder die prices.pID, ist also polymorph und nicht als Herkunftsnachweis nutzbar.

2.2 Backend V2

PriceController (index/show/store/update/duplicate/bulkDestroy/destroy) und PriceCategoryController (voll-CRUD) sind komplett. PriceFinderService::findForCustomer() löst die Kette Kunde → Preisgruppe → Preiszeile auf und wird von BookingController::assignType(), ::actualizePrices() und GET /prices/lookup genutzt.

2.3 Frontend V2

  • pages/price/index.vue — Liste, nur Massenlöschen.
  • pages/price/[id].vue — Leseansicht mit fest verdrahteter 12-Feld-Liste (statt DynamicDetailCard, wie es alle anderen Detailseiten tun) plus Löschen.
  • pages/price-category/index.vuevoll-CRUD über SettingsGrid.
  • pages/standard-article/index.vuevoll-CRUD über SettingsGrid.
  • components/booking/BookingPositions.vue — die einzige reiche Preis-UI: Positionsdialog, /prices/lookup-Vorschau, „Preise aktualisieren", Summenblock netto/MwSt./brutto.

Gap G3 aus v1-ordermanagement-analysis.md („keine Preisgruppen-Seite, keine Katalogartikel-Seite") ist damit erledigt; die Lücke sitzt an anderer Stelle, nämlich in der Preisliste selbst.

2.4 Preisfindung heute

Rechnungskunde → customers.price_category_id → prices WHERE type_tID = ?
                 [AND price_cat_uID = ?] [AND complexity = ?]
                 AND (start_date IS NULL OR start_date <= :datum)
                 AND (end_date   IS NULL OR end_date   >= :datum)
              ORDER BY start_date DESC LIMIT 1

2.5 Gefundene Defekte

Sechs Punkte, die vor oder mit dem Ausbau zu erledigen sind. Nr. 1 ist der gravierendste; Nr. 6 (2026-08-03 ergänzt) trifft nicht die Preisfindung, sondern die Auftragsposition und ist unabhängig zu klären.

  1. Treiberabhängiges Preisergebnis. PriceFinderService:62 sortiert orderByDesc('start_date'). Eine unbefristete Preiszeile hat start_date = NULL. PostgreSQL sortiert NULL bei DESC standardmäßig zuerst, MySQL und MS SQL Server zuletzt. Dieselben Daten liefern also je nach Backend einen anderen Preis: PostgreSQL nimmt den unbefristeten Basispreis, MySQL den datierten Sonderpreis. Genau der Fall, den die Multi-DB-Regel in CLAUDE.md verhindern soll. Fix: deterministische Reihenfolge ohne NULL-Sortierung, etwa „datierte Zeilen vor unbefristeten" per abgeleitetem Rangfeld.
  2. Kunde ohne Preisgruppe bekommt einen fremden Preis. Ist price_category_id leer, entfällt der price_cat_uID-Filter (PriceFinderService:45) und es gewinnt eine beliebige Gruppe, im Zweifel der Großkundenrabatt. Erwartbar wäre: definierte Standard-Gruppe oder gar kein Preis.
  3. complexity ist per API nicht setzbar. store() und update() validieren die Spalte nicht (PriceController:96-109, :140-151), die Preisfindung filtert aber danach. Über V2 angelegte Preise haben immer complexity = NULL und werden von einer nach ISO oder DAKKS suchenden Position nie gefunden.
  4. Keine Eindeutigkeitsprüfung. V1 erzwingt Eindeutigkeit über (type_tID, price_cat_uID, start_date, complexity) per ECompositeUniqueKeyValidatable (FrontendPrices.php:66-80); der DB-Index wurde 2017 entfernt (m170306_031248), PHP ist also die einzige Sicherung. V2 prüft gar nicht. Zwei identische Preiszeilen sind anlegbar, und dann entscheidet Punkt 1, welche gilt.

Korrektur zur ersten Fassung dieses Dokuments: Dort stand, V1 validiere zusätzlich price_min ≤ price_max und start_date ≤ end_date. Das ist falsch — FrontendPrices::rules() kennt beides nicht, die Regeln kommen ausschließlich aus der Feldkonfiguration. Wo V2 diese Prüfungen einführt, ist das eine bewusste Ergänzung, keine Parität. 5. Kleinkram mit realer Wirkung. quantity ist decimal(13,2) in der DB, aber integer im Model-Cast und in der Validierung: Bruchmengen werden still abgeschnitten. duplicate() kopiert getAttributes() samt modified_by des Originals. Der Menüeintrag /price hängt an inventory_view, die API verlangt price_view (layouts/default.vue:135-139): wer nur inventory_view hat, sieht den Punkt und bekommt ein 403-Grid.

Korrektur 2026-08-03: Hier stand zusätzlich, Price nutze HasDynamicFields nicht, weshalb Zusatzfelder über die API weder les- noch schreibbar seien. Das Model nutzt das Trait (laravel/app/Models/Price.php:36); der Teilpunkt ist erledigt. Ohne ihn wäre der Befund aus Abschnitt 3.6 auch nicht möglich — die Artikelnummer der Produktivliste ist genau so ein Zusatzfeld.

  1. article.article_ID bedeutet in V1 und V2 Verschiedenes. Für article_type = 'type' legt V1 die prices.pID ab (FrontendArticleController::actionAssignType, Relation typePrice in FrontendArticle.php:209), V2 die types.tID (Article::type()belongsTo(DeviceType, 'article_ID', 'tID'), ArticlePriceContextService::resolveTypeId()). Dieselbe Spalte, zwei Semantiken, dieselbe Tabelle. Bei gemischtem Betrieb einer Installation ist das ein reales Datenproblem und kein Schönheitsfehler. Unabhängig vom Ausbau zu klären: welche Bedeutung gilt, und wie werden Altzeilen erkannt.

2.6 Doku beschreibt ein anderes System

docs-v2/handbuch/preise/{preisliste,anlegen,preisgruppen}.md (de und en) beschreiben einen Preiskatalog mit den Feldern Bezeichnung, Artikelnummer, Netto-Preis, MwSt.-Satz, Brutto-Preis, dazu Preishistorie, Export-Endpunkt GET /prices/export, Route /prices/create und das Recht price_manage. Nichts davon existiert. Der Code kennt eine Matrix aus Gerätetyp und Preisgruppe, kein name-Feld, keine Artikelnummer, keinen Brutto-Wert, kein price_manage.

Die Handbuchseiten sind damit doppelt zu bewerten: als veröffentlichte Falschaussage ein Defekt, inhaltlich aber ein brauchbarer Hinweis darauf, was Anwender unter Preisverwaltung erwarten. Beides fließt unten ein.

3. Die eigentliche Entscheidung: welches Preismodell?

3.1 Variante A — V1-Matrix behalten, bedienbar und erweiterbar machen

Preis bleibt „Gerätetyp × Preisgruppe, befristet, nach Komplexität abgestuft". Katalogartikel bleiben der zweite, freie Weg. Ausbau additiv.

  • Für: Keine Datenmigration, keine Schemaänderung an V1-Tabellen, damit keine Yii-Migration und kein Risiko für Bestandskunden. Modell passt zur Domäne: Der Preis einer Kalibrierung hängt am Gerätetyp und am Kundensegment.
  • Gegen: Preise ohne Gerätetyp gehen weiterhin nur über Katalogartikel. Wer „einfach eine Preisliste" erwartet, muss zwei Objekte verstehen.

3.2 Variante B — Preiskatalog nach Handbuch-Vorbild

Neue V2-Tabelle mit name, article_number, net_price, vat_rate, optionaler Preisgruppe. Die Matrix wird Sonderfall oder entfällt.

  • Für: Deckt sich mit dem veröffentlichten Handbuch und der Erwartung vieler Anwender. Ein Objekt statt zwei.
  • Gegen: standard_article ist bereits dieser Katalog. Variante B schafft eine zweite Wahrheit für dieselbe Sache. Die Typ-Preisfindung, die V1-Kunden produktiv nutzen, müsste nachgebaut oder aufgegeben werden; Bestandsdaten aus prices bräuchten eine Migration, die auf V1-Systemen gar nicht ankommt. Hoher Aufwand, hohes Risiko, kein fachlicher Gewinn.

3.3 Variante C — Preisregel-Engine

Preisregeln mit Bedingungen (Kunde, Kundengruppe, Gerätetyp, Kategorie, Menge, Datum, Komplexität, Verfahren) und Prioritäten; die Matrix wird zu einer Regelsorte.

  • Für: Maximale Flexibilität, deckt Staffeln, Zuschläge und Kundensonderpreise in einem Mechanismus ab.
  • Gegen: Deutlich mehr Konzept- und Testaufwand, schwer erklärbar („warum kostet das jetzt 82 Euro?"), und niemand hat den Bedarf bisher belegt. Der klassische Fall, in dem die Engine gebaut wird, bevor die Regeln bekannt sind.

3.4 Vergleich und Empfehlung

Kriterium A (Matrix) B (Katalog) C (Regel-Engine)
Aufwand bis „Preise bearbeitbar" 2–3 PT 8–12 PT 15+ PT
V1-Schemaänderung nötig nein ja (neue Tabelle, aber V1 ohne Nutzen) ja
Datenmigration Bestandskunden nein ja ja
Zweite Wahrheit zu standard_article nein ja teilweise
Erklärbarkeit im Labor hoch hoch niedrig
Deckt Staffeln/Zuschläge über Ausbau nein ja

Empfehlung: Variante A, mit gezielten Anleihen bei C für Staffelpreise und Zuschläge als eigene, additive V2-Tabellen (Abschnitt 6.3). Die Matrix ist nicht das Problem, ihre Bedienbarkeit ist es. Begründung und Konsequenzen sind als ADR-012 festgehalten.

3.5 Nachtrag: die fehlende Leistungsachse

Der in 3.1 gezogene Schnitt („mit Gerätetypbezug in prices, ohne in standard_article") trägt einen realen Fall nicht: Zu einem Gerätetyp gehört nicht nur die Kalibrierung, sondern auch „Reinigung Buchsen Gerät X" und „Verpackungspauschale Gerät X". Alle drei haben Gerätetypbezug, nur eine ist eine Kalibrierung.

Heute ist das nicht abbildbar, und zwar nicht unschön, sondern gar nicht:

Weg Warum er scheitert
Zweite Zeile in prices ValidatesPriceConsistency belegt den Slot (type_tID, price_cat_uID, start_date, complexity) bereits mit der Kalibrierung → 422
Positionstext unterscheiden prices hat kein Bezeichnungsfeld; der Text kommt aus dem Gerätetyp (Article::displayName())
Katalogartikel je Typ standard_article hat keinen Typbezug → Namenskonvention, skaliert mit Typen × Leistungen
complexity missbrauchen assignType() ruft den Finder ohne complexity; mostSpecific() wählt dann beliebig unter allen Zeilen des Typs

Die fehlende Dimension ist die Leistung, nicht die Zusammenlegung der beiden Objekte. Fachlich ist ein Preis „Leistung × Gerätetyp × Preisgruppe × Gültigkeit"; bisher bedeutete der Gerätetyp die Leistung stillschweigend mit. Mit der Achse rücken die beiden Objekte von selbst zusammen: standard_article ist der Leistungskatalog (was), die Bepreisung sagt wie viel für wen ab wann, und ein Katalogartikel ohne Gerätetyp ist derselbe Fall mit leerem Typ.

Umsetzung additiv über eine neue V2-Tabelle service_prices; prices bleibt unangetastet und gilt als Leistung „Kalibrierung".

Stand der Umsetzung (AP 10, vollständig): Tabelle, Modell und die zweistufige Zugriffsfolge (ServicePriceFinder: Leistung × Gerätetyp, dann Pauschale ohne Gerätebezug) stehen, ebenso GET /prices/lookup?service_id=… und CRUD unter /service-prices (Teil 1). Teil 2 liefert die Oberfläche: die Pflegemaske /price/services, den Leistungsvorschlag beim Hinzufügen einer Katalogposition (samt getroffener Stufe, übernommen per Knopf statt automatisch) und die Preismatrix als gemeinsame Sicht — eine Auswahl „Leistung" schaltet dieselbe Matrix zwischen prices (Kalibrierung) und service_prices um. Eine Zelle der Leistungsachse rechnet über beide Stufen, sonst meldete sie Lücken, die die Pauschale längst füllt; welche Stufe getroffen hat, steht als matched_by an der Zelle.

Zwei bewusste Grenzen in Teil 2: die Sammelanlage für Lücken bleibt auf der Kalibrierung (sie schreibt nach /prices/bulk-create), und ein Klick auf eine Zelle, in der eine Pauschale steht, legt eine Zeile für diesen Gerätetyp an, statt die Pauschale zu ändern — die gilt für alle anderen mit.

price_class_id lag von AP 10 an im Schema und wird seit AP 11 Teil 1 gelesen — das hat eine Migration auf einer erst wenige Wochen alten Tabelle erspart. Die Preisgruppe ist bewusst kein eigener Kaskadenschritt, sondern ein Filter auf jeder Stufe: sonst verdoppelt sich die Stufenzahl mit jeder weiteren Achse und die Erklärbarkeit, die ADR-012 einfordert, ist dahin. Entscheidung, verworfene Alternativen (complexity umwidmen; Spalte auf prices plus Yii-Migration) und offene Grenzen stehen im Nachtrag zu ADR-012. Arbeitspaket: AP 10.

3.6 Nachtrag: die fehlende Stufe der Geräteachse

Abschnitt 3.5 hat die Leistungsachse ergänzt. Sie erklärt, was bepreist wird, aber nicht, warum die Pflege teuer ist. Der Auftraggeber beschreibt die Preise als drei Kategorien: gerätespezifisch, artikelspezifisch und kundenspezifisch. Das sind keine drei Tabellen, sondern drei Achsen einer Preiszeile, und jede hat eine Hierarchie, auf der geerbt werden könnte:

Achse woran der Preis hängt speziell → allgemein Stand heute
Gerät Gerätetyp (prices.type_tID) Gerätetyp → ? nur der Gerätetyp, dazwischen nichts
Artikel/Leistung Katalogartikel (standard_article), künftig die Leistung Leistung → alle implizit; wird mit AP 10 zur echten Achse
Kunde keinem eigenen Anker — Spalte auf der Zeile (prices.price_cat_uID) Kunde → Preisgruppe vorhanden (customers.price_category_id)

Die Geräteachse erbt nicht: zwischen „ein Gerätetyp" und „alle Gerätetypen" gibt es keine Stufe. Genau daher kommt der Pflegeaufwand. Abschnitt 3.6 zieht diese Stufe ein.

Die Kundenachse erbt nur die Zuordnung, nicht den Betrag: viele Kunden zeigen auf eine Gruppe, aber jede Gruppe muss jeden Betrag selbst tragen. Ein Fallback von der Gruppe auf einen Basispreis existiert nicht, und das ist der Grund für die still erzeugten 0,00-Positionen aus Abschnitt 6.3. Die Leistungsachse bekommt ihre Vererbung mit AP 10.

Die Kundenachse ist dabei anders gebaut als die beiden anderen, und das ist kein Zufall: Gerät und Artikel benennen jeweils einen Gegenstand, den man bepreist, der Kunde nicht. „Kundenspezifisch" ist deshalb kein dritter Zeilentyp neben Geräte- und Artikelzeile, sondern die Frage, für wen eine solche Zeile gilt — technisch die Preisgruppe als Spalte auf der Zeile. Dieses Dokument arbeitet mit dieser Lesart; sie ist eine Annahme, nicht bestätigt. Wäre stattdessen ein eigener Betrag je Kunde gemeint, kippte das Nicht-Ziel „keine kundenindividuellen Einzelpreise" (ADR-012 Entscheidung 6) und damit ein Teil der Kaskade unten. Offen als Punkt 12 in Abschnitt 10.

Die Artikelachse kennt den Kunden nicht

Aus der Dreiteilung folgt eine Prüffrage: trägt jede der drei Kategorien die Kundenachse? Für Katalogartikel nicht. standard_article hat keine Preisgruppen-Spalte (Migration 2026_02_28_500006, V1 identisch: BaseFrontendStandardArticle kennt das Feld nicht), und BookingController::assignStandardArticle() kopiert price, discount, delivery_costs und tax unverändert in die Position, ohne PriceFinderService und ohne Kundenbezug. V1 macht es genauso ($article->attributes = $n->attributes).

Ein Gerätetypenpreis kann also je Preisgruppe verschieden sein, ein Versandkosten-, Zertifikats- oder Pauschalpreis nicht. Wer einer Kundengruppe andere Versandkosten geben will, muss heute einen zweiten Artikel mit anderem Namen anlegen. Das ist kein V2-Defekt, sondern eine Modellgrenze, die V2 originalgetreu von V1 geerbt hat — deshalb steht sie hier und nicht in der Defektliste in Abschnitt 2.5.

Das Zielmodell schließt die Lücke nebenbei: service_prices trägt price_category_id auf jeder Zeile, auch auf der Stufe ohne Gerätebezug (Stufe 4 der Zugriffsfolge unten). Damit wird die Pauschale erstmals gruppierbar. Der Bestand in standard_article bleibt davon unberührt und kundenblind, solange er nicht überführt wird — die Überführung ist nicht Teil von AP 11.

Befund aus einer Produktiv-Preisliste

Eine gelieferte Preisliste (180 Zeilen, eine Installation, Stichprobe und keine Vollerhebung) zeigt, dass sich die fehlende Stufe bereits gebildet hat, nur ohne Systemunterstützung:

  1. Zwei Sorten Zeilen unter einem Dach. Rund 30 Zeilen sind echte Gerätetypen (Tektronix DMM4040, Keithley DMM6500, HP 34401A). Rund 140 sind keine Geräte, sondern Positionen eines Leistungsverzeichnisses, die in die Typ-Tabelle gezwungen wurden, weil die Matrix nur Typen kennt: 101 Federmanometer -1 bis 30 bar, >= Kl. 1,0.
  2. Es gibt bereits eine Klassensystematik. Sie steht im Feld „Artikelnummer" und ist dreistufig: Fachgebiet (100 Druck, 200 Temperatur, 300 Feuchte, 400 Kraft, 500 Drehmoment) . Gerätegattung (101 Federmanometer, 104 Digitalmanometer, 110 Druckkalibratoren) . Bereichs- und Genauigkeitsstufe. Ein zweites Schema (622.011.02, 649.021.01) liegt daneben; Herkunft offen.
  3. Die Zuordnung geschieht durch Kopieren des Betrags, nicht durch Verweis. 100.110.01 steht auf der Klasse 110 Druckkalibratoren -1 bis 30 bar (180,00) und auf dem Gerätetyp Beamex MC5-IS (180,00). 400.408.02 steht auf 408 Handkraftmessgeräte 0 bis 1000 N, zwei Richtungen und auf Mecmesin AFG 1000N und auf Mecmesin Ltd AFG 250N, dreimal 90,00. Ändert sich der Klassenpreis, müssen alle Kopien von Hand nachgezogen werden. Das ist der gemeldete Pflegeaufwand, und er wächst mit der Zahl der Gerätetypen.
  4. Von 13 Spalten tragen 4 Information. Konstant über alle Zeilen sind Menge (1,00), Preisgruppe (Standard), Einheit (STK.), Enddatum (2099-12-31), Kosten (0,00), Diskont (0 %) und Steuer (19 %). Variabel sind nur Typname, Artikelnummer, Preis und Komplexität. Der Pflegeaufwand kommt also nicht von zu wenigen Achsen, sondern von der fehlenden Vererbung.
  5. Klassenname und Nummer sind nicht deckungsgleich. 101 Federmanometer -1 bis 30 bar, >= Kl. 1,0 existiert als 100.101.01 zu 50,00 (ISO) und als 100.112.08 zu 88,00 (DAkkS). Die Nummer ist feiner als der Name.
  6. Ein Preis steht auf 0,00 (Keithley 2000) — der stille Nullbetrag aus Leitidee 2, live im Bestand.

Die Artikelnummer hat heute keine Systemwirkung: sie ist ein benutzerdefiniertes Feld auf prices (field_configuration, column_name = 'Artikelnummer', is_user_defined = 1), kein Katalogbezug. standard_article hat gar keine Artikelnummernspalte. Das ist die gute Nachricht für die Migration: die Zuordnung steht bereits maschinenlesbar in den Daten.

Warum nicht die vorhandene Kategorie

Gerätetypen können bereits eine Kategorie tragen (CategoryField, category_item mit type = 'types', V1 m251008_015632). Als Preisschlüssel taugt sie nicht: die Zuordnung ist n:m — „an item may sit in several categories" (laravel/app/Support/CategoryField.php), Sortierung ist deshalb bewusst nicht unterstützt. Ein Typ in zwei Kategorien hätte einen mehrdeutigen Preis. Die Vererbungsstufe braucht eine eindeutige Zuordnung je Gerätetyp.

Der Auftraggeber nennt einen zweiten Grund, der schwerer wiegt: die Produktkategorie wäre konzeptionell die richtige Achse, aber sie über 20.000 Gerätetypen nachzupflegen ist der Aufwand, den man gerade vermeiden will. Genau daran scheitert jede Achse, die nur für die Bepreisung existiert. Die Prozedur scheitert nicht daran (siehe „Zielmodell"): sie wird angelegt, weil ohne sie nicht kalibriert wird, und ihre Mehrdeutigkeit ist über standard_procedures.is_default bereits aufgelöst.

Wie andere Systeme das lösen

Fünf Muster, beschrieben als Struktur, nicht als Produkt:

  1. Leistungsverzeichnis mit Positionsnummern (Prüf- und Kalibrierlabore, VOB, öffentliche Ausschreibungen): Preis hängt an der Position, Prüflinge werden zugeordnet. Taugt für Kataloge, die nach außen gehen. Kostet: die Zuordnung muss gepflegt werden. Genau das hat das Labor oben gebaut.
  2. Konditionstechnik mit Zugriffsfolge (klassische ERP-Preisfindung): mehrere Konditionstabellen in fester Reihenfolge von spezifisch nach generisch, erste Fundstelle gewinnt, darüber ein Stapel aus Konditionsarten. Der eigentliche Standard. Kostet: die Konfigurierbarkeit selbst — wer die Zugriffsfolge frei einstellen kann, kann nicht mehr erklären, warum ein Betrag herauskam.
  3. Arbeitswerte und Zeitvorgaben (Werkstatt, Instandhaltung): Preis gleich Zeitvorgabe mal Verrechnungssatz. Pflegearm. Kostet: der Listenpreis wird zum Rechenergebnis, Festpreisangebote werden schwierig.
  4. Vertrags- und Rahmenpreise (Field Service, Serviceverträge): Preise kommen aus einem Vertrag mit Laufzeit statt aus einer globalen Liste.
  5. Merkmalsbasierte Bepreisung (CPQ, Konfiguratoren): Preis aus Messgröße, Bereich, Genauigkeitsklasse. Kein Pflegeaufwand je Typ, aber schlecht erklär- und testbar. Das ist Variante C aus Abschnitt 3.3.

Der Bestand oben zeigt Muster 1 in Handarbeit, die Kundenachse ist Muster 2 in der einfachsten Form, und die Klassennamen (`Federmanometer 0 bis 30 bar,

= Kl. 1,0`) sind Muster 5 in Textform. Der Vorschlag nimmt Muster 1 als Struktur und Muster 2 als Findungsprinzip, aber mit fester statt konfigurierbarer Zugriffsfolge. Muster 3 und 5 bleiben draußen (siehe Nicht-Ziele), Muster 4 bleibt Option, falls die Preisgruppen durch Rahmenverträge wuchern (offener Punkt 7).

Zielmodell

Geändert am 2026-08-04. Dieser Abschnitt hatte zwei Vorfassungen. Die erste sah eine eigene Preisklasse als Entität vor (price_classes + price_class_types, ADR-029), die zweite machte die Prozedur selbst zum Preisschlüssel und ließ die Klasse entfallen. Beide sind überholt. Maßgeblich ist jetzt die Preiskategorie als Container, der Gerätetypen und Prozeduren aufnimmt. Entscheidung und Begründung: ADR-032.

Die Vererbungsstufe der Geräteachse ist die Preiskategorie. Sie ist ein Container mit zwei Sorten Mitgliedern, und sie trägt den Preis:

Mitglied wann Beispiel
Prozedur Regelfall die Prozedur reicht die ihr zusortierten Gerätetypen weiter
Gerätetyp direkt wenn es keine Prozedur gibt extern durch einen Dienstleister kalibrierte Geräte

Der Unterschied zur verworfenen typweisen Klasse steckt allein im Weiterreichen. Die Zahlen dazu:

Ebene Größenordnung wer pflegt sie, und warum
Gerätetypen ~20.000 Bestand, wächst mit dem Gerätepark
Prozeduren 200–2000 entsteht ohnehin, weil ohne sie nicht kalibriert wird
Preiskategorien 10–100 die eigentliche Preispflege

Die teure Zuordnung (Typ zu Prozedur) entsteht aus einem anderen Grund als dem Preis. Die für den Preis neu anfallende Zuordnung (Prozedur zu Kategorie) umfasst 200 bis 2000 Zeilen statt 20.000. Genau daran scheiterte die Kostenrechnung der typweise befüllten Klasse.

Eine Zuordnung, zwei Nutzen. Wer einer Prozedur Typen zusortiert, setzt damit zugleich deren Standardprozedur. Heute sind das zwei getrennte Pflegestellen für dieselbe Aussage: procedure_types (n:m, ohne Default, für Preise unbrauchbar) und standard_procedures (mit is_default, polymorph auf types/inventories). Sie werden zusammengeführt; die Auflösung bleibt beim vorhandenen StandardProcedureResolver.

Die Kategorie ist hierarchisch, die Ebenen sind optional. Oben das Fachgebiet (Druck, Elektrisch, Temperatur), darunter Gattung und Bereichs- oder Genauigkeitsstufe. Die Erstsortierung geschieht oben, der Preis hängt an der feinsten Ebene, an der einer gesetzt ist; gesucht wird von unten nach oben. Das ist der Bestand, nicht eine neue Idee: die dreistufige Nummer 100.110.01 im Zusatzfeld „Artikelnummer" ist genau diese Hierarchie, von Hand gepflegt. Eine Kategorie muss nicht alle Ebenen ausfüllen.

Der Name ist belegt. price_category bezeichnet im Schema bereits die Kundenpreisgruppe (customers.price_category_id). Der Container heißt deshalb technisch price_classes und in der Oberfläche „Preiskategorie". Beide Achsen bleiben und stehen senkrecht zueinander: Container ist die Geräteachse, price_category die Kundenachse.

Neue V2-Tabellen, additiv nach dem Muster von device_type_blueprints (2026_07_21_130000): V2-only, uuid als PK, V1-Schlüssel als string(80), keine Yii-Migration nötig.

Tabelle Zweck Kernspalten
price_classes der Container (fachlich Preiskategorie) id, name, description
price_class_members Mitglieder des Containers price_class_id, member_table (types|procedures), member_id
service_prices Bepreisung über alle drei Achsen (aus AP 10, hier um die Containerstufe erweitert) service_id (nullable), type_tID (nullable), price_class_id (nullable), price_category_id, complexity, start_date/end_date, Beträge

prices bleibt unangetastet und gilt in der neuen Lesart als die Leistung „Kalibrierung, an einen konkreten Gerätetyp gebunden": die spezifischste Stufe der Geräteachse und zugleich der V1-Bestand, der in V1 weiter pflegbar bleibt.

Preisfindung als Zugriffsfolge

Fest verdrahtet, speziell vor allgemein, erste Fundstelle gewinnt. Auf den so gefundenen Grundpreis wirken zwei Aufschläge in fester Reihenfolge:

Grundpreis:
  0. Einzelgerät: eigener Preis, sonst Kategorie seiner eigenen Prozedur
  1. prices: Gerätetyp direkt                     (V1-Bestand, Ausnahme)
  2. Kategorie des Typs, direkt oder über seine Standardprozedur,
     von der feinsten Hierarchieebene aufwärts
  3. Pauschale ohne Gerätebezug                   (Versand, Bearbeitung)
  --> kein Treffer: Lücke melden, nicht still 0

darauf, in dieser Reihenfolge:
  + Komplexitätsaufschlag   (was geleistet wird: ISO, DAkkS, XY)
  + Preisgruppen-Ableitung  (für wen, einstufig, befristbar)

Stufe 0 ist die Kundenachse. „Kundenspezifisch" meint ein Eigengerät des Kunden oder einen besonderen Kalibrierwunsch zu einem konkreten Gerät, nicht einen Kundenrabatt. Beides wird am Einzelgerät bepreist, entweder direkt oder über eine nur für dieses Gerät angelegte Prozedur. standard_procedures ist dafür bereits polymorph auf types und inventories gebaut.

Innerhalb von Stufe 2 gilt: die direkte Zuordnung schlägt die über die Prozedur geerbte. Sonst wäre ein Typ, der selbst in Kategorie A liegt und dessen Prozedur in Kategorie B, mehrdeutig bepreist.

Die Reihenfolge der beiden Aufschläge ist nicht beliebig. Vertauschen ist nur folgenlos, solange beide prozentual sind; sobald einer ein fester Betrag ist, kommt eine andere Summe heraus. Festgelegt ist: erst die Leistung vollständig bepreisen, dann die Kundenkondition darauf anwenden. Ein Händlerrabatt gilt damit auch auf den DAkkS-Aufschlag.

Stufe 1 und die direkte Zuordnung tragen das „eigentlich". Auf die Frage, ob zwei Gerätetypen mit derselben Prozedur immer gleich viel kosten, lautet die Antwort des Auftraggebers „eigentlich ja", also eine Regel mit unbeziffertem Ausnahmeanteil. Ein Typ, der trotz gleicher Prozedur anders kostet, bekommt eine eigene prices-Zeile oder eine eigene Kategoriezuordnung. Dieselbe Eigenschaft fängt eine unvollständige Prozedurabdeckung ab: solange Prozeduren erst entstehen, werden Typen direkt zusortiert, statt dass die Findung bricht.

Drei Dinge halten die Erklärbarkeit, die ADR-012 einfordert („warum kostet das 82 Euro?" in einem Satz):

  • Die Stufenzahl ist fest und klein, keine konfigurierbare Zugriffsfolge.
  • PriceFinderService liefert matched_by und reason mit — bereits als Anforderung in Abschnitt 6.1 vorgesehen, hier eingelöst.
  • Geschrieben wird nur an der Quelle; die gemeinsame Preisliste bleibt eine lesende Projektion mit source-Merkmal (ADR-012 Entscheidung 14).

Die Kundenachse bleibt bewusst außerhalb der Kaskade: sie ist ein Filter auf jeder Stufe, keine eigene Stufe. Sonst wären es neun statt drei. Das Nicht-Ziel „keine kundenindividuellen Einzelpreise" gilt unverändert. Das setzt die oben benannte Annahme voraus, dass „kundenspezifisch" die Preisgruppe meint; sie ist nicht bestätigt (Abschnitt 10, Punkt 12).

Preisgruppen erben

Der Filter auf die Preisgruppe ist heute hart: PriceFinderService::find() sucht ausschließlich Zeilen der angefragten Gruppe und fällt nicht auf einen Basispreis zurück. Wer eine zweite Gruppe anlegt und nicht jeden Betrag darin wiederholt, erzeugt still 0,00-Positionen (Abschnitt 6.3). Das ist der teuerste unsichtbare Fallstrick der heutigen Preisverwaltung.

Künftig leitet eine Preisgruppe ihren Betrag ab:

Angabe an der Gruppe Wirkung
Bezug der Betrag der Preiskategorie ohne Gruppenbezug; einstufig, keine Gruppe leitet von einer anderen Gruppe ab
Art des Aufschlags prozentual oder fixer Betrag
Wert positiv (Aufschlag) oder negativ (Nachlass)
Zeitraum start_date/end_date, für befristete Marketingaktionen

Ein in der Gruppe hinterlegter eigener Betrag schlägt die Ableitung. Damit ist die typische Preisrunde („Händler bekommt 15 Prozent weniger als Liste") eine Angabe statt einer zweiten vollständigen Preisliste.

Zwei Festlegungen, ohne die das Modell auseinanderläuft:

  • Rundung: kaufmännisch auf zwei Nachkommastellen, und zwar je Stufe. Ohne festgelegte Rundung zeigen zwei Installationen mit denselben Stammdaten verschiedene Preise.
  • Herkunft sichtbar: bei einem abgeleiteten Betrag reicht matched_by nicht. Die Anzeige muss die Ableitung nennen („Gruppe B, 10 Prozent auf Gruppe A"), sonst ist die Forderung aus ADR-012 nicht mehr erfüllbar.

Das ist die riskanteste Einzeländerung dieses Konzepts, weil sie das Verhalten bestehender Installationen ändert: wo heute nichts gefunden wird, wird künftig ein abgeleiteter Betrag gefunden. Vor dem Ausrollen gehört ein Bericht dazu, der genau diese Positionen auflistet.

Einstufig, und befristbar. Beides ist entschieden (ADR-032). Eine mehrstufige Kette (Händler → Großkunde → Konzern) wäre mächtiger, braucht aber Zyklenschutz und macht die Herkunftsanzeige länger; sie ist nachrüstbar, falls der Bedarf auftaucht. Die Befristung kommt aus dem Aktionsgeschäft: ohne sie wäre jede Rabattaktion ein Eingriff in den Bestand, der von Hand zurückgenommen werden muss, und genau dabei bleiben Zeilen liegen.

Die Komplexität ist ein Aufschlag, kein Filter

ISO, DAkkS und weitere Stufen sind Leistungsmerkmale: dieselbe Prozedur wird mit unterschiedlichem Aufwand gefahren, und der Unterschied ist ein Aufpreis. Eine Prozedur kann für DAkkS und ISO geschrieben sein, und bei der Durchführung wird bestimmt, mit welcher Komplexität ein konkretes Gerät heute gestartet wird.

Heute ist complexity ein exakter Filter auf der Preiszeile (PriceFinderService::find()). Jede Komplexität braucht ihre eigene Zeile:

als Filter (heute) als Aufschlag
20.000 Typen, 3 Stufen 60.000 Zeilen 20.000 Zeilen plus 3 Aufschläge
neue Stufe einführen jede Zeile kopieren eine Zeile anlegen
Aufschlag ändern jede Zeile anfassen eine Zeile ändern

Der Aufschlag wird global je Komplexität definiert und kann je Kategorie optional überschrieben werden. Ohne Überschreibung gilt der globale Wert. Der Regelfall („DAkkS kostet überall 30 Prozent mehr") kostet damit eine Zeile, der Sonderfall („bei Elektrisch sind es 50") eine zweite, statt einer vollständigen Matrix aus Kategorien mal Komplexitäten.

Der Mechanismus ist bereits geplant: price_surcharges aus AP 9 (scope, reference_id, mode percent|absolute, value, Gültigkeit) passt ohne Änderung. AP 9 und die Komplexität gehören damit zusammen und waren bisher getrennt veranschlagt.

Der Container gruppiert, er rechnet nicht

Die Frage aus dem Auftrag, ob Preise aus dem Aufwand kommen, ist mit dem Auftraggeber geklärt: nein. Es gibt heute keinen Aufwandsbezug im Preis (status_processing_times misst Durchlaufzeit zwischen Statuswechseln, nicht Arbeitszeit; einziger Hebel ist prices.quantity mit unit auf Stunden), und es soll keinen geben. Der Listenpreis bleibt fest und verbindlich.

Das ist kein Widerspruch dazu, dass Prozedur und Preiskategorie den Preis bestimmen. Beide sind Gruppierungsschlüssel, nicht Rechengrundlage: gleiche Kategorie heißt gleicher Listenpreis, nicht Preis gleich Zeitvorgabe mal Stundensatz. Keine Zeitvorgabe an Prozedur oder Leistung, kein Stundensatz an der Preisgruppe.

Der prozentuale Aufschlag aus der Gruppenvererbung ist davon nicht berührt: er rechnet einen Preis aus einem anderen Preis, nicht aus einem Aufwand.

Migrationspfad

Die dreistufige Nummer im Zusatzfeld „Artikelnummer" (100.110.01) bleibt die maschinenlesbare Spur der bereits gebildeten Klassen. Sie dient jetzt als Vorschlagsquelle für die Kategoriebildung, und nachgelagert für die Prozedurzuordnung:

  1. Bericht (dry_run): prices-Zeilen nach dem Wert des Zusatzfelds gruppieren. Ergebnis: Gruppen gleicher Nummer, Mehrfachbelegungen, Zeilen ohne Nummer.
  2. Gruppen auf Preiskategorien abbilden: jede Nummerngruppe ist Kandidat für eine Kategorie. Vorschlag, nicht Automatik.
  3. Kategoriepreise anlegen: die Zeile des Pseudo-Typs wird zum Betrag der Kategorie auf Stufe 2.
  4. Mitglieder zuordnen, je nachdem was vorliegt: existiert für die Gruppe bereits eine Prozedur, wird die Prozedur Mitglied und reicht ihre Typen weiter. Sonst werden die Gerätetypen direkt zusortiert. Die prices-Zeilen der Typen entfallen; das löst die Kopien auf.
  5. Pseudo-Typen stilllegen, nicht löschen, sie hängen an Altaufträgen. Kennzeichnen und aus den Auswahllisten nehmen.
  6. Handarbeit bleibt für die Typen ohne Nummer (bleiben auf Stufe 1), das zweite Nummernschema und den Fall aus Befund 5.

Schritt 4 ist der Grund, aus dem der Container die Prozedur nicht braucht, um nützlich zu sein: die Migration läuft auch dort, wo noch keine einzige Prozedur existiert. Die Prozedurzuordnung kann später nachgezogen werden, ohne dass die Kategorie neu gebildet wird.

Solange die prices-Zeilen nur stillgelegt und nicht gelöscht werden, ist der Schritt rückrollbar.

Was das kostet, ehrlich

Die Größenordnung entscheidet, und sie ist bekannt. Frühere Fassungen dieses Abschnitts schlossen aus der 180-Zeilen-Stichprobe, die Stufe rechne sich nicht und Massenanpassung (AP 6) plus Import/Export (AP 8) seien die wirtschaftlichere Antwort. Das galt für 180 Zeilen. Bei 20.000 Gerätetypen bleiben 20.000 Zeilen auch dann 20.000 Zeilen, wenn man sie bequem bearbeiten kann, und zwar je Preisgruppe und Kalibrierart. 200 bis 2000 Prozeduren sind Faktor 10 bis 100 weniger Preiszeilen. Selbst am pessimistischen Ende bleibt es eine Zehntelung.

Die Kaskade erhöht die Zahl der Orte, an denen ein Betrag entstehen kann, von einem auf drei — die Sorge aus ADR-012. Gegengewicht sind die feste Reihenfolge, die matched_by-Auskunft und das Schreiben nur an der Quelle. Es bleibt Komplexität, die vorher nicht da war.

Die Terminabhängigkeit vom Prozedur-Rollout ist entschärft, nicht weg. Eine frühere Fassung machte die Preisfindung von Daten abhängig, die ein anderer Arbeitsstrang gerade erst anlegt. Mit dem Container gilt das nicht mehr: Typen lassen sich direkt zusortieren, die Kategorie ist ohne eine einzige Prozedur nutzbar. Was bleibt, ist der Nutzenunterschied. Ohne Prozeduren wird der Container typweise befüllt, und dann kostet die Erstbefüllung wieder Handarbeit über 20.000 Zeilen statt über 200 bis 2000.

Der Ausnahmeanteil hinter „eigentlich ja" ist zu messen. Sind Ausnahmen selten, trägt das Modell. Sind sie häufig, entstehen wieder viele Einzelzeilen und der heutige Zustand ist zurück. Kennzahl: Anteil der Gerätetypen mit eigenem Betrag trotz vorhandener Standardprozedur. Vor dem Ausbau zu erheben, nicht danach.

Die Gruppenvererbung ändert bestehendes Verhalten. Sie ist der einzige Punkt dieses Konzepts, der Preise auf laufenden Installationen verschiebt: wo heute nichts gefunden wird und still 0,00 entsteht, wird künftig ein abgeleiteter Betrag gefunden. Das ist die Absicht, aber es braucht den Bericht vor dem Ausrollen.

V2-only. price_classes und standard_procedures haben keinen V1-Gegenpart; V1 kennt nur procedure_types (n:m, kein Default) und kann weder Container noch Vererbung bedienen. V1-Bestandssysteme pflegen weiter die Matrix. Das ist die bewusste Zweiteilung aus ADR-012 Entscheidung 4.

Die Gegenposition: nur Import/Export (AP 8) und Massenanpassung (AP 6) bauen, das Modell unangetastet lassen. Löst den Pflegeaufwand nicht, macht ihn aber erträglich, kostet ein Drittel und trägt kein Modellrisiko. Für Installationen mit wenigen hundert Gerätetypen ist das weiterhin die wirtschaftlichere Antwort. Beide Wege schließen sich nicht aus.

Entscheidung, verworfene Alternativen und offene Grenzen stehen in ADR-032, das ADR-029 in Teilen ersetzt. Arbeitspaket: AP 11.

4. Leitideen

  1. Die Preisgruppe ist der Einstieg, nicht ein Feld unter vielen. Preise pflegt man je Gruppe („Was zahlt ein Standardkunde?"), nicht als Liste undifferenzierter Zeilen.
  2. Lücken sichtbar machen. Die teuerste Situation ist nicht der falsche Preis, sondern der fehlende: Eine Position landet stillschweigend bei 0. Die Oberfläche muss zeigen, für welche Gerätetypen eine Gruppe keinen Preis hat.
  3. Preisänderung heißt neue Gültigkeit, nicht Überschreiben. Ein Labor muss belegen können, welcher Preis im März galt. Das Schema kann das bereits (start_date/end_date), die Oberfläche nutzt es nicht.
  4. Preisfindung erklärt sich selbst. Jede Auskunft nennt die Zeile, die gewonnen hat, und im Negativfall den Grund im Klartext.
  5. Nichts an V1-Tabellen ändern, solange es ohne geht. Stufe 1 und 2 kommen ohne jede Schemaänderung aus.

5. Zielbild Oberfläche

5.1 Preisliste (/price)

Bestehendes TableMaster-Muster, ergänzt nach dem Vorbild pages/contact/index.vue:

  • Knopf „Preis anlegen" (price_edit), CrudModal gegen /prices.
  • rowActions: Bearbeiten, Duplizieren (der ungenutzte Endpunkt, V1-actionClone-Parität), Löschen.
  • Standardfilter „nur aktuell gültige Preise", umschaltbar auf abgelaufene und künftige. Heute zeigt die Liste alles vermischt.
  • Spalten kommen wie überall aus der Feld-Registry, nicht aus einem Inline-Array.

5.2 Preis-Detail (/price/{id})

  • Fest verdrahtete headerFields durch DynamicDetailCard resource="prices" ersetzen; damit greifen automatisch Zusatzfelder und die Referenz-Auflösung. Voraussetzung: type_id und category_id müssen in der Feld-Registry stehen — die V2-Werksvorgabe kannte sie nicht, weshalb weder Grid noch Detail noch Formular Gerätetyp und Preisgruppe anzeigen konnten (Abschnitt 6.1).
  • DetailActionBar bekommt :primary „Bearbeiten" (price_edit), Löschen wird an price_delete gebunden.
  • Reiter „Historie" auf Basis des vorhandenen Audit-Moduls (Abschnitt 6.2).

5.3 Preismatrix (/price/matrix) — das neue Kernstück

Zeilen: Gerätetypen. Spalten: Preisgruppen. Zelle: gültiger Preis zum gewählten Stichtag, leer wenn keiner existiert.

  • Stichtag und Komplexität als Kopf-Auswahl; die Matrix zeigt genau das, was die Preisfindung an diesem Tag liefern würde.
  • Zelle anklicken öffnet den Preis-Dialog, vorbelegt mit Typ und Gruppe. Leere Zelle legt an, gefüllte ändert.
  • Leere Zellen sind farblich markiert: die Abdeckungslücke wird sichtbar, statt erst im Auftrag als 0,00 aufzufallen.
  • Serverseitig genügt der bestehende GET /prices mit Filtern; die Matrix ist eine Frontend-Projektion. Bei großen Typbeständen paginiert über die Zeilen (Gerätetypen).

5.4 Preisgruppen (/price-category)

Die Seite bleibt, bekommt aber Substanz:

  • Detailansicht je Gruppe mit: Anzahl zugeordneter Kunden (V1-Parität countCustomer), Anzahl Preiszeilen, Abdeckungsgrad (wie viele Gerätetypen einen gültigen Preis haben).
  • Aktion „Gruppe duplizieren" mit Faktor: „Standard minus 10 Prozent ergibt Großkunde". Vorschau vor dem Anwenden, kein stiller Massenschreib.
  • Aktion „Preise anpassen": Prozent oder Absolutbetrag auf eine gefilterte Auswahl, wahlweise als Überschreiben (Korrektur) oder als neue Gültigkeit ab Datum (Preiserhöhung). Letzteres schließt die alten Zeilen mit end_date statt sie zu verlieren.

5.5 Preisgruppen-Auswahl im Preis-Dialog

Auswahlfeld über /price-categories. Der Picker steht seit AP 3 als Lookup-Ressource bereit und wird von DynamicForm automatisch gerendert.

Zurückgestellt: Inline-Anlage „Neue Preisgruppe …". Das Preisformular wird vollständig aus der Feld-Registry erzeugt; ein Sonderweg für genau ein Feld müsste LookupSelect erweitern, also eine an vielen Stellen genutzte Komponente. Der Nutzen (ein Seitenwechsel weniger) rechtfertigt den Eingriff noch nicht. Wieder aufgreifen, wenn es einen zweiten Bedarfsfall gibt — dann als allgemeines LookupSelect-Feature, nicht als Preis-Sonderfall.

5.6 Preis prüfen

Panel „Preis prüfen": Kunde, Gerätetyp, Datum, Komplexität rein, Ergebnis raus, inklusive Angabe welche Preiszeile gewonnen hat und über welche Preisgruppe. Bei Treffer ohne Preis der Grund im Klartext („Kunde hat keine Preisgruppe", „keine Zeile für ISO", „Gültigkeit abgelaufen am 31.03."). Erreichbar aus der Preisliste, aus der Kundendetailseite und aus dem Positionsdialog.

5.7 Anschluss an vorhandene Seiten

  • Gerätetyp-Detail: Reiter „Preise" mit den Zeilen dieses Typs (V1-Parität type_price-Grid), Anlegen direkt aus dem Typ heraus.
  • Kunden-Detail: Preisgruppe sichtbar und änderbar, Link auf die wirksame Preisliste dieses Kunden.
  • Geräte-Picker im Auftrag: Spalte mit dem ermittelten Preis (V1-Parität price_info, m241218_043547).

6. Zielbild Technik

6.1 Ohne Schemaänderung (Stufe 1 und 2)

Backend

  • StorePriceRequest / UpdatePriceRequest als FormRequests (heute inline, entgegen Regel 2 in laravel/CLAUDE.md), mit Nichtnegativität der Geldfelder sowie den beiden Ergänzungen price_min <= price_max (nur wenn beide gesetzt, 0 gilt als „nicht gesetzt") und start_date <= end_date. complexity bleibt Freitext bis 255 Zeichen: Die Auswahlliste ist installationsspezifisch, und die Preisfindung filtert auf den gespeicherten Wert.
  • complexity in store und update aufnehmen (Defekt 3).
  • Eindeutigkeitsprüfung über (type_id, category_id, start_date, complexity) mit sprechendem 422 statt stiller Dublette (Defekt 4).
  • PriceFinderService: deterministische, treiberunabhängige Reihenfolge (Defekt 1); definiertes Verhalten ohne Preisgruppe (Defekt 2); Rückgabe um matched_by und reason erweitern, damit /prices/lookup sich erklären kann.
  • Price nutzt Auditable (Historie). HasDynamicFields ist bereits drin.
  • quantity als decimal behandeln statt als integer.
  • Neue Endpunkte: GET /prices/{id}/audits, POST /price-categories/{id}/duplicate (Gruppe samt Preisen, mit Faktor, dry_run unterstützt), POST /prices/bulk-adjust (Auswahl, Faktor, Modus overwrite oder new_validity, dry_run unterstützt).
  • PriceFactory und PriceCategoryFactory ergänzen (fehlen bislang; Tests hängen am TestDatabaseSeeder).

Feld-Registry

  • type_id und category_id fehlen in default_field_definitions.json für prices. Ohne sie liefert GET /field-definitions/prices keine Definition, und damit können Grid, DynamicDetailCard und DynamicForm die beiden wichtigsten Felder überhaupt nicht darstellen — die Werte stehen zwar in jeder API-Antwort (type_id_label, category_id_label), nur weiß keine Oberfläche davon. Beide als RequiredDatabase mit link_table_note ergänzen; der FieldStorageResolver löst v1_name (type_tID, price_cat_uID) gegen das Schema auf, der Seeder ist additiv und idempotent.
  • Damit der Preisgruppen-Picker im Formular etwas zu suchen hat, braucht LookupService eine Ressource price-categories (price_category_view).

Frontend

  • pages/price/index.vue und [id].vue nach dem etablierten Muster (Abschnitt 5.1/5.2).
  • Neue Seiten pages/price/matrix.vue und pages/price-category/[id].vue, beide in e2e/support/routes.ts eintragen (erzwungen durch utils/route-inventory.test.ts).
  • Menü-Gate /price von inventory_view auf price_view korrigieren.

6.2 Historie

Das Audit-Modul ist vorhanden (Auditable-Trait, audit-Tabelle, GET /audits, Detail-Endpunkte an Inventar, Kunde, Kalibrierung, Reparatur, Auftrag). Price einzuhängen kostet wenig und liefert genau die Preishistorie, die das V2-Handbuch schon behauptet. Achtung: Massen-Updates über den Query Builder lösen keine Eloquent-Events aus, bulk-adjust muss AuditService::record selbst aufrufen.

6.3 Mit neuem Schema (Stufe 3), V1 bleibt unberührt

prices und price_category sind V1-Tabellen. Jede Spaltenänderung daran müsste laut Wurzel-CLAUDE.md zusätzlich als Yii-Migration ausgeliefert werden, sonst erreicht sie Bestandskunden nie. Deshalb gehen alle Erweiterungen in neue V2-Tabellen; V1 kennt sie nicht und stolpert nicht darüber. Preis dafür: Die Zusatzfunktionen wirken nur in V2. Das ist bewusst so gewählt.

Tabelle Zweck AP
price_scales Staffelpreise: price_id, min_quantity, price, discount 9
price_surcharges Zuschläge (Eil, Verfahren, Qualitätsstufe): scope, reference_id, mode (percent|absolute), value, Gültigkeit 9
service_prices Leistungsachse: service_id, type_tID (nullable), price_class_id (nullable), price_category_id, complexity, Gültigkeit, Beträge 10 + 11
price_classes der Container (fachlich Preiskategorie): id, name, description 11
price_class_members Mitglieder: price_class_id, member_table (types|procedures), member_id 11

AP 11 bringt zwei eigene Tabellen mit. Die Zuordnung Gerätetyp → Prozedur steht weiterhin in standard_procedures (V2-only, bereits vorhanden) und wird nicht kopiert; neu ist nur die Ebene darüber (Abschnitt 3.6, ADR-032). Die Gruppenvererbung ergänzt zusätzlich Vorgänger, Aufschlagsart und Wert an der Preisgruppe.

Alle werden vom PriceFinderService angewandt: service_prices als Stufen der Zugriffsfolge (Abschnitt 3.6), price_scales und price_surcharges danach auf den gefundenen Grundpreis. Das Ergebnis bleibt ein Betrag pro Position, die Positionszeile ändert sich nicht.

Muster für alle fünf ist device_type_blueprints (2026_07_21_130000_create_device_type_blueprints_table.php): V2-only, uuid als PK, V1-Schlüssel als string(80) ohne DB-seitigen Fremdschlüssel, DB-agnostische Typen.

Korrektur 2026-08-03: Bis hierher stand bei price_surcharges die Anlehnung an procedure_provision und quality_provision. Das ist falsch. Beide Tabellen sind Qualitätsvorgaben (Messbereich, Unsicherheit, Auflösung; m190221_020751), und procedure_provision ist eine reine n:m-Zuordnung Prozedur ↔ Vorgabe (BaseFrontendProcedureProvision) ohne jeden Preisbezug. Der Irrtum stammt aus v1-ordermanagement-analysis.md §7.4 und ist dort ebenfalls korrigiert. V1 kennt keine Preiszuschläge; AP 9 baut sie neu, ohne Vorbild.

6.4 Währung

Heute existiert kein Währungsbegriff: keine Spalte, kein Konfigwert, kein Formatierer. Beträge rendern als value.toFixed(2) ohne Symbol (BookingPositions.vue:83). Für die E-Rechnung ist das ein Pflichtfeld (BT-5, siehe konzept-zugferd-rechnung.md §4).

Empfehlung: eine Währung je Installation als Systemeinstellung (company_currency, Default EUR), zentral formatiert über Intl.NumberFormat in einem formatMoney()-Util. Das deckt den ZUGFeRD-Bedarf und den Anzeigebedarf, ohne eine V1-Tabelle anzufassen. Währung je Preisgruppe erst, wenn ein Kunde tatsächlich in zwei Währungen fakturiert; dann als Spalte auf einer neuen V2-Tabelle, nicht auf prices.

6.5 Import und Export

Für Labore mit vierstelliger Gerätetypzahl ist die Pflege von Hand unrealistisch. Umgesetzt als CSV-Roundtrip mit dem Schlüssel Gerätetyp-Name, Preisgruppen-Name, Komplexität und start_date; unbekannte Typen oder Gruppen werden gemeldet, nicht still angelegt.

Abweichung von der ursprünglichen Planung: der Export läuft nicht über den TableMaster-Weg. Der schreibt die sichtbaren Spalten des jeweiligen Anwenders mit dessen Beschriftungen und trägt Referenzen als UUID — die Datei ließe sich ansehen, aber nicht zurückspielen. Stattdessen ein eigener Endpunkt mit festem Spaltensatz (app/Support/PriceCsv.php als geteilter Vertrag von Export und Import). Statt dry_run als Flag auf demselben Endpunkt gibt es import-preview und import getrennt, weil der Upload beide Male derselbe ist und ein Flag am Multipart-Request leichter übersehen wird.

7. Rechte

Die vorhandene Trias reicht, kein neues Recht nötig:

  • price_view, price_edit, price_delete für Preiszeilen, Matrix, Massenanpassung und Import.
  • price_category_view, price_category_edit, price_category_delete für Gruppen samt „Gruppe duplizieren".
  • Inline-Anlage einer Gruppe aus dem Preis-Dialog verlangt zusätzlich price_category_edit; ohne das Recht fehlt der Eintrag „Neue Preisgruppe …".

Nebenbefund: McpConfigRegistry:347-356 sichert die Ressource price_category mit price_view/price_edit/price_delete statt mit den price_category_*-Rechten. Die HTTP-Routen trennen sauber (RbacCrudTierTest prüft das), der MCP-Layer nicht. Mit Stufe 1 angleichen.

8. Arbeitspakete

Stand 2026-08-07: AP 0 bis 6, AP 8, AP 10, AP 11b, AP 12 und AP 13 sind umgesetzt, AP 11 in den Teilen 1, 2a und 2b (Schema, Auflösung, Zugriffsfolge auf beiden Achsen, Pflegemaske, Matrix), AP 14 in den Teilen 1 und 2 (Bericht, Kategorien samt Mitgliedern, Kategoriebeträge, Stilllegung), AP 9 vollständig (Teil 1: Schema, Anwendung, API; Teil 2: Pflege-UI und Herkunftsanzeige) und AP 7 (Währung als Systemeinstellung, an Summen, Beleg und Anzeige). Damit sind alle Arbeitspakete dieses Konzepts umgesetzt; offen an AP 14 bleiben die dort genannten Reste.

Nachtrag 2026-08-08: Die Rückmeldung aus der Nutzung — zu kompliziert — hat eine Überprüfung der Oberflächen-Absprachen ausgelöst. Das Modell (Kaskade, Kategorien, Herkunft) bleibt; die Sieben-Reiter-Struktur und der doppelte Pflegeort für Artikel/Leistungen werden ersetzt. Neues Leitartefakt ist die menschenlesbare Preisliste pro Preisgruppe, mit eigenen Arbeitspaketen P1–P5: konzept-preisliste-als-leitartefakt.md. Damit steht die im Text als Vorbedingung eingestufte Bedienbarkeit (Massenanpassung + Import/Export) vollständig, die Preiskategorie bepreist Kalibrierung wie Leistung, die Überführung des Bestands läuft durchgängig (Kategorien aus der Artikelnummer, Beträge je Preisgruppe × Komplexität, Typzeilen per Enddatum stillgelegt), und der stille 0,00-Fallstrick der Preisgruppen ist über die Ableitung geschlossen. Offen an AP 14 bleiben die Konflikt-Slots (fachliche Betragswahl, Handarbeit) und der Überführungsweg über die Kopiencluster aus AP 12.

AP Inhalt Aufwand Status
0 — Doku docs-v2/handbuch/preise/* (de + en) auf den tatsächlichen Stand bringen, v1-ordermanagement-analysis.md §7.5/G3 nachziehen 1 PT ✔ erledigt
1 — Preise bearbeitbar CrudModal + rowActions + Anlegen in pages/price/, :primary Bearbeiten im Detail, DynamicDetailCard statt headerFields, Menü-Gate-Fix, Duplizieren verdrahtet 1,5–2 PT ✔ erledigt
2 — Backend-Härtung FormRequests, complexity setzbar, Eindeutigkeitsprüfung, deterministische Sortierung (Defekt 1), Verhalten ohne Preisgruppe (Defekt 2), quantity als Dezimal, duplicate()-Fix, HasDynamicFields, Factories 2–3 PT ✔ erledigt
3 — Preisgruppe als Einstieg Gruppen-Detail mit Kennzahlen, Gruppe duplizieren mit Faktor (+dry_run), Preisgruppen-Lookup, MCP-Rechte angleichen 2–3 PT ✔ erledigt (Inline-Anlage zurückgestellt, §5.5)
4 — Preismatrix pages/price/matrix.vue + GET /prices/matrix, Stichtag/Komplexität, Zell-Edit, Lückenmarkierung, E2E-Route 3–4 PT ✔ erledigt
5 — Transparenz + Historie Auditable auf Price + GET /prices/{id}/audits + Historie-Abschnitt, „Preis prüfen"-Panel auf der Übersicht, lookup liefert matched_by/reason/message/candidates in meta, Preise-Reiter am Gerätetyp 2–3 PT umgesetzt
6 — Massenanpassung POST /prices/bulk-adjust mit dry_run und Modi new_validity (Vorgabe) / overwrite, prozentual oder fix, Rundungsstufe, Auswahl über markierte Zeilen oder ganze Preisgruppe, Vorschau-vor-Anwenden im Dialog, AuditService-Aufruf je Schreibung 2–3 PT umgesetzt
7 — Währung company_currency-Einstellung, formatMoney(), Anzeige in Positionen/Summen/Reports, Anschluss ZUGFeRD BT-5 1–2 PT umgesetzt: Einstellung im Katalog (settings.price.company_currency, ISO 4217, Default EUR, CompanyCurrency mit Fallback), Währung additiv an den Auftragssummen — Bildschirm (GET /bookings/{id}/totals) wie Beleg (statistics.currency, Report-Contract 1.5, BT-5-Anschluss) — und im /auth/me-Meta; im Frontend formatMoney() über Intl.NumberFormat in Positionen und Summen. Bewusst eine Währung je Installation; je Preisgruppe erst bei echtem Zwei-Währungs-Bedarf (§6.4)
8 — Import/Export GET /prices/export (feste Spalten, Referenzen als Name) + POST /prices/import-preview / POST /prices/import mit zeilenweiser Einordnung, Vorschau-vor-Übernahme im Dialog; unbekannte Typen und Gruppen werden gemeldet, nicht angelegt 2–3 PT umgesetzt
9 — Staffeln und Zuschläge price_scales, price_surcharges, Anwendung im PriceFinderService, UI. Trägt zugleich den Komplexitätsaufschlag (ISO/DAkkS/XY, global je Komplexität, je Kategorie optional überschreibbar) und löst damit den heutigen complexity-Filter ab 5–8 PT Teil 1 umgesetzt (siehe ADR 2026-08-07): Schema (beide Tabellen V2-only, Staffeln am Zeilenschlüssel beider Achsen), Komplexitätszuschlag im PriceFinderService (Grundzeile ohne Komplexität über die volle Kaskade, gepflegte Komplexitätszeile schlägt den Zuschlag, Überschreibung je Preiskategorie), Staffelanwendung im Lookup (quantity) und in „Preise aktualisieren", CRUD unter /price-scales + /price-surcharges, Herkunft in meta.surcharge/meta.scale. Rechenfolge: Ableitung → Zuschlag → Staffel, je Stufe gerundet. Teil 2 umgesetzt: Zuschläge-Pflege als Reiter /price/surcharges (global je Komplexität, je Preiskategorie überschreibbar), Staffelpflege als Abschnitt der Preiszeilen-Detailseite (wiederverwendbar je Zeilenschlüssel), „Preis prüfen" mit Mengenfeld und Herkunftskette (Ableitung → Zuschlag → Staffel, eine Zeile je Rechenschritt), Handbuch (Abschnitt „Staffelpreise und Zuschläge")
10 — Leistungsachse service_prices (Leistung × Typ × Gruppe × Gültigkeit) inkl. price_class_id für AP 11, ServicePriceFinder mit zweistufiger Zugriffsfolge und matched_by, GET /prices/lookup?service_id=…, CRUD unter /service-prices 6–9 PT umgesetzt (Teil 1: Schema, Kaskade, API; Teil 2: Pflegemaske /price/services, Leistungsvorschlag in der Auftragsposition, Matrix als gemeinsame Sicht über beide Achsen)
11 — Preiskategorie als Container price_classes + price_class_members, price_class_id auf service_prices, dreistufige Zugriffsfolge mit matched_by (direkt vor geerbt), Zusammenführung von procedure_types und standard_procedures zu einer Zuordnung, Migrationsbericht und -lauf aus der Artikelnummer, UI (Kategoriepflege, Mitgliederliste, Kategoriespalte in der Matrix), Handbuch (Abschnitt 3.6) 8–12 PT Teil 1 + 2a + 2b umgesetzt. Teil 1: Schema, PriceClassResolver, CRUD unter /price-classes samt Mitgliedern, Kategoriestufe im ServicePriceFinder, Kategoriespalte in der Leistungspflege. Teil 2a: Kategoriepflege unter /price/classes samt Mitgliederliste, Leistungsmatrix über die volle Zugriffsfolge inklusive Lückenfilter. Teil 2b: Kategoriestufe auch auf der Kalibrierung (CarriesPriceTerms über prices und service_prices), Kalibrierungsmatrix mit matched_by, Kopplung von Typzuordnung und Standardprozedur (Entscheidung 3). Migration aus der Artikelnummer siehe AP 14
11b — Vererbung der Preisgruppen Aufschlagsart (prozentual|fix), Wert und Zeitraum an der Preisgruppe, einstufige Ableitung im PriceFinderService inklusive Rundungsregel und Herkunftsanzeige, Vorher-Nachher-Bericht für Positionen, die heute still 0,00 sind 4–6 PT umgesetzt: Regel an der Preisgruppe (additive Spalten, Pflege in der Gruppenmaske), Ableitung als Fallback hinter der ganzen Kaskade (ein eigener Betrag der Gruppe schlägt die Ableitung auf jeder Stufe), kaufmännisch auf zwei Nachkommastellen gerundet, nie unter 0,00; Herkunft in meta.derived des Lookups und im „Preis prüfen"-Panel; Bericht per php artisan price-categories:derivation-report. Bewusste Grenzen: die Leistungsachse (service_prices mit Leistung) leitet nicht ab, und die Preismatrix zeigt abgeleitete Beträge nicht als Zellen — sie bleibt die Sicht auf gepflegte Zeilen
12 — Preis-Übersicht und Diagnose GET /prices/overview + PriceOverviewService: Kunden ohne Preisgruppe, Abdeckung je Gruppe, Kopiencluster (gleicher Betrag auf mehreren Gerätetypen), Zeilenanteil in Clustern, Ausnahmeanteil; /price/overview als erster RouteTabs-Eintrag und Ziel des Menüeintrags 3–5 PT umgesetzt
13 — Geführte Einrichtung Nächster-Schritt-Regelkette auf der Übersicht, Leitgruppe über SettingsCatalog, Erklärtexte als Feldregister-Seed für prices, Lücken-Filter (only_gaps) und Sammelanlage (POST /prices/bulk-create) in der Matrix 3–4 PT umgesetzt
14 — Geführte Überführung Bericht (dry_run) aus Kopienclustern und Artikelnummer, Vorschlag je Cluster (Preiskategorie mit Betrag, Mitglieder direkt oder über eine gemeinsame Prozedur), Übernahme mit Stilllegung der Typzeilen per Enddatum statt Löschen 4–6 PT Teile 1 + 2 umgesetzt: price-classes:derive liefert den Bericht (inkl. Betragslage je Slot) und legt mit --apply Kategorien samt Mitgliedern an; --amounts übernimmt die Kategoriebeträge je Preisgruppe × Komplexität (nur bei einheitlichem Betrag, Konflikte werden gemeldet statt gemittelt), --retire legt abgelöste Typzeilen per Enddatum still (nur Kopien mit deckendem Kategoriebetrag, Typ muss Mitglied der Kategorie sein). Offen: die Konflikt-Slots (fachliche Betragswahl) und der Weg über die Kopiencluster aus AP 12

AP 1+2 haben die gemeldete Beschwerde gelöst, AP 3+4 den fachlichen Ausbau nachgezogen. Für AP 5 bis 9 gibt es keine feste Reihenfolge; AP 7 (Währung) ist der einzige, der an einem anderen Vorhaben hängt (E-Rechnung, BT-5).

AP 12 bis 14 sind die Bedien- und Rollout-Seite, beschrieben in konzept-preiseinrichtung-gefuehrt.md. AP 12 und 13 ändern das Modell nicht, hängen an nichts und sind zuerst empfohlen: Sie machen den Pflegeaufwand messbar (Kopiencluster), bevor über die Modellumstellung entschieden wird, und liefern zugleich die Zahlen, mit denen sich AP 11 begründen oder verwerfen lässt. AP 14 setzt AP 11 voraus. Neu ist außerdem die Einordnung von AP 6 und AP 8 als Vorbedingung der Empfehlung „auf Gruppenebene bepreisen": ohne Massenanpassung und Import/Export bleibt eine Preisrunde Handarbeit je Zeile. Beide sind umgesetzt; die Entscheidung über die Modellumstellung (AP 10/11) ist damit nicht mehr davon abhängig, ob die Pflege überhaupt zumutbar ist.

AP 10 und AP 11 sind die beiden mit Modellwirkung und schneiden AP 9: Staffeln und Zuschläge sollten an der Leistung hängen, nicht nur an der Kalibrierzeile. Wer beides plant, baut AP 10 zuerst und lässt price_scales / price_surcharges gleich auf die Leistung zeigen. Zur ZUGFeRD-Arbeit sind beide unabhängig: Positionen kopieren ihre Werte weiterhin nach article, der Report-Datensatz bleibt unverändert.

Vor AP 10 zu klären, nicht währenddessen: was eine „kundenspezifische" Preiszeile ist (Abschnitt 10, Punkt 12). Alle drei Lesarten landen auf service_prices, aber mit verschiedenen Spalten. Die Antwort kostet ein Gespräch, die falsche Annahme kostet einen zweiten Schema-Wurf.

AP 10 vor AP 11. AP 11 setzt auf service_prices auf; die Prozedur ist eine zusätzliche Achse auf derselben Tabelle. Beide in einem Zug zu bauen (ein Schema-Wurf, zusammen 11–17 PT) vermeidet die Zwischenstufe, in der service_prices schon existiert, aber noch keine Prozedur kennt. Getrennt zu bauen liefert früher etwas Benutzbares. Empfehlung: getrennt, aber das Schema von service_prices gleich mit price_class_id anlegen, damit AP 11 keine Migration auf einer erst wenige Wochen alten Tabelle braucht.

AP 11 hängt nicht am Prozedur-Rollout, sein Nutzen schon. Der Container ist ohne eine einzige Prozedur nutzbar, weil Gerätetypen direkt zusortiert werden. Ohne Prozeduren kostet die Erstbefüllung aber Handarbeit über den Typenbestand statt über 200 bis 2000 Prozeduren. AP 11 ist deshalb technisch früher baubar als fachlich wirksam.

9. Nicht-Ziele

  • Kein Ersatz von standard_article durch die Preisliste und umgekehrt. standard_article wird mit AP 10 zum Leistungskatalog (was), die Preistabellen bleiben die Bepreisung (wie viel, für wen, ab wann). Die gemeinsame Preisliste ist eine lesende Projektion über beide, geschrieben wird an der Quelle.

  • Kein zweiter Preiskatalog neben standard_article (Variante B in Reinform). Die Leistungsachse aus AP 10 erweitert den vorhandenen Katalog, sie stellt keinen neuen daneben.

  • Keine kundenindividuellen Einzelpreise neben der Preisgruppe. Wer für einen Kunden Sonderpreise braucht, bekommt eine eigene Preisgruppe; das Modell trägt das bereits. Ein zusätzlicher Kunden-Override wäre eine dritte Preisquelle und macht die Findung unerklärbar.
  • Keine Regel-Engine (Variante C in Reinform). Das gilt auch für die merkmalsbasierte Bepreisung aus Abschnitt 3.6: Messgröße, Messbereich und Genauigkeitsklasse stehen als Text im Klassennamen und werden nicht zu Rechenmerkmalen gemacht. Die Containerstufe ist eine Zuordnung, keine Regel.
  • Kein Preis aus dem Aufwand. Keine Zeitvorgabe an Prozedur oder Leistung, kein Stundensatz an der Preisgruppe, keine Multiplikation zum Listenpreis. Der Listenpreis bleibt fest und verbindlich. Dass Prozedur und Preiskategorie den Preis bestimmen, ändert daran nichts: sie gruppieren, sie rechnen nicht (Abschnitt 3.6). Nachkalkulation gegen den Ist-Aufwand ist eine eigene Frage und nicht Teil der Preisverwaltung.
  • Keine konfigurierbare Zugriffsfolge. Die drei Stufen der Preisfindung sind fest verdrahtet. Wer sie je Installation einstellbar macht, kann nicht mehr beantworten, warum ein Betrag herauskam.
  • Keine Änderung an der Positionslogik im Auftrag: Positionen kopieren Preise weiterhin und werden von späteren Preisänderungen nicht rückwirkend angefasst. „Preise aktualisieren" bleibt die bewusste, manuelle Aktion. Davon unberührt ist die Erstbepreisung beim Anlegen (2026-08-07): eine neue Geräteposition — ob einzeln zusortiert, als „Auftrag aus Geräteauswahl" oder per Bulk-Hinzufügen — bekommt ihren Preis sofort aus derselben Findung, statt mit 0,00 auf den Extra-Klick zu warten. Positionen ohne Treffer bleiben auf 0 und werden dem Anwender gemeldet (Leitidee 2); Bestandspositionen fasst die Erstbepreisung nie an.
  • Keine Mehrwährungsfähigkeit je Beleg.

10. Risiken und offene Punkte

  1. Defekt 1 ändert Preise. Die Korrektur der Sortierung kann auf PostgreSQL-Installationen dazu führen, dass künftig ein anderer (der richtige) Preis gefunden wird als bisher. Vor dem Ausrollen ein Bericht „welche Typ/Gruppe-Kombinationen haben mehr als eine gültige Zeile" und Abgleich mit den betroffenen Installationen.
  2. Eindeutigkeitsprüfung trifft Altdaten. Bestehende Dubletten müssen gefunden und bereinigt werden, bevor die Prüfung greift, sonst schlägt jedes Speichern einer betroffenen Zeile fehl. Erst Bericht, dann Regel.
  3. Matrix-Performance. Bei mehreren tausend Gerätetypen mal mehreren Gruppen muss über die Zeilen paginiert und serverseitig gefiltert werden. Keine Vollmatrix im Browser aufbauen.
  4. V2-only-Funktionen. Staffeln, Zuschläge und Währung wirken nicht in V1. Solange Kundensysteme V1 fahren, ist das eine bewusste Zweiteilung und muss in der Doku stehen, nicht in einer Fußnote.
  5. Geklärt (2026-08-03): complexity bleibt die Kalibrierart und wird nicht zur Leistungsachse umgewidmet. Sie wäre technisch verfügbar (Freitext, der Finder filtert darauf), aber assignType() ruft den Finder ohne complexity auf, sodass mostSpecific() beliebig unter allen Zeilen des Typs wählte. Die Leistung bekommt mit AP 10 eine eigene Achse. Offen bleibt die engere Frage, ob die Kalibrierart besser am Verfahren hinge; betrifft AP 9 und ist mit einem produktiv nutzenden Labor zu klären.
  6. Entschieden (AP 6, umgesetzt): Die Massenanpassung legt standardmäßig eine neue Gültigkeit an, Überschreiben ist die bewusst zu wählende Korrektur und im Dialog mit einem Warnhinweis versehen. Der Zeilenzuwachs je Runde ist der Preis dafür, dass nachvollziehbar bleibt, was letztes Jahr galt. Beobachten bleibt, wie schnell die Tabelle wächst; ein Aufräumlauf für längst ausgelaufene Zeilen ist bisher nicht vorgesehen.
  7. Preisgruppen-Wildwuchs durch Pauschalen (AP 10). Pauschalen sind kundenabhängig, und das Modell bildet Kundenabhängigkeit ausschließlich über die Preisgruppe ab (Nicht-Ziel „keine kundenindividuellen Einzelpreise"). Weichen Verpackung oder Versand tatsächlich je Kunde statt je Segment ab, wächst die Gruppenzahl mit der Kundenzahl. Beobachten, ab ungefähr 20 Gruppen je Installation neu bewerten — dann als eigene Entscheidung mit Zahlen, nicht als stille Ausnahme.

  8. Der Ausnahmeanteil ist unbeziffert. Die Antwort auf „kosten zwei Typen mit derselben Prozedur immer gleich viel?" lautet „eigentlich ja", tragfähig solange die Ausnahmen selten sind. Kennzahl vor dem Ausbau erheben: Anteil der Gerätetypen mit eigenem Betrag trotz vorhandener Standardprozedur. Ist der Anteil hoch, ist der heutige Zustand zurück und AP 6 + AP 8 die wirtschaftlichere Antwort. Der Prozedur-Rollout ist dagegen kein Blocker mehr: der Container nimmt Typen auch direkt auf.

  9. Offen (AP 11): Woher kommt das zweite Nummernschema (622.011.02, 629.021.01, 644.064.02, 649.021.01, 664.021.01)? Eigene Systematik, Fremdkatalog oder Altlast? Bestimmt, ob die Nummer als Zuordnungsvorschlag für die Kategoriebildung überhaupt flächendeckend taugt.
  10. Offen (AP 11): Ist die Klassennummer je Kalibrierart verschieden (Befund 5 in Abschnitt 3.6: derselbe Klassenname als 100.101.01/ISO und 100.112.08/DAkkS), oder ist das ein Pflegefehler? Sind es zwei Prozeduren, trägt die Containerstufe die Kalibrierart von selbst und die Frage löst sich auf.
  11. Beantwortet (2026-08-04): Drei Modellfragen sind mit dem Auftraggeber geklärt und stehen als Entscheidung in ADR-032:

    • Tragen zwei Prozeduren jemals denselben Preis? Ja, und genau dafür ist der Container da.
    • Mitgliedschaft in der Preiskategorie ist n:1 auf beiden Seiten (Typ und Prozedur je höchstens eine Kategorie). Das betrifft nur die Mitgliedschaft; ein Typ darf weiterhin mehrere Prozeduren haben, für den Preis zählt die Standardprozedur.
    • Die Vererbung der Preisgruppen ist einstufig: jede Gruppe leitet direkt vom Betrag der Kategorie ab, keine Gruppe von einer anderen.
    • Die Ableitung ist befristbar (start_date/end_date), Anlass sind Marketingaktionen. Eine Aktion wird damit eine Gruppe mit Zeitraum statt einer manuellen Umpreisung mit Rückpreisung.
    • Beantwortet (2026-08-04): „kundenspezifisch" heißt Einzelgerät. Gemeint sind ein Eigengerät des Kunden oder ein besonderer Kalibrierwunsch zu einem konkreten Gerät. Beides wird am Einzelgerät bepreist, direkt oder über eine nur dafür angelegte Prozedur (Stufe 0 der Kaskade). Ein Kundenrabatt bleibt die Preisgruppe. Das Nicht-Ziel „keine kundenindividuellen Einzelpreise" bleibt damit erfüllt: der Preis hängt weiterhin an einem bepreisbaren Gegenstand, nur ist der Gegenstand jetzt ein Einzelgerät statt eines Typs.

    Die frühere Fassung dieses Punktes lautete:

12a. (überholt) Was ist eine „kundenspezifische" Preiszeile? Gerät und Artikel benennen einen bepreisbaren Gegenstand, der Kunde nicht. Dieses Dokument nimmt an, dass „kundenspezifisch" die Preisgruppe als Spalte meint, also für wen eine Geräte- oder Artikelzeile gilt (Abschnitt 3.6). Denkbar wären stattdessen: ein eigener Betrag je Kunde, der die Findung ersetzt — das kippt das Nicht-Ziel „keine kundenindividuellen Einzelpreise" und damit ADR-012 Entscheidung 6; eine Kondition auf die ganze Gruppe (Rabatt, Steuersatz, Mindestauftragswert), die zusätzlich zur gefundenen Zeile wirkt und eine fünfte Stufe wäre; oder Pauschalen je Kunde ohne Geräte- und Artikelbezug. Die drei Lesarten führen zu verschiedenen Tabellen. Vor Umsetzungsbeginn von AP 10/AP 11 zu klären, weil service_prices sonst zweimal gebaut wird.

11. Referenzen

Code V2: laravel/app/Http/Controllers/Api/V2/PriceController.php, PriceCategoryController.php, laravel/app/Services/Pricing/PriceFinderService.php, laravel/app/Models/{Price,PriceCategory,StandardArticle,Article}.php, laravel/database/migrations/2026_02_28_600000_create_price_tables.php, laravel/routes/api.php:1028-1058, frontend-v2/pages/{price,price-category,standard-article}/, frontend-v2/components/booking/BookingPositions.vue

Code V1: httpdocs/protected/modules/frontend/controllers/FrontendPriceController.php, FrontendArticleController.php, httpdocs/protected/modules/frontend/models/{FrontendPrices,FrontendPriceCategory}.php, Migrationen m170208_040802, m170306_031248, m170321_072033, m241218_043547, m251002_073125

Dokumente: v1-ordermanagement-analysis.md §7, konzept-preiseinrichtung-gefuehrt.md (Bedien- und Rollout-Seite, AP 12–14), konzept-zugferd-rechnung.md, DATABASE_ABSTRACTION_PLAN.md, ADR-012, ADR-029 (in Teilen ersetzt), ADR-032, docs-v2/handbuch/preise/ (mit AP 11 erneut nachzuziehen: preisliste.md sagt heute korrekt „Ein Eintrag gilt immer für die Kombination aus Gerätetyp und Preisgruppe"; mit Container und Gruppenvererbung stimmt das nicht mehr)