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>
293 lines
12 KiB
PHP
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,
|
|
];
|
|
}
|
|
}
|