V1-Feldmanagement: abgeleitete Felder und ihr Weg nach V2¶
Bestandsaufnahme der Felder, die V1 in der Feldverwaltung (und damit in den Grids) anbietet, ohne dass eine physische Spalte dahinter steht. Beispiel aus der Praxis: „letzter Standort", mit dem man am Inventar auf den aktuell gültig gesetzten Standort filtert.
Stand: Juli 2026. Quellen sind httpdocs/protected/ (V1),
laravel/database/sql/field_configuration.sql (Werksvorgabe V1),
httpdocs/protected/migrations/ (später ergänzte Felder) und
laravel/ (V2-Registry).
1. Was „abgeleitet" in V1 technisch heißt¶
Ein abgeleitetes Feld ist eine Zeile in field_configuration
(table_name = Grid, column_name = Feldname), zu der es keine Spalte in der
zugehörigen Tabelle gibt. Damit so ein Feld funktioniert, braucht V1 fünf
Bausteine, alle handgeschrieben:
| Baustein | Ort | Zweck |
|---|---|---|
field_configuration-Zeile |
Yii-Migration | Feld erscheint in Feldverwaltung und Spaltenauswahl |
public $feld + attributeLabels() |
Frontend*-Model / BaseFrontend* |
Attribut und Label |
getOtherFieldsFromFormula() |
Frontend*-Model |
Ausdruck für Sortieren/Filtern: relation + field, oder condition als fertiges SQL-Prädikat |
Getter (getLastLocationCustomer() …) |
Frontend*-Model |
Wert pro Zeile für die Anzeige |
EXPORT_OTHER_FIELDS |
Model-Konstante | Feld im Export mitnehmen |
getOtherFieldsFromFormula() ist der Dreh- und Angelpunkt und existiert in
sechs Modellen: FrontendInventory, FrontendCalibration, FrontendRepair,
FrontendBooking, CategoryModel (Basis) und Tickets.
Zwei Eigenschaften folgen direkt aus dem Mechanismus: es gibt keinen Schreibweg (die Felder sind per Konstruktion read-only), und für jedes Feld muss der SQL-Ausdruck einzeln hingeschrieben werden. Nichts davon ist generisch.
2. Bestandsaufnahme¶
A. „Aktiver / letzter Datensatz" über eine 1:n-Relation¶
Das ist die Klasse, um die es in der Frage geht: ein Wert aus einer Kind-Tabelle, ausgewählt über ein Aktiv-Kennzeichen.
| V1-Feld | Quelle | Relation | Anzahl Grids |
|---|---|---|---|
duedate |
C2303 (Fälligkeitsdatum) |
activeCalibrationGrid (C2339 = 1) |
14 |
lastcal |
C2301 (Kalibrierdatum) |
activeCalibrationGrid |
12 |
last_order_number |
C2314 (Auftragsnummer) |
activeCalibrationGrid |
12 |
last_certificate |
certificate.name |
lastCalibration mit Scope lastCert |
7 |
last_location_customer |
Kundenlabel | lastRental → lastUser |
10 |
last_delivery_customer |
Lieferkundenlabel | lastRental → lastDeliveryUser |
10 |
last_location_L2801 |
L2801 (Standort 1) |
lastRental |
Migration m260430_100000 |
last_location_L2802 |
L2802 (Standort 2) |
lastRental |
Migration m260430_100000 |
¹ Das einzige Feld dieser Klasse, dem V2 bewusst nicht folgt. V1 nimmt die Auftragsnummer, die am Kalibrierdatensatz hängt — eine Kopie, die der METTEAM-Sync dorthin schreibt und die bei Installationen ohne METTEAM leer bleibt. V2 löst stattdessen über das Auftragsmodul auf, siehe ADR-022.
Wichtig für das Verständnis des Namens: lastRental ist nicht die zeitlich
letzte Standortzeile, sondern die mit L2815 = 1 markierte:
'lastRental' => array(
self::HAS_ONE, 'FrontendLocation', 'MTAG',
'joinType' => 'INNER JOIN',
'condition' => 'L2815 = 1',
'together' => true
),
„Letzter Standort" heißt in V1 also tatsächlich „aktuell gültig gesetzter
Standort". Dasselbe bei den Kalibrierfeldern: activeCalibrationGrid filtert
auf C2339 = 1, nicht auf ein Maximum-Datum. Die last_*-Namen sind
historisch, die Semantik ist „aktiv".
Verteilung: die Felder hängen nicht nur am Inventargrid, sondern an allen
Grids, die Inventardaten zeigen (type_inventory, customer_inventory,
child_inventory, master_inventory, die vier assign_inventory*-Grids,
inventory_repair, repair) und duedate zusätzlich an notepad und
standards. last_location_customer / last_delivery_customer stehen
außerdem auf calibration, inventory_calibration und last_calibration.
Die beiden last_location_L28xx-Felder fehlen in der Werksvorgabe
(field_configuration.sql) und kommen nur über die Yii-Migration
m260430_100000 in Bestandssysteme.
B. Kategorie¶
| V1-Feld | Quelle | Grids |
|---|---|---|
category_name |
categories.name über category_item (m:n) |
19 |
assigned |
EXISTS in category_item, 0/1 |
14 |
rentable |
categories.rentable |
12 |
assigned ist ein Sonderfall: das Feld hat keinen Wert, nur eine
condition-Variante für 0 und 1. Es dient ausschließlich dem Filter
„Kategorie zugewiesen ja/nein".
C. Fremdtabellen-Spiegel (1:1-Join, gleiche Spalte, andere Tabelle)¶
Mengenmäßig die größte Klasse, konzeptionell die langweiligste: V1 registriert die Spalten der verbundenen Tabelle einzeln als Feld des Grids.
| Grid | gespiegelte Felder | aus |
|---|---|---|
inventory und Varianten |
K4601–K4640, KDSRC, KPUBLISH, kreplic |
customers |
inventory und Varianten |
T4101–T4110, typeName, type_created_by |
types (+ user) |
calibration, last_calibration, location, repair |
komplettes I42xx-Set, idsrc, IPUBLISH, replic, ktag |
inventory |
calibration, last_calibration, repair, location |
K46xx-Set, T41xx-Set, type_name, type_created_by |
customers, types |
booking |
K46xx, KTAG, billing_customer (invoiceCustomer.K4602), delivery_customer (deliveryCustomer.K4602) |
customers |
contact |
K4602–K4620, KTAG |
customers |
customers |
price_group (price_category.name über price_cat_uID) |
price_category |
types |
createdBy |
user |
Im Inventargrid allein sind das rund 55 der 74 nicht-physischen Felder.
D. DMS-Dokumentfelder¶
inventory_doc, inventory_doc_1–_4, inventory_img, inventory_img_1,
repair_doc, repair_doc_1–_2, certificate, certificate_1–_3,
wiki_doc.
Diese Felder schreibt V1 dynamisch: AdminFileSetting::afterSave() ruft
FrontendProperty::addFieldConfiguration(), jede Dokumentkategorie wird zur
Feldzeile.
E. Status-Ableitungen¶
| V1-Feld | Bedeutung |
|---|---|
web_status (20 Grids) |
Zweitspalte auf dasselbe Statusfeld, gerendert als Badge mit alternative_title, Farbe und Icon aus status |
inventory_processing_time, _start, _stop (je _1-Variante) |
Bearbeitungsdauer aus der audit-Historie über die Start/Stop-Statusregeln aus AdminStatusSetting |
Die Processing-Time-Felder existieren pro Bereich unter eigenem Namen:
calibration_processing_time* (umbenannt durch m180627_094545),
support_tickets_processing_time*.
web_status ist kein abgeleiteter Wert im engeren Sinn, sondern eine zweite
Darstellung derselben Spalte. Es existiert nur, weil V1 keinen Weg hatte, eine
Spalte doppelt mit unterschiedlichem Renderer zu zeigen.
F. Einzelfälle¶
| V1-Feld | Grid | Quelle |
|---|---|---|
mssql_sync |
inventory |
METTEAM-Sync-Flag aus inventory_sync |
price_info |
assign_inventory_booking |
Preisinfo aus prices |
article_overview |
booking |
Artikelliste als Text |
is_existed |
dms, download_dms, procedure_templates, assign_procedure_templates |
„schon zugewiesen" ja/nein |
favorite, contact |
11–12 Kontakt-Grids | Favoritenkennzeichen, Kontaktzuordnung |
customer_count, user_count |
Gruppen-Grids | Zählfelder |
ticket_count, commentCount |
Support-Grids | Zählfelder |
alias |
7 Support-Kataloge | Anzeigename |
standards |
procedures, type_procedure |
Normenliste |
3. V2-Stand¶
V2 hat für abgeleitete Felder ein eigenes Konstrukt: field_definitions-Zeilen
mit storage = 'virtual'. Der Wert wird beim Lesen pro Zeile aufgelöst, das
Feld verhält sich in Feldverwaltung und Spaltenauswahl wie jedes andere.
FieldStorageResolver (laravel/app/Services/FieldManagement/) klassifiziert,
welches Feld welche Speicherart hat.
Schon umgesetzt¶
| V1-Feld | V2 | Umsetzung |
|---|---|---|
category_name |
category_name |
Support/CategoryField.php + Concerns/HasCategoryAssignment.php |
| DMS-Dokumentfelder | dieselben Namen | FieldManagement/DocumentFieldSyncService.php + Concerns/ResolvesDocumentLinks.php |
last_location_L2801 |
current_location_1 |
Support/CurrentLocationField.php + Concerns/ResolvesCurrentLocation.php (ADR-019) |
last_location_L2802 |
current_location_2 |
dito |
last_location_customer |
current_location_customer |
dito |
last_delivery_customer |
current_location_delivery_customer |
dito |
lastcal |
current_calibration_date |
Support/CurrentCalibrationField.php + Concerns/ResolvesCurrentCalibration.php (ADR-020) |
duedate |
current_calibration_due_date |
dito |
last_order_number |
last_order_number |
Support/LastOrderField.php + Concerns/ResolvesLastOrder.php (ADR-022) — Quelle ist das Auftragsmodul, nicht die Kalibrierung |
last_certificate |
last_certificate |
Support/LastCertificateField.php + Concerns/ResolvesLastCertificate.php (ADR-026) — Zertifikat der aktiven Kalibrierung aus dem DMS |
Die V1-Codes leben in v1_name weiter, die api_names sind lesbar. Der
Import-/Sync-Rand und die Übersetzung alter Grid-Layouts hängen an v1_name —
die mitgelieferte V1-Ansicht „Kalibrieransicht" listet z. B. lastcal,duedate
namentlich.
Alle drei Resolver-Paare teilen den Anwendungs-Pfad im FilterService (Filter
über eine HasOne-Relation, „ist leer" als exaktes Komplement über
whereDoesntHave) und unterscheiden sich nur in der Auflösung. Zwei Punkte
weichen ab, beide von den Daten erzwungen:
- Die Kalibrierfelder tragen ihren echten
field_type(Date) statt eines Marker-Typs, damit das Grid einen Datumsfilter anbietet statt einer Substring-Box (ADR-020). - Der letzte Auftrag hat kein Aktiv-Kennzeichen, an dem er sich festhalten
könnte — die Relation sortiert (
bookings.date,created_atals Gleichstand-Brecher), statt zu filtern, und geht über zwei Tabellen (HasOneThroughüber die Auftragspositionen) statt über eine (ADR-022).
Beim dritten Vorkommen war der Punkt erreicht, an dem die geteilte Mechanik in
ein gemeinsames Gerüst gehört — erledigt mit ADR-023: VirtualRelationField
trägt sie, VirtualRelationFields::all() ist die eine Liste, über die
Storage-Resolver, Registry-Accessor und Filter iterieren. Eine Familie
deklariert nur noch ihre Feld-Map, ihre Tabellen und ihre Relation; verschieden
bleiben Wertlesung, Filterziel und Eager Loads, weil die Familien dort wirklich
verschieden sind. Eine vierte Familie ist damit eine Subklasse plus eine Zeile.
Namensfalle, die beim Bauen zweimal auffällt: inventory.next_calibration_date
ist I4221, die eigene Spalte des Geräts. Ein virtuelles Feld dieses Namens
wäre im FieldStorageResolver in den Spalten-Zweig gelaufen und stillschweigend
zum Alias darauf geworden — daher das Präfix current_calibration_.
Bewusst anders gelöst (kommt nicht als abgeleitetes Feld)¶
- Klasse C (Fremdtabellen-Spiegel) →
Support/GridRelations.phpplus{feld}_label(ADR-008). Statt 40 gespiegelterK46xx-Spalten im Inventargrid gibt es einen FKcustomer_idmitcustomer_id_label.ReferenceLabelCoverageTestbricht CI, wenn ein Referenzfeld kein Label liefert. Das ist die richtige Antwort auf diese Klasse und sollte so bleiben. web_status→statusmitstatus_label, Farbe und Icon überResolvesStatusLabel. Eine Spalte, ein Renderer, kein Duplikat.
Offen¶
| V1-Feld | Lage in V2 |
|---|---|
assigned, rentable |
Kein Gegenstück. Beide hängen an der Kategoriezuordnung, die in V2 existiert. |
inventory_processing_time* |
Kein Gegenstück; die Status-Start/Stop-Regeln sind in V2 nicht abgebildet. |
mssql_sync, price_info, article_overview |
Kein Gegenstück. |
price_group (customers) |
Weder Feld noch GridRelations-Eintrag. Gehört in Klasse C, also als price_category_id + Label, nicht als abgeleitetes Feld. |
| Klasse F (Zähl-/Listenfelder) | Kein Gegenstück; betrifft Nebengrids. |
Warum nichts davon automatisch mitkommt¶
php artisan fields:generate-defaults verwirft jedes Feld, das keine physische
V1-Spalte ist, mit Zähler skipped['not_a_v1_column']. Der Kommentar im Code
nennt duedate und lastcal ausdrücklich. Jedes abgeleitete Feld muss also
einzeln nachgezogen werden. Genau das ist bei category_name und den drei
current_location_*-Feldern passiert, über die Seed-Migrationen
2026_07_29_090000_seed_category_field_definitions.php und
2026_07_31_000010_seed_current_location_field_definitions.php.
4. Wie ein abgeleitetes Feld nach V2 kommt¶
Muster aus ADR-019, sieben Schritte:
- Support-Klasse in
laravel/app/Support/als einzige Quelle der Wahrheit:api_name→ Quellspalte,v1_name,STORAGE = 'virtual', eigenerFIELD_TYPE, Liste der Registry-Tabellen. Vorbild:CurrentLocationField.php. FieldStorageResolverum eine Verzweigung ergänzen, damit das Feldstorage = 'virtual'bekommt und nicht als JSON-Feld fehlklassifiziert wird.- Resolver-Trait in
laravel/app/Models/Concerns/mit einer MethodexxxValues(): array<string, ?string>, die pro Zeileapi_name→ Wert liefert. Im API Resource in denarray_mergeder Attribute aufnehmen. - Eager Load über eine statische
xxxEagerLoads()-Methode, die leer zurückgibt, wenn kein Feld registriert ist. Am Index-Endpoint mitladen und mit einem Statement-Count-Test absichern, sonst wird daraus ein N+1. - Filter über die Relation. Bei Referenzen greift der Filter das Label,
nicht die UUID, damit ein getipptes „Musterfirma" das trifft, was das Grid
anzeigt. „Ist leer" als exaktes Komplement von „ist nicht leer"
(
whereDoesntHave), nicht als OR zweier Subqueries. - Registry-Zeile: Eintrag in
database/data/default_field_definitions.jsonplus Top-up-Seed-Migration für Bestandsinstallationen.grid_show_defaultauf 0 (V1-Parität),is_protectedauf 1. - Tests: Unit-Test für die Support-Klasse, Feature-Test für Auflösung, Filter und Eager Load.
Eine Yii-Migration ist nicht nötig: field_definitions ist eine
V2-only-Registry, V1 liest sie nicht. Nur wenn ein Feld in V1 gar nicht
existiert und dort auch auftauchen soll, kommt eine Yii-Migration hinzu.
Fallstricke¶
column_namebleibt im Namensraum der eigenen Entität.getColumnToApiNameMap()schlüsselt nachcolumn_nameohne nachstoragezu filtern. Schreibt man die Quellspalte der Fremdtabelle dort hin (customer_id), überschreibt das virtuelle Feld die echte FK der Entität und deren_labelverschwindet. Die Zuordnungapi_name→ Quellspalte gehört in die Support-Klasse.- Sortierbar nur mit Aktiv-Kennzeichen (ADR-024). Familien, deren
activeFlagColumn()eine Spalte nennt (Standort, Kalibrierung), sortieren über einen gruppierten Left Join auf genau diese Zeile — ein Durchlauf statt einer korrelierten Subquery pro Zeile;is_activewird dabei gefiltert, nicht sortiert, ein NULL fällt also auf jedem Treiber gleich heraus. Familien ohne Kennzeichen (letzter Auftrag: „zuletzt" ist das neueste Datum) bleibensortable = false, undapplySort()überspringt sie. - Nicht schreibbar. Ein Schreibweg müsste entscheiden, ob er den aktiven Kind-Datensatz ändert oder einen neuen anlegt. Beides schreibt Historie um.
- Eager-Load-Kosten fallen an, sobald das Feld registriert ist, unabhängig davon, ob die Spalte eingeschaltet ist. Die Alternative wäre, die Spaltenauswahl der laufenden Anfrage in die Eager-Load-Entscheidung zu ziehen und damit den Resource-Layer von der Grid-Konfiguration abhängig zu machen.
5. Empfehlung zur Reihenfolge¶
- ~~
last_delivery_customer→current_location_delivery_customer.~~ Erledigt. Vierte Spalte desselben aktiven Standorteintrags. Dabei ist die Verzweigung „Referenzspalte oder Rohwert" aus einemifaufCUSTOMER_COLUMNin die MapCurrentLocationField::RELATION_COLUMNSgewandert, aus der Resolver-Trait und Filter ihren Relationspfad ableiten. Das nächste Referenzfeld desselben Datensatzes ist damit ein Map-Eintrag. - ~~
duedate/lastcalalscurrent_calibration_*.~~ Erledigt. Support-KlasseCurrentCalibrationFieldund TraitResolvesCurrentCalibration, analog zuCurrentLocationField/ResolvesCurrentLocation; der Anwendungs-Pfad imFilterServiceist geteilt (applyActiveRecordConditions), nur die Auflösung unterscheidet sich. Neu gelernt: ein Marker-field_typeals Discriminator trägt nur, solange die Felder alle Text sind. Datumsfelder brauchen ihren echten Typ, sonst bekommt eine Datumsspalte eine Substring-Box. - ~~
last_order_number.~~ Erledigt — im zweiten Anlauf, anders als im ersten. Anlauf eins ist V1s Formel gefolgt (Auftragsnummer der aktiven Kalibrierung) und war bereits ausgeliefert, als die Fachseite widersprach: der Wert dort ist eine Kopie, die der METTEAM-Sync schreibt, und bleibt ohne METTEAM leer. Anlauf zwei löst über das Auftragsmodul auf (inventories → article → bookings,Support/LastOrderField.php+Concerns/ResolvesLastOrder.php, ADR-022); das Kalibrierfeld ist wieder ausgebaut, inklusive Rückbau-Migration für Installationen, die es schon bekommen haben. Was bleibt, ist diecalibrations.order_number-Spalte aus Anlauf eins — sie war nie das Problem, sondern hat dem Kalibriergrid seine eigene Auftragsnummer lesbar gemacht.
Zwei Lehren. Erstens: das Muster aus ADR-019/020 trägt auch ohne
Aktiv-Kennzeichen — was sich ändern musste, war die Auswahlregel (sortieren
statt filtern), nicht der Mechanismus. Zweitens, die teurere: V1-Parität ist
nicht immer das Ziel. Wo V1s Formel eine Näherung ist, gewinnt die Sache
selbst — und diese Frage gehört vor die Umsetzung, nicht hinter sie.
4. ~~Gemeinsames Gerüst für die drei Feldfamilien.~~ Erledigt (ADR-023).
Vorgezogen vor last_certificate, weil das sonst die vierte Kopie desselben
Gerüsts in fünf Dateien geworden wäre. Verhaltensneutral; der einzige Beleg
ist der Testlauf.
5. ~~Export für virtuelle Felder.~~ Erledigt, in zwei Schritten. Die
Auflösung kam mit PR #3288: ExportService geht über toApiAttributes()
statt toApiArray() und rendert jede Spalte über den GridDisplayResolver,
also genau so, wie die Zelle sie zeigt. Übrig blieb eine leisere Lücke —
Liste und Export haben die Feldfamilien beide von Hand aufgezählt und sind
auseinandergelaufen: der Export nannte noch zwei, als es drei waren. Nichts
ist dabei ausgefallen, die Werte kamen weiter, nur lazy: eine Query pro
exportiertem Datensatz. Beide fragen jetzt VirtualRelationFields::eagerLoadsFor(),
und ein Test läuft über die Registry statt über eine Aufzählung.
Bekannte Grenze: der Test prüft den Inventar-Export. Sollte eine Familie
einmal an einem anderen Grid hängen, braucht dessen Export denselben Aufruf —
und einen eigenen Testfall.
6. ~~last_certificate.~~ Erledigt (ADR-026) — das letzte Feld der
Klasse A. Erste Familie, die zwei Relationen weit liest: die Relation bleibt
activeCalibration (V1s lastCalibration trägt denselben Aktiv-Scope), den
Sprung zum DMS-Dokument macht die Familie in readValue()/eagerLoads().
Das Gerüst aus ADR-023 hat dabei seinen Belastungstest bestanden: kein
Verbraucher musste angefasst werden, und die registryweiten Tests haben die
neue Familie ohne Zutun mitgeprüft. Neu gelernt: der uuid-gegen-varchar-Cast
ist zum dritten Mal aufgetreten (Kategorie-Pivot, Auftrag, Zertifikat) und
hat immer dieselbe Form — diesmal stand der SQL-Form-Test vor dem Push statt
danach.
7. rentable und assigned an CategoryField anhängen, wenn der Bedarf
belegt ist. assigned ist reiner Filter und passt möglicherweise besser als
Filteroperator auf category_name („ist leer" / „ist nicht leer") als als
eigenes Feld.
8. price_group als Klasse-C-Fall über GridRelations lösen
(price_category_id + Label), nicht als abgeleitetes Feld.
9. Alles Übrige (Processing-Time, mssql_sync,
price_info, article_overview, Zählfelder) erst bei belegtem Bedarf.
ADR-016 hat gerade Spalten abgebaut; ohne Bedarf welche anzulegen wäre
dasselbe Zuviel.