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
|
# EspoCRM Best Practices & Entwicklungsrichtlinien
|
||||||
|
|
||||||
**Version:** 2.2
|
**Version:** 2.3
|
||||||
**Datum:** 10. März 2026
|
**Datum:** 11. März 2026
|
||||||
**Zielgruppe:** AI Code Agents & Entwickler
|
**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)
|
## 🔄 Letzte Änderungen (v2.2 - 10. März 2026)
|
||||||
|
|
||||||
**Kritische Erkenntnisse:**
|
**Kritische Erkenntnisse:**
|
||||||
@@ -650,6 +666,129 @@ POST /api/v1/CAICollectionCDokumente
|
|||||||
|
|
||||||
**WICHTIG:** additionalColumns funktionieren NICHT über Standard-Relationship-Endpoints! Nur über Junction-Entity-API!
|
**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)
|
### 4. Parent Relationship (belongsToParent)
|
||||||
|
|
||||||
**Beispiel:** Dokument kann zu Räumungsklage ODER Mietinkasso gehören
|
**Beispiel:** Dokument kann zu Räumungsklage ODER Mietinkasso gehören
|
||||||
@@ -897,6 +1036,148 @@ public function __construct(
|
|||||||
- `Acl` - ACL-Prüfungen
|
- `Acl` - ACL-Prüfungen
|
||||||
- `User` - Aktueller Benutzer
|
- `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
|
### Praxis-Beispiele aus dem Projekt
|
||||||
|
|
||||||
#### Beispiel 1: Daten-Validierung & Normalisierung (CBankverbindungen)
|
#### 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...)
|
- [ ] Action-Methode korrekt benannt? (postAction..., getAction...)
|
||||||
- [ ] ACL-Rechte?
|
- [ ] 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)
|
### ⚠️ KRITISCH: InjectableFactory Error (Service-Klasse fehlt)
|
||||||
|
|
||||||
**Fehlermeldung in Logs:**
|
**Fehlermeldung in Logs:**
|
||||||
|
|||||||
@@ -301,6 +301,60 @@ cat custom/docs/ESPOCRM_BEST_PRACTICES.md | grep -A 200 "Troubleshooting"
|
|||||||
- i18n fehlt → beide Sprachen anlegen
|
- i18n fehlt → beide Sprachen anlegen
|
||||||
- Relationship kaputt → bidirektional prüfen
|
- Relationship kaputt → bidirektional prüfen
|
||||||
- ACL 403 → Rechte in Admin UI
|
- 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`
|
**Für Fragen oder Updates:** Siehe `custom/docs/ESPOCRM_BEST_PRACTICES.md`
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
# Many-to-Many Junction-Tabelle mit additionalColumns - Testergebnisse
|
# 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!
|
## ✅ 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
|
## 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
|
## ✅ Was funktioniert
|
||||||
|
|
||||||
@@ -282,6 +291,195 @@ response = requests.put(
|
|||||||
- ✅ Vollständige CRUD via `/api/v1/CAICollectionCDokumente`
|
- ✅ Vollständige CRUD via `/api/v1/CAICollectionCDokumente`
|
||||||
- ❌ NICHT in CDokumente detail view als relationship panel anzeigen
|
- ❌ 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
|
||||||
|
<?php
|
||||||
|
namespace Espo\Custom\Hooks\CAdvowareAkten;
|
||||||
|
|
||||||
|
use Espo\ORM\Entity;
|
||||||
|
use Espo\Core\Hook\Hook\AfterRelate;
|
||||||
|
|
||||||
|
class DokumenteSyncStatus implements AfterRelate
|
||||||
|
{
|
||||||
|
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 !== 'dokumentes') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setze Junction-Spalten via updateColumns()
|
||||||
|
$repository = $this->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
|
## 🎯 Fazit
|
||||||
|
|
||||||
Die **Junction-Tabelle mit `additionalColumns` ist vollständig via REST-API nutzbar**!
|
Die **Junction-Tabelle mit `additionalColumns` ist vollständig via REST-API nutzbar**!
|
||||||
|
|||||||
Reference in New Issue
Block a user