====== 2.2 ENIVERSCAFM Asset-Modell ====== //Stand: 2026-03-05// Übergeordnet: [[de:int:wvdsshell:notes:02-cafm:start|CAFM-Architektur — Gesamtübersicht]] Verwandt: [[de:int:wvdsshell:notes:02-cafm:system-layers|2.1 Systemebenen]] | [[de:int:wvdsshell:notes:02-cafm:migration|2.4 Migration]] ===== Kernkonzept: asset.item ===== ''asset.item'' ist die **universelle Tabelle für Technische Plätze und Equipment**. Beide Konzepte werden in derselben Tabelle geführt — der Unterschied liegt im ''status_code'' und der Verknüpfung über ''asset.item_installation''. ==== Tabellenstruktur ==== CREATE TABLE asset.item ( id INT NOT NULL IDENTITY PRIMARY KEY, legacy_id VARCHAR(50) NULL, -- Original-PK aus Quellsystem legacy_source VARCHAR(20) NULL, -- 'AMED','ENIVERS','TECDB',... type_code VARCHAR(20) NOT NULL, -- FK → asset.item_type.code status_code VARCHAR(20) NOT NULL, -- 'planned','active','decommissioned' item_number VARCHAR(50) NULL, -- Anlagenkennzeichen / EMR-Stelle name NVARCHAR(200) NOT NULL, description NVARCHAR(MAX) NULL, branch_id INT NULL, -- FK → org.branch location_id INT NULL, -- FK → org.location parent_id INT NULL, -- FK → asset.item.id (TP-Hierarchie) properties NVARCHAR(MAX) NULL, -- JSON: type-spezifische Felder is_active BIT NOT NULL DEFAULT 1, created_at DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(), updated_at DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(), created_by INT NULL, updated_by INT NULL ) ==== Befüllungsstand (Ist-Stand 2026-03-05) ==== ^ type_code ^ Anzahl Datensätze ^ Herkunft ^ | device | 11.652 | AMED APPMASTER (erwAppNr) | | motor | 8.461 | AMED MOTORMASTER | | instrument | — | Geplant (EZAMASTER-Migration) | | lighting | — | Geplant (tblLocationKV-Migration) | | hvac | — | Geplant | | safety | — | Geplant (AVI_Equip-Migration) | | electrical | — | Geplant (BESTAND/LD-Migration) | | piping | — | Geplant (TecDB-Migration) | | structure | — | Geplant (Standortstruktur) | ===== Metadaten-getriebenes Schema: asset.property_schema ===== ''asset.property_schema'' beschreibt **alle Felder** eines ''type_code''. Formulare, Validierungen und Views werden daraus generiert — ohne hardcodierte Logik. ==== Tabellenstruktur (Auszug) ==== CREATE TABLE asset.property_schema ( id INT NOT NULL IDENTITY PRIMARY KEY, type_code VARCHAR(20) NOT NULL, property_name VARCHAR(100) NOT NULL, display_name NVARCHAR(200) NOT NULL, display_group NVARCHAR(100) NULL, sql_type VARCHAR(20) NOT NULL, -- 'nvarchar','int','decimal','bit',... sql_length INT NULL, sql_precision INT NULL, sql_scale INT NULL, is_lookup BIT NOT NULL DEFAULT 0, lookup_schema VARCHAR(50) NULL, lookup_table VARCHAR(100) NULL, lookup_key_col VARCHAR(100) NULL, lookup_disp_col VARCHAR(100) NULL, is_required BIT NOT NULL DEFAULT 0, is_readonly BIT NOT NULL DEFAULT 0, is_unique BIT NOT NULL DEFAULT 0, default_value NVARCHAR(200) NULL, min_value NVARCHAR(50) NULL, max_value NVARCHAR(50) NULL, regex_pattern NVARCHAR(500) NULL, allowed_values NVARCHAR(MAX) NULL, -- JSON Array unit NVARCHAR(50) NULL, ui_widget VARCHAR(50) NULL, -- 'text','select','checkbox','date',... is_searchable BIT NOT NULL DEFAULT 1, is_filterable BIT NOT NULL DEFAULT 1, is_sortable BIT NOT NULL DEFAULT 1, is_exportable BIT NOT NULL DEFAULT 1, is_visible BIT NOT NULL DEFAULT 1, sort_order INT NOT NULL DEFAULT 0, legacy_column VARCHAR(100) NULL, -- Quellspalte im Altsystem is_active BIT NOT NULL DEFAULT 1 ) Aktueller Stand: **90 Einträge** (device-Typ vollständig; motor-Typ vollständig). ===== Automatische View-Generierung: trg_regen_type_views ===== Ein DDL-Trigger auf ''asset.property_schema'' generiert bei jeder Schema-Änderung automatisch eine typenspezifische View (z.B. ''asset.v_item_device''). ==== Vorteil gegenüber dynamischem SQL zur Laufzeit ==== ^ Ansatz ^ Plan Cache ^ Trigger-Zeitpunkt ^ Aufwand ^ | Dynamisches SQL (Laufzeit) | Nein | Jeder SELECT | Kein Plan-Cache möglich | | trg_regen_type_views | **Ja** | Schema-Änderung | einmalig pro Änderung | ==== Trigger-Logik (vereinfacht) ==== CREATE OR ALTER TRIGGER asset.trg_regen_type_views ON asset.property_schema AFTER INSERT, UPDATE, DELETE AS BEGIN SET NOCOUNT ON -- 1. Betroffene type_codes sammeln DECLARE @types TABLE (type_code VARCHAR(20)) INSERT INTO @types SELECT DISTINCT type_code FROM inserted UNION SELECT DISTINCT type_code FROM deleted -- 2. Pro type_code: CREATE OR ALTER VIEW generieren DECLARE @type VARCHAR(20), @sql NVARCHAR(MAX), @cols NVARCHAR(MAX) DECLARE cur CURSOR LOCAL FAST_FORWARD FOR SELECT type_code FROM @types OPEN cur; FETCH NEXT FROM cur INTO @type WHILE @@FETCH_STATUS = 0 BEGIN SET @cols = N'' SELECT @cols += N', ' + CASE ps.sql_type WHEN 'int' THEN 'CAST(JSON_VALUE(i.properties,''$.' + ps.property_name + ''') AS int)' WHEN 'float' THEN 'CAST(JSON_VALUE(i.properties,''$.' + ps.property_name + ''') AS float)' WHEN 'decimal' THEN 'CAST(JSON_VALUE(i.properties,''$.' + ps.property_name + ''') AS decimal(' + CAST(ps.sql_precision AS VARCHAR) + ',' + CAST(ps.sql_scale AS VARCHAR) + '))' WHEN 'bit' THEN 'CAST(JSON_VALUE(i.properties,''$.' + ps.property_name + ''') AS bit)' ELSE 'JSON_VALUE(i.properties,''$.' + ps.property_name + ''')' END + ' AS [' + ps.property_name + ']' FROM asset.property_schema ps WHERE ps.type_code = @type AND ps.is_active = 1 ORDER BY ps.sort_order SET @sql = N'CREATE OR ALTER VIEW asset.v_item_' + @type + N' AS SELECT i.id, i.item_number, i.name, i.description, i.type_code, i.status_code, i.branch_id, i.location_id, i.parent_id, i.legacy_id, i.legacy_source, i.is_active, i.created_at, i.updated_at' + @cols + N' FROM asset.item i WHERE i.type_code = ''' + @type + N'''' EXEC sp_executesql @sql FETCH NEXT FROM cur INTO @type END CLOSE cur; DEALLOCATE cur END Der Trigger feuert **nur bei Schema-Änderungen** (kein Laufzeit-Overhead). Generierte Views wie ''asset.v_item_device'' erhalten einen stabilen Query Plan. ===== Technischer Platz vs. Equipment: asset.item_installation ===== ==== Problem ==== ''asset.item.parent_id'' darf nur **strukturelle Hierarchie** ausdrücken (TP in TP). Die Frage "welches Equipment ist aktuell an diesem TP verbaut?" braucht eine separate Relation. ==== Lösung ==== CREATE TABLE asset.item_installation ( id INT NOT NULL IDENTITY PRIMARY KEY, fl_item_id INT NOT NULL, -- FK → asset.item (Technischer Platz) eq_item_id INT NOT NULL, -- FK → asset.item (Equipment) installed_from DATE NOT NULL, installed_until DATE NULL, -- NULL = aktuell verbaut removed_reason NVARCHAR(500) NULL, created_at DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(), created_by INT NULL, CONSTRAINT UQ_installation_active UNIQUE (fl_item_id, installed_until) -- nur ein aktives Equipment pro TP ) ==== TP ↔ Equipment Lebenszyklus ==== TP (asset.item, status='planned') │ ├── Equipment einbauen │ → item_installation (installed_from=heute, installed_until=NULL) │ → Equipment: status='active' │ → TP: status='active' │ ├── Equipment tauschen │ → item_installation.installed_until = heute (altes EQ) │ → neues item_installation für neues EQ │ └── TP außer Betrieb → TP: status='decommissioned' → offene item_installation wird geschlossen ==== Abfrage: aktuell verbautes Equipment ==== SELECT tp.item_number AS tp_nummer, tp.name AS tp_name, eq.item_number AS eq_nummer, eq.name AS eq_name, ii.installed_from FROM asset.item tp JOIN asset.item_installation ii ON ii.fl_item_id = tp.id AND ii.installed_until IS NULL JOIN asset.item eq ON eq.id = ii.eq_item_id WHERE tp.type_code IN ('device','motor') AND tp.is_active = 1 ===== Elektrische Verknüpfung: asset.electrical_connection ===== Verknüpft Schaltschrankabgang (''kv_number'', ''kv_code'') mit einem TP (''device_id'') und einem Motor (''motor_id''). CREATE TABLE asset.electrical_connection ( id INT NOT NULL IDENTITY PRIMARY KEY, outlet_id INT NULL, -- FK → asset.item (Schaltschrankabgang) device_id INT NULL, -- FK → asset.item (Gerät / TP) motor_id INT NULL, -- FK → asset.item (Motor / Equipment) kv_number NVARCHAR(50) NULL, -- Kraftverteiler-Nummer (aus LD) kv_code NVARCHAR(50) NULL, -- EMR-Stelle (aus LD BESTAND) cabinet NVARCHAR(100) NULL, switchhouse NVARCHAR(100) NULL, properties NVARCHAR(MAX) NULL -- JSON: weitere Felder ) Diese Tabelle **löst die Disziplin-Grenze** zwischen Mechanik (device/motor) und Elektrik (kv_code): Beide Perspektiven auf dasselbe physische Objekt sind in einem Record sichtbar. ===== Bausteine: Wiederverwendbare Eigenschaftsgruppen ===== Ein **Baustein** ist eine benannte Gruppe von Unter-Eigenschaften, die in mehreren ''type_code''-Schemata wiederverwendet werden. Beispiel: ''standort'' enthält immer Anlage, Betrieb, Gebäude, Stock, Zone — unabhängig davon, ob das Item ein ''device'', ''motor'' oder ''piping'' ist. ==== Erweiterung: asset.property_schema mit parent_code ==== -- Erweiterung der property_schema-Tabelle: ALTER TABLE asset.property_schema ADD parent_code VARCHAR(100) NULL -- NULL = einfache Eigenschaft -- non-NULL = Unter-Eigenschaft eines Bausteins -- REFERENCES asset.property_schema(property_name + type_code) Ein Baustein hat ''sql_type = 'COMPOSITE''' und ''parent_code = NULL''. Seine Unter-Eigenschaften haben ''parent_code = ''. ==== Beispiel: Baustein "standort" ==== -- Baustein-Definition (sql_type='COMPOSITE') INSERT INTO asset.property_schema (type_code, property_name, display_name, sql_type, parent_code, sort_order) VALUES ('*', 'standort', 'Standort', 'COMPOSITE', NULL, 10); -- Unter-Eigenschaften INSERT INTO asset.property_schema (type_code, property_name, display_name, sql_type, parent_code, sort_order) VALUES ('*', 'standort.anlage', 'Anlage', 'FK_LIST', 'standort', 1), -- list_type='ANLAGE' ('*', 'standort.betrieb', 'Betrieb', 'FK_LIST', 'standort', 2), -- list_type='BETRIEB' ('*', 'standort.gebaeude', 'Gebäude', 'nvarchar','standort', 3), ('*', 'standort.stock', 'Stock / Etage', 'nvarchar','standort', 4), ('*', 'standort.zone', 'Zone', 'nvarchar','standort', 5); Der Wert eines Bausteins wird als **verschachteltes JSON-Objekt** in ''asset.item.properties'' gespeichert: { "standort": { "anlage": "LDB2", "betrieb": "2", "gebaeude": "A", "stock": "EG", "zone": "01" }, "nennleistung": 15.5, "schutzart": "IP54" } ==== Trigger-Erweiterung für COMPOSITE ==== Der ''trg_regen_type_views''-Trigger muss für ''sql_type = 'COMPOSITE''' erweitert werden: -- Für COMPOSITE: JSON_QUERY statt JSON_VALUE (gibt Objekt zurück) WHEN 'COMPOSITE' THEN 'JSON_QUERY(i.properties, ''$.' + ps.property_name + ''')' -- Für Unter-Eigenschaften (parent_code IS NOT NULL): gepunkteter Pfad ELSE 'JSON_VALUE(i.properties, ''$.' + ps.parent_code + '.' + ps.property_name_short + ''')' ==== Weitere Bausteine (geplant) ==== ^ Baustein ^ Unter-Felder ^ Verwendung ^ | ''standort'' | anlage, betrieb, gebaeude, stock, zone | Alle item-Typen | | ''abmessung'' | laenge_mm, breite_mm, hoehe_mm, gewicht_kg | device, motor, piping | | ''elektrisch'' | spannung_v, strom_a, leistung_kw, frequenz_hz | motor, electrical, safety | | ''druckmedium''| medium, druck_bar, temperatur_c, nennweite_dn | piping (aus TecDB Medien/Faktoren)| | ''pruefzyklus''| iv_code, bz_code, gp_code, pv_code | Link zu PIMS inspection.rule | ===== Einheitliche Listen: shared.list_item ===== Alle Auswahllisten (Combo-Boxen, Enums, Lookup-Tabellen) werden in **einer einzigen Tabelle** geführt. Inspiriert vom WIS-Konzept (RuleKey/Rules), aber allgemeiner anwendbar. ==== Tabellenstruktur ==== CREATE TABLE shared.list_item ( id INT NOT NULL IDENTITY PRIMARY KEY, parent_id INT NULL REFERENCES shared.list_item(id), -- für hierarchische Listen list_type VARCHAR(50) NOT NULL, -- z.B. 'ANLAGE', 'ARBEIT_ART', 'SIL_KLASSE' code VARCHAR(20) NOT NULL, -- Kurzschlüssel: 'R', 'LDB2', 'SIL2' label NVARCHAR(200) NOT NULL, -- Anzeige-Text: 'Reparatur', 'LDB2 - Werk 2' value_type VARCHAR(10) NOT NULL DEFAULT 'VARCHAR', -- INT, DATE, NUMERIC, DECIMAL, VARCHAR value_data NVARCHAR(500) NULL, -- gespeicherter Wert (als Text, via value_type konvertieren) sort_order SMALLINT NOT NULL DEFAULT 0, is_active BIT NOT NULL DEFAULT 1, description NVARCHAR(MAX) NULL, CONSTRAINT UQ_list_item_type_code UNIQUE (list_type, code) ) ''value_type'' gibt an, in welchen Datentyp ''value_data'' beim Lesen konvertiert wird. Das ''code''-Feld ist der maschinelle Schlüssel (für Programm-Logik); ''label'' ist der angezeigte Text (kann sprachabhängig erweitert werden). ==== Beispiel-Seed: Arbeitstypen (aus AMED Arbeitsart + ENIVERS ARBEIT.ART) ==== INSERT INTO shared.list_item (list_type, code, label, sort_order) VALUES ('ARBEIT_ART', 'R', 'Reparatur', 1), ('ARBEIT_ART', 'W', 'Wartung', 2), ('ARBEIT_ART', 'S', 'Störungsbeseitigung', 3), ('ARBEIT_ART', 'U', 'Wiederkehrende Überprüfung', 4), ('ARBEIT_ART', 'B', 'Betriebliche Anforderung', 5), ('ARBEIT_ART', 'P', 'Projektauftrag (Rep./Investition)',6), ('ARBEIT_ART', 'A', 'Wiederkehrende Arbeiten', 7), ('ARBEIT_ART', 'N', 'Normale Arbeiten', 8), ('ARBEIT_ART', 'V', 'Verbesserungsvorschlag', 9), ('ARBEIT_ART', 'K', 'Anlagenänderungen', 10); ==== Hierarchische Listen (parent_id) ==== Für Bäume wie Anlage → Betrieb → Betriebseinheit: -- Ebene 1: Anlage INSERT INTO shared.list_item (list_type, code, label) VALUES ('ANLAGE', 'LDB1', 'LDB1 - Werk 1'); INSERT INTO shared.list_item (list_type, code, label) VALUES ('ANLAGE', 'LDB2', 'LDB2 - Werk 2'); -- Ebene 2: Betrieb (parent_id → LDB2) INSERT INTO shared.list_item (list_type, code, label, parent_id) VALUES ('BETRIEB', '51', 'Betrieb 51 - Kompressor', (SELECT id FROM shared.list_item WHERE list_type='ANLAGE' AND code='LDB2')); ==== Nutzung in property_schema ==== Eigenschaften mit ''sql_type = 'FK_LIST''' referenzieren ''shared.list_item'' über ''list_type'': -- In asset.property_schema: -- sql_type='FK_LIST', lookup_table='shared.list_item', lookup_key_col='code', -- lookup_disp_col='label', regex_pattern='ANLAGE' ← enthält list_type Alternativ: neues Feld ''list_type_ref VARCHAR(50) NULL'' in ''asset.property_schema''.