Skip to content

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 lastRentallastUser 10
last_delivery_customer Lieferkundenlabel lastRentallastDeliveryUser 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 K4601K4640, KDSRC, KPUBLISH, kreplic customers
inventory und Varianten T4101T4110, 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 K4602K4620, 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_at als 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.php plus {feld}_label (ADR-008). Statt 40 gespiegelter K46xx-Spalten im Inventargrid gibt es einen FK customer_id mit customer_id_label. ReferenceLabelCoverageTest bricht CI, wenn ein Referenzfeld kein Label liefert. Das ist die richtige Antwort auf diese Klasse und sollte so bleiben.
  • web_statusstatus mit status_label, Farbe und Icon über ResolvesStatusLabel. 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:

  1. Support-Klasse in laravel/app/Support/ als einzige Quelle der Wahrheit: api_name → Quellspalte, v1_name, STORAGE = 'virtual', eigener FIELD_TYPE, Liste der Registry-Tabellen. Vorbild: CurrentLocationField.php.
  2. FieldStorageResolver um eine Verzweigung ergänzen, damit das Feld storage = 'virtual' bekommt und nicht als JSON-Feld fehlklassifiziert wird.
  3. Resolver-Trait in laravel/app/Models/Concerns/ mit einer Methode xxxValues(): array<string, ?string>, die pro Zeile api_name → Wert liefert. Im API Resource in den array_merge der Attribute aufnehmen.
  4. 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.
  5. 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.
  6. Registry-Zeile: Eintrag in database/data/default_field_definitions.json plus Top-up-Seed-Migration für Bestandsinstallationen. grid_show_default auf 0 (V1-Parität), is_protected auf 1.
  7. 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_name bleibt im Namensraum der eigenen Entität. getColumnToApiNameMap() schlüsselt nach column_name ohne nach storage zu filtern. Schreibt man die Quellspalte der Fremdtabelle dort hin (customer_id), überschreibt das virtuelle Feld die echte FK der Entität und deren _label verschwindet. Die Zuordnung api_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_active wird dabei gefiltert, nicht sortiert, ein NULL fällt also auf jedem Treiber gleich heraus. Familien ohne Kennzeichen (letzter Auftrag: „zuletzt" ist das neueste Datum) bleiben sortable = false, und applySort() ü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

  1. ~~last_delivery_customercurrent_location_delivery_customer.~~ Erledigt. Vierte Spalte desselben aktiven Standorteintrags. Dabei ist die Verzweigung „Referenzspalte oder Rohwert" aus einem if auf CUSTOMER_COLUMN in die Map CurrentLocationField::RELATION_COLUMNS gewandert, aus der Resolver-Trait und Filter ihren Relationspfad ableiten. Das nächste Referenzfeld desselben Datensatzes ist damit ein Map-Eintrag.
  2. ~~duedate / lastcal als current_calibration_*.~~ Erledigt. Support-Klasse CurrentCalibrationField und Trait ResolvesCurrentCalibration, analog zu CurrentLocationField / ResolvesCurrentLocation; der Anwendungs-Pfad im FilterService ist geteilt (applyActiveRecordConditions), nur die Auflösung unterscheidet sich. Neu gelernt: ein Marker-field_type als Discriminator trägt nur, solange die Felder alle Text sind. Datumsfelder brauchen ihren echten Typ, sonst bekommt eine Datumsspalte eine Substring-Box.
  3. ~~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 die calibrations.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.