Housekeeping from the code review

Adds wk-sync:purge to remove old files under var/wk-sync (purge() was
never reachable), makes stored file names unique within a second,
excludes widgets from service autowiring, removes unused repository
methods, and adds a planner test covering re-imports with and without
member groups.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jürgen MummertandClaude Fable 5.1 committed 2026-09-26 18:56:59 +02:00
1 parent 995b39e916
commit 8aebfcd28d
9 files changed
+140 -64

No files matched your search

+1 -1
View File
@@ -101,7 +101,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/`.
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.
Auf der Konsole:
+1
View File
@@ -17,6 +17,7 @@ services:
- '../src/Booking/{Availability,BookingFields}.php'
- '../src/Inbox/{InboxItem,InboxState}.php'
- '../src/Sync/CalendarConfig.php'
- '../src/Widget/'
# Ohne Notification Center werden Anmeldungen gespeichert, aber keine Mails verschickt
Mummert\ContaoWkSyncBundle\Booking\BookingNotifierInterface: '@Mummert\ContaoWkSyncBundle\Booking\NullNotifier'
-12
View File
@@ -59,18 +59,6 @@ class BookingRepository
return false === $row ? null : $row;
}
/**
* Gibt es zu dieser E-Mail-Adresse bereits eine offene Anmeldung für die Veranstaltung?
*/
public function hasOpenBookingForEmail(int $eventId, string $email, int $excludeId = 0): bool
{
return (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM tl_wk_booking WHERE pid = ? AND email = ? AND id != ? AND state != ?',
[$eventId, $email, $excludeId, InboxState::Spam->value],
) > 0;
}
/**
* @param array<string, mixed> $data
*/
-16
View File
@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Mummert\ContaoWkSyncBundle\Category;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Connection;
use Symfony\Contracts\Service\ResetInterface;
@@ -76,21 +75,6 @@ class CategoryRepository implements ResetInterface
return (int) $this->connection->lastInsertId();
}
/**
* @param list<int> $ids
*
* @return list<int> IDs, die tatsächlich existieren
*/
public function existingIds(array $ids): array
{
if ([] === $ids) {
return [];
}
return array_map(intval(...), $this->connection->fetchFirstColumn('SELECT id FROM tl_wk_category WHERE id IN (?)', [$ids], [ArrayParameterType::INTEGER]));
}
public function reset(): void
{
$this->cache = null;
+37
View File
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Mummert\ContaoWkSyncBundle\Command;
use Mummert\ContaoWkSyncBundle\Import\SyncFileStorage;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'wk-sync:purge', description: 'Löscht Import- und Exportdateien unter var/wk-sync, die älter als die angegebene Anzahl Tage sind.')]
class PurgeCommand extends Command
{
public function __construct(private readonly SyncFileStorage $storage)
{
parent::__construct();
}
protected function configure(): void
{
$this->addOption('days', null, InputOption::VALUE_REQUIRED, 'Dateien behalten, die jünger sind als so viele Tage', '90');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$days = max(1, (int) $input->getOption('days'));
$deleted = $this->storage->purge($days);
(new SymfonyStyle($input, $output))->success(\sprintf('%d Datei(en) gelöscht, die älter als %d Tage waren. Protokolleinträge bleiben erhalten.', $deleted, $days));
return Command::SUCCESS;
}
}
-13
View File
@@ -7,7 +7,6 @@ namespace Mummert\ContaoWkSyncBundle\Import;
use Contao\CoreBundle\Cache\CacheTagManager;
use Contao\CoreBundle\Framework\ContaoFramework;
use Contao\CoreBundle\Slug\Slug;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Connection;
use Mummert\ContaoWkSyncBundle\Sync\CalendarConfig;
@@ -145,16 +144,4 @@ class ImportExecutor
fn (string $alias): bool => (int) $this->connection->fetchOne('SELECT COUNT(*) FROM tl_calendar_events WHERE alias = ?', [$alias]) > 0,
);
}
/**
* @param list<int> $ids
*/
public function eventsExist(array $ids): bool
{
if ([] === $ids) {
return true;
}
return (int) $this->connection->fetchOne('SELECT COUNT(*) FROM tl_calendar_events WHERE id IN (?)', [$ids], [ArrayParameterType::INTEGER]) === \count(array_unique($ids));
}
}
+1 -1
View File
@@ -104,6 +104,6 @@ class SyncFileStorage
$base = preg_replace('/[^A-Za-z0-9_-]+/', '_', $base) ?? 'datei';
$base = trim(substr($base, 0, 60), '_') ?: 'datei';
return date('Ymd-His').'_'.$base.'.'.$extension;
return date('Ymd-His').'_'.bin2hex(random_bytes(3)).'_'.$base.'.'.$extension;
}
}
-21
View File
@@ -42,25 +42,4 @@ class CalendarConfigRepository
return array_map(CalendarConfig::fromRow(...), $rows);
}
public function findForEvent(int $eventId): CalendarConfig|null
{
$calendarId = $this->connection->fetchOne('SELECT pid FROM tl_calendar_events WHERE id = ?', [$eventId]);
return false === $calendarId ? null : $this->find((int) $calendarId);
}
/**
* @return list<CalendarConfig>
*/
public function findByBookingForm(int $formId): array
{
if ($formId < 1) {
return [];
}
$rows = $this->connection->fetchAllAssociative(self::SELECT.' WHERE c.bookingForm = ?', [$formId]);
return array_map(CalendarConfig::fromRow(...), $rows);
}
}
+100
View File
@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace Mummert\ContaoWkSyncBundle\Tests\Import;
use Doctrine\DBAL\Connection;
use Mummert\ContaoWkSyncBundle\Category\Category;
use Mummert\ContaoWkSyncBundle\Category\CategoryKind;
use Mummert\ContaoWkSyncBundle\Category\CategoryRepository;
use Mummert\ContaoWkSyncBundle\Category\CategoryResolver;
use Mummert\ContaoWkSyncBundle\Import\CsvTable;
use Mummert\ContaoWkSyncBundle\Import\ImportPlanner;
use Mummert\ContaoWkSyncBundle\Import\MemberResolver;
use Mummert\ContaoWkSyncBundle\Import\PlannedEvent;
use Mummert\ContaoWkSyncBundle\Import\RowMapper;
use Mummert\ContaoWkSyncBundle\Sync\CalendarConfig;
use PHPUnit\Framework\TestCase;
class ImportPlannerTest extends TestCase
{
public function testReimportWithoutMemberGroupsIsUnchanged(): void
{
$plan = $this->plan(existingInstructor: 0, existingSpeakers: [], memberIds: [], instructorGroup: 0, speakerGroup: 0);
self::assertSame(PlannedEvent::UNCHANGED, $plan->events[0]->action);
self::assertSame([], $plan->events[0]->changes);
}
public function testResolvedMembersAreComparedById(): void
{
$plan = $this->plan(existingInstructor: 11, existingSpeakers: [12], memberIds: ['Anna Muster' => 11, 'Bernd Beispiel' => 12], instructorGroup: 0, speakerGroup: 0);
self::assertSame(PlannedEvent::UNCHANGED, $plan->events[0]->action);
}
public function testMemberGroupsPlanMemberCreation(): void
{
$plan = $this->plan(existingInstructor: 0, existingSpeakers: [], memberIds: [], instructorGroup: 4, speakerGroup: 5);
self::assertSame(PlannedEvent::UPDATE, $plan->events[0]->action);
self::assertSame(['wkInstructor', 'wkSpeakers'], array_keys($plan->events[0]->changes));
self::assertSame(['', 'Anna Muster'], $plan->events[0]->changes['wkInstructor']);
}
public function testChangedTitleIsAnUpdate(): void
{
$plan = $this->plan(existingInstructor: 0, existingSpeakers: [], memberIds: [], instructorGroup: 0, speakerGroup: 0, existingOverrides: ['title' => 'Alter Titel']);
self::assertSame(PlannedEvent::UPDATE, $plan->events[0]->action);
self::assertSame(['Alter Titel', 'Testseminar'], $plan->events[0]->changes['title']);
}
/**
* @param array<string, int> $memberIds Name => Mitglieds-ID (nur diese Namen sind Mitglieder)
* @param list<int> $existingSpeakers
* @param array<string, mixed> $existingOverrides
*/
private function plan(int $existingInstructor, array $existingSpeakers, array $memberIds, int $instructorGroup, int $speakerGroup, array $existingOverrides = []): \Mummert\ContaoWkSyncBundle\Import\ImportPlan
{
$row = [
'kbnr' => '0001234',
'kbvsnr' => 'ABC-1',
'kbtitel' => 'Testseminar',
'kbanreise' => '01.03.2027',
'kbabreise' => '02.03.2027',
'kbstatus1' => '2',
'kbsgebiet' => 'Grundschule',
'kbkln1' => 'Anna Muster',
'kbmref' => "\nBernd Beispiel",
];
$mapped = (new RowMapper())->map($row, 2);
$existing = [
...$mapped->data,
...$existingOverrides,
'id' => 7,
'published' => 1,
'wkCategories' => serialize(['3']),
'wkCategoryPrimary' => 3,
'wkInstructor' => $existingInstructor,
'wkSpeakers' => serialize(array_map(strval(...), $existingSpeakers)),
];
$connection = $this->createStub(Connection::class);
$connection->method('fetchAllAssociative')->willReturn([$existing]);
$categories = $this->createStub(CategoryRepository::class);
$categories->method('findAll')->willReturn([new Category(3, 'Grundschule', 'grundschule', CategoryKind::School, ['Grundschule'], 16)]);
$members = $this->createStub(MemberResolver::class);
$members->method('findId')->willReturnCallback(static fn (string $name): int|null => $memberIds[$name] ?? null);
$members->method('namesByIds')->willReturnCallback(static fn (array $ids): array => array_filter(array_flip($memberIds), static fn (int $id): bool => \in_array($id, $ids, true)));
$planner = new ImportPlanner($connection, new RowMapper(), $categories, new CategoryResolver($categories), $members);
$calendar = new CalendarConfig(1, 1, 'Kalender', 'Einstellungen', 0, true, 2, 8, 9, 0, 1, 31, ['TPI Moritzburg'], 1, $instructorGroup, $speakerGroup, [], 0, '', 0, []);
return $planner->plan(new CsvTable(array_keys($row), [$row + ['__record' => '2']], 'UTF-8'), $calendar);
}
}