diff --git a/README.md b/README.md index af3ca6c..dc46b89 100644 --- a/README.md +++ b/README.md @@ -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 (`"` statt `"`) 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 diff --git a/config/services.yaml b/config/services.yaml index bd4b9c5..9efa624 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -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' diff --git a/contao/dca/tl_calendar_events.php b/contao/dca/tl_calendar_events.php index 4378168..03a7e23 100644 --- a/contao/dca/tl_calendar_events.php +++ b/contao/dca/tl_calendar_events.php @@ -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', diff --git a/contao/languages/de/default.php b/contao/languages/de/default.php index bc4287f..e5ed160 100644 --- a/contao/languages/de/default.php +++ b/contao/languages/de/default.php @@ -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.'; diff --git a/contao/languages/en/default.php b/contao/languages/en/default.php index 8af1aa7..3ee1a20 100644 --- a/contao/languages/en/default.php +++ b/contao/languages/en/default.php @@ -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.'; diff --git a/src/Booking/EventResolver.php b/src/Booking/EventResolver.php index 27daa33..3b6a03a 100644 --- a/src/Booking/EventResolver.php +++ b/src/Booking/EventResolver.php @@ -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 $row + * @param array $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 diff --git a/src/EventListener/DataContainer/PriceFieldListener.php b/src/EventListener/DataContainer/PriceFieldListener.php new file mode 100644 index 0000000..0587cba --- /dev/null +++ b/src/EventListener/DataContainer/PriceFieldListener.php @@ -0,0 +1,49 @@ +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, '.', ''); + } +} diff --git a/src/Import/ImportExecutor.php b/src/Import/ImportExecutor.php index 19d97d1..2d7686b 100644 --- a/src/Import/ImportExecutor.php +++ b/src/Import/ImportExecutor.php @@ -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; diff --git a/src/Import/ImportPlanner.php b/src/Import/ImportPlanner.php index 32212b8..326d499 100644 --- a/src/Import/ImportPlanner.php +++ b/src/Import/ImportPlanner.php @@ -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 (" statt ") + 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)); } diff --git a/src/Migration/EventFieldsToVirtualMigration.php b/src/Migration/EventFieldsToVirtualMigration.php new file mode 100644 index 0000000..e7a361b --- /dev/null +++ b/src/Migration/EventFieldsToVirtualMigration.php @@ -0,0 +1,22 @@ + array_fill_keys(EventFields::virtual(), EventFields::STORAGE), + ]; + } +} diff --git a/src/Sync/EventFields.php b/src/Sync/EventFields.php new file mode 100644 index 0000000..ce59956 --- /dev/null +++ b/src/Sync/EventFields.php @@ -0,0 +1,73 @@ + 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 + */ + 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); + } +} diff --git a/src/Sync/EventRowCodec.php b/src/Sync/EventRowCodec.php new file mode 100644 index 0000000..2b73723 --- /dev/null +++ b/src/Sync/EventRowCodec.php @@ -0,0 +1,108 @@ + $row + * + * @return array + */ + 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 $set + * @param array|string|null $storage bisheriger Inhalt des JSON-Felds + * + * @return array + */ + 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 $fields + * + * @return list + */ + 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 + */ + 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 : ''; + } +} diff --git a/tests/EventListener/PriceFieldListenerTest.php b/tests/EventListener/PriceFieldListenerTest.php new file mode 100644 index 0000000..0f22c3c --- /dev/null +++ b/tests/EventListener/PriceFieldListenerTest.php @@ -0,0 +1,53 @@ +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); + } +} diff --git a/tests/Import/ImportPlannerTest.php b/tests/Import/ImportPlannerTest.php index 8bc3e1a..7c245e5 100644 --- a/tests/Import/ImportPlannerTest.php +++ b/tests/Import/ImportPlannerTest.php @@ -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' => '

Er sagte "Hallo" & ging

'], + 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 $memberIds Name => Mitglieds-ID (nur diese Namen sind Mitglieder) * @param list $existingSpeakers * @param array $existingOverrides + * @param array $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)]); diff --git a/tests/Sync/EventRowCodecTest.php b/tests/Sync/EventRowCodecTest.php new file mode 100644 index 0000000..976cadb --- /dev/null +++ b/tests/Sync/EventRowCodecTest.php @@ -0,0 +1,121 @@ +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()); + } +}