Files
wk-sync-bundle/src/Import/ImportPlanner.php
T
admin.juergenandClaude Fable 5.1 142afa6e52 Initial version of the W&K sync bundle for Contao 5.7
Import of W&K seminar exports with preview, booking form via form generator
with event-dependent field rules, notification routing, inbox with folders
and CSV export for W&K.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 17:42:34 +02:00

248 lines
11 KiB
PHP

<?php
declare(strict_types=1);
namespace Mummert\ContaoWkSyncBundle\Import;
use Contao\StringUtil;
use Doctrine\DBAL\Connection;
use Mummert\ContaoWkSyncBundle\Category\Category;
use Mummert\ContaoWkSyncBundle\Category\CategoryRepository;
use Mummert\ContaoWkSyncBundle\Category\CategoryResolver;
use Mummert\ContaoWkSyncBundle\Sync\CalendarConfig;
/**
* Vergleicht die W&K-Datei mit dem Bestand und erstellt die Vorschau (nichts wird geschrieben).
*/
class ImportPlanner
{
/**
* Felder, die verglichen werden (alle übrigen Spalten bleiben beim Update unangetastet).
*/
public const array COMPARED_FIELDS = [
'title', 'startDate', 'endDate', 'addTime', 'startTime', 'endTime', 'location', 'teaser',
'wkState', 'wkSubTitle', 'wkSubTitleAdditional', 'wkTopics', 'wkAdditionalDates', 'wkTags',
'wkAudience', 'wkCategoryText', 'wkInternalEventNumber', 'wkEventType', 'wkMaxPlaces', 'wkPlacesLeft',
'wkOrganizer', 'wkOrganizerAdditional', 'wkOrganizerSalutation', 'wkOrganizerAcademicTitle',
'wkOrganizerLastname', 'wkOrganizerFirstname', 'wkOrganizerStreet', 'wkOrganizerCountryCode',
'wkOrganizerPostal', 'wkOrganizerCity', 'wkOrganizerEmail',
'wkPrice1', 'wkPriceType1', 'wkPrice2', 'wkPriceType2', 'wkPrice3', 'wkPriceType3',
'wkPrice4', 'wkPriceType4', 'wkPrice5', 'wkPriceType5', 'wkPrice6', 'wkPriceType6',
];
public function __construct(
private readonly Connection $connection,
private readonly RowMapper $rowMapper,
private readonly CategoryRepository $categoryRepository,
private readonly CategoryResolver $categoryResolver,
private readonly MemberResolver $memberResolver,
) {
}
public function plan(CsvTable $table, CalendarConfig $calendar): ImportPlan
{
$missing = $table->missingColumns(RowMapper::REQUIRED_COLUMNS);
if ([] !== $missing) {
throw new ImportException(\sprintf('Die Datei ist kein W&K-Seminarexport: Die Spalten %s fehlen.', implode(', ', $missing)));
}
$categories = $this->categoryRepository->findAll();
$existing = $this->loadExistingEvents($calendar->id);
$messages = $table->warnings;
$events = [];
$seen = [];
if ([] === $categories) {
$messages[] = ImportMessage::warning('Es sind noch keine W&K-Kategorien angelegt. Alle Seminare würden mangels Kategorie storniert; bitte zuerst die Kategorien pflegen.');
}
foreach ($table->rows as $row) {
$record = (int) ($row['__record'] ?? 0);
unset($row['__record']);
$wkRow = $this->rowMapper->map($row, $record);
if ($wkRow->skipped) {
$events[] = new PlannedEvent(PlannedEvent::SKIP, $wkRow->wkId, (string) ($row['kbtitel'] ?? ''), 0, [], [], [], 0, '', [], $wkRow->messages, $record);
$seen[$wkRow->wkId] = true;
continue;
}
if (isset($seen[$wkRow->wkId])) {
$events[] = new PlannedEvent(PlannedEvent::SKIP, $wkRow->wkId, $wkRow->data['title'], 0, [], [], [], 0, '', [], [
ImportMessage::error(\sprintf('Seminar %s „%s“: Die W&K-Nummer kommt in der Datei mehrfach vor; nur der erste Datensatz wird verwendet.', $wkRow->wkId, $wkRow->data['title']), $wkRow->wkId, $record),
], $record);
continue;
}
$seen[$wkRow->wkId] = true;
$events[] = $this->planRow($wkRow, $existing[$wkRow->wkId] ?? null, $categories, $calendar);
}
// Seminare, die nicht mehr in der Datei stehen, werden storniert
foreach ($existing as $wkId => $row) {
if (isset($seen[$wkId])) {
continue;
}
if (WkState::isCancelled($row['wkState']) && !$row['published']) {
continue;
}
$events[] = new PlannedEvent(PlannedEvent::CANCEL, (string) $wkId, (string) $row['title'], (int) $row['id'], [], [
'wkState' => [(string) $row['wkState'], WkState::CANCELLED],
'published' => [(bool) $row['published'], false],
], [], 0, '', [], [
ImportMessage::info(\sprintf('Seminar %s „%s“ steht nicht mehr in der W&K-Datei und wird storniert und ausgeblendet.', $wkId, $row['title']), (string) $wkId),
]);
}
return new ImportPlan($calendar->id, $events, $messages);
}
/**
* @param array<string, mixed>|null $existing
* @param list<Category> $categories
*/
private function planRow(WkRow $row, array|null $existing, array $categories, CalendarConfig $calendar): PlannedEvent
{
$data = $row->data;
$messages = $row->messages;
$wkId = $row->wkId;
$title = (string) $data['title'];
// Kategorien
$resolved = $this->categoryResolver->resolve($row->categoryText, $categories);
$categoryIds = array_map(static fn (Category $c): int => $c->id, $resolved);
$primary = $categoryIds[0] ?? 0;
if ([] === $resolved) {
$messages[] = ImportMessage::error('' === trim($row->categoryText)
? \sprintf('Seminar %s „%s“: Das Feld kbsgebiet enthält keinen Kategorienamen. Das Seminar wird storniert und ausgeblendet.', $wkId, $title)
: \sprintf('Seminar %s „%s“: Für „%s“ (kbsgebiet) gibt es keine W&K-Kategorie. Das Seminar wird storniert und ausgeblendet.', $wkId, $title, $row->categoryText), $wkId, $row->line);
$data['wkState'] = WkState::CANCELLED;
}
$cancelled = WkState::isCancelled($data['wkState']);
// Personen (nur nachschlagen; angelegt wird beim Anwenden)
$instructorId = '' !== $row->instructorName ? $this->memberResolver->findId($row->instructorName) : null;
$speakerIds = [];
foreach ($row->speakerNames as $name) {
$speakerIds[] = $this->memberResolver->findId($name);
}
if (null === $existing) {
$data['published'] = !$cancelled;
if ('' !== $row->instructorName && null === $instructorId && $calendar->instructorGroup < 1) {
$messages[] = ImportMessage::info(\sprintf('Seminar %s „%s“: Kursleitung „%s“ ist kein Mitglied; ohne Mitgliedergruppe im Kalender wird kein Mitglied angelegt.', $wkId, $title, $row->instructorName), $wkId, $row->line);
}
return new PlannedEvent(PlannedEvent::CREATE, $wkId, $title, 0, $data, [], $categoryIds, $primary, $row->instructorName, $row->speakerNames, $messages, $row->line);
}
$changes = [];
foreach (self::COMPARED_FIELDS as $field) {
if (!\array_key_exists($field, $data)) {
continue;
}
$old = $this->normalize($field, $existing[$field] ?? null);
$new = $this->normalize($field, $data[$field]);
if ($old !== $new) {
$changes[$field] = [$existing[$field] ?? null, $data[$field]];
}
}
// Veröffentlichung folgt nur einem Statuswechsel, damit manuelles Ausblenden erhalten bleibt
$wasCancelled = WkState::isCancelled($existing['wkState']);
$published = (bool) $existing['published'];
if ($cancelled && $published) {
$data['published'] = false;
$changes['published'] = [true, false];
} elseif (!$cancelled && $wasCancelled && !$published) {
$data['published'] = true;
$changes['published'] = [false, true];
}
$existingCategories = array_map(intval(...), StringUtil::deserialize($existing['wkCategories'] ?? null, true));
sort($existingCategories);
$sortedNew = $categoryIds;
sort($sortedNew);
if ($existingCategories !== $sortedNew) {
$changes['wkCategories'] = [$existingCategories, $categoryIds];
}
if ((int) $existing['wkCategoryPrimary'] !== $primary) {
$changes['wkCategoryPrimary'] = [(int) $existing['wkCategoryPrimary'], $primary];
}
$existingInstructor = (int) $existing['wkInstructor'];
if (('' !== $row->instructorName || $existingInstructor > 0) && $existingInstructor !== (int) $instructorId) {
$changes['wkInstructor'] = [$this->memberName($existingInstructor), $row->instructorName];
}
$existingSpeakers = array_map(intval(...), StringUtil::deserialize($existing['wkSpeakers'] ?? null, true));
if ($existingSpeakers !== array_map(static fn (int|null $id): int => (int) $id, $speakerIds) && ([] !== $existingSpeakers || [] !== $row->speakerNames)) {
$changes['wkSpeakers'] = [implode(', ', array_values($this->memberResolver->namesByIds($existingSpeakers))), implode(', ', $row->speakerNames)];
}
$action = [] === $changes ? PlannedEvent::UNCHANGED : PlannedEvent::UPDATE;
return new PlannedEvent($action, $wkId, $title, (int) $existing['id'], $data, $changes, $categoryIds, $primary, $row->instructorName, $row->speakerNames, $messages, $row->line);
}
/**
* @return array<string, array<string, mixed>> W&K-Nummer => Zeile
*/
private function loadExistingEvents(int $calendarId): array
{
$columns = array_unique(['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;
}
return $events;
}
private function normalize(string $field, mixed $value): string
{
if (str_starts_with($field, 'wkPrice') && !str_starts_with($field, 'wkPriceType')) {
return number_format((float) $value, 2, '.', '');
}
if (\in_array($field, ['startDate', 'endDate', 'startTime', 'endTime', 'wkEventType', 'wkMaxPlaces', 'wkPlacesLeft'], true)) {
return (string) (int) $value;
}
if ('addTime' === $field) {
return $value ? '1' : '0';
}
return trim(str_replace("\r\n", "\n", (string) $value));
}
private function memberName(int $id): string
{
if ($id < 1) {
return '';
}
return $this->memberResolver->namesByIds([$id])[$id] ?? (string) $id;
}
}