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>
248 lines
11 KiB
PHP
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;
|
|
}
|
|
}
|