Zum Inhalt

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:

  1. Geräteklassen (44 Zeilen). „µCANSAS 1 channel", „Pressure sensor 2 bar - 200 bar". Sie beschreiben, was kalibriert wird. Das sind Preiskategorien.

  2. 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_article plus service_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.

  1. 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 complexity heute ein exakter Filter und kein Aufschlag (price_surcharges ist 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:derive leitet Kategorien aus dem Zusatzfeld „Artikelnummer" bestehender prices-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.

docker exec calserver-api-v2 php artisan price-classes:import /pfad/preiskategorien.csv

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:

  • PriceFinderService filtert 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/matrix unsichtbar (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:

  1. 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").
  2. Unter /price/classes/<id> die Prozedur zuordnen (Art „Prozedur"). Damit erben alle Gerätetypen, deren Standardprozedur sie ist, den Preis.
  3. Nur die Ausreißer direkt zuordnen: Geräte ohne Prozedur, typischerweise solche, die extern kalibriert werden.
  4. Mit GET /price-classes/resolve?type_id=<tID> stichprobenartig prüfen, welche Kategorie ein Gerätetyp zieht und über welchen Weg (origin: direct oder procedure).

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), und DeviceType fü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

  1. Netto oder brutto? Steht nicht in der Datei. tax wird sonst 19.
  2. Preisgruppen im Einsatz? Wenn ja, gelten die 44 Preise für welche? Ohne Gruppe greifen sie nur bei Kunden ohne Gruppe.
  3. 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.
  4. Gilt „Acceleration sensor per axis" je Achse? Dann ist 145 € ein Einheitspreis mit quantity-Bezug, kein Festpreis.
  5. Existieren die Prozeduren schon? Davon hängt ab, ob Teil 2 über Prozeduren läuft (Dutzende Zuordnungen) oder über Gerätetypen (Tausende).

Referenzen