presseportale/app/Services/Web/CategoryPageData.php
Kevin Adametz 0474d22347 Boost-Slot-Grenzen (5/3) + Kategorie-Boost zum halben Satz
Entscheidung 13.07.: verkauft wird nur, was sichtbar ist — max. 5
gleichzeitige Boosts je Startseite, 3 je Branchenseite (= die sichtbaren
Plaetze; Hero-Slider damit vertagt). Neue Stufe Kategorie-Boost (nur
Branchenseite, 6/10/18 Credits = 50 % aufgerundet); der Startseiten-Boost
(12/20/35) erscheint weiter auf Start- UND Branchenseite.

- boosts.placement (home/category, Bestand = home) + BoostPlacement-Enum;
  Preise/Slots in config/credits.php (boost_category, boost_slots)
- BoostService::slotAvailability() je Ausgabe (Portal x Sprache, Kategorie
  zusaetzlich je Rubrik); ausgebucht -> soldOut-Exception mit fruehestem
  freien Termin; Verlaengerungen halten ihren Slot, Stufen verlaengern
  unabhaengig; Kategorie-Boost invalidiert nur Kategorie-Caches
- Startseite zeigt nur Home-Boosts; Branchenseite: Kategorie-Boosts mit
  Vorrang (ihre 3 Slots sind die Garantie), Home-Boosts fuellen den Rest
- Add-on-Panel der PM-Seite: zwei Boost-Angebote, bei vollem Inventar
  "Derzeit ausgebucht — frei ab" statt Kauf-Buttons (serverseitig gegated);
  Buchungsseiten-Preisliste um die Kategorie-Staffel ergaenzt

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 12:28:03 +00:00

293 lines
12 KiB
PHP

<?php
namespace App\Services\Web;
use App\Enums\BoostPlacement;
use App\Models\Category;
use App\Models\Company;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Support\WebPortalContext;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
/**
* Datenaufbereitung der Kategorie-/Branchenseite je Ausgabe: Lead-/Top-/Feed-
* Meldungen, Kennzahlen, aktivste Newsrooms sowie Unter- und verwandte Rubriken.
*
* Das Ergebnis wird kurz gecacht (analog TickerData): Der Aufbau umfasst
* ~30 Queries über das gesamte Archiv und die Daten ändern sich höchstens
* minütlich. Ein 404 (unbekannte Rubrik) wird nicht gecacht, da abort()
* vor dem Cache-Write wirft.
*/
class CategoryPageData
{
private const CACHE_TTL_SECONDS = 300;
/**
* @return array<string, mixed>
*/
public function build(WebPortalContext $context, string $slug): array
{
$cacheKey = sprintf('web.category.%s.%s.%s', $context->portal->value, $context->language, $slug);
return Cache::remember(
$cacheKey,
self::CACHE_TTL_SECONDS,
fn (): array => $this->buildFresh($context, $slug)
);
}
/**
* @return array<string, mixed>
*/
private function buildFresh(WebPortalContext $context, string $slug): array
{
$portalValues = $context->portalValues;
$language = $context->language;
$category = Category::query()
->whereIn('portal', $portalValues)
->where('is_active', true)
->whereHas('translations', fn (Builder $query) => $query->where('locale', $language)->where('slug', $slug))
->with([
'translations' => fn ($query) => $query->where('locale', $language),
'parent.translations' => fn ($query) => $query->where('locale', $language),
])
->first();
if (! $category) {
abort(404);
}
$childCategories = Category::query()
->where('parent_id', $category->id)
->where('is_active', true)
->with(['translations' => fn ($query) => $query->where('locale', $language)])
->get();
$categoryIds = collect([$category->id])->merge($childCategories->pluck('id'))->unique()->values()->all();
$base = static fn (): Builder => PressRelease::query()
->withoutGlobalScope(PortalScope::class)
->forPortalValues($portalValues)
->forLanguage($language)
->visibleInArchive()
->whereIn('category_id', $categoryIds);
// Kategoriespezifischer Stichtag: jüngste Meldung dieser Branche.
$latest = $base()->max('published_at');
$referenceNow = $latest ? Carbon::parse($latest) : now();
if ($referenceNow->gt(now())) {
$referenceNow = now();
}
$recentSince = $referenceNow->copy()->subDays(7);
$previousSince = $referenceNow->copy()->subDays(14);
$monthSince = $referenceNow->copy()->subDays(30);
// Bereichsvergleich statt whereDate(): DATE(published_at) würde die
// Index-Nutzung auf published_at verhindern (non-sargable).
$todayRange = [$referenceNow->copy()->startOfDay(), $referenceNow->copy()->endOfDay()];
$with = [
'company',
'category.translations' => fn ($query) => $query->where('locale', $language),
'images' => fn ($query) => $query->orderByDesc('is_preview')->orderBy('sort_order')->limit(1),
];
// Gebuchte Boosts (Decision-Update §2.2): geboostete PMs der Branche
// besetzen Lead- und Top-Plätze vor der Chronologie; die Views
// markieren sie über `boostedIds`. Kategorie-Boosts haben Vorrang —
// ihr Slot-Inventar (max. 3 je Branchenseite) ist die verkaufte
// Garantie; Startseiten-Boosts erscheinen hier zusätzlich, solange
// Plätze übrig sind (Entscheidung 13.07.2026).
$categoryBoosted = $base()
->boosted(BoostPlacement::Category)
->with($with)
->orderByDesc('published_at')
->limit(3)
->get();
$homeBoostSlots = 3 - $categoryBoosted->count();
$homeBoosted = $homeBoostSlots > 0
? $base()
->boosted(BoostPlacement::Home)
->when($categoryBoosted->isNotEmpty(), fn (Builder $query) => $query->whereNotIn('id', $categoryBoosted->modelKeys()))
->with($with)
->orderByDesc('published_at')
->limit($homeBoostSlots)
->get()
: collect();
$boostedReleases = $categoryBoosted->concat($homeBoosted)->values();
$boostedIds = $boostedReleases->modelKeys();
$leadRelease = $boostedReleases->first()
?? $base()->with($with)->orderByDesc('published_at')->first();
// Ohne mediumText-Spalte `text`: Top-/Feed-Listen rendern nur
// Titel/Meta, der Volltext wäre unnötiger I/O.
$listColumns = ['id', 'portal', 'language', 'slug', 'title', 'category_id', 'company_id', 'published_at', 'content_score'];
$topBoosted = $boostedReleases->slice(1)->take(2)->values();
$topChronoSlots = 2 - $topBoosted->count();
$topReleases = $topChronoSlots > 0
? $base()
->with($with)
->when($leadRelease, fn (Builder $query) => $query->whereKeyNot($leadRelease->getKey()))
->when($boostedIds !== [], fn (Builder $query) => $query->whereNotIn('id', $boostedIds))
->orderByDesc('published_at')
->limit($topChronoSlots)
->get($listColumns)
: collect();
$topReleases = $topBoosted->concat($topReleases)->values();
$excludeIds = collect([$leadRelease?->getKey()])->merge($topReleases->modelKeys())->filter()->all();
$feedReleases = $base()
->with($with)
->when($excludeIds !== [], fn (Builder $query) => $query->whereNotIn('id', $excludeIds))
->orderByDesc('published_at')
->limit(12)
->get($listColumns);
$mostReadReleases = $base()
->orderByDesc('hits')
->orderByDesc('published_at')
->limit(5)
->get(['id', 'slug', 'title', 'hits', 'portal', 'language']);
$stats = [
'today' => $base()->whereBetween('published_at', $todayRange)->count(),
'week' => $base()->where('published_at', '>=', $recentSince)->count(),
'previousWeek' => $base()->where('published_at', '>=', $previousSince)->where('published_at', '<', $recentSince)->count(),
'month' => $base()->where('published_at', '>=', $monthSince)->count(),
'total' => $base()->count(),
'activeCompanies' => $base()->where('published_at', '>=', $recentSince)->distinct()->count('company_id'),
'totalCompanies' => $base()->distinct()->count('company_id'),
];
$newsroomFilter = static function (Builder $query) use ($portalValues, $categoryIds, $language): void {
$query
->forPortalValues($portalValues)
->forLanguage($language)
->published()
->whereIn('category_id', $categoryIds);
};
$topNewsrooms = Company::query()
->withoutGlobalScope(PortalScope::class)
->whereIn('portal', $portalValues)
->where('is_active', true)
->whereHas('pressReleases', function (Builder $query) use ($newsroomFilter, $recentSince): void {
$newsroomFilter($query);
$query->where('published_at', '>=', $recentSince);
})
->withCount([
'pressReleases as recent_releases_count' => function (Builder $query) use ($newsroomFilter, $recentSince): void {
$newsroomFilter($query);
$query->where('published_at', '>=', $recentSince);
},
'pressReleases as today_releases_count' => function (Builder $query) use ($newsroomFilter, $todayRange): void {
$newsroomFilter($query);
$query->whereBetween('published_at', $todayRange);
},
])
->orderByDesc('recent_releases_count')
->limit(6)
->get()
->map(fn (Company $company): array => [
'name' => $company->name,
'slug' => $company->slug,
'initial' => mb_strtoupper(mb_substr((string) $company->name, 0, 1)),
'count' => (int) $company->recent_releases_count,
'today' => (int) $company->today_releases_count > 0,
]);
$publishedCount = static fn (Builder $query) => $query
->forPortalValues($portalValues)
->forLanguage($language)
->published();
// Alle Zählwerte der Unterkategorien in einer Query (statt 3 Counts
// pro Kind): bedingte Aggregate, gruppiert nach category_id.
$childCounts = $childCategories->isEmpty()
? collect()
: PressRelease::query()
->withoutGlobalScope(PortalScope::class)
->tap($publishedCount)
->whereIn('category_id', $childCategories->modelKeys())
->groupBy('category_id')
->selectRaw('category_id, count(*) as total_count')
->selectRaw('sum(case when published_at >= ? then 1 else 0 end) as recent_count', [$recentSince])
->selectRaw('sum(case when published_at >= ? and published_at < ? then 1 else 0 end) as previous_count', [$previousSince, $recentSince])
->get()
->keyBy('category_id');
$subCategories = $childCategories
->map(function (Category $child) use ($childCounts): ?array {
$translation = $child->translations->first();
if (! $translation) {
return null;
}
$counts = $childCounts->get($child->getKey());
$recent = (int) ($counts->recent_count ?? 0);
$previous = (int) ($counts->previous_count ?? 0);
return [
'name' => $translation->name,
'href' => $translation->slug ? route('kategorie', ['slug' => $translation->slug]) : route('web.home'),
'total' => (int) ($counts->total_count ?? 0),
'recent' => $recent,
'delta' => $recent - $previous,
];
})
->filter()
->sortByDesc('total')
->values();
$relatedCategories = Category::query()
->whereIn('portal', $portalValues)
->where('is_active', true)
->whereNull('parent_id')
->whereKeyNot($category->id)
->when($category->parent_id, fn (Builder $query) => $query->whereKeyNot($category->parent_id))
->with(['translations' => fn ($query) => $query->where('locale', $language)])
->withCount([
'pressReleases as total_count' => fn (Builder $query) => $publishedCount($query),
])
->orderByDesc('total_count')
->limit(8)
->get()
->map(function (Category $related): ?array {
$translation = $related->translations->first();
if (! $translation) {
return null;
}
return [
'name' => $translation->name,
'href' => $translation->slug ? route('kategorie', ['slug' => $translation->slug]) : route('web.home'),
'count' => (int) ($related->total_count ?? 0),
];
})
->filter()
->values();
return [
'category' => $category,
'referenceNow' => $referenceNow,
'leadRelease' => $leadRelease,
'topReleases' => $topReleases,
'feedReleases' => $feedReleases,
'boostedIds' => $boostedIds,
'mostReadReleases' => $mostReadReleases,
'categoryStats' => $stats,
'topNewsrooms' => $topNewsrooms,
'subCategories' => $subCategories,
'relatedCategories' => $relatedCategories,
];
}
}