*/ 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 */ 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`. $boostedReleases = $base() ->boosted() ->with($with) ->orderByDesc('published_at') ->limit(3) ->get(); $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, ]; } }