slotAvailability() liefert zusaetzlich free/limit (knappste Ausgabe, ohne den eigenen gehaltenen Slot); solange buchbar, steht unter den Kauf-Buttons "Noch X von Y Plaetzen frei." — bei laufendem Boost weiterhin nur der Verlaengerungs-Hinweis. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
222 lines
8.7 KiB
PHP
222 lines
8.7 KiB
PHP
<?php
|
||
|
||
namespace App\Services\PressRelease;
|
||
|
||
use App\Enums\BoostPlacement;
|
||
use App\Enums\Portal;
|
||
use App\Enums\PressReleaseClassification;
|
||
use App\Enums\PressReleaseStatus;
|
||
use App\Exceptions\BoostNotAllowedException;
|
||
use App\Models\Boost;
|
||
use App\Models\PressRelease;
|
||
use App\Models\User;
|
||
use App\Scopes\PortalScope;
|
||
use App\Services\Billing\CreditPricingService;
|
||
use App\Services\Billing\CreditWalletService;
|
||
use Illuminate\Database\Eloquent\Builder;
|
||
use Illuminate\Support\Carbon;
|
||
use Illuminate\Support\Facades\Cache;
|
||
use Illuminate\Support\Facades\DB;
|
||
|
||
/**
|
||
* Boost-Kauf laut Decision-Update §2.2 + Platzierungs-Entscheidung 13.07.2026.
|
||
* Gate: nur veröffentlichte, grüne PMs. Bezahlung über die Credit-Wallet,
|
||
* Preis nach Laufzeit (7/14/30 Tage) und Stufe: Startseiten-Boost (Startseite
|
||
* + Branchenseite) bzw. Kategorie-Boost (nur Branchenseite, halber Satz).
|
||
* Slot-Inventar je Ausgabe (config credits.boost_slots): mehr gleichzeitige
|
||
* Boosts als sichtbare Plätze werden nicht verkauft — der Kauf wirft dann
|
||
* BoostNotAllowedException::soldOut() mit dem frühesten freien Termin.
|
||
* Mehrfachkauf derselben Stufe verlängert: der neue Zeitraum schließt an ein
|
||
* laufendes Ende an und braucht keinen freien Slot (die PM hält ihren).
|
||
*/
|
||
class BoostService
|
||
{
|
||
public function __construct(
|
||
private readonly CreditPricingService $pricing,
|
||
private readonly CreditWalletService $wallet,
|
||
) {}
|
||
|
||
/**
|
||
* Boostbar = veröffentlicht UND grün klassifiziert (gelb/rot nicht).
|
||
*/
|
||
public function canBoost(PressRelease $pressRelease): bool
|
||
{
|
||
return $pressRelease->status === PressReleaseStatus::Published
|
||
&& $pressRelease->classification === PressReleaseClassification::Green;
|
||
}
|
||
|
||
public function isBoosted(PressRelease $pressRelease, ?BoostPlacement $placement = null): bool
|
||
{
|
||
return $pressRelease->boosts()
|
||
->active()
|
||
->when($placement, fn (Builder $query) => $query->forPlacement($placement))
|
||
->exists();
|
||
}
|
||
|
||
/**
|
||
* Ende des aktuell laufenden Boosts (das späteste zukünftige `ends_at`),
|
||
* optional je Stufe, oder null, wenn die PM gerade nicht geboostet ist.
|
||
*/
|
||
public function activeUntil(PressRelease $pressRelease, ?BoostPlacement $placement = null): ?Carbon
|
||
{
|
||
$endsAt = $pressRelease->boosts()
|
||
->active()
|
||
->when($placement, fn (Builder $query) => $query->forPlacement($placement))
|
||
->max('ends_at');
|
||
|
||
return $endsAt ? Carbon::parse($endsAt) : null;
|
||
}
|
||
|
||
/**
|
||
* Slot-Verfügbarkeit der Stufe für diese PM. Eine Verlängerung ist immer
|
||
* verfügbar (die PM hält ihren Slot); sonst muss auf jeder Ausgabe, auf
|
||
* der die PM erscheint (Portal × Sprache, Kategorie-Boost zusätzlich je
|
||
* Rubrik), ein Slot frei sein. `free` zählt die noch freien Plätze
|
||
* (knappste Ausgabe, ohne einen eigenen gehaltenen Slot); `freeAt` nennt
|
||
* bei „ausgebucht" den frühesten Zeitpunkt, zu dem ein Platz frei wird.
|
||
*
|
||
* @return array{available: bool, free: int, limit: int, freeAt: Carbon|null}
|
||
*/
|
||
public function slotAvailability(PressRelease $pressRelease, BoostPlacement $placement): array
|
||
{
|
||
$limit = $this->slotLimit($placement);
|
||
$holdsSlot = $this->isBoosted($pressRelease, $placement);
|
||
$free = $limit;
|
||
$freeAt = null;
|
||
|
||
foreach ($this->affectedPortals($pressRelease) as $portal) {
|
||
// Je Konkurrent zählt seine PM einen Slot, gehalten bis zum
|
||
// spätesten Boost-Ende (Verlängerungen schließen lückenlos an).
|
||
$heldUntil = Boost::query()
|
||
->forPlacement($placement)
|
||
->active()
|
||
->where('press_release_id', '!=', $pressRelease->id)
|
||
->whereHas('pressRelease', function (Builder $query) use ($portal, $pressRelease, $placement): void {
|
||
$query
|
||
->withoutGlobalScope(PortalScope::class)
|
||
->whereIn('portal', [$portal, Portal::Both->value])
|
||
->where('language', $pressRelease->language)
|
||
->when(
|
||
$placement === BoostPlacement::Category,
|
||
fn (Builder $query) => $query->where('category_id', $pressRelease->category_id),
|
||
);
|
||
})
|
||
->groupBy('press_release_id')
|
||
->selectRaw('press_release_id, MAX(ends_at) AS held_until')
|
||
->pluck('held_until');
|
||
|
||
$free = min($free, max(0, $limit - $heldUntil->count()));
|
||
|
||
if ($holdsSlot || $heldUntil->count() < $limit) {
|
||
continue;
|
||
}
|
||
|
||
// Diese Ausgabe ist voll: frei, sobald der zuerst endende
|
||
// Konkurrent seinen Slot räumt. Bei mehreren vollen Ausgaben
|
||
// zählt die späteste (die PM braucht überall einen Platz).
|
||
$portalFreeAt = Carbon::parse($heldUntil->min());
|
||
$freeAt = $freeAt === null ? $portalFreeAt : $freeAt->max($portalFreeAt);
|
||
}
|
||
|
||
return [
|
||
'available' => $holdsSlot || $free > 0,
|
||
'free' => $free,
|
||
'limit' => $limit,
|
||
'freeAt' => $freeAt,
|
||
];
|
||
}
|
||
|
||
public function slotLimit(BoostPlacement $placement): int
|
||
{
|
||
return (int) config("credits.boost_slots.{$placement->value}");
|
||
}
|
||
|
||
/**
|
||
* Bucht einen Boost der angegebenen Stufe. Wirft BoostNotAllowedException
|
||
* am Gate (nicht boostbar bzw. Slots ausgebucht) und
|
||
* InvalidArgumentException bei unbekannter Laufzeit; die Wallet-Belastung
|
||
* wirft InsufficientCreditsException, falls das Guthaben nicht reicht.
|
||
*/
|
||
public function boost(User $user, PressRelease $pressRelease, int $days, BoostPlacement $placement = BoostPlacement::Home): Boost
|
||
{
|
||
if (! $this->canBoost($pressRelease)) {
|
||
throw BoostNotAllowedException::notBoostable();
|
||
}
|
||
|
||
$credits = $this->pricing->boostCredits($days, $placement);
|
||
|
||
$boost = DB::transaction(function () use ($user, $pressRelease, $days, $credits, $placement): Boost {
|
||
$availability = $this->slotAvailability($pressRelease, $placement);
|
||
if (! $availability['available']) {
|
||
throw BoostNotAllowedException::soldOut($placement, $availability['freeAt']);
|
||
}
|
||
|
||
$startsAt = $this->activeUntil($pressRelease, $placement) ?? now();
|
||
|
||
$boost = $pressRelease->boosts()->create([
|
||
'user_id' => $user->id,
|
||
'placement' => $placement,
|
||
'days' => $days,
|
||
'credits_charged' => $credits,
|
||
'starts_at' => $startsAt,
|
||
'ends_at' => $startsAt->copy()->addDays($days),
|
||
]);
|
||
|
||
$this->wallet->debit(
|
||
$user,
|
||
$credits,
|
||
"{$placement->label()} {$days} Tage – PM #{$pressRelease->id}",
|
||
$boost,
|
||
);
|
||
|
||
return $boost;
|
||
});
|
||
|
||
$this->forgetPlacementCaches($pressRelease, $placement);
|
||
|
||
return $boost;
|
||
}
|
||
|
||
/**
|
||
* Gecachte Start-/Branchenseiten der betroffenen Ausgaben verwerfen,
|
||
* damit die bezahlte Platzierung sofort sichtbar wird statt erst nach
|
||
* Ablauf der Cache-TTL (HomePageData/CategoryPageData, 300 s). Der
|
||
* Kategorie-Boost berührt die Startseite nicht.
|
||
*/
|
||
private function forgetPlacementCaches(PressRelease $pressRelease, BoostPlacement $placement): void
|
||
{
|
||
$portals = $this->affectedPortals($pressRelease);
|
||
|
||
// Branchenseite der eigenen Rubrik plus Eltern-Rubrik (deren Seite
|
||
// schließt Unterkategorien mit ein), je hinterlegter Sprache.
|
||
$category = $pressRelease->category()
|
||
->with(['translations', 'parent.translations'])
|
||
->first();
|
||
$translations = collect($category?->translations ?? [])
|
||
->merge($category?->parent?->translations ?? []);
|
||
|
||
foreach ($portals as $portal) {
|
||
if ($placement === BoostPlacement::Home) {
|
||
Cache::forget(sprintf('web.home.%s.%s', $portal, $pressRelease->language));
|
||
}
|
||
|
||
foreach ($translations as $translation) {
|
||
if (filled($translation->slug)) {
|
||
Cache::forget(sprintf('web.category.%s.%s.%s', $portal, $translation->locale, $translation->slug));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Portale, auf deren Ausgaben die PM erscheint.
|
||
*
|
||
* @return list<string>
|
||
*/
|
||
private function affectedPortals(PressRelease $pressRelease): array
|
||
{
|
||
return $pressRelease->portal === Portal::Both
|
||
? [Portal::Presseecho->value, Portal::Businessportal24->value]
|
||
: [$pressRelease->portal->value];
|
||
}
|
||
}
|