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 AuftragspositionenKurzfassung: 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}undPOST /prices/{id}/duplicateexistieren 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;
pricesundprice_categorybleiben 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(PKuID): Preisgruppe als Kundensegment.name(unique),description. Kunden zeigen übercustomers.price_category_iddarauf.prices(PKpID): eine Preiszeile je Gerätetyp × Preisgruppe, zusätzlich abgestuft nachcomplexity(V1-AuswahllisteISO#DAKKS) und befristet überstart_date/end_date. Werte:price,price_min,price_max,costs,quantity,unit(DefaultEA),discount,tax(Default 19). Seit2026_07_03_000002zusätzlichcustom_fields.standard_article(PKuID): freier Katalogartikel mitarticle_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 (stattDynamicDetailCard, wie es alle anderen Detailseiten tun) plus Löschen.pages/price-category/index.vue— voll-CRUD überSettingsGrid.pages/standard-article/index.vue— voll-CRUD überSettingsGrid.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.
- Treiberabhängiges Preisergebnis.
PriceFinderService:62sortiertorderByDesc('start_date'). Eine unbefristete Preiszeile hatstart_date = NULL. PostgreSQL sortiert NULL beiDESCstandardmäß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 inCLAUDE.mdverhindern soll. Fix: deterministische Reihenfolge ohne NULL-Sortierung, etwa „datierte Zeilen vor unbefristeten" per abgeleitetem Rangfeld. - Kunde ohne Preisgruppe bekommt einen fremden Preis.
Ist
price_category_idleer, entfällt derprice_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. complexityist per API nicht setzbar.store()undupdate()validieren die Spalte nicht (PriceController:96-109,:140-151), die Preisfindung filtert aber danach. Über V2 angelegte Preise haben immercomplexity = NULLund werden von einer nach ISO oder DAKKS suchenden Position nie gefunden.- Keine Eindeutigkeitsprüfung.
V1 erzwingt Eindeutigkeit über (
type_tID,price_cat_uID,start_date,complexity) perECompositeUniqueKeyValidatable(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_maxundstart_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.quantityistdecimal(13,2)in der DB, aberintegerim Model-Cast und in der Validierung: Bruchmengen werden still abgeschnitten.duplicate()kopiertgetAttributes()samtmodified_bydes Originals. Der Menüeintrag/pricehängt aninventory_view, die API verlangtprice_view(layouts/default.vue:135-139): wer nurinventory_viewhat, sieht den Punkt und bekommt ein 403-Grid.Korrektur 2026-08-03: Hier stand zusätzlich,
PricenutzeHasDynamicFieldsnicht, 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.
article.article_IDbedeutet in V1 und V2 Verschiedenes. Fürarticle_type = 'type'legt V1 dieprices.pIDab (FrontendArticleController::actionAssignType, RelationtypePriceinFrontendArticle.php:209), V2 dietypes.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_articleist 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 auspricesbrä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:
- 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. - 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. - Die Zuordnung geschieht durch Kopieren des Betrags, nicht durch Verweis.
100.110.01steht auf der Klasse110 Druckkalibratoren -1 bis 30 bar(180,00) und auf dem GerätetypBeamex MC5-IS(180,00).400.408.02steht auf408 Handkraftmessgeräte 0 bis 1000 N, zwei Richtungenund aufMecmesin AFG 1000Nund aufMecmesin 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. - 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. - Klassenname und Nummer sind nicht deckungsgleich.
101 Federmanometer -1 bis 30 bar, >= Kl. 1,0existiert als100.101.01zu 50,00 (ISO) und als100.112.08zu 88,00 (DAkkS). Die Nummer ist feiner als der Name. - 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:
- 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.
- 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.
- Arbeitswerte und Zeitvorgaben (Werkstatt, Instandhaltung): Preis gleich Zeitvorgabe mal Verrechnungssatz. Pflegearm. Kostet: der Listenpreis wird zum Rechenergebnis, Festpreisangebote werden schwierig.
- Vertrags- und Rahmenpreise (Field Service, Serviceverträge): Preise kommen aus einem Vertrag mit Laufzeit statt aus einer globalen Liste.
- 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.
PriceFinderServiceliefertmatched_byundreasonmit — 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_bynicht. 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:
- Bericht (
dry_run):prices-Zeilen nach dem Wert des Zusatzfelds gruppieren. Ergebnis: Gruppen gleicher Nummer, Mehrfachbelegungen, Zeilen ohne Nummer. - Gruppen auf Preiskategorien abbilden: jede Nummerngruppe ist Kandidat für eine Kategorie. Vorschlag, nicht Automatik.
- Kategoriepreise anlegen: die Zeile des Pseudo-Typs wird zum Betrag der Kategorie auf Stufe 2.
- 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. - Pseudo-Typen stilllegen, nicht löschen, sie hängen an Altaufträgen. Kennzeichnen und aus den Auswahllisten nehmen.
- 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¶
- Die Preisgruppe ist der Einstieg, nicht ein Feld unter vielen. Preise pflegt man je Gruppe („Was zahlt ein Standardkunde?"), nicht als Liste undifferenzierter Zeilen.
- 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.
- 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. - Preisfindung erklärt sich selbst. Jede Auskunft nennt die Zeile, die gewonnen hat, und im Negativfall den Grund im Klartext.
- 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),CrudModalgegen/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
headerFieldsdurchDynamicDetailCard resource="prices"ersetzen; damit greifen automatisch Zusatzfelder und die Referenz-Auflösung. Voraussetzung:type_idundcategory_idmü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). DetailActionBarbekommt:primary„Bearbeiten" (price_edit), Löschen wird anprice_deletegebunden.- 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 /pricesmit 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_datestatt 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/UpdatePriceRequestals FormRequests (heute inline, entgegen Regel 2 inlaravel/CLAUDE.md), mit Nichtnegativität der Geldfelder sowie den beiden Ergänzungenprice_min <= price_max(nur wenn beide gesetzt,0gilt als „nicht gesetzt") undstart_date <= end_date.complexitybleibt Freitext bis 255 Zeichen: Die Auswahlliste ist installationsspezifisch, und die Preisfindung filtert auf den gespeicherten Wert.complexityin 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 ummatched_byundreasonerweitern, damit/prices/lookupsich erklären kann.PricenutztAuditable(Historie).HasDynamicFieldsist bereits drin.quantityalsdecimalbehandeln statt alsinteger.- Neue Endpunkte:
GET /prices/{id}/audits,POST /price-categories/{id}/duplicate(Gruppe samt Preisen, mit Faktor,dry_rununterstützt),POST /prices/bulk-adjust(Auswahl, Faktor, Modusoverwriteodernew_validity,dry_rununterstützt). PriceFactoryundPriceCategoryFactoryergänzen (fehlen bislang; Tests hängen amTestDatabaseSeeder).
Feld-Registry
type_idundcategory_idfehlen indefault_field_definitions.jsonfürprices. Ohne sie liefertGET /field-definitions/priceskeine Definition, und damit können Grid,DynamicDetailCardundDynamicFormdie 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 alsRequiredDatabasemitlink_table_noteergänzen; derFieldStorageResolverlöstv1_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
LookupServiceeine Ressourceprice-categories(price_category_view).
Frontend
pages/price/index.vueund[id].vuenach dem etablierten Muster (Abschnitt 5.1/5.2).- Neue Seiten
pages/price/matrix.vueundpages/price-category/[id].vue, beide ine2e/support/routes.tseintragen (erzwungen durchutils/route-inventory.test.ts). - Menü-Gate
/pricevoninventory_viewaufprice_viewkorrigieren.
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_surchargesdie Anlehnung anprocedure_provisionundquality_provision. Das ist falsch. Beide Tabellen sind Qualitätsvorgaben (Messbereich, Unsicherheit, Auflösung;m190221_020751), undprocedure_provisionist eine reine n:m-Zuordnung Prozedur ↔ Vorgabe (BaseFrontendProcedureProvision) ohne jeden Preisbezug. Der Irrtum stammt ausv1-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_deletefür Preiszeilen, Matrix, Massenanpassung und Import.price_category_view,price_category_edit,price_category_deletefü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_articledurch die Preisliste und umgekehrt.standard_articlewird 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¶
- 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.
- 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.
- Matrix-Performance. Bei mehreren tausend Gerätetypen mal mehreren Gruppen muss über die Zeilen paginiert und serverseitig gefiltert werden. Keine Vollmatrix im Browser aufbauen.
- 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.
- Geklärt (2026-08-03):
complexitybleibt die Kalibrierart und wird nicht zur Leistungsachse umgewidmet. Sie wäre technisch verfügbar (Freitext, der Finder filtert darauf), aberassignType()ruft den Finder ohnecomplexityauf, sodassmostSpecific()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. - 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.
-
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.
-
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.
- 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. - Offen (AP 11): Ist die Klassennummer je Kalibrierart verschieden
(Befund 5 in Abschnitt 3.6: derselbe Klassenname als
100.101.01/ISO und100.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. -
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)