feat(songs): add CTS song ID matching, info slide date filter, arrangement ordering, translation defaults
- Add cts_song_id column to songs and service_songs for CCLI-free matching fallback - Filter information slides by uploaded_at <= service date (not shown before upload) - New arrangements use song's default group ordering instead of cloning - Auto-set use_translation=true when matched song has translation - Update syncSongs/syncServiceAgendaSongs to store and use cts_song_id - Add tests for CTS song ID fallback, upload date filtering, and translation defaults
This commit is contained in:
parent
f561c0ada9
commit
a36841f920
|
|
@ -23,19 +23,18 @@ public function store(Request $request, Song $song): RedirectResponse
|
|||
'is_default' => false,
|
||||
]);
|
||||
|
||||
$source = $song->arrangements()
|
||||
->where('is_default', true)
|
||||
->with('arrangementGroups')
|
||||
->first();
|
||||
$groups = $song->groups()->orderBy('order')->get();
|
||||
$rows = $groups->map(fn ($group, $index) => [
|
||||
'song_arrangement_id' => $arrangement->id,
|
||||
'song_group_id' => $group->id,
|
||||
'order' => $index + 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
])->all();
|
||||
|
||||
if ($source === null) {
|
||||
$source = $song->arrangements()
|
||||
->with('arrangementGroups')
|
||||
->orderBy('id')
|
||||
->first();
|
||||
if ($rows !== []) {
|
||||
$arrangement->arrangementGroups()->insert($rows);
|
||||
}
|
||||
|
||||
$this->cloneGroups($source, $arrangement);
|
||||
});
|
||||
|
||||
return back()->with('success', 'Arrangement wurde hinzugefügt.');
|
||||
|
|
|
|||
|
|
@ -111,6 +111,7 @@ public function edit(Service $service): Response
|
|||
$q->whereNull('expire_date')
|
||||
->orWhereDate('expire_date', '>=', $service->date);
|
||||
})
|
||||
->whereDate('uploaded_at', '<=', $service->date)
|
||||
)
|
||||
->orderByDesc('uploaded_at')
|
||||
->get();
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ class ServiceSong extends Model
|
|||
'order',
|
||||
'cts_song_name',
|
||||
'cts_ccli_id',
|
||||
'cts_song_id',
|
||||
'matched_at',
|
||||
'request_sent_at',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ class Song extends Model
|
|||
|
||||
protected $fillable = [
|
||||
'ccli_id',
|
||||
'cts_song_id',
|
||||
'title',
|
||||
'author',
|
||||
'copyright_text',
|
||||
|
|
|
|||
|
|
@ -108,20 +108,36 @@ public function syncSongs(): int
|
|||
|
||||
foreach ($songs as $song) {
|
||||
$ccliId = $this->normalizeCcli($song->getCcli() ?? null);
|
||||
if ($ccliId === null) {
|
||||
continue;
|
||||
$ctsSongId = (string) ($song->getId() ?? '');
|
||||
|
||||
if ($ccliId !== null) {
|
||||
DB::table('songs')->updateOrInsert(
|
||||
['ccli_id' => $ccliId],
|
||||
[
|
||||
'cts_song_id' => $ctsSongId !== '' ? $ctsSongId : null,
|
||||
'title' => (string) ($song->getName() ?? ''),
|
||||
'updated_at' => Carbon::now(),
|
||||
'created_at' => Carbon::now(),
|
||||
]
|
||||
);
|
||||
|
||||
$count++;
|
||||
}
|
||||
|
||||
DB::table('songs')->updateOrInsert(
|
||||
['ccli_id' => $ccliId],
|
||||
[
|
||||
'title' => (string) ($song->getName() ?? ''),
|
||||
'updated_at' => Carbon::now(),
|
||||
'created_at' => Carbon::now(),
|
||||
]
|
||||
);
|
||||
if ($ctsSongId !== '' && $ccliId === null) {
|
||||
$existing = DB::table('songs')->where('cts_song_id', $ctsSongId)->first();
|
||||
|
||||
$count++;
|
||||
if (! $existing) {
|
||||
DB::table('songs')->insert([
|
||||
'cts_song_id' => $ctsSongId,
|
||||
'title' => (string) ($song->getName() ?? ''),
|
||||
'created_at' => Carbon::now(),
|
||||
'updated_at' => Carbon::now(),
|
||||
]);
|
||||
}
|
||||
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
|
|
@ -335,6 +351,7 @@ private function syncServiceAgendaSongs(int $serviceId, int $eventId): array
|
|||
[
|
||||
'cts_song_name' => (string) ($song->getName() ?? ''),
|
||||
'cts_ccli_id' => $ccliId,
|
||||
'cts_song_id' => $ctsSongId,
|
||||
'updated_at' => Carbon::now(),
|
||||
'created_at' => Carbon::now(),
|
||||
]
|
||||
|
|
@ -346,7 +363,11 @@ private function syncServiceAgendaSongs(int $serviceId, int $eventId): array
|
|||
|
||||
$matched = false;
|
||||
|
||||
if ($serviceSong !== null && $serviceSong->cts_ccli_id && ! $serviceSong->song_id) {
|
||||
if (
|
||||
$serviceSong !== null
|
||||
&& ! $serviceSong->song_id
|
||||
&& ($serviceSong->cts_ccli_id || $serviceSong->cts_song_id)
|
||||
) {
|
||||
$matched = $songMatchingService->autoMatch($serviceSong);
|
||||
} elseif ($serviceSong !== null && $serviceSong->song_id) {
|
||||
$matched = true;
|
||||
|
|
|
|||
|
|
@ -11,23 +11,21 @@
|
|||
|
||||
class SongMatchingService
|
||||
{
|
||||
/**
|
||||
* Auto-match a service song to a song in the DB by CCLI ID.
|
||||
*
|
||||
* Returns true if a match was found and assigned, false otherwise.
|
||||
* Skips if already matched or no CCLI ID present.
|
||||
*/
|
||||
public function autoMatch(ServiceSong $serviceSong): bool
|
||||
{
|
||||
if ($serviceSong->song_id !== null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($serviceSong->cts_ccli_id === null || $serviceSong->cts_ccli_id === '') {
|
||||
return false;
|
||||
$song = null;
|
||||
|
||||
if ($serviceSong->cts_ccli_id !== null && $serviceSong->cts_ccli_id !== '') {
|
||||
$song = Song::where('ccli_id', $serviceSong->cts_ccli_id)->first();
|
||||
}
|
||||
|
||||
$song = Song::where('ccli_id', $serviceSong->cts_ccli_id)->first();
|
||||
if ($song === null && $serviceSong->cts_song_id) {
|
||||
$song = Song::where('cts_song_id', $serviceSong->cts_song_id)->first();
|
||||
}
|
||||
|
||||
if ($song === null) {
|
||||
return false;
|
||||
|
|
@ -36,6 +34,7 @@ public function autoMatch(ServiceSong $serviceSong): bool
|
|||
$serviceSong->update([
|
||||
'song_id' => $song->id,
|
||||
'matched_at' => Carbon::now(),
|
||||
'use_translation' => $song->has_translation,
|
||||
]);
|
||||
|
||||
return true;
|
||||
|
|
@ -50,6 +49,7 @@ public function manualAssign(ServiceSong $serviceSong, Song $song): void
|
|||
$serviceSong->update([
|
||||
'song_id' => $song->id,
|
||||
'matched_at' => Carbon::now(),
|
||||
'use_translation' => $song->has_translation,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class () extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('songs', function (Blueprint $table) {
|
||||
$table->string('cts_song_id')->nullable()->index()->after('ccli_id');
|
||||
});
|
||||
|
||||
Schema::table('service_songs', function (Blueprint $table) {
|
||||
$table->string('cts_song_id')->nullable()->after('cts_ccli_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('service_songs', function (Blueprint $table) {
|
||||
$table->dropColumn('cts_song_id');
|
||||
});
|
||||
|
||||
Schema::table('songs', function (Blueprint $table) {
|
||||
$table->dropColumn('cts_song_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -15,7 +15,7 @@ class ArrangementControllerTest extends TestCase
|
|||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_create_arrangement_clones_groups_from_default_arrangement(): void
|
||||
public function test_create_arrangement_uses_default_song_group_ordering(): void
|
||||
{
|
||||
[$song, $normal] = $this->createSongWithDefaultArrangement();
|
||||
$user = User::factory()->create();
|
||||
|
|
@ -34,10 +34,10 @@ public function test_create_arrangement_clones_groups_from_default_arrangement()
|
|||
|
||||
$this->assertNotNull($newArrangement);
|
||||
|
||||
$normalGroups = SongArrangementGroup::query()
|
||||
->where('song_arrangement_id', $normal->id)
|
||||
$defaultGroupOrder = SongGroup::query()
|
||||
->where('song_id', $song->id)
|
||||
->orderBy('order')
|
||||
->pluck('song_group_id')
|
||||
->pluck('id')
|
||||
->all();
|
||||
|
||||
$newGroups = SongArrangementGroup::query()
|
||||
|
|
@ -46,7 +46,7 @@ public function test_create_arrangement_clones_groups_from_default_arrangement()
|
|||
->pluck('song_group_id')
|
||||
->all();
|
||||
|
||||
$this->assertSame($normalGroups, $newGroups);
|
||||
$this->assertSame($defaultGroupOrder, $newGroups);
|
||||
}
|
||||
|
||||
public function test_clone_arrangement_duplicates_current_arrangement_groups(): void
|
||||
|
|
|
|||
|
|
@ -209,6 +209,35 @@
|
|||
);
|
||||
});
|
||||
|
||||
test('information slides uploaded after service date are not shown', function () {
|
||||
Carbon::setTestNow('2026-03-01 10:00:00');
|
||||
|
||||
$service = Service::factory()->create(['date' => '2026-03-10']);
|
||||
|
||||
$visibleSlide = Slide::factory()->create([
|
||||
'type' => 'information',
|
||||
'service_id' => null,
|
||||
'expire_date' => '2026-03-20',
|
||||
'uploaded_at' => '2026-03-09 10:00:00',
|
||||
]);
|
||||
|
||||
Slide::factory()->create([
|
||||
'type' => 'information',
|
||||
'service_id' => null,
|
||||
'expire_date' => '2026-03-20',
|
||||
'uploaded_at' => '2026-03-12 10:00:00',
|
||||
]);
|
||||
|
||||
$response = $this->get(route('services.edit', $service));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(
|
||||
fn ($page) => $page
|
||||
->has('informationSlides', 1)
|
||||
->where('informationSlides.0.id', $visibleSlide->id)
|
||||
);
|
||||
});
|
||||
|
||||
test('information slides ordered by uploaded_at descending', function () {
|
||||
Carbon::setTestNow('2026-03-01 10:00:00');
|
||||
|
||||
|
|
|
|||
|
|
@ -15,10 +15,11 @@
|
|||
*/
|
||||
|
||||
test('autoMatch ordnet Song per CCLI-ID zu', function () {
|
||||
$song = Song::factory()->create(['ccli_id' => '7115744']);
|
||||
$song = Song::factory()->create(['ccli_id' => '7115744', 'has_translation' => true]);
|
||||
$serviceSong = ServiceSong::factory()->create([
|
||||
'cts_ccli_id' => '7115744',
|
||||
'song_id' => null,
|
||||
'use_translation' => false,
|
||||
'matched_at' => null,
|
||||
]);
|
||||
|
||||
|
|
@ -29,6 +30,31 @@
|
|||
$serviceSong->refresh();
|
||||
expect($serviceSong->song_id)->toBe($song->id);
|
||||
expect($serviceSong->matched_at)->not->toBeNull();
|
||||
expect($serviceSong->use_translation)->toBeTrue();
|
||||
});
|
||||
|
||||
test('autoMatch nutzt CTS-Song-ID als Fallback wenn keine CCLI passt', function () {
|
||||
$song = Song::factory()->create([
|
||||
'ccli_id' => '7115744',
|
||||
'cts_song_id' => 'cts-123',
|
||||
'has_translation' => true,
|
||||
]);
|
||||
|
||||
$serviceSong = ServiceSong::factory()->create([
|
||||
'cts_ccli_id' => '0000000',
|
||||
'cts_song_id' => 'cts-123',
|
||||
'song_id' => null,
|
||||
'use_translation' => false,
|
||||
'matched_at' => null,
|
||||
]);
|
||||
|
||||
$service = app(SongMatchingService::class);
|
||||
$result = $service->autoMatch($serviceSong);
|
||||
|
||||
expect($result)->toBeTrue();
|
||||
$serviceSong->refresh();
|
||||
expect($serviceSong->song_id)->toBe($song->id);
|
||||
expect($serviceSong->use_translation)->toBeTrue();
|
||||
});
|
||||
|
||||
test('autoMatch gibt false zurück wenn kein CCLI-ID vorhanden', function () {
|
||||
|
|
@ -79,9 +105,10 @@
|
|||
});
|
||||
|
||||
test('manualAssign ordnet Song manuell zu', function () {
|
||||
$song = Song::factory()->create();
|
||||
$song = Song::factory()->create(['has_translation' => true]);
|
||||
$serviceSong = ServiceSong::factory()->create([
|
||||
'song_id' => null,
|
||||
'use_translation' => false,
|
||||
'matched_at' => null,
|
||||
]);
|
||||
|
||||
|
|
@ -91,6 +118,7 @@
|
|||
$serviceSong->refresh();
|
||||
expect($serviceSong->song_id)->toBe($song->id);
|
||||
expect($serviceSong->matched_at)->not->toBeNull();
|
||||
expect($serviceSong->use_translation)->toBeTrue();
|
||||
});
|
||||
|
||||
test('manualAssign überschreibt bestehende Zuordnung', function () {
|
||||
|
|
|
|||
Loading…
Reference in a new issue