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>
This commit is contained in:
parent
b1be14e1b3
commit
4853d48b83
19 changed files with 702 additions and 98 deletions
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Services\Billing;
|
||||
|
||||
use App\Enums\BoostPlacement;
|
||||
use App\Enums\Tier;
|
||||
use App\Models\User;
|
||||
|
||||
|
|
@ -29,11 +30,12 @@ class CreditPricingService
|
|||
}
|
||||
|
||||
/**
|
||||
* Boost-Preis (Credits) für eine Laufzeit in Tagen.
|
||||
* Boost-Preis (Credits) für eine Laufzeit in Tagen, je Boost-Stufe
|
||||
* (Startseiten-Boost bzw. Kategorie-Boost zum halben Satz).
|
||||
*/
|
||||
public function boostCredits(int $days): int
|
||||
public function boostCredits(int $days, BoostPlacement $placement = BoostPlacement::Home): int
|
||||
{
|
||||
$table = config('credits.boost', []);
|
||||
$table = $this->boostTable($placement);
|
||||
|
||||
if (! isset($table[$days])) {
|
||||
throw new \InvalidArgumentException("Unbekannte Boost-Laufzeit: {$days} Tage.");
|
||||
|
|
@ -47,14 +49,22 @@ class CreditPricingService
|
|||
*
|
||||
* @return array<int, int>
|
||||
*/
|
||||
public function boostOptions(): array
|
||||
public function boostOptions(BoostPlacement $placement = BoostPlacement::Home): array
|
||||
{
|
||||
$table = config('credits.boost', []);
|
||||
$table = $this->boostTable($placement);
|
||||
ksort($table);
|
||||
|
||||
return $table;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function boostTable(BoostPlacement $placement): array
|
||||
{
|
||||
return config($placement === BoostPlacement::Home ? 'credits.boost' : 'credits.boost_category', []);
|
||||
}
|
||||
|
||||
public function proofPdfCredits(): int
|
||||
{
|
||||
return (int) config('credits.proof_pdf');
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Services\PressRelease;
|
||||
|
||||
use App\Enums\BoostPlacement;
|
||||
use App\Enums\Portal;
|
||||
use App\Enums\PressReleaseClassification;
|
||||
use App\Enums\PressReleaseStatus;
|
||||
|
|
@ -9,16 +10,24 @@ 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. Gate: nur veröffentlichte, grüne PMs.
|
||||
* Bezahlung über die Credit-Wallet, Preis nach Laufzeit (7/14/30 Tage).
|
||||
* Mehrfachkauf verlängert: der neue Zeitraum schließt an ein laufendes Ende an.
|
||||
* 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
|
||||
{
|
||||
|
|
@ -36,40 +45,111 @@ class BoostService
|
|||
&& $pressRelease->classification === PressReleaseClassification::Green;
|
||||
}
|
||||
|
||||
public function isBoosted(PressRelease $pressRelease): bool
|
||||
public function isBoosted(PressRelease $pressRelease, ?BoostPlacement $placement = null): bool
|
||||
{
|
||||
return $pressRelease->boosts()->active()->exists();
|
||||
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`)
|
||||
* oder null, wenn die PM gerade nicht geboostet ist.
|
||||
* 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): ?Carbon
|
||||
public function activeUntil(PressRelease $pressRelease, ?BoostPlacement $placement = null): ?Carbon
|
||||
{
|
||||
$endsAt = $pressRelease->boosts()->active()->max('ends_at');
|
||||
$endsAt = $pressRelease->boosts()
|
||||
->active()
|
||||
->when($placement, fn (Builder $query) => $query->forPlacement($placement))
|
||||
->max('ends_at');
|
||||
|
||||
return $endsAt ? Carbon::parse($endsAt) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bucht einen Boost. Wirft BoostNotAllowedException am Gate und
|
||||
* 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. `freeAt` nennt bei „ausgebucht" den
|
||||
* frühesten Zeitpunkt, zu dem wieder ein Platz frei wird.
|
||||
*
|
||||
* @return array{available: bool, freeAt: Carbon|null}
|
||||
*/
|
||||
public function slotAvailability(PressRelease $pressRelease, BoostPlacement $placement): array
|
||||
{
|
||||
if ($this->isBoosted($pressRelease, $placement)) {
|
||||
return ['available' => true, 'freeAt' => null];
|
||||
}
|
||||
|
||||
$limit = $this->slotLimit($placement);
|
||||
$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');
|
||||
|
||||
if ($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' => $freeAt === null, '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): Boost
|
||||
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);
|
||||
$credits = $this->pricing->boostCredits($days, $placement);
|
||||
|
||||
$boost = DB::transaction(function () use ($user, $pressRelease, $days, $credits): Boost {
|
||||
$startsAt = $this->activeUntil($pressRelease) ?? now();
|
||||
$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,
|
||||
|
|
@ -79,14 +159,14 @@ class BoostService
|
|||
$this->wallet->debit(
|
||||
$user,
|
||||
$credits,
|
||||
"Boost {$days} Tage – PM #{$pressRelease->id}",
|
||||
"{$placement->label()} {$days} Tage – PM #{$pressRelease->id}",
|
||||
$boost,
|
||||
);
|
||||
|
||||
return $boost;
|
||||
});
|
||||
|
||||
$this->forgetPlacementCaches($pressRelease);
|
||||
$this->forgetPlacementCaches($pressRelease, $placement);
|
||||
|
||||
return $boost;
|
||||
}
|
||||
|
|
@ -94,13 +174,12 @@ class BoostService
|
|||
/**
|
||||
* 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).
|
||||
* Ablauf der Cache-TTL (HomePageData/CategoryPageData, 300 s). Der
|
||||
* Kategorie-Boost berührt die Startseite nicht.
|
||||
*/
|
||||
private function forgetPlacementCaches(PressRelease $pressRelease): void
|
||||
private function forgetPlacementCaches(PressRelease $pressRelease, BoostPlacement $placement): void
|
||||
{
|
||||
$portals = $pressRelease->portal === Portal::Both
|
||||
? [Portal::Presseecho->value, Portal::Businessportal24->value]
|
||||
: [$pressRelease->portal->value];
|
||||
$portals = $this->affectedPortals($pressRelease);
|
||||
|
||||
// Branchenseite der eigenen Rubrik plus Eltern-Rubrik (deren Seite
|
||||
// schließt Unterkategorien mit ein), je hinterlegter Sprache.
|
||||
|
|
@ -111,7 +190,9 @@ class BoostService
|
|||
->merge($category?->parent?->translations ?? []);
|
||||
|
||||
foreach ($portals as $portal) {
|
||||
Cache::forget(sprintf('web.home.%s.%s', $portal, $pressRelease->language));
|
||||
if ($placement === BoostPlacement::Home) {
|
||||
Cache::forget(sprintf('web.home.%s.%s', $portal, $pressRelease->language));
|
||||
}
|
||||
|
||||
foreach ($translations as $translation) {
|
||||
if (filled($translation->slug)) {
|
||||
|
|
@ -120,4 +201,16 @@ class BoostService
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Services\Web;
|
||||
|
||||
use App\Enums\BoostPlacement;
|
||||
use App\Models\Category;
|
||||
use App\Models\Company;
|
||||
use App\Models\PressRelease;
|
||||
|
|
@ -97,13 +98,27 @@ class CategoryPageData
|
|||
|
||||
// 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()
|
||||
// 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()
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Services\Web;
|
||||
|
||||
use App\Enums\BoostPlacement;
|
||||
use App\Enums\Portal;
|
||||
use App\Models\Category;
|
||||
use App\Models\Company;
|
||||
|
|
@ -75,8 +76,10 @@ class HomePageData
|
|||
// 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()
|
||||
->boosted(BoostPlacement::Home)
|
||||
->with($with)
|
||||
->orderByDesc('published_at')
|
||||
->limit(5)
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ class PreisePageData
|
|||
->values()
|
||||
->all();
|
||||
|
||||
$boost = collect(config('credits.boost', []))
|
||||
$staffel = static fn (array $table): array => collect($table)
|
||||
->map(static fn (int $credits, int|string $days): array => [
|
||||
'days' => (int) $days,
|
||||
'credits' => $credits,
|
||||
|
|
@ -38,7 +38,9 @@ class PreisePageData
|
|||
'plans' => Plan::query()->active()->get(),
|
||||
'singlePmPriceCents' => (int) config('billing.single_pm_price_cents'),
|
||||
'extraPm' => $extraPm,
|
||||
'boost' => $boost,
|
||||
'boost' => $staffel(config('credits.boost', [])),
|
||||
// Kategorie-Boost (nur Branchenseite, halber Satz — 13.07.2026).
|
||||
'boostCategory' => $staffel(config('credits.boost_category', [])),
|
||||
'proofPdf' => (int) config('credits.proof_pdf'),
|
||||
'creditPacks' => config('credits.packs', []),
|
||||
// TODO: Lineup/Preise hängen am Bookings-/Magic-Link-Rework — final abstimmen.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue