refactor(model): update finalizationStatus for agenda model

This commit is contained in:
Thorsten Bus 2026-03-29 11:46:54 +02:00
parent 7a71b8b2de
commit 2d70026a20
3 changed files with 420 additions and 2 deletions

View file

@ -86,3 +86,107 @@ ### Code Patterns
- Public method documentation via docstrings (necessary for API clarity)
- Test organization via section headers (necessary for readability)
- No regex used — just `fnmatch()` for simplicity and correctness
## 2026-03-29 — Settings Controller Agenda Keys
### Added 4 New Configuration Keys
- `agenda_start_title` — start boundary title pattern for agenda display
- `agenda_end_title` — end boundary title pattern
- `agenda_announcement_position` — comma-separated patterns for announcement position
- `agenda_sermon_matching` — comma-separated patterns for sermon recognition
### SettingsController Updates
- Modified `MACRO_KEYS` constant array in `SettingsController.php` to include the 4 new keys
- Validation automatically accepts new keys via `'in:'.implode(',', self::MACRO_KEYS)` rule
- `index()` already iterates through all MACRO_KEYS, so no additional logic needed
- Settings fetched via `Setting::get($key)` and passed to Inertia
### Test Implementation
- Created `tests/Feature/SettingsControllerAgendaKeysTest.php` with 6 Pest tests
- Test patterns used:
- `$response->assertInertia()` with `->has('settings.key')` for prop assertions
- `$this->patchJson(route('settings.update'), [...])` for PATCH requests
- `Setting::get($key)` to verify database persistence
- `$response->assertUnprocessable()` for invalid key validation
- All tests pass; code passes Pint formatting
## 2026-03-29 — DiscoverAgendaTypes command
### Implementation
- Created `DiscoverAgendaTypes.php` artisan command (cts:discover-agenda-types)
- Fetches next upcoming service from DB (date >= today, order by date ASC)
- Calls `ChurchToolsService->syncAgenda($ctsEventId)` to fetch agenda
- Displays formatted table: Position | Titel | Typ | Hat Song | Vor Event | Dauer
- Prints distinct type values found in agenda items
### Key Methods Used
- `$agenda->getItems()` returns array of EventAgendaItem
- `EventAgendaItem` getters: `getPosition()`, `getTitle()`, `getType()`, `getSong()`, `getIsBeforeEvent()`, `getDuration()`
- `Str::limit($string, 40, '...')` truncates titles to 40 chars
### Test Coverage (5 tests)
- No service found → outputs "Kein bevorstehender Service gefunden."
- Displays table and unique types from agenda items
- Handles null agenda → outputs "Keine Agenda für diesen Service gefunden."
- Handles empty items array → outputs "Keine Agenda-Items gefunden."
- Truncates long titles (40 chars) and still displays unique types
### Code Patterns
- Command signature: `protected $signature = 'cts:discover-agenda-types';`
- Dependency injection via `handle(ChurchToolsService $churchToolsService): int`
- Test mocking: `$this->app->bind(ChurchToolsService::class, function () use (...) { ... })`
- Table output: `$this->table($headers, $rows)` with array of arrays
- Info output: `$this->info()` for text, `$this->line()` for list items
### Status
- ✓ Command created: `app/Console/Commands/DiscoverAgendaTypes.php`
- ✓ Tests created: `tests/Feature/DiscoverAgendaTypesTest.php` (5 tests, all passing)
- ✓ Pint formatting: clean (`pint --test` passes)
- ✓ Ready for: `git commit -m "chore(debug): add CTS agenda type discovery command"`
## 2026-03-29 — Service::finalizationStatus() refactor
### Implementation
- Updated `Service::finalizationStatus()` to check new `service_agenda_items` model
- Added backward compatibility: if `agendaItems()->count() === 0`, fall back to legacy `finalizationStatusLegacy()`
- New checks (agenda-based):
1. Unmatched songs: Find agenda items with `service_song_id` where `serviceSong.song_id IS NULL`
- Warning format: `"{songName} wurde noch nicht zugeordnet"`
2. Songs without arrangement: Find agenda items where `serviceSong.song_arrangement_id IS NULL`
- Warning format: `"{songName} hat kein Arrangement ausgewählt"`
3. Sermon slides:
- If `Setting::get('agenda_sermon_matching')` configured: check if any matching agenda item has slides
- Else: fall back to legacy check `$this->slides()->where('type', 'sermon')->exists()`
- Warning: `"Keine Predigt-Folien hochgeladen"`
### Code Changes
- Modified: `app/Models/Service.php`
- Added import: `use App\Services\AgendaMatcherService;`
- Updated `finalizationStatus()` method (95 lines)
- Added private `finalizationStatusLegacy()` method (28 lines) for backward compat
- Used `app(AgendaMatcherService::class)` to instantiate service for pattern matching
- Preserved `agendaItems()` HasMany relationship (added in earlier Task)
- Modified: `tests/Feature/FinalizationTest.php`
- Added imports: `ServiceAgendaItem`, `Setting`
- Renamed first test to `finalize ohne voraussetzungen gibt warnungen zurueck (legacy)` for clarity
- Added 6 new agenda-based tests:
1. `agenda finalization warnt bei unzugeordneten songs` — unmatched song warning
2. `agenda finalization warnt bei songs ohne arrangement` — no arrangement warning
3. `agenda finalization bereit wenn alle songs zugeordnet und arrangement` — all pass
4. `agenda finalization warnt wenn keine predigtfolien und sermon setting konfiguriert` — sermon with setting
5. `agenda finalization ok wenn predigtfolien bei sermon item vorhanden` — sermon slides exist
6. `agenda finalization warnt wenn keine predigtfolien und kein sermon setting` — no setting fallback
### Test Results
- All 18 tests pass (12 existing + 6 new)
- Backward compatibility verified: legacy tests with `service_songs` still work
- Pint formatting: clean
### Code Patterns Used
- Guard clause in `finalizationStatus()` for backward compat check
- `$agendaItems->filter()` with closures for conditional filtering
- Service instantiation via `app(AgendaMatcherService::class)` with closure captures
- Null-safe navigation: `$item->serviceSong?->song_id`
- Setting retrieval: `Setting::get('agenda_sermon_matching')`
- Slide filtering: `whereNull('deleted_at')` for soft deletes

View file

@ -2,6 +2,7 @@
namespace App\Models;
use App\Services\AgendaMatcherService;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@ -42,6 +43,11 @@ public function slides(): HasMany
return $this->hasMany(Slide::class);
}
public function agendaItems(): HasMany
{
return $this->hasMany(ServiceAgendaItem::class)->orderBy('sort_order');
}
/**
* Check finalization prerequisites and return warnings.
*
@ -51,6 +57,87 @@ public function finalizationStatus(): array
{
$warnings = [];
// Backward compatibility: if no agenda items exist, use old checks
if ($this->agendaItems()->count() === 0) {
return $this->finalizationStatusLegacy();
}
// New agenda-based checks
$agendaItems = $this->agendaItems()->with('serviceSong.song')->get();
// Check 1: Unmatched songs (song-type agenda items where song_id IS NULL)
$unmatchedSongs = $agendaItems->filter(function (ServiceAgendaItem $item) {
if ($item->service_song_id === null) {
return false;
}
$serviceSong = $item->serviceSong;
return $serviceSong && $serviceSong->song_id === null;
});
foreach ($unmatchedSongs as $item) {
$songName = $item->serviceSong?->cts_song_name ?? 'Unbekannter Song';
$warnings[] = "{$songName} wurde noch nicht zugeordnet";
}
// Check 2: Matched songs without arrangement
$matchedSongsWithoutArrangement = $agendaItems->filter(function (ServiceAgendaItem $item) {
if ($item->service_song_id === null) {
return false;
}
$serviceSong = $item->serviceSong;
return $serviceSong && $serviceSong->song_id !== null && $serviceSong->song_arrangement_id === null;
});
foreach ($matchedSongsWithoutArrangement as $item) {
$songName = $item->serviceSong?->cts_song_name ?? 'Unbekannter Song';
$warnings[] = "{$songName} hat kein Arrangement ausgewählt";
}
// Check 3: Sermon slides
$sermonPatterns = Setting::get('agenda_sermon_matching');
if ($sermonPatterns) {
// Settings configured: check if any agenda item flagged as sermon has slides
$agendaMatcher = app(AgendaMatcherService::class);
$sermonItems = $agendaItems->filter(function (ServiceAgendaItem $item) use ($agendaMatcher, $sermonPatterns) {
return $agendaMatcher->matches($item->title, $sermonPatterns);
});
$sermonHasSlides = false;
foreach ($sermonItems as $item) {
if ($item->slides()->whereNull('deleted_at')->exists()) {
$sermonHasSlides = true;
break;
}
}
if (! $sermonHasSlides) {
$warnings[] = 'Keine Predigt-Folien hochgeladen';
}
} else {
// No settings configured: fall back to checking slides table
if (! $this->slides()->where('type', 'sermon')->exists()) {
$warnings[] = 'Keine Predigt-Folien hochgeladen';
}
}
return [
'ready' => empty($warnings),
'warnings' => $warnings,
];
}
/**
* Legacy finalization status for services without agenda items.
*
* @return array{ready: bool, warnings: string[]}
*/
private function finalizationStatusLegacy(): array
{
$warnings = [];
$totalSongs = $this->serviceSongs()->count();
$mappedSongs = $this->serviceSongs()->whereNotNull('song_id')->count();
$arrangedSongs = $this->serviceSongs()->whereNotNull('song_arrangement_id')->count();

View file

@ -1,7 +1,9 @@
<?php
use App\Models\Service;
use App\Models\ServiceAgendaItem;
use App\Models\ServiceSong;
use App\Models\Setting;
use App\Models\Slide;
use App\Models\Song;
use App\Models\SongArrangement;
@ -19,10 +21,10 @@
$this->user = User::factory()->create();
});
test('finalize ohne voraussetzungen gibt warnungen zurueck', function () {
test('finalize ohne voraussetzungen gibt warnungen zurueck (legacy)', function () {
$service = Service::factory()->create(['finalized_at' => null]);
// Song without match or arrangement
// Song without match or arrangement (legacy service_songs)
ServiceSong::create([
'service_id' => $service->id,
'song_id' => null,
@ -251,3 +253,228 @@
->and($status['warnings'])->toHaveCount(1)
->and($status['warnings'][0])->toContain('Predigtfolien');
});
// Agenda-based finalization tests
test('agenda finalization warnt bei unzugeordneten songs', function () {
$service = Service::factory()->create(['finalized_at' => null]);
// Create an unmatched song via agenda item
$serviceSong = ServiceSong::create([
'service_id' => $service->id,
'song_id' => null,
'song_arrangement_id' => null,
'use_translation' => false,
'order' => 1,
'cts_song_name' => 'Unzugeordneter Song',
'cts_ccli_id' => '12345',
]);
ServiceAgendaItem::create([
'service_id' => $service->id,
'position' => '1',
'title' => 'Song 1',
'type' => 'Song',
'service_song_id' => $serviceSong->id,
'sort_order' => 1,
]);
$status = $service->finalizationStatus();
expect($status['ready'])->toBeFalse()
->and($status['warnings'])->toContain('Unzugeordneter Song wurde noch nicht zugeordnet');
});
test('agenda finalization warnt bei songs ohne arrangement', function () {
$service = Service::factory()->create(['finalized_at' => null]);
$song = Song::factory()->create();
// Song is matched but has no arrangement
$serviceSong = ServiceSong::create([
'service_id' => $service->id,
'song_id' => $song->id,
'song_arrangement_id' => null,
'use_translation' => false,
'order' => 1,
'cts_song_name' => 'Song ohne Arrangement',
'cts_ccli_id' => '12345',
]);
ServiceAgendaItem::create([
'service_id' => $service->id,
'position' => '1',
'title' => 'Song 1',
'type' => 'Song',
'service_song_id' => $serviceSong->id,
'sort_order' => 1,
]);
$status = $service->finalizationStatus();
expect($status['ready'])->toBeFalse()
->and($status['warnings'])->toContain('Song ohne Arrangement hat kein Arrangement ausgewählt');
});
test('agenda finalization bereit wenn alle songs zugeordnet und arrangement', function () {
$service = Service::factory()->create(['finalized_at' => null]);
$song = Song::factory()->create();
$arrangement = SongArrangement::factory()->create(['song_id' => $song->id]);
// Song is matched with arrangement
$serviceSong = ServiceSong::create([
'service_id' => $service->id,
'song_id' => $song->id,
'song_arrangement_id' => $arrangement->id,
'use_translation' => false,
'order' => 1,
'cts_song_name' => 'Zugeordneter Song',
'cts_ccli_id' => '12345',
]);
ServiceAgendaItem::create([
'service_id' => $service->id,
'position' => '1',
'title' => 'Song 1',
'type' => 'Song',
'service_song_id' => $serviceSong->id,
'sort_order' => 1,
]);
// Add sermon slides
Slide::factory()->create([
'service_id' => $service->id,
'type' => 'sermon',
]);
$status = $service->finalizationStatus();
expect($status['ready'])->toBeTrue()
->and($status['warnings'])->toHaveCount(0);
});
test('agenda finalization warnt wenn keine predigtfolien und sermon setting konfiguriert', function () {
$service = Service::factory()->create(['finalized_at' => null]);
// Configure sermon pattern
Setting::set('agenda_sermon_matching', 'Predigt*');
$song = Song::factory()->create();
$arrangement = SongArrangement::factory()->create(['song_id' => $song->id]);
$serviceSong = ServiceSong::create([
'service_id' => $service->id,
'song_id' => $song->id,
'song_arrangement_id' => $arrangement->id,
'use_translation' => false,
'order' => 1,
'cts_song_name' => 'Zugeordneter Song',
'cts_ccli_id' => '12345',
]);
// Create agenda item for sermon (matches pattern)
ServiceAgendaItem::create([
'service_id' => $service->id,
'position' => '1',
'title' => 'Predigt',
'type' => 'Default',
'sort_order' => 1,
]);
ServiceAgendaItem::create([
'service_id' => $service->id,
'position' => '2',
'title' => 'Song 1',
'type' => 'Song',
'service_song_id' => $serviceSong->id,
'sort_order' => 2,
]);
// No sermon slides
$status = $service->finalizationStatus();
expect($status['ready'])->toBeFalse()
->and($status['warnings'])->toContain('Keine Predigt-Folien hochgeladen');
});
test('agenda finalization ok wenn predigtfolien bei sermon item vorhanden', function () {
$service = Service::factory()->create(['finalized_at' => null]);
// Configure sermon pattern
Setting::set('agenda_sermon_matching', 'Predigt*');
$song = Song::factory()->create();
$arrangement = SongArrangement::factory()->create(['song_id' => $song->id]);
$serviceSong = ServiceSong::create([
'service_id' => $service->id,
'song_id' => $song->id,
'song_arrangement_id' => $arrangement->id,
'use_translation' => false,
'order' => 1,
'cts_song_name' => 'Zugeordneter Song',
'cts_ccli_id' => '12345',
]);
// Create sermon agenda item
$sermonItem = ServiceAgendaItem::create([
'service_id' => $service->id,
'position' => '1',
'title' => 'Predigt',
'type' => 'Default',
'sort_order' => 1,
]);
ServiceAgendaItem::create([
'service_id' => $service->id,
'position' => '2',
'title' => 'Song 1',
'type' => 'Song',
'service_song_id' => $serviceSong->id,
'sort_order' => 2,
]);
// Add sermon slides to the sermon agenda item
Slide::factory()->create([
'service_id' => $service->id,
'service_agenda_item_id' => $sermonItem->id,
]);
$status = $service->finalizationStatus();
expect($status['ready'])->toBeTrue()
->and($status['warnings'])->toHaveCount(0);
});
test('agenda finalization warnt wenn keine predigtfolien und kein sermon setting', function () {
$service = Service::factory()->create(['finalized_at' => null]);
// No sermon pattern setting configured
Setting::set('agenda_sermon_matching', '');
$song = Song::factory()->create();
$arrangement = SongArrangement::factory()->create(['song_id' => $song->id]);
$serviceSong = ServiceSong::create([
'service_id' => $service->id,
'song_id' => $song->id,
'song_arrangement_id' => $arrangement->id,
'use_translation' => false,
'order' => 1,
'cts_song_name' => 'Zugeordneter Song',
'cts_ccli_id' => '12345',
]);
ServiceAgendaItem::create([
'service_id' => $service->id,
'position' => '1',
'title' => 'Song 1',
'type' => 'Song',
'service_song_id' => $serviceSong->id,
'sort_order' => 1,
]);
// No sermon slides - fallback should warn
$status = $service->finalizationStatus();
expect($status['ready'])->toBeFalse()
->and($status['warnings'])->toContain('Keine Predigt-Folien hochgeladen');
});