Eine Kunden-Preisliste nach V2 übernehmen¶
Anlass: Ein Kalibrierlabor will sein Preissystem in calServer aufbauen. Es bringt eine Excel-Liste mit 45 Zeilen mit, zwei Spalten: „Preisklasse" und „Preis [€]". Diese Liste kennen seine Kunden so; sie ist das Angebot, nicht ein interner Auszug. Sie soll möglichst einfach importiert und danach auf die Gerätetypen zusortiert werden.
Dieses Dokument beantwortet drei Fragen: Wo landet so eine Liste im V2-Datenmodell, wie kommt sie heute dorthin, und was müsste gebaut werden, damit der nächste Kunde es selbst kann.
Die zugehörigen Dateien liegen unter
samples/preiskategorien-uebernahme/.
1. Was diese Liste im Datenmodell ist¶
Der Begriff „Preisklasse" aus der Kundendatei trifft in V2 auf
price_classes, fachlich die Preiskategorie (ADR-032). Das passt in
allen drei Punkten: Der Name trägt 255 Zeichen, die längste Zeile der Datei hat
75. Die Größenordnung stimmt, ADR-032 veranschlagt 10 bis 100 Preiskategorien je
Installation. Und ihr Zweck ist genau der gesuchte, nämlich Gerätetypen zu
bündeln, statt jeden einzeln zu bepreisen.
Nicht zu verwechseln mit price_category: Das ist trotz des Namens die
Kundenpreisgruppe (Endkunde, Händler), also die andere Achse. Die
Namenskollision ist historisch und in ADR-032 Entscheidung 2 begründet.
Eine Excel-Zeile wird zu zwei Datensätzen. price_classes hat keine
Betragsspalte; eine Kategorie trägt ihren Preis nicht selbst. Der Betrag ist
eine service_prices-Zeile mit gesetztem price_class_id und leerer
service_id (leer bedeutet: die Leistung ist die Kalibrierung).
| Excel | wird zu |
|---|---|
Preisklasse (Name) |
price_classes.name |
Preis [€] |
service_prices.price, dazu price_class_id, service_id = NULL |
| (nicht in der Datei) | price_class_members: welcher Gerätetyp in der Kategorie liegt |
2. Der Fallstrick: die Liste vermischt drei Achsen¶
Eine Preisliste, die Kunden lesen, ist nach Verkaufslogik sortiert, nicht nach Datenmodell. Diese hier enthält drei verschiedene Dinge:
-
Geräteklassen (44 Zeilen). „µCANSAS 1 channel", „Pressure sensor 2 bar - 200 bar". Sie beschreiben, was kalibriert wird. Das sind Preiskategorien.
-
Eine Zusatzleistung (1 Zeile). „Temperature sensor additional point", 25 €. Sie beschreibt keine Geräteklasse, sondern eine Mehrleistung an einem bereits bepreisten Gerät. Diese Zeile gehört auf die Leistungsachse (
standard_articleplusservice_prices.service_id), nicht in eine Preiskategorie.
Das ist kein Schönheitsfehler. price_class_members hat einen
Unique-Index auf (member_table, member_id): Ein Gerätetyp liegt in
höchstens einer Preiskategorie. Würde „additional point" eine Kategorie,
könnte derselbe Temperatursensor nicht gleichzeitig in „Temperature sensor
2 points" liegen. Das Datenmodell blockiert dann genau den Fall, für den die
Zeile gedacht ist.
- Kalibrierart und Leistungsumfang, eingebacken in Namen. „Torque sensor
DKD procedure" gegen „Torque sensor regular", viermal „(inhouse)",
einmal „(incl. adjustment)". Fachlich sind das Aufschläge auf dieselbe
Grundleistung, technisch ist
complexityheute ein exakter Filter und kein Aufschlag (price_surchargesist als AP 9 geplant, nicht gebaut).
Für die Übernahme ist das kein Problem, weil die Datei die Kombinationen bereits ausgeschrieben hat: „DKD" und „regular" sind zwei Zeilen, also zwei Kategorien. Das ist die ehrliche Abbildung des Ist-Zustands. Erst wenn eine dritte Stufe dazukommt und die Zeilenzahl multiplikativ wächst, lohnt der Umbau auf Aufschläge.
Konsequenz: 44 Zeilen werden Preiskategorien, 1 Zeile wird eine Leistung.
Aufbereitet liegt das in
preiskategorien.csv
und
leistungen.csv.
3. Was heute geht, und was nicht¶
Seit
ADR 2026-08-07
gibt es dafür einen Weg: php artisan price-classes:import liest CSV oder
Excel und legt Preiskategorien samt Betrag an. Ohne --apply nur ein Bericht.
Seit
ADR 2026-08-13
geht derselbe Lauf auch ohne Shell — als Upload in der Oberfläche: Preise →
Preiskategorien → „Preisliste hochladen" (Recht price_edit). Erst Vorschau,
dann Übernahme, danach führt der Dialog zum Zusortieren. Eine Datei ohne
Nummernspalte — genau die Form der Kundenliste — bekommt die Nummern dabei
automatisch vergeben; ein Kunde spielt seine Preisliste nie über ein
Artisan-Command ein.
Die beiden naheliegenden Alternativen führen weiterhin ins Leere, und es lohnt zu wissen warum:
POST /prices/import(der CSV-Rundlauf der Preisliste) adressiert Zeilen über Gerätetyp und Preisgruppe, beide als Pflichtspalten mit bereits existierenden Namen. Die Kundendatei hat weder das eine noch das andere.php artisan price-classes:deriveleitet Kategorien aus dem Zusatzfeld „Artikelnummer" bestehenderprices-Zeilen ab. Es liest keine Datei. Bei einem Kunden, dessen Preise noch nirgends stehen, ist es wirkungslos.
Beide bleiben nebeneinander stehen: verschiedene Quellen, dieselbe Zielform.
Drei Wege, mit Trade-off¶
| Weg | Aufwand | Ergebnis |
|---|---|---|
A. Upload in der Oberfläche (/price/classes) |
Minuten | Vorschau, dann Übernahme, dann Zusortieren — ohne Shell, ohne Nummern-Vorarbeit. Der Weg für den Anwender. |
B. price-classes:import |
Minuten | Dasselbe auf der Shell. Wiederholbar, Hierarchie und Preisgruppe inbegriffen. Für begleitete Einrichtungen und Skripte. |
C. Skript gegen die API (samples/) |
Minuten | Dasselbe Ergebnis über HTTP, wenn weder Oberfläche noch Container-Zugang passen, sondern nur ein Token. |
D. Pseudo-Gerätetypen anlegen und /prices/import fahren |
1 h | Funktioniert, hinterlässt 44 Karteileichen im Typenkatalog, die an Altaufträgen hängen bleiben. |
Empfehlung: A, für begleitete Läufe B oder C. Weg D ist exakt der Defekt, den ADR-029 an der Produktiv-Preisliste dokumentiert hat, wo 140 von 180 Zeilen Leistungspositionen waren, die in die Typ-Tabelle gezwungen wurden. Ihn wissentlich zu wiederholen, um eine Stunde zu sparen, ist der schlechteste der vier Handel.
Zur Nummernspalte¶
Der Import erkennt eine Kategorie über ihre Nummer wieder, nicht über den
Namen: price_classes.number ist eindeutig, name nicht. Deshalb trägt die
Vorlage eine Nummernspalte, obwohl die Kundendatei keine hatte — sie zu
vergeben ist ein Arbeitsschritt vor dem Import, und dabei entsteht zugleich die
Gliederung.
Beim Upload entfällt diese Vorarbeit: Eine Datei ganz ohne Nummernspalte
wird automatisch nummeriert (PK001, …, hinter der höchsten vergebenen
PK<zahl>-Nummer). Damit ein zweiter Upload wiedererkennt statt verdoppelt,
schlägt die Vergabe zuerst im Bestand nach — heißt genau eine Kategorie so wie
die Zeile, übernimmt die Zeile deren Nummer. Ein mehrdeutiger Name wird
gemeldet, nicht geraten. Eine vorhandene Nummernspalte bleibt unangetastet,
auch in leeren Zellen.
Die Nummern der Vorlage beginnen mit PK. Das ist kein Zierrat: Eine Zelle wie
100.10 liest jede Tabellensoftware als Zahl, und 100.10 und 100.1 sind
dieselbe Zahl. Die nachlaufende Null wäre weg, bevor der Importer die Datei
überhaupt sieht.
4. Anleitung: die Liste einspielen¶
In der Oberfläche (der Weg für den Anwender)¶
Unter Preise → Preiskategorien → „Preisliste hochladen" die Datei wählen — so, wie sie vorliegt, eine Nummernspalte ist nicht nötig. Vorschau zeigt je Zeile, was passieren würde (angelegt, liegt bereits vor, gemeldet — samt der automatisch vergebenen Nummern); Übernehmen schreibt. Optional lässt sich eine Preisgruppe wählen, für die die Beträge gelten sollen; bei mehreren Gruppen den Upload je Gruppe wiederholen. Nach der Übernahme führt der Dialog direkt zum Zusortieren (Teil 2).
Auf der Shell (begleitete Einrichtung)¶
Schritt 1 — Datei bereitlegen. Spalten Nummer und Preiskategorie sind
Pflicht, Oberkategorie, Preis, Preisgruppe, Komplexität, Steuer % und
Beschreibung sind optional. Die Überschrift darf einen Klammerzusatz tragen
(Preis [€]), und statt Preiskategorie wird auch Preisklasse erkannt. CSV
und Excel gehen beide.
Schritt 2 — Probelauf. Er schreibt nichts und zeigt, was er täte. Einzeln aufgeführt wird nur, was Aufmerksamkeit braucht.
Erwartet: Gelesen: 54 Zeilen. und darunter wird angelegt 54. Das sind 10
Oberkategorien plus 44 Preiskategorien.
Schritt 3 — Übernehmen.
docker exec calserver-api-v2 php artisan price-classes:import /pfad/preiskategorien.csv --apply --force
Ergebnis: Angelegt: 54 Preiskategorien, 44 Beträge. Ein zweiter Lauf meldet
alles als liegt bereits vor und schreibt nichts.
Nutzt der Kunde Preisgruppen, den Lauf je Gruppe wiederholen:
docker exec calserver-api-v2 php artisan price-classes:import /pfad/preiskategorien.csv \
--apply --force --price-group='Händler'
Ohne Container-Zugang¶
Wenn nur ein API-Token zur Verfügung steht, macht
import-preiskategorien.py
dasselbe über HTTP. Rechte: price_view, price_edit, price_category_view.
export CALSERVER_URL="https://<host>/api/v2"
export CALSERVER_TOKEN="$(curl -s -X POST "$CALSERVER_URL/auth/login" \
-H 'Content-Type: application/json' \
-d '{"email":"...","password":"..."}' \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["data"]["attributes"]["token"])')"
cd samples/preiskategorien-uebernahme
./import-preiskategorien.py preiskategorien.csv # Probelauf
./import-preiskategorien.py preiskategorien.csv --apply # übernehmen
Schritt 4 — die Zusatzleistung. Ein Datensatz, von Hand schneller als per
Skript: unter /standard-article (Leistungskatalog, Recht article_edit)
„Temperature sensor additional point" mit 25 € anlegen. Sie wird im Auftrag als
Position gewählt, nicht über den Gerätetyp gefunden.
Zur Hierarchie¶
Die CSV schlägt 10 Oberkategorien vor (Datenerfassung, Messverstärker,
Elektrische Messgeräte, Prüfsysteme, Strommessung, Kraft und Drehmoment, Druck,
Temperatur, Schwingung, Drehzahl). Das ist ein Vorschlag zur Sortierung, keine
Setzung: Die Ebenen sind optional (ADR-032 Entscheidung 12), die Oberkategorien
tragen bewusst keinen Betrag, und der Preis hängt an der feinsten Ebene. Wer die
flache Liste will, leert die Spalte Oberkategorie und löscht die zehn
Gruppenzeilen. Die Gruppierung nachzuziehen kostet später keine Migration.
Zwei Dinge, die die Datei nicht sagt¶
Die Preisgruppe. Die Liste kennt nur einen Preis je Klasse, also gilt er für alle. Das Skript schreibt deshalb ohne Preisgruppe. Zwei Folgen, beide unangenehm:
PriceFinderServicefiltert hart auf die Preisgruppe des Kunden und fällt nicht auf einen gruppenlosen Betrag zurück. Hat der Kunde bereits Preisgruppen gepflegt und Kunden zugeordnet, laufen die 44 Preise für genau diese Kunden ins Leere. Die Vererbung ist beschlossen (ADR-032 Entscheidung 6), aber als AP 11b nicht gebaut.- Gruppenlose Kategoriezeilen sind in
/price/matrixunsichtbar (PriceMatrixService::classServicePrices()überspringt sie, weil die Spalten der Matrix die Preisgruppen sind). Im Auftrag greifen sie trotzdem. Preise, die wirken, aber nicht angezeigt werden, sind ein schlechter erster Eindruck.
Wenn der Kunde Preisgruppen nutzt: --price-group <uID> setzen und den Lauf je
Gruppe wiederholen.
Steuer, Währung, Gültigkeit. tax wird beim Anlegen auf 19 gesetzt.
start_date/end_date bleiben leer, die Beträge gelten unbefristet. Eine
Währung ist im Modell nicht abgebildet, alle Beträge stehen in der impliziten
Installationswährung. Ob die 82 € netto oder brutto sind, steht nicht in der
Datei und ist vor dem Lauf zu klären.
5. Teil 2: auf die Gerätetypen zusortieren¶
Die Kategorien stehen, aber sie sind leer. Eine Kategorie ohne Mitglieder trägt
einen Preis und findet nie einen Gerätetyp; die Spalte members_count unter
/price/classes zeigt das mit Warnfarbe.
Der Hebel ist die Prozedur, nicht der Gerätetyp. price_class_members nimmt
zwei Sorten Mitglieder auf, und eine Prozedur reicht alle Gerätetypen weiter,
für die sie Standardprozedur ist:
Gerätetypen ~20.000 Bestand, wächst mit dem Gerätepark
Prozeduren 200–2000 entsteht ohnehin, ohne sie wird nicht kalibriert
Kategorien 10–100 die eigentliche Preispflege
Wer 44 Kategorien direkt mit Gerätetypen füllt, sortiert Tausende Einzelzeilen. Wer sie mit Prozeduren füllt, sortiert ein paar Dutzend. Das ist die eigentliche Antwort auf „wie kriege ich das zusortiert", und sie steht als Entscheidung 1 und 3 in ADR-032.
Praktisch:
- Für jede Kategorie die passende Prozedur bestimmen. Bei diesem Kunden liegt das oft 1:1, weil die Klassennamen bereits Prüfverfahren beschreiben („Torque sensor DKD procedure").
- Unter
/price/classes/<id>die Prozedur zuordnen (Art „Prozedur"). Damit erben alle Gerätetypen, deren Standardprozedur sie ist, den Preis. - Nur die Ausreißer direkt zuordnen: Geräte ohne Prozedur, typischerweise solche, die extern kalibriert werden.
- Mit
GET /price-classes/resolve?type_id=<tID>stichprobenartig prüfen, welche Kategorie ein Gerätetyp zieht und über welchen Weg (origin:directoderprocedure).
Ein bereits zugeordnetes Mitglied wird beim erneuten Zuordnen umgehängt
statt abgewiesen (HTTP 200, meta.moved = true). Umsortieren ist also möglich,
ohne vorher zu löschen.
Zusortieren in Menge¶
Seit ADR 2026-08-07 gibt es zwei Endpunkte dafür:
GET /api/v2/price-classes/unassigned-types welche Gerätetypen finden keine Kategorie?
POST /api/v2/price-classes/members/bulk bis zu 500 auf einmal zusortieren
Die Lückenliste zählt einen Gerätetyp nur dann als versorgt, wenn er direkt
zugeordnet ist oder seine Standardprozedur in einer Kategorie liegt.
Eine Alternativ-Prozedur sähe zugeordnet aus und lieferte keinen Preis. Die
Antwort trägt meta.types_total mit: ohne die Bezugsgröße ist „412 ohne
Kategorie" nicht einzuordnen.
Der Bulk-Aufruf trägt die Kategorie je Zeile, arbeitet partiell (eine unbekannte Kategorie hält den Lauf nicht an) und hängt ein Mitglied um, statt es abzuweisen:
{"members": [
{"price_class_id": "…", "member_table": "procedure", "member_id": "…"},
{"price_class_id": "…", "member_table": "type", "member_id": "…"}
]}
Seit ADR 2026-08-13 gibt es dafür auch die Oberfläche: /price/classes/assign
(„Zusortieren", verlinkt von der Kategorienliste und aus dem Upload-Dialog)
zeigt die Lückenliste mit Mehrfachauswahl — auch über Seitengrenzen hinweg —
und ordnet die Auswahl einer Kategorie in einem Zug zu.
Was dabei fehlt¶
- Kein Textabgleich Klassenname gegen Gerätetyp. Es gibt im Backend keine
Ähnlichkeitssuche (kein
levenshtein,similar_text,soundex). Bei dieser Liste wäre der Ertrag hoch, weil 23 der 44 Namen einen Hersteller oder eine Baureihe tragen (Keithley, Yokogawa, PhoenixTM, NI, HBK, LEM, Schleich, Vector, MicroControl, CANSAS, CronosFlex, CSM), undDeviceTypeführt Hersteller (I4202) und Modell (I4203) als eigene Felder. ADR-029 verwirft allerdings merkmalsbasierte Regeln ausdrücklich, ein Vorschlagswerkzeug wäre also erst zu begründen.
6. Was gebaut ist, und was noch fehlt¶
Gebaut (ADR 2026-08-07): price-classes:import mit Spaltenvertrag
(App\Support\PriceClassCsv), Importer
(App\Services\Pricing\PriceClassFileImporter) und Tests. Zunächst bewusst als
Artisan-Command ohne Oberfläche; die Rechnung („der Service darunter ist
derselbe, den sie später bräuchte") ist aufgegangen: Seit ADR 2026-08-13
steht die Oberfläche — Upload mit Vorschau (POST /price-classes/import-preview
und /price-classes/import, Dialog unter /price/classes), automatische
Nummernvergabe für Dateien ohne Nummernspalte (PriceClassAutoNumberer) und
die Zusortieren-Seite /price/classes/assign auf den beiden Endpunkten aus dem
zweiten ADR.
Dabei fiel ein Fehler auf, der älter ist als dieser Importer: Der
SpreadsheetReader gab Dezimalzahlen mit voller Float-Präzision aus, aus
100.01 wurde 100.010000000000005. Für Beträge folgenlos (sie werden gerundet
gespeichert), für Bezeichner ein Datenfehler — und er hätte genau die
Wiedererkennung über die Nummer gebrochen, auf der die Wiederholbarkeit steht.
Behoben im Leser, wirkt auf alle Importe (Inventar, Kunden, Kalibrierungen,
Kategorien, Preise). Begründung im ADR.
Ebenfalls gebaut (ADR 2026-08-07, zweiter Teil): der Bulk-Endpunkt für
price_class_members und die Lückenliste unassigned-types — siehe
Abschnitt 5.
Was noch fehlt, nach Hebelwirkung:
| Fehlt | Aufwand | Wirkung |
|---|---|---|
| Vererbung der Preisgruppen (AP 11b) | offen | Beseitigt die Notwendigkeit, den Import je Gruppe zu wiederholen |
Nicht mehr offen, entgegen einer früheren Einschätzung: die serverseitige
Suche in den Auswahllisten. Die Kategorie-Detailseite nutzt LookupSelect und
sucht über /lookup/{resource}; nur die Elternkategorie-Auswahl lädt eine
feste Seite, und Kategorien sind laut ADR-032 zehn bis hundert.
Nicht tun: preisklasse als Alias in PriceCsv::HEADER_ALIASES['type_name']
nachtragen. Das macht die Kundendatei importierbar und schreibt
Pseudo-Gerätetypen, also Weg C von oben, nur schneller erreicht und schlechter
sichtbar.
7. Offene Fragen an den Kunden¶
- Netto oder brutto? Steht nicht in der Datei.
taxwird sonst 19. - Preisgruppen im Einsatz? Wenn ja, gelten die 44 Preise für welche? Ohne Gruppe greifen sie nur bei Kunden ohne Gruppe.
- Ist „(inhouse)" eine Eigenschaft des Geräts oder der Leistung? Wenn Letzteres und es dazu Gegenstücke („on-site") gibt, verdoppelt sich die Kategorienzahl, und der Fall gehört auf die Komplexitätsachse statt in den Namen.
- Gilt „Acceleration sensor per axis" je Achse? Dann ist 145 € ein
Einheitspreis mit
quantity-Bezug, kein Festpreis. - Existieren die Prozeduren schon? Davon hängt ab, ob Teil 2 über Prozeduren läuft (Dutzende Zuordnungen) oder über Gerätetypen (Tausende).
Referenzen¶
- ADR-032: Die Preiskategorie als Container der Geräteachse. Maßgeblich für dieses Dokument.
- ADR-029: enthält die Analyse der 180-Zeilen-Produktivpreisliste und die Begründung, warum Pseudo-Gerätetypen ein Defekt sind.
docs/konzept-preisverwaltung-v2.md,docs/konzept-preiseinrichtung-gefuehrt.md: Stufenplan, in dem dieser Fall Stufe 4 („Geführte Überführung des Bestands") ist.- Handbuch:
docs-v2/handbuch/preise/preisliste.md