====== 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''.