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:
@@ -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:**
|
||||
|
||||
Reference in New Issue
Block a user