Compare commits
3
Commits
main
...
contao-5.7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4adf44fbbb | ||
|
|
4a5ae83f03 | ||
|
|
f4853b51ab |
No files matched your search
@@ -1,13 +1,14 @@
|
||||
# W&K-Sync Bundle
|
||||
|
||||
Verbindet **Contao 5.7 und 6** mit der Seminarverwaltung **W&K** (Windows-Software ohne Schnittstelle) über CSV-Dateien:
|
||||
Verbindet **Contao 5.7** mit der Seminarverwaltung **W&K** (Windows-Software ohne Schnittstelle) über CSV-Dateien:
|
||||
|
||||
1. **Import**: Die Redaktion exportiert in W&K die Seminarliste als CSV und lädt sie im Contao-Backend hoch. Vor dem Schreiben zeigt eine **Vorschau**, was sich ändert (neu, geändert, storniert, unverändert, übersprungen, Warnungen). Erst nach Bestätigung werden die Veranstaltungen im Kalender angelegt bzw. aktualisiert.
|
||||
2. **Online-Anmeldung**: Besucher melden sich über ein Formular des **Formulargenerators** zu einer Veranstaltung an. Das Bundle blendet je nach Veranstaltung Felder ein und aus, prüft Plätze und Anmeldeschluss, speichert die Anmeldung, verschickt die passende Benachrichtigung (Notification Center 2) und leitet auf die Bestätigungsseite weiter.
|
||||
3. **Posteingang**: Anmeldungen und Kontaktanfragen werden in Ordnern bearbeitet (Neu, Download-Liste, Bereits bearbeitet, Ignoriert/Spam, Exportiert).
|
||||
4. **Export**: Die Download-Liste wird als CSV für den W&K-Import erzeugt (ISO-8859-1, Semikolon, feste Spalten), die Datensätze werden als exportiert markiert und die freien Plätze der Veranstaltungen reduziert.
|
||||
|
||||
- Contao **5.7** oder **6.0**, PHP **8.5**, Doctrine DBAL **4**, MySQL 8 oder MariaDB
|
||||
- Contao **5.7**, PHP **8.5**, Doctrine DBAL **4**, MySQL 8 oder MariaDB
|
||||
- **Wartungszweig `contao-5.7`**: Fehlerkorrekturen für Installationen unter Contao 5.7. Die Weiterentwicklung für Contao 6 liegt auf `main`.
|
||||
- Keine Abhängigkeiten außer dem Contao-Core und dem Kalender-Bundle
|
||||
- **Notification Center 2** wird für Benachrichtigungen empfohlen (Typ „W&K-Anmeldung“), ist aber nicht erforderlich; ohne Notification Center werden Anmeldungen gespeichert und protokolliert, aber keine Mails verschickt (Stand September 2026 unterstützt das Notification Center Contao 6 noch nicht)
|
||||
- Newsletter-Anmeldung (Double-Opt-in) mit dem Newsletter-Bundle, optional
|
||||
@@ -101,7 +102,7 @@ Regeln beim Import:
|
||||
| `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 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:
|
||||
|
||||
@@ -168,11 +169,19 @@ Alle Unterpunkte von „W&K-Tools“ sind normale Contao-Backend-Module und werd
|
||||
- Verfügbarkeit wird beim Absenden erneut geprüft (Wettlauf um den letzten Platz).
|
||||
- Anmeldungen und Anfragen werden nur mit bekannten Spalten gespeichert.
|
||||
|
||||
## Contao 5.7 und 6
|
||||
## Wartungszweig für Contao 5.7
|
||||
|
||||
Ein Stand des Bundles läuft in beiden Versionen. Contao 6 speichert Eingaben unkodiert und kodiert erst bei der Ausgabe, Contao 5 speichert sie kodiert. Die Backend-Oberfläche des Bundles ist komplett in Twig (automatisches Escaping); die wenigen Stellen, die HTML außerhalb von Twig erzeugen (Kategorienliste, Prüfung der Einrichtung, Tooltips der Navigation), kodieren selbst ohne doppeltes Kodieren und geben in Contao 6 ein `RecordLabel` zurück. Der CSV-Export dekodiert gespeicherte Werte, damit unter Contao 5 keine HTML-Entities (z. B. `(` für eine Klammer) in der W&K-Datei landen.
|
||||
Dieser Zweig (`contao-5.7`) ist der Stand für Projekte unter Contao 5.7. Im Projekt einbinden mit:
|
||||
|
||||
Das Backend-Modul (Import, Posteingang, Exporte) ist ein klassisches `BE_MOD`-Callback-Modul und das Anmeldeformular wird über `Controller::getForm()` gerendert; beides ist in Contao 6 weiterhin unterstützt und nicht als veraltet markiert.
|
||||
```json
|
||||
"require": {
|
||||
"mummert/wk-sync-bundle": "dev-contao-5.7"
|
||||
}
|
||||
```
|
||||
|
||||
Der Zweig `main` setzt Contao 6 voraus. Dort sind die reinen Anzeigefelder der Veranstaltungen virtuelle Felder; beim späteren Wechsel auf Contao 6 und `main` stellt die Migration `EventFieldsToVirtualMigration` die Spalten bei `contao:migrate` um. Vorher die Datenbank sichern.
|
||||
|
||||
Contao 5 speichert Eingaben aus dem Backend kodiert (z. B. `(` für eine Klammer), der Import schreibt Klartext. Das Bundle gleicht das aus: Veranstaltungen werden beim Lesen dekodiert, damit Benachrichtigungen und Protokoll Klartext enthalten; die Import-Vorschau ignoriert die Schreibweise von Entities; der CSV-Export dekodiert gespeicherte Werte.
|
||||
|
||||
## Entwicklung
|
||||
|
||||
|
||||
+9
-9
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mummert/wk-sync-bundle",
|
||||
"description": "W&K-Sync für Contao 5.7 und 6: Kursimport aus der Seminarverwaltung W&K (CSV), Online-Anmeldungen mit Posteingang und CSV-Export zurück nach W&K",
|
||||
"description": "W&K-Sync für Contao 5.7: Kursimport aus der Seminarverwaltung W&K (CSV), Online-Anmeldungen mit Posteingang und CSV-Export zurück nach W&K",
|
||||
"type": "contao-bundle",
|
||||
"license": "MIT",
|
||||
"authors": [
|
||||
@@ -13,15 +13,15 @@
|
||||
"php": "^8.5",
|
||||
"ext-iconv": "*",
|
||||
"ext-mbstring": "*",
|
||||
"contao/core-bundle": "^5.7 || ^6.0",
|
||||
"contao/calendar-bundle": "^5.7 || ^6.0",
|
||||
"contao/core-bundle": "^5.7",
|
||||
"contao/calendar-bundle": "^5.7",
|
||||
"doctrine/dbal": "^4.0",
|
||||
"symfony/config": "^7.4 || ^8.0",
|
||||
"symfony/console": "^7.4 || ^8.0",
|
||||
"symfony/dependency-injection": "^7.4 || ^8.0",
|
||||
"symfony/event-dispatcher": "^7.4 || ^8.0",
|
||||
"symfony/http-foundation": "^7.4 || ^8.0",
|
||||
"symfony/http-kernel": "^7.4 || ^8.0",
|
||||
"symfony/config": "^7.4",
|
||||
"symfony/console": "^7.4",
|
||||
"symfony/dependency-injection": "^7.4",
|
||||
"symfony/event-dispatcher": "^7.4",
|
||||
"symfony/http-foundation": "^7.4",
|
||||
"symfony/http-kernel": "^7.4",
|
||||
"twig/twig": "^3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
|
||||
@@ -41,10 +41,11 @@ $text = static fn (int $length, string $class = 'w50', array $eval = []): array
|
||||
'sql' => ['type' => 'string', 'length' => $length, 'default' => ''],
|
||||
];
|
||||
|
||||
// Eingabe und Format prüft der PriceFieldListener (Dezimalkomma oder -punkt, zwei Nachkommastellen)
|
||||
$price = static fn (): array => [
|
||||
'exclude' => true,
|
||||
'inputType' => 'text',
|
||||
'eval' => ['rgxp' => 'prcnt', 'maxlength' => 10, 'tl_class' => 'w50'],
|
||||
'eval' => ['maxlength' => 13, 'tl_class' => 'w50'],
|
||||
'sql' => "decimal(10,2) NOT NULL default '0.00'",
|
||||
];
|
||||
|
||||
|
||||
@@ -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.';
|
||||
@@ -56,6 +56,8 @@ $lang['notificationOverview'] = '<p><strong>So wird die Benachrichtigung für ei
|
||||
</tbody>
|
||||
</table>';
|
||||
|
||||
$lang['notificationUnavailable'] = 'Das Notification Center ist nicht installiert. Anmeldungen werden gespeichert und im Systemprotokoll vermerkt, es wird aber keine E-Mail verschickt. Sobald das Notification Center installiert ist, lassen sich hier Benachrichtigungen vom Typ „W&K-Anmeldung“ zuordnen.';
|
||||
|
||||
$lang['notificationNotice'] = 'Es ist noch keine Benachrichtigung vom Typ „W&K-Anmeldung“ angelegt, deshalb sind die Auswahlfelder leer. Bitte zuerst unter <a href="%s">Notification Center → Benachrichtigungen</a> eine Benachrichtigung mit diesem Typ anlegen; danach lässt sie sich hier zuordnen.';
|
||||
|
||||
$lang['notification1'] = ['Bedingung 1: Online-Kurs', 'Veranstaltungsort „Online“, alle Kategorie-Arten, ein- oder mehrtägig. Leer = die Bedingung der Kategorie-Art zählt.'];
|
||||
|
||||
@@ -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.';
|
||||
@@ -56,6 +56,8 @@ $lang['notificationOverview'] = '<p><strong>How the notification for a booking i
|
||||
</tbody>
|
||||
</table>';
|
||||
|
||||
$lang['notificationUnavailable'] = 'The Notification Center is not installed. Bookings are stored and logged, but no e-mail is sent. Once the Notification Center is installed, notifications of the type "W&K booking" can be assigned here.';
|
||||
|
||||
$lang['notificationNotice'] = 'There is no notification of the type "W&K booking" yet, so the selects are empty. Please create one under <a href="%s">Notification Center → Notifications</a> first; it can then be assigned here.';
|
||||
|
||||
$lang['notification1'] = ['Condition 1: online course', 'Location "Online", all category kinds, single or multi-day. Empty = the condition of the category kind applies.'];
|
||||
|
||||
@@ -60,7 +60,9 @@
|
||||
{% for line in item.dataLines %}{{ line|nl2br }}{% if not loop.last %}<br>{% endif %}{% endfor %}
|
||||
</td>
|
||||
<td class="tl_file_list tl_right_nowrap wk-sync-col-actions">
|
||||
<a href="{{ row.edit_url }}" class="wk-sync-action" title="{{ 'MSC.wk_sync.inbox.edit'|trans }}">{{ 'MSC.wk_sync.inbox.edit'|trans }}</a>
|
||||
{% if row.edit_url %}
|
||||
<a href="{{ row.edit_url }}" class="wk-sync-action" title="{{ 'MSC.wk_sync.inbox.edit'|trans }}">{{ 'MSC.wk_sync.inbox.edit'|trans }}</a>
|
||||
{% endif %}
|
||||
{% for target in row.targets %}
|
||||
<button type="submit" form="wk-sync-single-{{ item.type }}-{{ item.id }}" name="target" value="{{ target.value }}" class="wk-sync-action wk-sync-action--{{ target.translationKey }}" title="{{ ('MSC.wk_sync.inbox.move_to'|trans([('MSC.wk_sync.state.' ~ target.translationKey)|trans])) }}">{{ ('MSC.wk_sync.state.' ~ target.translationKey)|trans }}</button>
|
||||
{% endfor %}
|
||||
|
||||
@@ -13,6 +13,7 @@ use Contao\CoreBundle\Exception\RedirectResponseException;
|
||||
use Contao\CoreBundle\Exception\ResponseException;
|
||||
use Contao\CoreBundle\Framework\Adapter;
|
||||
use Contao\CoreBundle\Framework\ContaoFramework;
|
||||
use Contao\CoreBundle\Security\ContaoCorePermissions;
|
||||
use Contao\Date;
|
||||
use Contao\Message;
|
||||
use Contao\StringUtil;
|
||||
@@ -223,12 +224,18 @@ class WkSyncBackend
|
||||
$items = $this->inbox->findByState($state, $state->isFinal() ? 300 : 0);
|
||||
$referer = (string) $request->attributes->get('_contao_referer_id', '');
|
||||
|
||||
// „Bearbeiten“ führt in die Module Anmeldungen/Kontaktanfragen; ohne Zugriff darauf gäbe es einen 403, also kein Button
|
||||
$canEdit = [
|
||||
true => $this->security->isGranted(ContaoCorePermissions::USER_CAN_ACCESS_MODULE, self::MODULE_BOOKINGS),
|
||||
false => $this->security->isGranted(ContaoCorePermissions::USER_CAN_ACCESS_MODULE, self::MODULE_INQUIRIES),
|
||||
];
|
||||
|
||||
$decorated = [];
|
||||
|
||||
foreach ($items as $item) {
|
||||
$decorated[] = [
|
||||
'item' => $item,
|
||||
'edit_url' => $this->editUrl($item, $referer),
|
||||
'edit_url' => $canEdit[$item->isBooking()] ? $this->editUrl($item, $referer) : '',
|
||||
'targets' => $state->moveTargets(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -17,4 +17,9 @@ interface BookingNotifierInterface
|
||||
* @return array<int, string> ID => Titel der verfügbaren Benachrichtigungen
|
||||
*/
|
||||
public function availableNotifications(): array;
|
||||
|
||||
/**
|
||||
* Ob überhaupt Benachrichtigungen verschickt werden können (Notification Center installiert).
|
||||
*/
|
||||
public function isAvailable(): bool;
|
||||
}
|
||||
@@ -86,7 +86,7 @@ class BookingTokens
|
||||
$calendarRow = $this->connection->fetchAssociative('SELECT * FROM tl_calendar WHERE id = ?', [$calendar->id]) ?: [];
|
||||
|
||||
foreach ($calendarRow as $key => $value) {
|
||||
$tokens['calendar_'.$key] = $this->scalar($value);
|
||||
$tokens['calendar_'.$key] = 'title' === $key ? StringUtil::decodeEntities((string) $value) : $this->scalar($value);
|
||||
}
|
||||
|
||||
// Anmeldung
|
||||
|
||||
@@ -6,10 +6,12 @@ namespace Mummert\ContaoWkSyncBundle\Booking;
|
||||
|
||||
use Contao\CoreBundle\Framework\ContaoFramework;
|
||||
use Contao\Input;
|
||||
use Contao\StringUtil;
|
||||
use Doctrine\DBAL\ArrayParameterType;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Mummert\ContaoWkSyncBundle\Category\CategoryKind;
|
||||
use Mummert\ContaoWkSyncBundle\Category\CategoryRepository;
|
||||
use Mummert\ContaoWkSyncBundle\Import\RowMapper;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
/**
|
||||
@@ -69,13 +71,31 @@ class EventResolver
|
||||
}
|
||||
|
||||
/**
|
||||
* Contao 5 speichert Eingaben aus dem Backend kodiert (z. B. ( für eine Klammer), der Import
|
||||
* schreibt Klartext. Die Textfelder werden deshalb beim Lesen dekodiert, damit Regeln, Protokoll und
|
||||
* Benachrichtigungen immer mit Klartext arbeiten. Felder mit HTML bleiben unverändert.
|
||||
*
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
public function fromRow(array $row): BookingEvent
|
||||
{
|
||||
foreach (self::textFields() as $field) {
|
||||
if (\is_string($row[$field] ?? null)) {
|
||||
$row[$field] = StringUtil::decodeEntities($row[$field]);
|
||||
}
|
||||
}
|
||||
|
||||
return BookingEvent::fromRow($row, $this->kindFor((int) ($row['wkCategoryPrimary'] ?? 0)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function textFields(): array
|
||||
{
|
||||
return array_values(array_unique([...array_values(RowMapper::TEXT_COLUMNS), 'wkTags', 'wkInternalEventNumber', 'wkOrganizerEmail']));
|
||||
}
|
||||
|
||||
public function kindFor(int $categoryId): CategoryKind
|
||||
{
|
||||
return $this->categories->find($categoryId)?->kind ?? CategoryKind::Other;
|
||||
|
||||
@@ -51,4 +51,9 @@ class NotificationCenterNotifier implements BookingNotifierInterface
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
public function isAvailable(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -29,4 +29,9 @@ class NullNotifier implements BookingNotifierInterface
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function isAvailable(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ use Contao\CoreBundle\DependencyInjection\Attribute\AsCallback;
|
||||
use Contao\CoreBundle\Slug\Slug;
|
||||
use Contao\DataContainer;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Mummert\ContaoWkSyncBundle\Sync\CalendarConfigRepository;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
|
||||
#[AsCallback(table: 'tl_wk_category', target: 'fields.alias.save')]
|
||||
class CategoryAliasListener
|
||||
@@ -15,6 +17,8 @@ class CategoryAliasListener
|
||||
public function __construct(
|
||||
private readonly Slug $slug,
|
||||
private readonly Connection $connection,
|
||||
private readonly CalendarConfigRepository $calendars,
|
||||
private readonly RequestStack $requestStack,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -24,7 +28,7 @@ class CategoryAliasListener
|
||||
$exists = fn (string $alias): bool => (int) $this->connection->fetchOne('SELECT COUNT(*) FROM tl_wk_category WHERE alias = ? AND id != ?', [$alias, (int) $dc->id]) > 0;
|
||||
|
||||
if ('' === $value) {
|
||||
return $this->slug->generate((string) $dc->getCurrentRecord()['title'], [], $exists);
|
||||
return $this->slug->generate((string) ($dc->getCurrentRecord()['title'] ?? ''), $this->slugOptions(), $exists);
|
||||
}
|
||||
|
||||
if ($exists($value)) {
|
||||
@@ -33,4 +37,21 @@ class CategoryAliasListener
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Weiterleitungsseite des W&K-Kalenders bestimmt Sprache und erlaubte Zeichen des Alias (deutsche
|
||||
* Seiten: „ö“ wird „oe“). Ohne Kalender zählt die Sprache des Backend-Benutzers.
|
||||
*
|
||||
* @return int|array<string, string>
|
||||
*/
|
||||
private function slugOptions(): int|array
|
||||
{
|
||||
foreach ($this->calendars->findEnabled() as $calendar) {
|
||||
if ($calendar->jumpTo > 0) {
|
||||
return $calendar->jumpTo;
|
||||
}
|
||||
}
|
||||
|
||||
return ['locale' => $this->requestStack->getCurrentRequest()?->getLocale() ?? 'de'];
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,12 @@ class NotificationNoticeListener
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->notifier->isAvailable()) {
|
||||
$GLOBALS['TL_DCA']['tl_wk_config']['fields']['notificationNotice']['eval']['text'] = $this->translator->trans('tl_wk_config.notificationUnavailable', [], 'contao_tl_wk_config');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$url = $this->router->generate('contao_backend', ['do' => 'nc_notifications']);
|
||||
|
||||
// Contao-Domänen werden per sprintf ersetzt, daher %s statt benannter Platzhalter
|
||||
|
||||
@@ -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, '.', '');
|
||||
}
|
||||
}
|
||||
@@ -199,10 +199,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)];
|
||||
}
|
||||
|
||||
@@ -243,7 +247,11 @@ class ImportPlanner
|
||||
return $value ? '1' : '0';
|
||||
}
|
||||
|
||||
return trim(str_replace("\r\n", "\n", (string) $value));
|
||||
// Contao 5 speichert Eingaben aus dem Backend kodiert (( für eine Klammer, " in HTML-Feldern),
|
||||
// der Import schreibt Klartext. Die Schreibweise von Entities ist deshalb keine Änderung.
|
||||
$value = html_entity_decode((string) $value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
|
||||
return trim(str_replace("\r\n", "\n", $value));
|
||||
}
|
||||
|
||||
private function memberName(int $id): string
|
||||
|
||||
@@ -20,7 +20,7 @@ class InboxRepository
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<InboxItem> älteste zuerst
|
||||
* @return list<InboxItem> älteste zuerst; im Ordner „Exportiert“ neueste zuerst (siehe InboxState::newestFirst())
|
||||
*/
|
||||
public function findByState(InboxState $state, int $limit = 0): array
|
||||
{
|
||||
@@ -55,6 +55,10 @@ class InboxRepository
|
||||
$items = \array_slice($items, -$limit);
|
||||
}
|
||||
|
||||
if ($state->newestFirst()) {
|
||||
$items = array_reverse($items);
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,14 @@ enum InboxState: string
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Exportierte Datensätze sind ein Archiv und werden neueste zuerst gezeigt; die Arbeitsordner bleiben älteste zuerst.
|
||||
*/
|
||||
public function newestFirst(): bool
|
||||
{
|
||||
return self::Exported === $this;
|
||||
}
|
||||
|
||||
public function translationKey(): string
|
||||
{
|
||||
return '' === $this->value ? 'new' : $this->value;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\ContaoWkSyncBundle\Tests\Booking;
|
||||
|
||||
use Contao\CoreBundle\Framework\ContaoFramework;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Mummert\ContaoWkSyncBundle\Booking\EventResolver;
|
||||
use Mummert\ContaoWkSyncBundle\Category\CategoryKind;
|
||||
use Mummert\ContaoWkSyncBundle\Category\CategoryRepository;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class EventResolverTest extends TestCase
|
||||
{
|
||||
public function testTextFieldsSavedInTheBackendAreDecoded(): void
|
||||
{
|
||||
$event = $this->resolver()->fromRow([
|
||||
'id' => 1,
|
||||
'pid' => 1,
|
||||
'title' => 'Pause für den Kopf (Religion und Ethik)',
|
||||
'location' => 'TPI Moritzburg (barrierefrei)',
|
||||
'teaser' => '<p>Er sagte "Hallo" & ging <weg></p>',
|
||||
'wkSubTitle' => 'Modul 1 & 2',
|
||||
'wkAudience' => 'Geschlossener Teilnehmerkreis',
|
||||
'wkInternalEventNumber' => 'EXTP-12',
|
||||
'wkCategories' => serialize(['3']),
|
||||
]);
|
||||
|
||||
self::assertSame('Pause für den Kopf (Religion und Ethik)', $event->title);
|
||||
self::assertSame('TPI Moritzburg (barrierefrei)', $event->location);
|
||||
self::assertSame('Modul 1 & 2', $event->row['wkSubTitle']);
|
||||
self::assertSame('<p>Er sagte "Hallo" & ging <weg></p>', $event->row['teaser'], 'HTML-Felder bleiben unverändert');
|
||||
self::assertSame([3], $event->categories);
|
||||
self::assertTrue($event->isClosedAudience());
|
||||
self::assertTrue($event->hasExtpNumber());
|
||||
self::assertSame(CategoryKind::Other, $event->kind);
|
||||
}
|
||||
|
||||
public function testPlainTextFromTheImportStaysAsItIs(): void
|
||||
{
|
||||
$event = $this->resolver()->fromRow(['id' => 2, 'pid' => 1, 'title' => 'Bibel & Bild (Teil 2)', 'location' => 'Online']);
|
||||
|
||||
self::assertSame('Bibel & Bild (Teil 2)', $event->title);
|
||||
self::assertTrue($event->isOnline());
|
||||
}
|
||||
|
||||
private function resolver(): EventResolver
|
||||
{
|
||||
$categories = $this->createStub(CategoryRepository::class);
|
||||
$categories->method('find')->willReturn(null);
|
||||
|
||||
return new EventResolver($this->createStub(Connection::class), $this->createStub(ContaoFramework::class), $categories);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -51,12 +51,57 @@ class ImportPlannerTest extends TestCase
|
||||
self::assertSame(['Alter Titel', 'Testseminar'], $plan->events[0]->changes['title']);
|
||||
}
|
||||
|
||||
public function testEntitySpellingAfterABackendSaveIsNoChange(): void
|
||||
{
|
||||
$plan = $this->plan(
|
||||
existingInstructor: 0,
|
||||
existingSpeakers: [],
|
||||
memberIds: [],
|
||||
instructorGroup: 0,
|
||||
speakerGroup: 0,
|
||||
existingOverrides: ['teaser' => '<p>Er sagte "Hallo" & ging</p>'],
|
||||
rowOverrides: ['kbpmemo' => 'Er sagte "Hallo" & ging'],
|
||||
);
|
||||
|
||||
self::assertSame(PlannedEvent::UNCHANGED, $plan->events[0]->action);
|
||||
}
|
||||
|
||||
public function testEncodedTextAfterABackendSaveIsNoChange(): void
|
||||
{
|
||||
$plan = $this->plan(
|
||||
existingInstructor: 0,
|
||||
existingSpeakers: [],
|
||||
memberIds: [],
|
||||
instructorGroup: 0,
|
||||
speakerGroup: 0,
|
||||
existingOverrides: ['title' => 'Testseminar (Teil 1)', 'location' => 'TPI Moritzburg (barrierefrei)'],
|
||||
rowOverrides: ['kbtitel' => 'Testseminar (Teil 1)', 'kbvort' => 'TPI Moritzburg (barrierefrei)'],
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* @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,6 +113,7 @@ class ImportPlannerTest extends TestCase
|
||||
'kbsgebiet' => 'Grundschule',
|
||||
'kbkln1' => 'Anna Muster',
|
||||
'kbmref' => "\nBernd Beispiel",
|
||||
...$rowOverrides,
|
||||
];
|
||||
|
||||
$mapped = (new RowMapper())->map($row, 2);
|
||||
|
||||
Reference in new issue
Block a user