presseportale/app/Services/Web/HomePageData.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

280 lines
12 KiB
PHP

<?php
namespace App\Services\Web;
use App\Enums\BoostPlacement;
use App\Enums\Portal;
use App\Models\Category;
use App\Models\Company;
use App\Models\PressRelease;
use App\Support\EditorialClock;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
/**
* Datenaufbereitung der öffentlichen Startseite einer Ausgabe: Lead-/Seiten-/
* meistgelesene Meldungen, aktive Newsrooms, Branchen-Index, „Heute im Fokus"
* (Spotlight) und Kennzahlen. Der Ad-Hoc-Ticker kommt zentral aus TickerData.
*
* Das Ergebnis wird kurz gecacht (analog TickerData): Der Aufbau umfasst
* ~25 Queries über das gesamte Archiv und die Daten ändern sich höchstens
* minütlich — neue Meldungen erscheinen spätestens nach Ablauf der TTL.
*/
class HomePageData
{
private const CACHE_TTL_SECONDS = 300;
/**
* @return array<string, mixed>
*/
public function build(Portal $portal, string $language = 'de'): array
{
$cacheKey = sprintf('web.home.%s.%s', $portal->value, $language);
return Cache::remember(
$cacheKey,
self::CACHE_TTL_SECONDS,
fn (): array => $this->buildFresh($portal, $language)
);
}
/**
* @return array<string, mixed>
*/
private function buildFresh(Portal $portal, string $language): array
{
$portalValues = [$portal->value, Portal::Both->value];
// Aktualitäts-Stichtag an den jüngsten Datensatz koppeln (historisches Archiv).
$referenceNow = EditorialClock::reference($portalValues, $language);
$recentSince = $referenceNow->copy()->subDays(7);
$previousSince = $referenceNow->copy()->subDays(14);
// Bereichsvergleich statt whereDate(): DATE(published_at) würde die
// Index-Nutzung auf published_at verhindern (non-sargable).
$todayRange = [$referenceNow->copy()->startOfDay(), $referenceNow->copy()->endOfDay()];
$publishedQuery = static fn (): Builder => PressRelease::query()
->forPortalValues($portalValues)
->forLanguage($language)
->visibleInArchive();
$with = [
'company',
'category.translations' => fn ($query) => $query->where('locale', $language),
'images' => fn ($query) => $query
->orderByDesc('is_preview')
->orderBy('sort_order')
->limit(1),
];
$oldestPublishedAt = $publishedQuery()
->oldest('published_at')
->value('published_at');
// Gebuchte Boosts (Decision-Update §2.2): geboostete PMs besetzen die
// Top-Positionen vor der Chronologie — die jüngste als Lead, weitere
// in der Seitenleiste. Die Views markieren sie über `boostedIds`.
// Nur Startseiten-Boosts; der günstigere Kategorie-Boost kauft
// ausschließlich die Branchenseite (Entscheidung 13.07.2026).
$boostedReleases = $publishedQuery()
->boosted(BoostPlacement::Home)
->with($with)
->orderByDesc('published_at')
->limit(5)
->get();
$boostedIds = $boostedReleases->modelKeys();
$leadRelease = $boostedReleases->first()
?? $publishedQuery()
->with($with)
->orderByDesc('published_at')
->first();
// Ohne mediumText-Spalte `text`: die Seitenleisten-Teaser rendern nur
// Titel/Meta, der Volltext wäre unnötiger I/O.
$sideBoosted = $boostedReleases->slice(1)->take(4)->values();
$sideChronoSlots = 4 - $sideBoosted->count();
$sideReleases = $sideChronoSlots > 0
? $publishedQuery()
->with($with)
->when($leadRelease, fn (Builder $query) => $query->where('id', '!=', $leadRelease->id))
->when($boostedIds !== [], fn (Builder $query) => $query->whereNotIn('id', $boostedIds))
->orderByDesc('published_at')
->limit($sideChronoSlots)
->get(['id', 'portal', 'language', 'slug', 'title', 'category_id', 'company_id', 'published_at', 'content_score'])
: collect();
$sideReleases = $sideBoosted->concat($sideReleases)->values();
$mostReadReleases = $publishedQuery()
->orderByDesc('hits')
->orderByDesc('published_at')
->limit(4)
->get(['id', 'slug', 'title', 'hits', 'portal', 'language']);
$activeNewsrooms = Company::query()
->whereIn('portal', $portalValues)
->where('is_active', true)
->whereHas('pressReleases', function (Builder $query) use ($portalValues, $recentSince, $language): void {
$query
->forPortalValues($portalValues)
->forLanguage($language)
->published()
->where('published_at', '>=', $recentSince);
})
->withCount([
'pressReleases as recent_releases_count' => function (Builder $query) use ($portalValues, $recentSince, $language): void {
$query
->forPortalValues($portalValues)
->forLanguage($language)
->published()
->where('published_at', '>=', $recentSince);
},
'pressReleases as today_releases_count' => function (Builder $query) use ($portalValues, $todayRange, $language): void {
$query
->forPortalValues($portalValues)
->forLanguage($language)
->published()
->whereBetween('published_at', $todayRange);
},
])
->orderByDesc('today_releases_count')
->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,
]);
$industryIndex = Category::query()
->with(['translations' => fn ($query) => $query->where('locale', $language)])
->withCount([
'pressReleases as recent_count' => function (Builder $query) use ($portalValues, $recentSince, $language): void {
$query
->forPortalValues($portalValues)
->forLanguage($language)
->published()
->where('published_at', '>=', $recentSince);
},
'pressReleases as previous_count' => function (Builder $query) use ($portalValues, $recentSince, $previousSince, $language): void {
$query
->forPortalValues($portalValues)
->forLanguage($language)
->published()
->where('published_at', '>=', $previousSince)
->where('published_at', '<', $recentSince);
},
])
->whereIn('portal', $portalValues)
->where('is_active', true)
->whereNull('parent_id')
->orderByDesc('recent_count')
->limit(7)
->get()
->map(function (Category $category): ?array {
$translation = $category->translations->first();
if (! $translation) {
return null;
}
return [
'name' => $translation->name,
'href' => $translation->slug ? route('kategorie', ['slug' => $translation->slug]) : route('web.home'),
'count' => (int) ($category->recent_count ?? 0),
'delta' => (int) ($category->recent_count ?? 0) - (int) ($category->previous_count ?? 0),
];
})
->filter()
->values();
// "Heute im Fokus · Branche": aktivste Top-Rubrik im Stichtag-Fenster mit echten Meldungen.
$spotlightCategory = Category::query()
->with(['translations' => fn ($query) => $query->where('locale', $language)])
->withCount([
'pressReleases as window_count' => function (Builder $query) use ($portalValues, $recentSince, $referenceNow, $language): void {
$query
->forPortalValues($portalValues)
->forLanguage($language)
->published()
->where('published_at', '>=', $recentSince)
->where('published_at', '<=', $referenceNow);
},
])
->whereIn('portal', $portalValues)
->where('is_active', true)
->whereNull('parent_id')
->orderByDesc('window_count')
->first();
$spotlight = null;
if ($spotlightCategory && $spotlightTranslation = $spotlightCategory->translations->first()) {
$spotlightBase = static fn (): Builder => PressRelease::query()
->forPortalValues($portalValues)
->forLanguage($language)
->visibleInArchive()
->where('category_id', $spotlightCategory->id);
$spotlightReleases = $spotlightBase()
->with(['company'])
->orderByDesc('published_at')
->limit(3)
->get()
->map(static function (PressRelease $release) use ($spotlightTranslation): array {
$publishedAt = $release->published_at;
return [
'time' => $publishedAt?->format('H:i') ?? '',
'date' => $publishedAt?->translatedFormat('j. M') ?? '',
'category' => $spotlightTranslation->name,
'title' => $release->title,
'company' => $release->company?->name ?? '',
'city' => null,
'minutes' => $release->readTimeMinutes(),
'href' => $release->slug ? route('release.detail', ['slug' => $release->slug]) : '#',
];
});
if ($spotlightReleases->isNotEmpty()) {
$windowCount = (int) ($spotlightCategory->window_count ?? 0);
$archiveCount = $spotlightBase()->count();
$activeCompanies = (clone $spotlightBase())
->where('published_at', '>=', $recentSince)
->where('published_at', '<=', $referenceNow)
->distinct()
->count('company_id');
$spotlight = [
'industry' => $spotlightTranslation->name,
'stats' => [
['label' => __('web.spotlight.stat_window'), 'value' => number_format($windowCount, 0, ',', '.'), 'sub' => __('web.spotlight.stat_window_sub', ['date' => $referenceNow->translatedFormat('j. M Y')])],
['label' => __('web.spotlight.stat_companies'), 'value' => number_format($activeCompanies, 0, ',', '.'), 'sub' => __('web.spotlight.stat_companies_sub')],
['label' => __('web.spotlight.stat_archive'), 'value' => number_format($archiveCount, 0, ',', '.'), 'sub' => __('web.spotlight.stat_archive_sub')],
],
'releases' => $spotlightReleases->all(),
];
}
}
return [
'leadRelease' => $leadRelease,
'sideReleases' => $sideReleases,
'boostedIds' => $boostedIds,
'mostReadReleases' => $mostReadReleases,
'activeNewsrooms' => $activeNewsrooms,
'industryIndex' => $industryIndex,
'spotlight' => $spotlight,
'homeStats' => [
'publishedCount' => $publishedQuery()->count(),
'publishedToday' => $publishedQuery()->whereBetween('published_at', $todayRange)->count(),
'archiveSince' => $oldestPublishedAt ? (int) Carbon::parse($oldestPublishedAt)->format('Y') : null,
],
];
}
}