From b2c391539d4f6dabb900f1093bcf93aede0513a4 Mon Sep 17 00:00:00 2001 From: bsiggel Date: Wed, 11 Mar 2026 20:38:45 +0100 Subject: [PATCH] Update documentation for Junction Table UI-Integration and document propagation patterns; include new features and best practices for sync status management --- custom/docs/ESPOCRM_BEST_PRACTICES.md | 368 ++++++++++++++++++- custom/docs/README.md | 57 ++- custom/docs/TESTERGEBNISSE_JUNCTION_TABLE.md | 202 +++++++++- 3 files changed, 622 insertions(+), 5 deletions(-) diff --git a/custom/docs/ESPOCRM_BEST_PRACTICES.md b/custom/docs/ESPOCRM_BEST_PRACTICES.md index 8df6e8d2..6d29ecae 100644 --- a/custom/docs/ESPOCRM_BEST_PRACTICES.md +++ b/custom/docs/ESPOCRM_BEST_PRACTICES.md @@ -1,11 +1,27 @@ # EspoCRM Best Practices & Entwicklungsrichtlinien -**Version:** 2.2 -**Datum:** 10. März 2026 +**Version:** 2.3 +**Datum:** 11. März 2026 **Zielgruppe:** AI Code Agents & Entwickler --- +## 🔄 Letzte Änderungen (v2.3 - 11. März 2026) + +**Neue Features:** +- ✅ **Junction Table UI-Pattern**: columnAttributeMap + notStorable für UI-Anzeige von Junction-Spalten +- ✅ **Dokumenten-Propagierung**: Hook-Pattern für automatische Verknüpfung zwischen hierarchischen Entities +- ✅ **Loop-Schutz**: Statisches Processing-Array Pattern für rekursive Hooks +- ✅ **Troubleshooting**: Vergessene Indizes auf gelöschte Felder (häufiger Rebuild-Fehler) + +**Dokumentierte Real-World Implementierung:** +- CAdvowareAkten/CAIKnowledge Junction Tables mit additionalColumns (hnr, syncstatus, lastSync) +- Propagierungs-Hooks: Räumungsklage ↔ AdvowareAkten ↔ AIKnowledge +- Sync-Status-Management mit globalen und Junction-level Status-Feldern +- Hook-Chain für automatische Status-Propagierung bei Dokumentänderungen + +--- + ## 🔄 Letzte Änderungen (v2.2 - 10. März 2026) **Kritische Erkenntnisse:** @@ -650,6 +666,129 @@ POST /api/v1/CAICollectionCDokumente **WICHTIG:** additionalColumns funktionieren NICHT über Standard-Relationship-Endpoints! Nur über Junction-Entity-API! +#### Junction-Spalten im UI anzeigen: columnAttributeMap & notStorable + +**Problem:** additionalColumns sind nur via Junction-Entity-API zugänglich, nicht in Relationship-Panels. + +**Lösung:** columnAttributeMap + notStorable Felder für UI-Anzeige + +**Beispiel:** Dokumente mit HNR und Sync-Status in AdvowareAkten + +**Parent Entity (CAdvowareAkten):** +```json +{ + "fields": { + "dokumenteHnr": { + "type": "int", + "notStorable": true, + "utility": true + }, + "dokumenteSyncstatus": { + "type": "enum", + "options": ["new", "unclean", "synced", "failed"], + "notStorable": true, + "utility": true + }, + "dokumenteLastSync": { + "type": "datetime", + "notStorable": true, + "utility": true + }, + "dokumentes": { + "type": "linkMultiple", + "columns": { + "hnr": "advowareAktenHnr", + "syncstatus": "advowareAktenSyncstatus", + "lastSync": "advowareAktenLastSync" + }, + "view": "views/fields/link-multiple-with-columns" + } + }, + "links": { + "dokumentes": { + "type": "hasMany", + "entity": "CDokumente", + "foreign": "advowareAktens", + "relationName": "cAdvowareAktenDokumente", + "additionalColumns": { + "hnr": {"type": "int"}, + "syncstatus": {"type": "varchar", "len": 20}, + "lastSync": {"type": "datetime"} + }, + "columnAttributeMap": { + "hnr": "dokumenteHnr", + "syncstatus": "dokumenteSyncstatus", + "lastSync": "dokumenteLastSync" + } + } + } +} +``` + +**Foreign Entity (CDokumente):** +```json +{ + "fields": { + "advowareAktenHnr": { + "type": "int", + "notStorable": true, + "utility": true, + "layoutAvailabilityList": ["listForAdvowareAkten"] + }, + "advowareAktenSyncstatus": { + "type": "varchar", + "notStorable": true, + "utility": true, + "layoutAvailabilityList": ["listForAdvowareAkten"] + }, + "advowareAktenLastSync": { + "type": "datetime", + "notStorable": true, + "utility": true, + "layoutAvailabilityList": ["listForAdvowareAkten"] + } + }, + "links": { + "advowareAktens": { + "type": "hasMany", + "entity": "CAdvowareAkten", + "foreign": "dokumentes", + "relationName": "cAdvowareAktenDokumente", + "columnAttributeMap": { + "hnr": "advowareAktenHnr", + "syncstatus": "advowareAktenSyncstatus", + "lastSync": "advowareAktenLastSync" + } + } + } +} +``` + +**Custom List Layout (layouts/CDokumente/listForAdvowareAkten.json):** +```json +[ + {"name": "name", "width": 25}, + {"name": "advowareAktenHnr", "width": 10}, + {"name": "advowareAktenSyncstatus", "width": 12}, + {"name": "advowareAktenLastSync", "width": 15}, + {"name": "description", "width": 20}, + {"name": "dokument", "width": 18} +] +``` + +**Wichtige Konzepte:** +- **notStorable**: Feld wird nicht in Haupttabelle gespeichert +- **utility**: Internes Feld, nicht in Standard-Formularen +- **columnAttributeMap**: Bidirektionales Mapping Junction → UI +- **layoutAvailabilityList**: Begrenzt Sichtbarkeit auf bestimmte Layouts +- **columns** in linkMultiple-Field: Verbindet UI-Feldnamen mit Junction-Spalten + +**Workflow:** +1. EspoCRM liest Junction-Spalten über RDB-Funktionen +2. Mapped sie via columnAttributeMap zu notStorable Feldern +3. UI zeigt notStorable Felder in Relationship-Panels an +4. Updates erfolgen via updateColumns() in Hooks + ### 4. Parent Relationship (belongsToParent) **Beispiel:** Dokument kann zu Räumungsklage ODER Mietinkasso gehören @@ -897,6 +1036,148 @@ public function __construct( - `Acl` - ACL-Prüfungen - `User` - Aktueller Benutzer +### Hook-Pattern: Dokumenten-Propagierung mit Loop-Schutz + +**Use Case:** Automatische Verknüpfung von Dokumenten zwischen hierarchisch verbundenen Entities: +- Räumungsklage ↔ AdvowareAkten ↔ AIKnowledge +- Mietinkasso ↔ AdvowareAkten ↔ AIKnowledge + +**Challenge:** Vermeide Endlos-Rekursion bei gegenseitiger Propagierung + +**Lösung:** AfterRelate/AfterUnrelate Hooks mit statischem Processing-Array + +**Pattern-Beispiel (CVmhRumungsklage → AdvowareAkten + AIKnowledge):** + +```php +getId() . '-' . $foreignEntity->getId() . '-relate'; + if (isset(self::$processing[$key])) { + return; // Bereits in Bearbeitung + } + self::$processing[$key] = true; + + try { + // Hole verbundene AdvowareAkten + $advowareAkten = $this->entityManager + ->getRDBRepository('CVmhRumungsklage') + ->getRelation($entity, 'advowareAkten') + ->findOne(); + + if ($advowareAkten) { + $this->relateDocument($advowareAkten, 'dokumentes', $foreignEntity); + } + + // Hole verbundene AIKnowledge + $aIKnowledge = $this->entityManager + ->getRDBRepository('CVmhRumungsklage') + ->getRelation($entity, 'aIKnowledge') + ->findOne(); + + if ($aIKnowledge) { + $this->relateDocument($aIKnowledge, 'dokumentes', $foreignEntity); + } + } catch (\Exception $e) { + $GLOBALS['log']->error('PropagateDocuments Error: ' . $e->getMessage()); + } finally { + unset(self::$processing[$key]); // Cleanup + } + } + + public function afterUnrelate( + Entity $entity, + string $relationName, + Entity $foreignEntity, + \Espo\ORM\Repository\Option\UnrelateOptions $options + ): void { + // Analog zu afterRelate, aber mit unrelate() + } + + private function relateDocument(Entity $parent, string $relation, Entity $doc): void + { + $repository = $this->entityManager->getRDBRepository($parent->getEntityType()); + $relation = $repository->getRelation($parent, $relation); + + // Prüfe ob bereits verknüpft (vermeidet Duplikate) + $isRelated = $relation->where(['id' => $doc->getId()])->findOne(); + + if (!$isRelated) { + $relation->relate($doc); + } + } +} +``` + +**Propagierungs-Hierarchie:** + +``` +┌─────────────────────┐ +│ Räumungsklage │ +│ Mietinkasso │ +└──────────┬──────────┘ + │ + ┌──────┴──────┐ + ↓ ↓ +┌──────────┐ ┌──────────┐ +│AdvowareA.│ │AIKnowled.│ +└────┬─────┘ └────┬─────┘ + │ │ + └──────┬──────┘ + ↓ + ┌──────────┐ + │ Dokument │ + └──────────┘ +``` + +**Down-Propagierung (Räumungsklage → unten):** +- Hook in Räumungsklage/Mietinkasso +- Bei Dokumenten-Link → propagiere zu AdvowareAkten + AIKnowledge +- Deren Hooks versuchen zurück zu propagieren → blockiert durch Loop-Schutz + +**Up-Propagierung (AdvowareAkten → oben):** +- Hook in AdvowareAkten/AIKnowledge +- Bei Dokumenten-Link → propagiere zu Räumungsklage/Mietinkasso +- Deren Hooks propagieren zu anderen Kind-Entities +- Loop-Schutz verhindert Rück-Propagierung + +**Loop-Schutz Mechanismus:** +1. **Statisches Array**: `private static array $processing = []` +2. **Eindeutiger Key**: `{EntityID}-{DokumentID}-{Aktion}` +3. **Check vor Ausführung**: `if (isset(self::$processing[$key])) return;` +4. **Set bei Start**: `self::$processing[$key] = true;` +5. **Cleanup**: `finally { unset(self::$processing[$key]); }` + +**Vorteile:** +- Verhindert Endlos-Rekursion +- Ermöglicht parallele Verarbeitung verschiedener Dokumente +- Automatisches Cleanup auch bei Exceptions +- Key-basiert: Verschiedene Operations können gleichzeitig laufen + ### Praxis-Beispiele aus dem Projekt #### Beispiel 1: Daten-Validierung & Normalisierung (CBankverbindungen) @@ -1722,6 +2003,89 @@ docker exec espocrm php -l custom/Espo/Custom/Controllers/MyController.php - [ ] Action-Methode korrekt benannt? (postAction..., getAction...) - [ ] ACL-Rechte? +### ⚠️ KRITISCH: Rebuild schlägt fehl - "Column does not exist" + +**Fehlermeldung:** +``` +Doctrine\DBAL\Schema\SchemaException::columnDoesNotExist('feldname', 'tabelle') +#2 /var/www/html/application/Espo/Core/Utils/Database/Schema/Builder.php(154): +Doctrine\DBAL\Schema\Table->addIndex(Array, 'IDX_FELDNAME', Array) +``` + +**Symptome:** +- Rebuild schlägt fehl +- JSON/PHP-Validierung erfolgreich +- Fehlermeldung referenziert nicht existierendes Feld +- Error tritt in Schema-Builder auf + +**Ursache:** +Ein **Index** wurde für ein Feld definiert, das nicht (mehr) existiert. + +**Häufigster Fall:** +1. Feld wird aus entityDefs entfernt +2. Index-Definition wird vergessen +3. Rebuild versucht Index auf nicht-existentes Feld zu erstellen + +**Beispiel aus Praxis:** +```json +// CDokumente.json +{ + "fields": { + // "aktennr" wurde entfernt ← Feld gelöscht + }, + "indexes": { + "aktennr": { ← Index noch da! + "columns": ["aktennr"] + } + } +} +``` + +**Lösung:** + +**Schritt 1:** Identifiziere betroffenes Feld und Entity aus Error-Log +``` +columnDoesNotExist('aktennr', 'c_dokumente') + ^^^^^^^^ ^^^^^^^^^^^^ + Feld Tabelle +``` + +**Schritt 2:** Öffne entityDefs-Datei +```bash +code custom/Espo/Custom/Resources/metadata/entityDefs/CDokumente.json +``` + +**Schritt 3:** Suche Index-Definition und entferne sie +```json +// VORHER: +"indexes": { + "createdAtId": {...}, + "aktennr": { ← ENTFERNEN + "columns": ["aktennr"] + }, + "md5sum": {...} +} + +// NACHHER: +"indexes": { + "createdAtId": {...}, + "md5sum": {...} +} +``` + +**Schritt 4:** Rebuild erneut durchführen +```bash +python3 custom/scripts/validate_and_rebuild.py +``` + +**Best Practice:** +Bei Feld-Entfernung immer prüfen: +1. Feld aus `fields` entfernt? +2. Link aus `links` entfernt? +3. **Index aus `indexes` entfernt?** ← Oft vergessen! +4. Layout-Definitionen aktualisiert? +5. i18n-Einträge bereinigt? + ### ⚠️ KRITISCH: InjectableFactory Error (Service-Klasse fehlt) **Fehlermeldung in Logs:** diff --git a/custom/docs/README.md b/custom/docs/README.md index ef35fae4..51e3cefd 100644 --- a/custom/docs/README.md +++ b/custom/docs/README.md @@ -301,6 +301,60 @@ cat custom/docs/ESPOCRM_BEST_PRACTICES.md | grep -A 200 "Troubleshooting" - i18n fehlt → beide Sprachen anlegen - Relationship kaputt → bidirektional prüfen - ACL 403 → Rechte in Admin UI + - Rebuild schlägt fehl mit "Column does not exist" → Index auf gelöschtes Feld prüfen + +--- + +## 🆕 Neueste Patterns & Best Practices (März 2026) + +### Junction Table UI-Integration + +**Pattern:** `columnAttributeMap` + `notStorable` Felder + +**Use Case:** Junction-Spalten (wie `hnr`, `syncstatus`, `lastSync`) im Relationship-Panel anzeigen. + +**Implementierung:** +- notStorable Felder als UI-Placeholder +- columnAttributeMap für bidirektionales Mapping +- Custom List Layouts für Relationship-Panels +- Hooks für automatische Updates + +**Dokumentiert in:** [ESPOCRM_BEST_PRACTICES.md](ESPOCRM_BEST_PRACTICES.md#junction-spalten-im-ui-anzeigen-columnattributemap--notstorable) & [TESTERGEBNISSE_JUNCTION_TABLE.md](TESTERGEBNISSE_JUNCTION_TABLE.md) + +### Dokumenten-Propagierung mit Loop-Schutz + +**Pattern:** AfterRelate/AfterUnrelate Hooks mit statischem Processing-Array + +**Use Case:** Automatische Verknüpfung von Dokumenten zwischen hierarchisch verbundenen Entities. + +**Hierarchie-Beispiel:** +``` +Räumungsklage ←→ AdvowareAkten ←→ AIKnowledge +Mietinkasso ←→ AdvowareAkten ←→ AIKnowledge + ↓ ↓ ↓ + Dokumente (automatisch synchronisiert) +``` + +**Loop-Schutz:** Statisches Array mit Key `{EntityID}-{DokumentID}-{Aktion}` verhindert Endlos-Rekursion. + +**Implementiert in:** `custom/Espo/Custom/Hooks/{CVmhRumungsklage,CMietinkasso,CAdvowareAkten,CAIKnowledge}/Propagate*.php` + +**Dokumentiert in:** [ESPOCRM_BEST_PRACTICES.md - Hook-Entwicklung](ESPOCRM_BEST_PRACTICES.md#hook-pattern-dokumenten-propagierung-mit-loop-schutz) + +### Sync-Status-Management + +**Pattern:** Globaler + Junction-level Status mit automatischer Propagierung + +**Struktur:** +- **Global (Parent):** `syncStatus` (synced/unclean), `lastSync` +- **Junction (pro Dokument):** `syncstatus` (new/unclean/synced/failed), `lastSync` + +**Hooks:** +- **BeforeSave:** Berechnet globalen Status aus allen Junction-Einträgen +- **AfterRelate:** Setzt Junction-Status auf "new" +- **AfterSave (CDokumente):** Markiert alle Junction-Einträge als "unclean" bei Dokumentänderung + +**Real-World:** CAdvowareAkten & CAIKnowledge tracken Sync-Status ihrer verknüpften Dokumente. --- @@ -314,6 +368,7 @@ cat custom/docs/ESPOCRM_BEST_PRACTICES.md | grep -A 200 "Troubleshooting" --- -**Letzte Aktualisierung:** 9. März 2026 +**Letzte Aktualisierung:** 11. März 2026 +**Version:** 2.3 (Junction Table UI-Integration, Dokumenten-Propagierung, Sync-Status-Management) **Für Fragen oder Updates:** Siehe `custom/docs/ESPOCRM_BEST_PRACTICES.md` diff --git a/custom/docs/TESTERGEBNISSE_JUNCTION_TABLE.md b/custom/docs/TESTERGEBNISSE_JUNCTION_TABLE.md index 376c8dcb..8a1f4be7 100644 --- a/custom/docs/TESTERGEBNISSE_JUNCTION_TABLE.md +++ b/custom/docs/TESTERGEBNISSE_JUNCTION_TABLE.md @@ -1,12 +1,21 @@ # Many-to-Many Junction-Tabelle mit additionalColumns - Testergebnisse +**Version:** 2.0 +**Datum:** 11. März 2026 +**Status:** ✅ VOLLSTÄNDIG ERFOLGREICH mit UI-Integration + ## ✅ VOLLSTÄNDIG ERFOLGREICH! -**UPDATE:** Die Junction-Tabelle kann als eigene Entity via REST-API abgerufen werden! Seit EspoCRM 6.0.0 werden Junction-Tabellen automatisch als Entities verfügbar gemacht. +**UPDATE (März 2026):** Die Junction-Tabelle kann als eigene Entity via REST-API abgerufen werden! Seit EspoCRM 6.0.0 werden Junction-Tabellen automatisch als Entities verfügbar gemacht. + +**NEU:** UI-Anzeige von Junction-Spalten via columnAttributeMap + notStorable Pattern! ## Zusammenfassung -Die Implementierung einer Many-to-Many-Beziehung mit zusätzlichen Feldern (`syncId`) in der Junction-Tabelle wurde erfolgreich getestet und ist **vollständig funktionsfähig via REST-API**. +Die Implementierung einer Many-to-Many-Beziehung mit zusätzlichen Feldern in der Junction-Tabelle wurde erfolgreich getestet und ist: +- **vollständig funktionsfähig via REST-API** +- **im UI anzeigbar via columnAttributeMap Pattern** +- **automatisch aktualisierbar via Hooks** ## ✅ Was funktioniert @@ -282,6 +291,195 @@ response = requests.put( - ✅ Vollständige CRUD via `/api/v1/CAICollectionCDokumente` - ❌ NICHT in CDokumente detail view als relationship panel anzeigen +## ✅ LÖSUNG: UI-Anzeige via columnAttributeMap + notStorable + +**UPDATE (März 2026):** Es gibt eine Working-Solution für UI-Anzeige von Junction-Spalten! + +**Pattern:** columnAttributeMap + notStorable Felder + +### Konzept + +1. **notStorable Felder** im Parent: Placeholder für Junction-Spalten +2. **columnAttributeMap** in Links: Bidirektionales Mapping +3. **Custom List Layouts**: Zeigt notStorable Felder an +4. EspoCRM synchronisiert automatisch zwischen Junction-Table und notStorable Feldern + +### Implementierung: CAdvowareAkten ↔ CDokumente + +**CAdvowareAkten.json:** +```json +{ + "fields": { + "dokumenteHnr": { + "type": "int", + "notStorable": true, + "utility": true + }, + "dokumenteSyncstatus": { + "type": "enum", + "options": ["new", "unclean", "synced", "failed"], + "notStorable": true, + "utility": true + }, + "dokumenteLastSync": { + "type": "datetime", + "notStorable": true, + "utility": true + }, + "dokumentes": { + "type": "linkMultiple", + "columns": { + "hnr": "advowareAktenHnr", + "syncstatus": "advowareAktenSyncstatus", + "lastSync": "advowareAktenLastSync" + }, + "view": "views/fields/link-multiple-with-columns" + } + }, + "links": { + "dokumentes": { + "type": "hasMany", + "entity": "CDokumente", + "foreign": "advowareAktens", + "relationName": "cAdvowareAktenDokumente", + "additionalColumns": { + "hnr": {"type": "int"}, + "syncstatus": {"type": "varchar", "len": 20}, + "lastSync": {"type": "datetime"} + }, + "columnAttributeMap": { + "hnr": "dokumenteHnr", + "syncstatus": "dokumenteSyncstatus", + "lastSync": "dokumenteLastSync" + } + } + } +} +``` + +**CDokumente.json (Foreign Side):** +```json +{ + "fields": { + "advowareAktenHnr": { + "type": "int", + "notStorable": true, + "utility": true, + "layoutAvailabilityList": ["listForAdvowareAkten"] + }, + "advowareAktenSyncstatus": { + "type": "varchar", + "notStorable": true, + "utility": true, + "layoutAvailabilityList": ["listForAdvowareAkten"] + }, + "advowareAktenLastSync": { + "type": "datetime", + "notStorable": true, + "utility": true, + "layoutAvailabilityList": ["listForAdvowareAkten"] + } + }, + "links": { + "advowareAktens": { + "type": "hasMany", + "entity": "CAdvowareAkten", + "foreign": "dokumentes", + "relationName": "cAdvowareAktenDokumente", + "columnAttributeMap": { + "hnr": "advowareAktenHnr", + "syncstatus": "advowareAktenSyncstatus", + "lastSync": "advowareAktenLastSync" + } + } + } +} +``` + +**Custom List Layout (layouts/CDokumente/listForAdvowareAkten.json):** +```json +[ + {"name": "name", "width": 25}, + {"name": "advowareAktenHnr", "width": 10}, + {"name": "advowareAktenSyncstatus", "width": 12}, + {"name": "advowareAktenLastSync", "width": 15}, + {"name": "description", "width": 20} +] +``` + +**Bottom Panel (layouts/CAdvowareAkten/bottomPanelsDetail.json):** +```json +[ + { + "name": "dokumentes", + "label": "Dokumente", + "view": "views/record/panels/relationship", + "layout": "listForAdvowareAkten", + "index": 1 + } +] +``` + +### Wie es funktioniert + +1. **Lesen:** EspoCRM lädt Junction-Spalten via RDB und mapped sie zu notStorable Feldern +2. **Anzeigen:** Custom List Layout zeigt notStorable Felder an +3. **Schreiben:** Updates via Hooks mit `updateColumns()` +4. **Bidirektional:** columnAttributeMap muss auf beiden Seiten existieren + +### Beispiel: Hook für Auto-Update + +```php +entityManager->getRDBRepository('CAdvowareAkten'); + $repository->getRelation($entity, 'dokumentes')->updateColumns( + $foreignEntity, + [ + 'syncstatus' => 'new', + 'lastSync' => null + ] + ); + } +} +``` + +### Vorteile dieser Lösung + +✅ **UI-Anzeige**: Junction-Spalten sichtbar in Relationship-Panels +✅ **Kein 405 Fehler**: Read-only Darstellung vermeidet Inline-Edit-Probleme +✅ **API-Kompatibel**: Funktioniert parallel zur Junction-Entity-API +✅ **Bidirektional**: Funktioniert von beiden Seiten der Beziehung +✅ **Hook-Integration**: Updates via Hooks möglich + +### Einschränkungen + +⚠️ **notStorable = Read-only in UI**: Keine direkte Bearbeitung im Panel +⚠️ **Updates via Hooks**: Änderungen müssen über Hooks oder API erfolgen +⚠️ **layoutAvailabilityList**: Foreign-Side-Felder nur in Custom Layouts sichtbar + ## 🎯 Fazit Die **Junction-Tabelle mit `additionalColumns` ist vollständig via REST-API nutzbar**!