Update documentation for Junction Table UI-Integration and document propagation patterns; include new features and best practices for sync status management

This commit is contained in:
2026-03-11 20:38:45 +01:00
parent e7b14406fb
commit b2c391539d
3 changed files with 622 additions and 5 deletions

View File

@@ -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
<?php
namespace Espo\Custom\Hooks\CVmhRumungsklage;
use Espo\ORM\Entity;
use Espo\Core\Hook\Hook\AfterRelate;
use Espo\Core\Hook\Hook\AfterUnrelate;
class PropagateDocuments implements AfterRelate, AfterUnrelate
{
private static array $processing = [];
public function __construct(
private \Espo\ORM\EntityManager $entityManager
) {}
public function afterRelate(
Entity $entity,
string $relationName,
Entity $foreignEntity,
array $columnData,
\Espo\ORM\Repository\Option\RelateOptions $options
): void {
if ($relationName !== 'dokumentesvmhraumungsklage') {
return;
}
// Loop-Schutz: Eindeutiger Key pro Operation
$key = $entity->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:**