feat: Refactor CPuls entity and related resources; enhance localization, update layouts, and improve validation logic

This commit is contained in:
2026-02-13 10:28:21 +01:00
parent e1a963ffab
commit bf7eaa965f
12 changed files with 175 additions and 191 deletions

View File

@@ -1,121 +1,90 @@
<?php <?php
namespace Espo\Custom\Api\CPuls; namespace Espo\Custom\Api\CPuls;
use Espo\Core\Api\Action; use Espo\Core\Api\Action;
use Espo\Core\Api\Request; use Espo\Core\Api\Request;
use Espo\Core\Api\Response; use Espo\Core\Api\Response;
use Espo\Core\Api\ResponseComposer;
use Espo\Core\Exceptions\BadRequest; use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Forbidden; use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\NotFound; use Espo\Core\Exceptions\NotFound;
use Espo\ORM\EntityManager;
class AbschliessenFuerTeam implements Action class AbschliessenFuerTeam implements Action
{ {
public function __construct( public function __construct(
private \Espo\ORM\EntityManager $entityManager, private EntityManager $entityManager
private \Espo\Core\Acl\Table $acl,
private \Espo\Entities\User $user,
private \Espo\Core\Utils\Log $log
) {} ) {}
public function process(Request $request): Response public function process(Request $request): Response
{ {
$pulsId = $request->getRouteParam('id'); $id = $request->getRouteParam('id');
$data = $request->getParsedBody(); if (!$id) {
$teamId = $data->teamId ?? null; throw new BadRequest('ID fehlt.');
if (!$pulsId || !$teamId) {
throw new BadRequest('pulsId oder teamId fehlt');
} }
// 1. Validierung: Ist User in diesem Team? $puls = $this->entityManager->getEntityById('CPuls', $id);
$userTeams = $this->user->getLinkMultipleIdList('teams');
if (!in_array($teamId, $userTeams)) {
throw new Forbidden('User nicht in angegebenem Team');
}
// 2. Lade Puls
$puls = $this->entityManager->getEntity('CPuls', $pulsId);
if (!$puls) { if (!$puls) {
throw new NotFound('Puls nicht gefunden'); throw new NotFound('Puls nicht gefunden.');
} }
// 3. Validierung: syncStatus = clean? // Prüfe, ob bereits finalisiert
if ($puls->get('syncStatus') !== 'clean') { if ($puls->get('status') === 'Finalisiert') {
throw new BadRequest('Puls hat neue Dokumente (unclean) - bitte warten Sie auf die KI-Analyse'); throw new Forbidden('Puls wurde bereits finalisiert.');
} }
// 4. Finde Zuordnung // Prüfe syncStatus
$zuordnung = $this->entityManager if ($puls->get('syncStatus') === 'unclean') {
->getRDBRepository('CPulsTeamZuordnung') throw new BadRequest('Neue Dokumente vorhanden. Bitte warten Sie auf die KI-Analyse.');
}
// Hole aktuelle Team-ID des Benutzers
$user = $this->entityManager->getEntityById('User', $request->getServerParam('ESPO_USER_ID'));
if (!$user) {
throw new Forbidden('Benutzer nicht gefunden.');
}
$teamIds = $user->getLinkMultipleIdList('teams');
if (empty($teamIds)) {
throw new Forbidden('Sie sind keinem Team zugeordnet.');
}
// Finde Zuordnung für erstes Team des Benutzers
$zuordnung = $this->entityManager->getRDBRepositoryByClass('CPulsTeamZuordnung')
->where([ ->where([
'pulsId' => $pulsId, 'pulsId' => $id,
'teamId' => $teamId, 'teamId' => $teamIds,
'aktiv' => true 'aktiv' => true
]) ])
->findOne(); ->findOne();
if (!$zuordnung) { if (!$zuordnung) {
throw new NotFound('Team-Zuordnung nicht gefunden oder nicht aktiv'); throw new Forbidden('Sie sind diesem Puls nicht zugeordnet oder nicht aktiv.');
} }
// 5. Bereits abgeschlossen? // Markiere Zuordnung als abgeschlossen
if ($zuordnung->get('abgeschlossen')) {
return Response::json([
'success' => true,
'message' => 'Bereits abgeschlossen',
'alreadyCompleted' => true
]);
}
// 6. Abschluss setzen
$zuordnung->set([ $zuordnung->set([
'abgeschlossen' => true, 'abgeschlossen' => true,
'abgeschlossenAm' => date('Y-m-d H:i:s'), 'abgeschlossenAm' => date('Y-m-d H:i:s'),
'abgeschlossenVonId' => $this->user->getId() 'abgeschlossenVon' => $user->getId()
]); ]);
$this->entityManager->saveEntity($zuordnung); $this->entityManager->saveEntity($zuordnung);
// 6.5. FIRST-READ-CLOSES: Finalisiere Block bei erstem Abschluss // FIRST-READ-CLOSES: Finalisiere den Puls sofort
if (!$puls->get('finalisiert')) { $puls->set([
$puls->set([ 'status' => 'Finalisiert',
'finalisiert' => true, 'finalisiertAm' => date('Y-m-d H:i:s'),
'finalisierungsGrund' => 'Erstes Team', 'finalisiertVon' => $user->getId()
'finalisiertAm' => date('Y-m-d H:i:s'), ]);
'finalisiertVonId' => $this->user->getId()
]);
$this->log->info("Block finalisiert durch erstes Team (Team {$teamId}, User {$this->user->getId()})");
}
// 7. Prüfe: Alle Teams abgeschlossen?
$offeneTeams = $this->entityManager
->getRDBRepository('CPulsTeamZuordnung')
->where([
'pulsId' => $pulsId,
'aktiv' => true,
'abgeschlossen' => false
])
->count();
// 8. Update Puls-Status
if ($offeneTeams === 0) {
$puls->set('status', 'Abgeschlossen');
} else {
$puls->set('status', 'Teilweise abgeschlossen');
}
$this->entityManager->saveEntity($puls); $this->entityManager->saveEntity($puls);
$this->log->info("Team {$teamId} hat Puls {$pulsId} abgeschlossen"); $GLOBALS['log']->info("Puls {$id} durch Team {$zuordnung->get('teamId')} finalisiert (First-Read-Closes).");
return Response::json([ return ResponseComposer::json([
'success' => true, 'success' => true,
'status' => $puls->get('status'), 'finalized' => true,
'finalisiert' => $puls->get('finalisiert'), 'message' => 'Puls wurde finalisiert (First-Read-Closes).'
'offeneTeams' => $offeneTeams
]); ]);
} }
} }

View File

@@ -2,6 +2,7 @@
namespace Espo\Custom\Hooks\CPuls; namespace Espo\Custom\Hooks\CPuls;
use Espo\ORM\Entity; use Espo\ORM\Entity;
use Espo\ORM\Repository\Option\SaveOptions;
use Espo\Core\Hook\Hook\BeforeSave; use Espo\Core\Hook\Hook\BeforeSave;
class UpdateTeamStats implements BeforeSave class UpdateTeamStats implements BeforeSave
@@ -10,7 +11,7 @@ class UpdateTeamStats implements BeforeSave
private \Espo\ORM\EntityManager $entityManager private \Espo\ORM\EntityManager $entityManager
) {} ) {}
public function beforeSave(Entity $entity, array $options): void public function beforeSave(Entity $entity, SaveOptions $options): void
{ {
// Zähle Dokumente // Zähle Dokumente
if ($entity->isNew() || $entity->isAttributeChanged('id')) { if ($entity->isNew() || $entity->isAttributeChanged('id')) {

View File

@@ -1,37 +1,35 @@
{ {
"labels": { "labels": {
"Create CPuls": "Puls erstellen",
"CPuls": "Puls", "CPuls": "Puls",
"cPuls": "Pulse" "CPulse": "Pulse",
}, "Create CPuls": "Puls erstellen",
"fields": { "name": "Name",
"name": "Bezeichnung",
"status": "Status", "status": "Status",
"syncStatus": "Synchronisations-Status", "syncStatus": "Synchronisierungs-Status",
"kiAnalyse": "KI-Analyse", "kiAnalyse": "KI-Analyse",
"zusammenfassung": "Zusammenfassung", "zusammenfassung": "Zusammenfassung",
"anzahlDokumente": "Anzahl Dokumente", "anzahlDokumente": "Anzahl Dokumente",
"anzahlTeamsAktiv": "Teams (aktiv)", "anzahlTeamsAktiv": "Teams aktiv",
"anzahlTeamsAbgeschlossen": "Teams (abgeschlossen)", "anzahlTeamsAbgeschlossen": "Teams abgeschlossen",
"finalisiert": "Finalisiert",
"finalisierungsGrund": "Finalisierungsgrund",
"finalisiertAm": "Finalisiert am", "finalisiertAm": "Finalisiert am",
"finalisiertVon": "Finalisiert von", "finalisiertVon": "Finalisiert von",
"mandantMitteilung": "Mitteilung an Mandant",
"mandantMitteilungText": "Mitteilungstext",
"parent": "Vorgang", "parent": "Vorgang",
"dokumente": "Dokumente", "assignedUser": "Zugewiesen"
"teamZuordnungen": "Team-Zuordnungen"
}, },
"links": { "links": {
"parent": "Vorgang",
"dokumente": "Dokumente", "dokumente": "Dokumente",
"teamZuordnungen": "Team-Zuordnungen" "teamZuordnungen": "Team-Zuordnungen"
}, },
"tooltips": { "tooltips": {
"syncStatus": "clean = KI-Analyse aktuell | unclean = Neue Dokumente, Analyse ausstehend", "syncStatus": "Status der KI-Analyse für neue Dokumente",
"kiAnalyse": "Automatisch generierte Zusammenfassung durch KI-Middleware", "kiAnalyse": "Vollständige Analyse der KI zu den Dokumenten",
"zusammenfassung": "Kurze Zusammenfassung für Listen-Ansicht", "zusammenfassung": "Kurze Zusammenfassung der KI-Analyse",
"finalisiert": "Block wurde geschlossen - neue Dokumente erzeugen automatisch einen neuen Block (First-Read-Closes Prinzip)", "finalisiertAm": "Zeitpunkt, zu dem dieser Puls finalisiert wurde",
"finalisierungsGrund": "Grund der Finalisierung: Erstes Team = Team hat abgeschlossen | Manuell = Admin-Aktion | Automatisch = System-Regel" "finalisiertVon": "Benutzer, der den Puls durch Abschluss finalisiert hat",
"mandantMitteilung": "Soll eine Mitteilung an den Mandanten versendet werden?",
"mandantMitteilungText": "Text der Mitteilung an den Mandanten"
}, },
"options": { "options": {
"status": { "status": {
@@ -40,19 +38,12 @@
"Bereit": "Bereit", "Bereit": "Bereit",
"In Review": "In Review", "In Review": "In Review",
"Teilweise abgeschlossen": "Teilweise abgeschlossen", "Teilweise abgeschlossen": "Teilweise abgeschlossen",
"Abgeschlossen": "Abgeschlossen" "Abgeschlossen": "Abgeschlossen",
"Finalisiert": "Finalisiert"
}, },
"syncStatus": { "syncStatus": {
"clean": "Aktuell", "clean": "Synchron",
"unclean": "Ausstehend" "unclean": "Ausstehend"
},
"finalisierungsGrund": {
"Erstes Team": "Erstes Team",
"Manuell": "Manuell",
"Automatisch": "Automatisch"
} }
},
"presetFilters": {
"meineOffenen": "Meine offenen Pulse"
} }
} }

View File

@@ -1,58 +1,49 @@
{ {
"labels": { "labels": {
"Create CPuls": "Create Pulse",
"CPuls": "Pulse", "CPuls": "Pulse",
"cPuls": "Pulses" "CPulse": "Pulses",
}, "Create CPuls": "Create Pulse",
"fields": {
"name": "Name", "name": "Name",
"status": "Status", "status": "Status",
"syncStatus": "Sync Status", "syncStatus": "Sync Status",
"kiAnalyse": "AI Analysis", "kiAnalyse": "AI Analysis",
"zusammenfassung": "Summary", "zusammenfassung": "Summary",
"anzahlDokumente": "Number of Documents", "anzahlDokumente": "Document Count",
"anzahlTeamsAktiv": "Teams (active)", "anzahlTeamsAktiv": "Active Teams",
"anzahlTeamsAbgeschlossen": "Teams (completed)", "anzahlTeamsAbgeschlossen": "Completed Teams",
"finalisiert": "Finalized",
"finalisierungsGrund": "Finalization Reason",
"finalisiertAm": "Finalized At", "finalisiertAm": "Finalized At",
"finalisiertVon": "Finalized By", "finalisiertVon": "Finalized By",
"parent": "Parent Record", "mandantMitteilung": "Client Notification",
"dokumente": "Documents", "mandantMitteilungText": "Notification Text",
"teamZuordnungen": "Team Assignments" "parent": "Case",
"assignedUser": "Assigned To"
}, },
"links": { "links": {
"parent": "Parent Record",
"dokumente": "Documents", "dokumente": "Documents",
"teamZuordnungen": "Team Assignments" "teamZuordnungen": "Team Assignments"
}, },
"tooltips": { "tooltips": {
"syncStatus": "clean = AI analysis up-to-date | unclean = New documents, analysis pending", "syncStatus": "AI analysis status for new documents",
"kiAnalyse": "Automatically generated summary by AI middleware", "kiAnalyse": "Complete AI analysis of the documents",
"zusammenfassung": "Short summary for list views", "zusammenfassung": "Brief summary of the AI analysis",
"finalisiert": "Block has been closed - new documents will automatically create a new block (First-Read-Closes principle)", "finalisiertAm": "Date and time when this pulse was finalized",
"finalisierungsGrund": "Reason for finalization: First Team = Team completed | Manual = Admin action | Automatic = System rule" "finalisiertVon": "User who finalized the pulse by completion",
"mandantMitteilung": "Should a notification be sent to the client?",
"mandantMitteilungText": "Text of the notification to the client"
}, },
"options": { "options": {
"status": { "status": {
"Neu": "New", "Neu": "New",
"In Verarbeitung": "Processing", "In Verarbeitung": "In Progress",
"Bereit": "Ready", "Bereit": "Ready",
"In Review": "In Review", "In Review": "In Review",
"Teilweise abgeschlossen": "Partially Completed", "Teilweise abgeschlossen": "Partially Completed",
"Abgeschlossen": "Completed" "Abgeschlossen": "Completed",
"Finalisiert": "Finalized"
}, },
"syncStatus": { "syncStatus": {
"clean": "Up-to-date", "clean": "Synchronized",
"unclean": "Pending" "unclean": "Pending"
},
"finalisierungsGrund": {
"Erstes Team": "First Team",
"Manuell": "Manual",
"Automatisch": "Automatic"
} }
},
"presetFilters": {
"meineOffenen": "My Open Pulses"
} }
} }

View File

@@ -1,17 +1,27 @@
{ {
"teamZuordnungen": { "_delimiter_": {
"disabled": true
},
"activities": {
"disabled": true
},
"history": {
"disabled": true
},
"_tabBreak_0": {
"index": 0, "index": 0,
"sticked": true, "tabBreak": true,
"style": "info", "tabLabel": "Dokumente"
"label": "Team-Zuordnungen"
}, },
"dokumente": { "dokumente": {
"index": 1, "index": 1
"sticked": false, },
"label": "Dokumente" "_tabBreak_1": {
"index": 2,
"tabBreak": true,
"tabLabel": "Aktivitäten"
}, },
"stream": { "stream": {
"index": 2, "index": 3
"sticked": false
} }
} }

View File

@@ -0,0 +1,11 @@
[
{
"name": ":assignedUser"
},
{
"name": "teams"
},
{
"name": "teamZuordnungen"
}
]

View File

@@ -1,6 +1,8 @@
[ [
{ {
"label": "Übersicht", "label": "Übersicht",
"style": "default",
"tabBreak": false,
"rows": [ "rows": [
[ [
{"name": "name"}, {"name": "name"},
@@ -14,10 +16,6 @@
{"name": "anzahlDokumente"}, {"name": "anzahlDokumente"},
{"name": "anzahlTeamsAktiv"} {"name": "anzahlTeamsAktiv"}
], ],
[
{"name": "finalisiert"},
{"name": "finalisierungsGrund"}
],
[ [
{"name": "zusammenfassung", "span": 2} {"name": "zusammenfassung", "span": 2}
] ]
@@ -25,6 +23,8 @@
}, },
{ {
"label": "KI-Analyse", "label": "KI-Analyse",
"style": "default",
"tabBreak": false,
"rows": [ "rows": [
[ [
{"name": "kiAnalyse", "span": 2} {"name": "kiAnalyse", "span": 2}
@@ -32,7 +32,24 @@
] ]
}, },
{ {
"label": "System", "label": "Mandant",
"style": "default",
"tabBreak": false,
"rows": [
[
{"name": "mandantMitteilung"},
{}
],
[
{"name": "mandantMitteilungText", "span": 2}
]
]
},
{
"label": "Metadaten",
"style": "default",
"tabBreak": true,
"tabLabel": "Erweitert",
"rows": [ "rows": [
[ [
{"name": "createdAt"}, {"name": "createdAt"},
@@ -41,6 +58,10 @@
[ [
{"name": "createdBy"}, {"name": "createdBy"},
{"name": "modifiedBy"} {"name": "modifiedBy"}
],
[
{"name": "finalisiertAm"},
{"name": "finalisiertVon"}
] ]
] ]
} }

View File

@@ -15,7 +15,8 @@
"Bereit", "Bereit",
"In Review", "In Review",
"Teilweise abgeschlossen", "Teilweise abgeschlossen",
"Abgeschlossen" "Abgeschlossen",
"Finalisiert"
], ],
"default": "Neu", "default": "Neu",
"required": true, "required": true,
@@ -26,7 +27,8 @@
"Bereit": "success", "Bereit": "success",
"In Review": "warning", "In Review": "warning",
"Teilweise abgeschlossen": "info", "Teilweise abgeschlossen": "info",
"Abgeschlossen": "success" "Abgeschlossen": "success",
"Finalisiert": "danger"
} }
}, },
"syncStatus": { "syncStatus": {
@@ -66,24 +68,6 @@
"notStorable": false, "notStorable": false,
"isCustom": true "isCustom": true
}, },
"finalisiert": {
"type": "bool",
"default": false,
"readOnly": true,
"isCustom": true,
"tooltip": true
},
"finalisierungsGrund": {
"type": "enum",
"options": [
"Erstes Team",
"Manuell",
"Automatisch"
],
"readOnly": true,
"isCustom": true,
"tooltip": true
},
"finalisiertAm": { "finalisiertAm": {
"type": "datetime", "type": "datetime",
"readOnly": true, "readOnly": true,
@@ -95,6 +79,17 @@
"readOnly": true, "readOnly": true,
"isCustom": true "isCustom": true
}, },
"mandantMitteilung": {
"type": "bool",
"default": false,
"isCustom": true,
"tooltip": true
},
"mandantMitteilungText": {
"type": "text",
"isCustom": true,
"tooltip": true
},
"createdAt": { "createdAt": {
"type": "datetime", "type": "datetime",
"readOnly": true "readOnly": true
@@ -117,10 +112,6 @@
"type": "link", "type": "link",
"entity": "User", "entity": "User",
"isCustom": true "isCustom": true
},
"teams": {
"type": "linkMultiple",
"isCustom": true
} }
}, },
"links": { "links": {
@@ -180,9 +171,6 @@
"syncStatus": { "syncStatus": {
"columns": ["syncStatus"] "columns": ["syncStatus"]
}, },
"finalisiert": {
"columns": ["finalisiert"]
},
"createdAt": { "createdAt": {
"columns": ["createdAt"] "columns": ["createdAt"]
} }

View File

@@ -4,12 +4,14 @@
"type": "varchar", "type": "varchar",
"notStorable": true, "notStorable": true,
"select": { "select": {
"select": "CONCAT:(team.name, ' - ', puls.name)" "select": "CONCAT:(team.name, ' - ', puls.name)",
"leftJoins": ["team", "puls"]
}, },
"orderBy": { "orderBy": {
"order": [ "order": [
["team.name", "{direction}"] ["team.name", "{direction}"]
] ],
"leftJoins": ["team"]
} }
}, },
"puls": { "puls": {

View File

@@ -1,3 +1,3 @@
{ {
"beforeSaveApiScript": "// Verhindere Abschluss bei unclean Status\nif (\n (status == 'Abgeschlossen' || entity\\isAttributeChanged('status'))\n && syncStatus == 'unclean'\n) {\n recordService\\throwBadRequest('Puls kann nicht abgeschlossen werden: Neue Dokumente vorhanden (Status: unclean). Bitte warten Sie auf die KI-Analyse.');\n}\n\n// Verhindere Änderungen an finalisiertem Puls\nif (\n finalisiert == true\n && entity\\isAttributeChanged('finalisiert') == false\n && (entity\\isAttributeChanged('status') || entity\\isAttributeChanged('syncStatus'))\n) {\n recordService\\throwBadRequest('Puls ist finalisiert. Neue Dokumente erzeugen automatisch einen neuen Block.');\n}" "beforeSaveApiScript": "// Verhindere Abschluss bei unclean Status\nif (\n entity\\isAttributeChanged('status') &&\n status == 'Abgeschlossen' &&\n syncStatus == 'unclean'\n) {\n recordService\\throwBadRequest('Puls kann nicht abgeschlossen werden: Neue Dokumente vorhanden (Status: unclean). Bitte warten Sie auf die KI-Analyse.');\n}\n\n// Verhindere Änderungen an finalisiertem Puls\nif (\n status == 'Finalisiert' &&\n !entity\\isAttributeChanged('status') &&\n (entity\\isAttributeChanged('syncStatus'))\n) {\n recordService\\throwBadRequest('Puls ist finalisiert. Neue Dokumente erzeugen automatisch einen neuen Block.');\n}"
} }

View File

@@ -358,7 +358,7 @@ return [
0 => 'youtube.com', 0 => 'youtube.com',
1 => 'google.com' 1 => 'google.com'
], ],
'microtime' => 1770973606.273594, 'microtime' => 1770974863.576723,
'siteUrl' => 'https://crm.bitbylaw.com', 'siteUrl' => 'https://crm.bitbylaw.com',
'fullTextSearchMinLength' => 4, 'fullTextSearchMinLength' => 4,
'webSocketUrl' => 'ws://api.bitbylaw.com:5000/espocrm/ws', 'webSocketUrl' => 'ws://api.bitbylaw.com:5000/espocrm/ws',

View File

@@ -1,7 +1,7 @@
<?php <?php
return [ return [
'cacheTimestamp' => 1770973606, 'cacheTimestamp' => 1770974863,
'microtimeState' => 1770973606.458758, 'microtimeState' => 1770974863.697017,
'currencyRates' => [ 'currencyRates' => [
'EUR' => 1.0 'EUR' => 1.0
], ],