Store the display fields of events as virtual fields

- Move 29 display-only event fields (subtitles, audience, tags, prices,
  price types, organizer) from columns into the jsonData storage; fields
  used for filtering, joins and calculations stay real columns
- Add EventFieldsToVirtualMigration, which copies the values and drops
  the columns on contao:migrate
- Read and write the fields in the import, the preview and the booking
  through EventRowCodec; other bundles' keys in the storage are kept
- Validate prices in a save callback instead of the percent check that
  rejected every price above 100; accept a decimal comma
- Do not report events as changed after a backend save: ignore the
  spelling of HTML entities in teaser and topics and the order of the
  speakers

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jürgen MummertandClaude Fable 5.1 committed 2026-09-27 20:42:47 +02:00
1 parent 07f7b4828a
commit 3aad6aa559
15 files changed
+582 -47

No files matched your search

+7 -3
View File
@@ -96,12 +96,12 @@ Regeln beim Import:
| `kbwmemo` | `wkTags` | Kommagetrennt, dedupliziert |
| `kbkln1`, `kbmref` | `wkInstructor`, `wkSpeakers` | Mitglieder nach Nachname; werden nur angelegt, wenn in den Einstellungen Mitgliedergruppen gesetzt sind (die Vorschau kündigt das an). „N.N.“ wird ignoriert |
| `kbanzmax`, `kbanztat` | `wkMaxPlaces`, `wkPlacesLeft` | Freie Plätze = max − belegt |
| `kbkp1`–`kbkp6`, `kbktxp1`–`kbktxp6` | `wkPrice1`–`6`, `wkPriceType1`–`6` | Dezimalzahlen |
| `kbkp1`–`kbkp6`, `kbktxp1`–`kbktxp6` | `wkPrice1`–`6`, `wkPriceType1`–`6` | Dezimalzahlen, gespeichert mit zwei Nachkommastellen |
| `kbkd*` | `wkOrganizer*` | Straße und Landeskennzahl richtig zugeordnet (war vertauscht) |
| `kbkdemail` | `wkOrganizerEmail` | E-Mail **oder** URL (Veranstaltungsart 3: Anmeldung beim Veranstalter) |
| `kbvort`, `kbgruppe`, `kbvsart`, `kbztermin` | `location`, `wkAudience`, `wkEventType`, `wkAdditionalDates` | „geschlossener Teilnehmerkreis“ verhindert die Online-Anmeldung |
Das Euro-Zeichen, das W&K als „Ç“ exportiert, wird im Umfeld von Zahlen korrigiert. Aliase werden nur beim Anlegen erzeugt und bleiben danach stabil. Ein erneuter Import derselben Datei meldet alle Seminare als unverändert. Jeder Import wird protokolliert (W&K-Tools → Protokoll); die Datei liegt unter `var/wk-sync/imports/`. Alte Import- und Exportdateien räumt `vendor/bin/contao-console wk-sync:purge --days=90` auf (z. B. per Cronjob); die Protokolleinträge bleiben erhalten.
Das Euro-Zeichen, das W&K als „Ç“ exportiert, wird im Umfeld von Zahlen korrigiert. Aliase werden nur beim Anlegen erzeugt und bleiben danach stabil. Ein erneuter Import derselben Datei meldet alle Seminare als unverändert, auch nachdem eine Veranstaltung im Backend gespeichert wurde: Die Schreibweise von HTML-Entities in Teaser und Themen (`&#34;` statt `&quot;`) und die Reihenfolge der Referenten gelten nicht als Änderung. Jeder Import wird protokolliert (W&K-Tools → Protokoll); die Datei liegt unter `var/wk-sync/imports/`. Alte Import- und Exportdateien räumt `vendor/bin/contao-console wk-sync:purge --days=90` auf (z. B. per Cronjob); die Protokolleinträge bleiben erhalten.
Auf der Konsole:
@@ -175,7 +175,11 @@ Das Bundle setzt Contao 6 voraus und nutzt dessen Konventionen:
- **Output-Encoding:** Contao speichert Eingaben unkodiert. Import, Anmeldungen und Export arbeiten durchgehend mit Klartext, die W&K-Datei enthält also nie HTML-Entities. Die Backend-Oberfläche ist Twig (automatisches Escaping); die Kategorienliste liefert ein kodiertes `RecordLabel`.
- **Anzeigefelder:** Die Prüfung der Einrichtung, die Erklärung der Benachrichtigungslogik und der Hinweis bei fehlenden Benachrichtigungen sind `input_field_callback`-Felder ohne Spalte. Sie legen kein virtuelles Feld (`jsonData`) an.
- **Backend-Stylesheet:** `public/wk-sync-backend.css` wird über `contao.backend.custom_css` eingebunden (Icon der Navigationsgruppe, Posteingang, Einstellungen).
- **Veranstaltungsfelder:** Die 43 `wk…`-Felder in `tl_calendar_events` sind echte Spalten, keine virtuellen Felder, weil Import und Vorschau sie per SQL vergleichen und die Platzprüfung darauf rechnet.
- **Veranstaltungsfelder:** Von den 43 `wk…`-Feldern in `tl_calendar_events` sind 29 reine Anzeigefelder **virtuelle Felder** (Untertitel, Bereich, Zielgruppe, Schlagworte, Zusatztermine, Preise und Preisarten, Veranstalter). Contao speichert sie gemeinsam im JSON-Feld `jsonData` statt in je einer Spalte; das spart rund 13 KB der auf 65 KB begrenzten Zeilengröße. Echte Spalten bleiben alle Felder, mit denen gerechnet, gefiltert oder verknüpft wird (`wkId`, `wkState`, `wkInternalEventNumber`, `wkEventType`, `wkMaxPlaces`, `wkPlacesLeft`, `wkImported`, `wkCategories`, `wkCategoryPrimary`, `wkTopics`, `wkInstructor`, `wkSpeakers`, `wkNotification`, `wkConfirmationPage`). Die Liste steht in `Sync\EventFields`.
- Backend, Modelle und Templates (`event.wkSubTitle`, `event.wkPrice1` …) arbeiten unverändert, auch die Backend-Suche findet die Felder.
- Eigene SQL-Abfragen lesen ein virtuelles Feld mit `JSON_UNQUOTE(JSON_EXTRACT(jsonData, '$.wkSubTitle'))`. In PHP liefert `Sync\EventRowCodec::expand($row)` die Zeile mit allen Feldern, `combine($set, $bisherigesJson)` bereitet Werte zum Speichern auf.
- Bestehende Installationen stellt die Migration `EventFieldsToVirtualMigration` bei `contao:migrate` um: Werte in das JSON-Feld kopieren, Spalten entfernen.
- Preise werden als Text mit zwei Nachkommastellen gespeichert (`240.00`). Im Backend sind `240`, `240,50` und `1.240,50` als Eingabe erlaubt.
- **Ohne Ersatz im Core:** Das Backend-Modul (Import, Posteingang, Exporte) ist ein `BE_MOD`-Callback-Modul, das Anmeldeformular wird über `Controller::getForm()` gerendert und die Veranstaltung wird über `Input::get('auto_item')` ermittelt. Alle drei sind in Contao 6 nicht als veraltet markiert.
## Entwicklung
+1 -1
View File
@@ -16,7 +16,7 @@ services:
- '../src/Import/{ImportPlan,PlannedEvent,ImportMessage,WkRow}.php'
- '../src/Booking/{Availability,BookingFields}.php'
- '../src/Inbox/{InboxItem,InboxState}.php'
- '../src/Sync/CalendarConfig.php'
- '../src/Sync/{CalendarConfig,EventFields}.php'
# Ohne Notification Center werden Anmeldungen gespeichert, aber keine Mails verschickt
Mummert\ContaoWkSyncBundle\Booking\BookingNotifierInterface: '@Mummert\ContaoWkSyncBundle\Booking\NullNotifier'
+43 -32
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
use Contao\CoreBundle\DataContainer\PaletteManipulator;
use Mummert\ContaoWkSyncBundle\Import\WkState;
use Mummert\ContaoWkSyncBundle\Sync\EventFields;
$dca = &$GLOBALS['TL_DCA']['tl_calendar_events'];
@@ -41,11 +42,21 @@ $text = static fn (int $length, string $class = 'w50', array $eval = []): array
'sql' => ['type' => 'string', 'length' => $length, 'default' => ''],
];
$price = static fn (): array => [
// Reine Anzeigefelder sind virtuelle Felder: keine eigene Spalte, gespeichert im JSON-Feld (siehe EventFields)
$virtualText = static fn (string $field, string $class = 'w50'): array => [
'exclude' => true,
'search' => true,
'inputType' => 'text',
'eval' => ['maxlength' => EventFields::TEXTS[$field], 'tl_class' => $class],
'targetColumn' => EventFields::STORAGE,
];
// Eingabe und Format prüft der PriceFieldListener (Dezimalkomma oder -punkt, zwei Nachkommastellen)
$virtualPrice = static fn (): array => [
'exclude' => true,
'inputType' => 'text',
'eval' => ['rgxp' => 'prcnt', 'maxlength' => 10, 'tl_class' => 'w50'],
'sql' => ['type' => 'decimal', 'precision' => 10, 'scale' => 2, 'default' => '0.00'],
'eval' => ['maxlength' => 13, 'tl_class' => 'w50'],
'targetColumn' => EventFields::STORAGE,
];
$fields = [
@@ -92,8 +103,8 @@ $fields = [
'eval' => ['rgxp' => 'datim', 'readonly' => true, 'tl_class' => 'w50'],
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0],
],
'wkSubTitle' => $text(255, 'clr long'),
'wkSubTitleAdditional' => $text(255, 'long'),
'wkSubTitle' => $virtualText('wkSubTitle', 'clr long'),
'wkSubTitleAdditional' => $virtualText('wkSubTitleAdditional', 'long'),
'wkCategories' => [
'filter' => true,
'exclude' => true,
@@ -111,9 +122,9 @@ $fields = [
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0],
'relation' => ['type' => 'hasOne', 'load' => 'lazy'],
],
'wkCategoryText' => $text(255),
'wkAudience' => $text(255),
'wkTags' => $text(255),
'wkCategoryText' => $virtualText('wkCategoryText'),
'wkAudience' => $virtualText('wkAudience'),
'wkTags' => $virtualText('wkTags'),
'wkTopics' => [
'search' => true,
'exclude' => true,
@@ -121,7 +132,7 @@ $fields = [
'eval' => ['rte' => 'tinyMCE', 'tl_class' => 'clr'],
'sql' => ['type' => 'text', 'notnull' => false],
],
'wkAdditionalDates' => $text(512, 'clr long'),
'wkAdditionalDates' => $virtualText('wkAdditionalDates', 'clr long'),
'wkInstructor' => [
'filter' => true,
'exclude' => true,
@@ -138,29 +149,29 @@ $fields = [
'eval' => ['multiple' => true, 'chosen' => true, 'tl_class' => 'w50'],
'sql' => ['type' => 'blob', 'length' => 65535, 'notnull' => false],
],
'wkPrice1' => $price(),
'wkPriceType1' => $text(64),
'wkPrice2' => $price(),
'wkPriceType2' => $text(64),
'wkPrice3' => $price(),
'wkPriceType3' => $text(64),
'wkPrice4' => $price(),
'wkPriceType4' => $text(64),
'wkPrice5' => $price(),
'wkPriceType5' => $text(64),
'wkPrice6' => $price(),
'wkPriceType6' => $text(64),
'wkOrganizer' => $text(128),
'wkOrganizerAdditional' => $text(128),
'wkOrganizerSalutation' => $text(64),
'wkOrganizerAcademicTitle' => $text(64),
'wkOrganizerFirstname' => $text(64),
'wkOrganizerLastname' => $text(128),
'wkOrganizerStreet' => $text(128),
'wkOrganizerPostal' => $text(16),
'wkOrganizerCity' => $text(128),
'wkOrganizerCountryCode' => $text(16),
'wkOrganizerEmail' => $text(255, 'w50'),
'wkPrice1' => $virtualPrice(),
'wkPriceType1' => $virtualText('wkPriceType1'),
'wkPrice2' => $virtualPrice(),
'wkPriceType2' => $virtualText('wkPriceType2'),
'wkPrice3' => $virtualPrice(),
'wkPriceType3' => $virtualText('wkPriceType3'),
'wkPrice4' => $virtualPrice(),
'wkPriceType4' => $virtualText('wkPriceType4'),
'wkPrice5' => $virtualPrice(),
'wkPriceType5' => $virtualText('wkPriceType5'),
'wkPrice6' => $virtualPrice(),
'wkPriceType6' => $virtualText('wkPriceType6'),
'wkOrganizer' => $virtualText('wkOrganizer'),
'wkOrganizerAdditional' => $virtualText('wkOrganizerAdditional'),
'wkOrganizerSalutation' => $virtualText('wkOrganizerSalutation'),
'wkOrganizerAcademicTitle' => $virtualText('wkOrganizerAcademicTitle'),
'wkOrganizerFirstname' => $virtualText('wkOrganizerFirstname'),
'wkOrganizerLastname' => $virtualText('wkOrganizerLastname'),
'wkOrganizerStreet' => $virtualText('wkOrganizerStreet'),
'wkOrganizerPostal' => $virtualText('wkOrganizerPostal'),
'wkOrganizerCity' => $virtualText('wkOrganizerCity'),
'wkOrganizerCountryCode' => $virtualText('wkOrganizerCountryCode'),
'wkOrganizerEmail' => $virtualText('wkOrganizerEmail', 'w50'),
'wkNotification' => [
'exclude' => true,
'inputType' => 'select',
+2
View File
@@ -120,3 +120,5 @@ $lang['wk_sync']['exports']['content'] = 'Inhalt';
$lang['wk_sync']['exports']['summary'] = '%s Anmeldungen, %s Kontaktanfragen';
$lang['wk_sync']['exports']['download'] = 'CSV herunterladen';
$lang['wk_sync']['exports']['missing'] = 'Datei nicht mehr vorhanden';
$GLOBALS['TL_LANG']['ERR']['wk_price'] = 'Bitte geben Sie einen gültigen Preis ein, z. B. 240 oder 240,50.';
+2
View File
@@ -116,3 +116,5 @@ $lang['wk_sync']['exports']['content'] = 'Content';
$lang['wk_sync']['exports']['summary'] = '%s bookings, %s inquiries';
$lang['wk_sync']['exports']['download'] = 'Download CSV';
$lang['wk_sync']['exports']['missing'] = 'file no longer available';
$GLOBALS['TL_LANG']['ERR']['wk_price'] = 'Please enter a valid price, e.g. 240 or 240.50.';
+4 -2
View File
@@ -10,6 +10,7 @@ use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Connection;
use Mummert\ContaoWkSyncBundle\Category\CategoryKind;
use Mummert\ContaoWkSyncBundle\Category\CategoryRepository;
use Mummert\ContaoWkSyncBundle\Sync\EventRowCodec;
use Symfony\Component\HttpFoundation\Request;
/**
@@ -21,6 +22,7 @@ class EventResolver
private readonly Connection $connection,
private readonly ContaoFramework $framework,
private readonly CategoryRepository $categories,
private readonly EventRowCodec $codec = new EventRowCodec(),
) {
}
@@ -70,11 +72,11 @@ class EventResolver
}
/**
* @param array<string, mixed> $row
* @param array<string, mixed> $row Zeile aus der Datenbank (mit JSON-Feld) oder bereits aufbereitete Daten
*/
public function fromRow(array $row): BookingEvent
{
return BookingEvent::fromRow($row, $this->kindFor((int) ($row['wkCategoryPrimary'] ?? 0)));
return BookingEvent::fromRow($this->codec->expand($row), $this->kindFor((int) ($row['wkCategoryPrimary'] ?? 0)));
}
public function kindFor(int $categoryId): CategoryKind
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Mummert\ContaoWkSyncBundle\EventListener\DataContainer;
use Contao\CoreBundle\DependencyInjection\Attribute\AsCallback;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* Preisfelder der Veranstaltungen: akzeptiert „240“, „240.5“, „240,50“ und „1.240,50“ und speichert
* immer mit zwei Nachkommastellen („240.50“), wie der Import. Leer bedeutet 0.
*/
#[AsCallback(table: 'tl_calendar_events', target: 'fields.wkPrice1.save')]
#[AsCallback(table: 'tl_calendar_events', target: 'fields.wkPrice2.save')]
#[AsCallback(table: 'tl_calendar_events', target: 'fields.wkPrice3.save')]
#[AsCallback(table: 'tl_calendar_events', target: 'fields.wkPrice4.save')]
#[AsCallback(table: 'tl_calendar_events', target: 'fields.wkPrice5.save')]
#[AsCallback(table: 'tl_calendar_events', target: 'fields.wkPrice6.save')]
class PriceFieldListener
{
public function __construct(private readonly TranslatorInterface $translator)
{
}
public function __invoke(mixed $value): string
{
$value = preg_replace('/\s+|\x{00A0}|€|EUR/iu', '', (string) $value) ?? '';
if ('' === $value) {
return '0.00';
}
// „240.50“ (Dezimalpunkt) oder „1.240,50“ / „240,50“ (deutsches Format)
if (!preg_match('/^\d+(\.\d{1,2})?$/', $value)) {
if (!preg_match('/^(\d{1,3}(\.\d{3})+|\d+)(,\d{1,2})?$/', $value)) {
throw new \RuntimeException($this->translator->trans('ERR.wk_price', [], 'contao_default'));
}
$value = str_replace(['.', ','], ['', '.'], $value);
}
if ((float) $value >= 100000000) {
throw new \RuntimeException($this->translator->trans('ERR.wk_price', [], 'contao_default'));
}
return number_format((float) $value, 2, '.', '');
}
}
+9 -2
View File
@@ -9,6 +9,8 @@ use Contao\CoreBundle\Framework\ContaoFramework;
use Contao\CoreBundle\Slug\Slug;
use Doctrine\DBAL\Connection;
use Mummert\ContaoWkSyncBundle\Sync\CalendarConfig;
use Mummert\ContaoWkSyncBundle\Sync\EventFields;
use Mummert\ContaoWkSyncBundle\Sync\EventRowCodec;
/**
* Wendet einen Importplan in einer Transaktion auf tl_calendar_events an.
@@ -21,6 +23,7 @@ class ImportExecutor
private readonly MemberResolver $memberResolver,
private readonly CacheTagManager $cacheTagManager,
private readonly ContaoFramework $framework,
private readonly EventRowCodec $codec = new EventRowCodec(),
) {
}
@@ -46,14 +49,18 @@ class ImportExecutor
$set['alias'] = $this->generateAlias($event->title, $calendar);
$set['source'] = 'default';
$this->connection->insert('tl_calendar_events', $set);
$this->connection->insert('tl_calendar_events', $this->codec->combine($set));
$touchedIds[] = (int) $this->connection->lastInsertId();
++$result['created'];
break;
case PlannedEvent::UPDATE:
$set = $this->buildSet($event, $calendar, $now, $result);
$this->connection->update('tl_calendar_events', $set, ['id' => $event->existingId]);
// Virtuelle Felder anderer Erweiterungen im JSON-Feld bleiben erhalten
$stored = $this->connection->fetchOne(\sprintf('SELECT `%s` FROM tl_calendar_events WHERE id = ? FOR UPDATE', EventFields::STORAGE), [$event->existingId]);
$this->connection->update('tl_calendar_events', $this->codec->combine($set, \is_string($stored) ? $stored : null), ['id' => $event->existingId]);
$touchedIds[] = $event->existingId;
++$result['updated'];
break;
+20 -3
View File
@@ -10,6 +10,7 @@ use Mummert\ContaoWkSyncBundle\Category\Category;
use Mummert\ContaoWkSyncBundle\Category\CategoryRepository;
use Mummert\ContaoWkSyncBundle\Category\CategoryResolver;
use Mummert\ContaoWkSyncBundle\Sync\CalendarConfig;
use Mummert\ContaoWkSyncBundle\Sync\EventRowCodec;
/**
* Vergleicht die W&K-Datei mit dem Bestand und erstellt die Vorschau (nichts wird geschrieben).
@@ -30,12 +31,18 @@ class ImportPlanner
'wkPrice4', 'wkPriceType4', 'wkPrice5', 'wkPriceType5', 'wkPrice6', 'wkPriceType6',
];
/**
* Felder mit HTML: Unterschiede nur in der Schreibweise von Entities gelten nicht als Änderung.
*/
private const array HTML_FIELDS = ['teaser', 'wkTopics'];
public function __construct(
private readonly Connection $connection,
private readonly RowMapper $rowMapper,
private readonly CategoryRepository $categoryRepository,
private readonly CategoryResolver $categoryResolver,
private readonly MemberResolver $memberResolver,
private readonly EventRowCodec $codec = new EventRowCodec(),
) {
}
@@ -199,10 +206,14 @@ class ImportPlanner
$changes['wkInstructor'] = [$this->memberName($existingInstructor), $row->instructorName];
}
// Die Reihenfolge zählt nicht: Das Backend speichert die Auswahl in der Reihenfolge der Optionen
$existingSpeakers = array_map(intval(...), StringUtil::deserialize($existing['wkSpeakers'] ?? null, true));
sort($existingSpeakers);
$sortedSpeakers = $speakerIds;
sort($sortedSpeakers);
$speakersWillBeCreated = $calendar->speakerGroup > 0 && [] !== $unresolvedSpeakers;
if ($existingSpeakers !== $speakerIds || $speakersWillBeCreated) {
if ($existingSpeakers !== $sortedSpeakers || $speakersWillBeCreated) {
$changes['wkSpeakers'] = [implode(', ', array_values($this->memberResolver->namesByIds($existingSpeakers))), implode(', ', $row->speakerNames)];
}
@@ -216,14 +227,15 @@ class ImportPlanner
*/
private function loadExistingEvents(int $calendarId): array
{
$columns = array_unique(['id', 'wkId', 'published', 'wkCategories', 'wkCategoryPrimary', 'wkInstructor', 'wkSpeakers', ...self::COMPARED_FIELDS]);
// Die reinen Anzeigefelder liegen als virtuelle Felder im JSON-Feld der Veranstaltung
$columns = $this->codec->columns(['id', 'wkId', 'published', 'wkCategories', 'wkCategoryPrimary', 'wkInstructor', 'wkSpeakers', ...self::COMPARED_FIELDS]);
$sql = \sprintf('SELECT %s FROM tl_calendar_events WHERE pid = ? AND wkId != "" ORDER BY id', implode(', ', array_map(static fn (string $c): string => "`$c`", $columns)));
$events = [];
foreach ($this->connection->fetchAllAssociative($sql, [$calendarId]) as $row) {
// Bei Dubletten im Bestand gewinnt der älteste Datensatz
$events[(string) $row['wkId']] ??= $row;
$events[(string) $row['wkId']] ??= $this->codec->expand($row);
}
return $events;
@@ -243,6 +255,11 @@ class ImportPlanner
return $value ? '1' : '0';
}
// Nach dem Speichern im Backend stehen in HTML-Feldern numerische Entities (&#34; statt &quot;)
if (\in_array($field, self::HTML_FIELDS, true)) {
$value = html_entity_decode((string) $value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
return trim(str_replace("\r\n", "\n", (string) $value));
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Mummert\ContaoWkSyncBundle\Migration;
use Contao\CoreBundle\Migration\Version600\AbstractColumnToVirtualMigration;
use Mummert\ContaoWkSyncBundle\Sync\EventFields;
/**
* Überträgt die bisherigen Spalten der Anzeigefelder in das JSON-Feld der Veranstaltungen und entfernt
* die Spalten. Läuft mit „contao:migrate“, solange noch eine der Spalten vorhanden ist.
*/
class EventFieldsToVirtualMigration extends AbstractColumnToVirtualMigration
{
protected function getMapping(): array
{
return [
EventFields::TABLE => array_fill_keys(EventFields::virtual(), EventFields::STORAGE),
];
}
}
+73
View File
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace Mummert\ContaoWkSyncBundle\Sync;
/**
* Die reinen Anzeigefelder der Veranstaltungen sind virtuelle Felder: Contao speichert sie gemeinsam im
* JSON-Feld „jsonData“ statt in je einer Spalte. Das hält die Zeilengröße von tl_calendar_events klein.
*
* Echte Spalten bleiben alle Felder, mit denen gerechnet, gefiltert oder verknüpft wird (wkId, wkState,
* wkInternalEventNumber, wkEventType, wkMaxPlaces, wkPlacesLeft, wkImported, wkCategories,
* wkCategoryPrimary, wkTopics, wkInstructor, wkSpeakers, wkNotification, wkConfirmationPage).
*/
final class EventFields
{
public const string TABLE = 'tl_calendar_events';
public const string STORAGE = 'jsonData';
/**
* Virtuelle Textfelder mit ihrer maximalen Länge.
*/
public const array TEXTS = [
'wkSubTitle' => 255,
'wkSubTitleAdditional' => 255,
'wkCategoryText' => 255,
'wkAudience' => 255,
'wkTags' => 255,
'wkAdditionalDates' => 512,
'wkPriceType1' => 64,
'wkPriceType2' => 64,
'wkPriceType3' => 64,
'wkPriceType4' => 64,
'wkPriceType5' => 64,
'wkPriceType6' => 64,
'wkOrganizer' => 128,
'wkOrganizerAdditional' => 128,
'wkOrganizerSalutation' => 64,
'wkOrganizerAcademicTitle' => 64,
'wkOrganizerFirstname' => 64,
'wkOrganizerLastname' => 128,
'wkOrganizerStreet' => 128,
'wkOrganizerPostal' => 16,
'wkOrganizerCity' => 128,
'wkOrganizerCountryCode' => 16,
'wkOrganizerEmail' => 255,
];
/**
* Virtuelle Preisfelder; gespeichert als Text mit zwei Nachkommastellen („240.00“), wie ihn die
* früheren DECIMAL-Spalten geliefert haben.
*/
public const array PRICES = ['wkPrice1', 'wkPrice2', 'wkPrice3', 'wkPrice4', 'wkPrice5', 'wkPrice6'];
/**
* @return list<string>
*/
public static function virtual(): array
{
return [...array_keys(self::TEXTS), ...self::PRICES];
}
public static function isVirtual(string $field): bool
{
return isset(self::TEXTS[$field]) || \in_array($field, self::PRICES, true);
}
public static function isPrice(string $field): bool
{
return \in_array($field, self::PRICES, true);
}
}
+108
View File
@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace Mummert\ContaoWkSyncBundle\Sync;
/**
* Übersetzt zwischen der Datenbankzeile einer Veranstaltung und der Sicht mit allen W&K-Feldern.
*
* Contao selbst (Backend, Modelle, Templates) liest und schreibt virtuelle Felder automatisch. Import,
* Vorschau und Anmeldung arbeiten direkt auf der Datenbank und gehen deshalb über diese Klasse.
*/
class EventRowCodec
{
/**
* Zeile aus der Datenbank: Die virtuellen Felder werden aus dem JSON-Feld gelesen und mit ihren
* Vorgaben ergänzt, das JSON-Feld selbst entfällt. Spalten der Zeile haben Vorrang, Felder anderer
* Erweiterungen aus dem JSON-Feld bleiben erhalten.
*
* @param array<string, mixed> $row
*
* @return array<string, mixed>
*/
public function expand(array $row): array
{
$stored = $this->decode($row[EventFields::STORAGE] ?? null);
unset($row[EventFields::STORAGE]);
$virtual = [];
foreach (EventFields::virtual() as $field) {
$virtual[$field] = $this->normalize($field, $stored[$field] ?? null);
}
return [...$stored, ...$virtual, ...$row];
}
/**
* Werte zum Speichern: Die virtuellen Felder wandern in das JSON-Feld. Beim Aktualisieren wird der
* bisherige Inhalt des JSON-Felds übergeben, damit nicht übergebene Felder erhalten bleiben.
*
* @param array<string, mixed> $set
* @param array<string, mixed>|string|null $storage bisheriger Inhalt des JSON-Felds
*
* @return array<string, mixed>
*/
public function combine(array $set, array|string|null $storage = null): array
{
$stored = $this->decode($storage);
foreach ($set as $field => $value) {
if (EventFields::isVirtual($field)) {
$stored[$field] = $this->normalize($field, $value);
unset($set[$field]);
}
}
$set[EventFields::STORAGE] = [] === $stored ? null : json_encode($stored, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return $set;
}
/**
* Spalten einer Feldliste, die es in der Tabelle wirklich gibt, ergänzt um das JSON-Feld.
*
* @param list<string> $fields
*
* @return list<string>
*/
public function columns(array $fields): array
{
$columns = array_values(array_filter(array_unique($fields), static fn (string $field): bool => !EventFields::isVirtual($field)));
$columns[] = EventFields::STORAGE;
return $columns;
}
/**
* @return array<string, mixed>
*/
private function decode(array|string|null $storage): array
{
if (\is_array($storage)) {
return $storage;
}
if (null === $storage || '' === $storage) {
return [];
}
try {
$decoded = json_decode($storage, true, 16, JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return [];
}
return \is_array($decoded) ? $decoded : [];
}
private function normalize(string $field, mixed $value): string
{
if (EventFields::isPrice($field)) {
return number_format((float) $value, 2, '.', '');
}
return \is_scalar($value) ? (string) $value : '';
}
}
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Mummert\ContaoWkSyncBundle\Tests\EventListener;
use Mummert\ContaoWkSyncBundle\EventListener\DataContainer\PriceFieldListener;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Contracts\Translation\TranslatorInterface;
class PriceFieldListenerTest extends TestCase
{
#[DataProvider('prices')]
public function testNormalizesPrices(string $input, string $expected): void
{
self::assertSame($expected, $this->listener()($input));
}
public static function prices(): iterable
{
yield 'empty' => ['', '0.00'];
yield 'above one hundred' => ['216.00', '216.00'];
yield 'integer' => ['240', '240.00'];
yield 'decimal comma' => ['240,5', '240.50'];
yield 'thousands' => ['1.250,50', '1250.50'];
yield 'currency sign' => ['12,90 €', '12.90'];
}
#[DataProvider('invalidPrices')]
public function testRejectsInvalidPrices(string $input): void
{
$this->expectException(\RuntimeException::class);
$this->listener()($input);
}
public static function invalidPrices(): iterable
{
yield 'text' => ['auf Anfrage'];
yield 'negative' => ['-5'];
yield 'three decimals' => ['1,234'];
yield 'too large' => ['100000000'];
}
private function listener(): PriceFieldListener
{
$translator = $this->createStub(TranslatorInterface::class);
$translator->method('trans')->willReturn('Ungültiger Preis');
return new PriceFieldListener($translator);
}
}
+68 -4
View File
@@ -15,10 +15,13 @@ use Mummert\ContaoWkSyncBundle\Import\MemberResolver;
use Mummert\ContaoWkSyncBundle\Import\PlannedEvent;
use Mummert\ContaoWkSyncBundle\Import\RowMapper;
use Mummert\ContaoWkSyncBundle\Sync\CalendarConfig;
use Mummert\ContaoWkSyncBundle\Sync\EventRowCodec;
use PHPUnit\Framework\TestCase;
class ImportPlannerTest extends TestCase
{
private string $sql = '';
public function testReimportWithoutMemberGroupsIsUnchanged(): void
{
$plan = $this->plan(existingInstructor: 0, existingSpeakers: [], memberIds: [], instructorGroup: 0, speakerGroup: 0);
@@ -51,12 +54,63 @@ class ImportPlannerTest extends TestCase
self::assertSame(['Alter Titel', 'Testseminar'], $plan->events[0]->changes['title']);
}
public function testVirtualFieldsAreComparedFromTheJsonStorage(): void
{
$plan = $this->plan(existingInstructor: 0, existingSpeakers: [], memberIds: [], instructorGroup: 0, speakerGroup: 0, existingOverrides: ['wkSubTitle' => 'Alter Untertitel', 'wkPrice1' => '99.00']);
self::assertSame(PlannedEvent::UPDATE, $plan->events[0]->action);
self::assertSame(['wkSubTitle', 'wkPrice1'], array_keys($plan->events[0]->changes));
self::assertSame(['Alter Untertitel', 'Modul 1'], $plan->events[0]->changes['wkSubTitle']);
self::assertSame(['99.00', 216.0], $plan->events[0]->changes['wkPrice1']);
}
public function testEntitySpellingAfterABackendSaveIsNoChange(): void
{
$plan = $this->plan(
existingInstructor: 0,
existingSpeakers: [],
memberIds: [],
instructorGroup: 0,
speakerGroup: 0,
existingOverrides: ['teaser' => '<p>Er sagte &#34;Hallo&#34; &amp; ging</p>'],
rowOverrides: ['kbpmemo' => 'Er sagte "Hallo" & ging'],
);
self::assertSame(PlannedEvent::UNCHANGED, $plan->events[0]->action);
}
public function testSpeakerOrderDoesNotMatter(): void
{
$plan = $this->plan(
existingInstructor: 11,
existingSpeakers: [13, 12],
memberIds: ['Anna Muster' => 11, 'Bernd Beispiel' => 12, 'Clara Chor' => 13],
instructorGroup: 0,
speakerGroup: 0,
rowOverrides: ['kbmref' => "Bernd Beispiel\nClara Chor"],
);
self::assertSame(PlannedEvent::UNCHANGED, $plan->events[0]->action);
}
public function testExistingRowsAreReadFromRealColumnsAndTheJsonStorage(): void
{
$this->plan(existingInstructor: 0, existingSpeakers: [], memberIds: [], instructorGroup: 0, speakerGroup: 0);
self::assertStringContainsString('`jsonData`', $this->sql);
self::assertStringContainsString('`wkState`', $this->sql);
self::assertStringNotContainsString('wkSubTitle', $this->sql);
self::assertStringNotContainsString('wkPrice1', $this->sql);
self::assertStringNotContainsString('wkOrganizer', $this->sql);
}
/**
* @param array<string, int> $memberIds Name => Mitglieds-ID (nur diese Namen sind Mitglieder)
* @param list<int> $existingSpeakers
* @param array<string, mixed> $existingOverrides
* @param array<string, string> $rowOverrides abweichende Werte der W&K-Zeile
*/
private function plan(int $existingInstructor, array $existingSpeakers, array $memberIds, int $instructorGroup, int $speakerGroup, array $existingOverrides = []): \Mummert\ContaoWkSyncBundle\Import\ImportPlan
private function plan(int $existingInstructor, array $existingSpeakers, array $memberIds, int $instructorGroup, int $speakerGroup, array $existingOverrides = [], array $rowOverrides = []): \Mummert\ContaoWkSyncBundle\Import\ImportPlan
{
$row = [
'kbnr' => '0001234',
@@ -68,10 +122,16 @@ class ImportPlannerTest extends TestCase
'kbsgebiet' => 'Grundschule',
'kbkln1' => 'Anna Muster',
'kbmref' => "\nBernd Beispiel",
'kbutitel' => 'Modul 1',
'kbkp1' => '216,00',
'kbktxp1' => 'Kursgebühr',
...$rowOverrides,
];
$mapped = (new RowMapper())->map($row, 2);
$existing = [
// Bestand wie in der Datenbank: echte Spalten plus JSON-Feld mit den virtuellen Feldern
$existing = (new EventRowCodec())->combine([
...$mapped->data,
...$existingOverrides,
'id' => 7,
@@ -80,10 +140,14 @@ class ImportPlannerTest extends TestCase
'wkCategoryPrimary' => 3,
'wkInstructor' => $existingInstructor,
'wkSpeakers' => serialize(array_map(strval(...), $existingSpeakers)),
];
]);
$connection = $this->createStub(Connection::class);
$connection->method('fetchAllAssociative')->willReturn([$existing]);
$connection->method('fetchAllAssociative')->willReturnCallback(function (string $sql) use ($existing): array {
$this->sql = $sql;
return [$existing];
});
$categories = $this->createStub(CategoryRepository::class);
$categories->method('findAll')->willReturn([new Category(3, 'Grundschule', 'grundschule', CategoryKind::School, ['Grundschule'], 16)]);
+121
View File
@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace Mummert\ContaoWkSyncBundle\Tests\Sync;
use Mummert\ContaoWkSyncBundle\Sync\EventFields;
use Mummert\ContaoWkSyncBundle\Sync\EventRowCodec;
use PHPUnit\Framework\TestCase;
class EventRowCodecTest extends TestCase
{
public function testExpandReadsVirtualFieldsFromTheStorage(): void
{
$row = (new EventRowCodec())->expand([
'id' => 7,
'title' => 'Basis-Kurs',
'wkId' => '0001234',
'jsonData' => '{"wkSubTitle":"Modul 1","wkPrice1":"216.00","wkOrganizerCity":"Moritzburg","playerSize":"16:9"}',
]);
self::assertSame('Modul 1', $row['wkSubTitle']);
self::assertSame('216.00', $row['wkPrice1']);
self::assertSame('Moritzburg', $row['wkOrganizerCity']);
self::assertSame('0001234', $row['wkId']);
self::assertSame('16:9', $row['playerSize'], 'Felder anderer Erweiterungen bleiben lesbar');
self::assertArrayNotHasKey('jsonData', $row);
}
public function testExpandFillsDefaultsForMissingFields(): void
{
$codec = new EventRowCodec();
foreach ([null, '', '{}', 'kein JSON', '"text"'] as $storage) {
$row = $codec->expand(['id' => 1, 'jsonData' => $storage]);
self::assertSame('', $row['wkSubTitle']);
self::assertSame('', $row['wkOrganizerEmail']);
self::assertSame('0.00', $row['wkPrice3']);
}
self::assertSame([], array_diff(EventFields::virtual(), array_keys($codec->expand(['id' => 1]))));
}
public function testExpandKeepsAlreadyExpandedValues(): void
{
// Daten aus einem Contao-Modell sind bereits aufbereitet und haben kein JSON-Feld mehr
$row = (new EventRowCodec())->expand(['id' => 1, 'wkSubTitle' => 'Modul 2', 'wkPrice1' => '12.50']);
self::assertSame('Modul 2', $row['wkSubTitle']);
self::assertSame('12.50', $row['wkPrice1']);
}
public function testCombineMovesVirtualFieldsIntoTheStorage(): void
{
$set = (new EventRowCodec())->combine([
'title' => 'Basis-Kurs',
'wkId' => '0001234',
'wkPlacesLeft' => 12,
'wkSubTitle' => 'Modul 1 „Einführung“',
'wkPrice1' => 216.0,
'wkPrice2' => 1250.5,
'wkPriceType1' => 'Kursgebühr',
]);
self::assertSame(['title', 'wkId', 'wkPlacesLeft', 'jsonData'], array_keys($set));
self::assertSame(
['wkSubTitle' => 'Modul 1 „Einführung“', 'wkPrice1' => '216.00', 'wkPrice2' => '1250.50', 'wkPriceType1' => 'Kursgebühr'],
json_decode($set['jsonData'], true, flags: JSON_THROW_ON_ERROR),
);
self::assertStringContainsString('„Einführung“', $set['jsonData'], 'Umlaute und Anführungszeichen stehen lesbar im JSON');
}
public function testCombineKeepsStoredFieldsThatAreNotPassed(): void
{
$set = (new EventRowCodec())->combine(
['wkSubTitle' => 'Neu', 'tstamp' => 5],
'{"wkSubTitle":"Alt","wkAudience":"Lehrkräfte","playerSize":"16:9"}',
);
self::assertSame(
['wkSubTitle' => 'Neu', 'wkAudience' => 'Lehrkräfte', 'playerSize' => '16:9'],
json_decode($set['jsonData'], true, flags: JSON_THROW_ON_ERROR),
);
self::assertSame(5, $set['tstamp']);
}
public function testCombineWithoutVirtualFieldsLeavesTheStorageEmpty(): void
{
self::assertNull((new EventRowCodec())->combine(['title' => 'Ohne'])['jsonData']);
}
public function testRoundTrip(): void
{
$codec = new EventRowCodec();
$data = ['id' => 3, 'wkId' => '0009', 'wkTags' => 'a, b', 'wkPrice6' => '9.90', 'wkOrganizerEmail' => 'info@example.org'];
$row = $codec->expand($codec->combine($data));
foreach ($data as $field => $value) {
self::assertSame($value, $row[$field]);
}
}
public function testColumnsSkipVirtualFields(): void
{
self::assertSame(
['id', 'wkId', 'wkState', 'jsonData'],
(new EventRowCodec())->columns(['id', 'wkId', 'wkSubTitle', 'wkState', 'wkPrice1', 'id']),
);
}
public function testRealColumnsAreNotVirtual(): void
{
foreach (['wkId', 'wkState', 'wkInternalEventNumber', 'wkEventType', 'wkMaxPlaces', 'wkPlacesLeft', 'wkImported', 'wkCategories', 'wkCategoryPrimary', 'wkTopics', 'wkInstructor', 'wkSpeakers', 'wkNotification', 'wkConfirmationPage'] as $field) {
self::assertFalse(EventFields::isVirtual($field), $field);
}
self::assertCount(29, EventFields::virtual());
}
}