web: Jahresarchiv je Branche, Kategoriebeschreibungen, Portalname im Titel (Roadmap 2.3)

- /de/category/{slug}/archiv/{jahr} (CategoryArchivePageData,
  CategoryArchiveController, View kategorie-archiv): 30 Meldungen je
  Seite, neueste zuerst, Jahresleiste mit Zahlen; ohne Jahr das juengste
  Jahr mit Meldungen. Canonical mit ?page=N auf Folgeseiten, hreflang
  nur auf Seite 1, CollectionPage + BreadcrumbList. Gecacht bis Seite 3.
  Leere Jahre, unbekannte Kategorien, leere Folgeseiten: 404.
- Branchenseite: Jahresleiste unter dem Feed (archiveYears, eine
  gruppierte Query); Sitemap listet alle Jahresarchive.
- categories:seed-descriptions {--dry-run} {--force}: fuellt leere DE/EN
  Beschreibungen aus database/seeders/data/category-descriptions.php
  (14 Kategorien, sachlich, ohne Werbeaussagen), leert den Seiten-Cache.
- Branchenseite: Titel trug config('app.name') ("– pressekonto") statt
  des Portalnamens — jetzt presseecho/businessportal24.
- Protokoll Teil 5 (2.3 mit Befund: 0 von 14 Beschreibungen, Sonstiges =
  28 % des Bestands), Roadmap 2.3 abgehakt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-17 10:25:29 +00:00
parent b60a28dc9c
commit 4616bb7aad
15 changed files with 793 additions and 13 deletions

View file

@ -0,0 +1,83 @@
<?php
namespace App\Console\Commands;
use App\Models\Category;
use App\Models\CategoryTranslation;
use App\Support\WebPageCache;
use Illuminate\Console\Command;
/**
* Beschreibungen der Branchenseiten einspielen (Roadmap 2.3). Ohne eigene
* Beschreibung zeigen alle Branchenseiten denselben Standardsatz — für
* Suchmaschinen 14 gleich aussehende Seiten. Texte liegen in
* database/seeders/data/category-descriptions.php, Schlüssel ist der
* DE-Slug. Nur leere Felder werden gefüllt; --force überschreibt.
*/
class SeedCategoryDescriptions extends Command
{
protected $signature = 'categories:seed-descriptions
{--dry-run : Nur zeigen, was geschrieben würde}
{--force : Auch vorhandene Beschreibungen überschreiben}';
protected $description = 'Füllt leere Kategoriebeschreibungen (DE/EN) aus database/seeders/data/category-descriptions.php.';
public function handle(): int
{
/** @var array<string, array<string, string>> $texts */
$texts = require database_path('seeders/data/category-descriptions.php');
$rows = [];
$written = 0;
foreach ($texts as $slug => $byLocale) {
$category = Category::query()
->whereHas('translations', fn ($query) => $query->where('locale', 'de')->where('slug', $slug))
->with('translations')
->first();
if (! $category) {
$rows[] = [$slug, '—', 'Kategorie nicht gefunden'];
continue;
}
foreach ($byLocale as $locale => $text) {
/** @var CategoryTranslation|null $translation */
$translation = $category->translations->firstWhere('locale', $locale);
if (! $translation) {
$rows[] = [$slug, $locale, 'keine Übersetzung'];
continue;
}
if (filled($translation->description) && ! $this->option('force')) {
$rows[] = [$slug, $locale, 'bereits vorhanden, übersprungen'];
continue;
}
$rows[] = [$slug, $locale, mb_strimwidth($text, 0, 60, '…')];
if (! $this->option('dry-run')) {
$translation->forceFill(['description' => $text])->save();
$written++;
}
}
}
$this->table(['Kategorie', 'Sprache', 'Ergebnis'], $rows);
if ($this->option('dry-run')) {
$this->info('[Dry-Run] Nichts geschrieben.');
return self::SUCCESS;
}
WebPageCache::flush();
$this->info(sprintf('%d Beschreibungen geschrieben, Seiten-Cache geleert.', $written));
return self::SUCCESS;
}
}

View file

@ -0,0 +1,22 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Services\Web\CategoryArchivePageData;
use App\Support\WebPortalContext;
use Illuminate\Contracts\View\View;
class CategoryArchiveController extends Controller
{
// $edition stammt aus dem Gruppen-Prefix {edition} (siehe CategoryController);
// der optionale Parameter muss hinter den injizierten Abhängigkeiten stehen.
public function __invoke(CategoryArchivePageData $pageData, string $edition, string $slug, ?string $year = null): View
{
return view('web.kategorie-archiv', $pageData->build(
WebPortalContext::fromRequest(),
$slug,
$year === null ? null : (int) $year,
));
}
}

View file

@ -7,6 +7,7 @@ use App\Models\Category;
use App\Models\Company;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Services\Web\CategoryArchivePageData;
use App\Support\WebPortalContext;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
@ -76,18 +77,28 @@ class SitemapController extends Controller
$urls[] = ['loc' => $base.'/de/'.$page, 'lastmod' => null];
}
// Kategorien über die lokalisierten Slugs (CategoryTranslation).
$categorySlugs = Category::query()
// Kategorien über die lokalisierten Slugs (CategoryTranslation),
// dazu je Kategorie die Jahresarchive (2.3).
$categories = Category::query()
->where('is_active', true)
->whereIn('portal', $context->portalValues)
->with('translations')
->get()
->map(fn (Category $category) => $category->translations->firstWhere('locale', 'de')?->slug)
->filter()
->unique();
->with(['translations', 'children' => fn ($query) => $query->where('is_active', true)])
->get();
foreach ($categories as $category) {
$slug = $category->translations->firstWhere('locale', 'de')?->slug;
if (! $slug) {
continue;
}
foreach ($categorySlugs as $slug) {
$urls[] = ['loc' => $base.'/de/category/'.$slug, 'lastmod' => null];
$categoryIds = $category->children->pluck('id')->prepend($category->id)->unique()->values()->all();
foreach (array_keys(CategoryArchivePageData::yearsWithCounts($categoryIds, $context->portalValues, 'de')) as $year) {
$urls[] = ['loc' => $base.'/de/category/'.$slug.'/archiv/'.$year, 'lastmod' => null];
}
}
// Öffentliche Newsrooms: aktive Firmen mit mindestens einer im Archiv

View file

@ -0,0 +1,150 @@
<?php
namespace App\Services\Web;
use App\Models\Category;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Support\WebPageCache;
use App\Support\WebPortalContext;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Facades\DB;
/**
* Jahresarchiv einer Branche (Roadmap 2.3): `/category/{slug}/archiv/{jahr}`.
* Die Branchenseite zeigt nur die jüngsten ~15 Meldungen; das Archiv macht
* den Bestand (34.000 Meldungen seit 2008) als stabile, verlinkte Seiten
* erreichbar. Jahr statt fortlaufender Seitenzahl, weil eine Jahresseite
* ihren Inhalt behält, während `?page=2` bei jeder neuen Meldung verrutscht.
*/
class CategoryArchivePageData
{
public const PerPage = 30;
/** Nur die ersten Seiten je Jahr werden gecacht (siehe NewsroomsPageData). */
private const MaxCachedPage = 3;
/**
* @return array<string, mixed>
*/
public function build(WebPortalContext $context, string $slug, ?int $year): array
{
$page = Paginator::resolveCurrentPage();
$build = fn (): array => $this->buildFresh($context, $slug, $year, $page);
if ($page > self::MaxCachedPage) {
return $build();
}
return WebPageCache::remember(
WebPageCache::key('category-archive', [$context->portal->value, $context->language, $slug, (string) $year, 'page', $page]),
$build
);
}
/**
* Jahre mit Meldungen einer Branche (inkl. Unterkategorien), absteigend
* — auch für die Jahresleiste auf der Branchenseite.
*
* @param list<int> $categoryIds
* @param list<string> $portalValues
* @return array<int, int> Jahr => Anzahl
*/
public static function yearsWithCounts(array $categoryIds, array $portalValues, string $language): array
{
$yearExpression = DB::connection()->getDriverName() === 'sqlite'
? "CAST(strftime('%Y', published_at) AS INTEGER)"
: 'YEAR(published_at)';
return PressRelease::query()
->withoutGlobalScope(PortalScope::class)
->forPortalValues($portalValues)
->forLanguage($language)
->visibleInArchive()
->whereIn('category_id', $categoryIds)
->selectRaw($yearExpression.' as year, COUNT(*) as releases_count')
->groupBy('year')
->orderByDesc('year')
->pluck('releases_count', 'year')
->map(fn ($count): int => (int) $count)
->all();
}
/**
* @return array<string, mixed>
*/
private function buildFresh(WebPortalContext $context, string $slug, ?int $year, int $page): 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);
}
$categoryIds = Category::query()
->where('parent_id', $category->id)
->where('is_active', true)
->pluck('id')
->prepend($category->id)
->unique()
->values()
->all();
$years = self::yearsWithCounts($categoryIds, $portalValues, $language);
if ($years === []) {
abort(404);
}
// Ohne Jahr: das jüngste Jahr mit Meldungen (nicht das Kalenderjahr —
// eine Branche ohne Meldung im laufenden Jahr hätte sonst eine leere Seite).
$year ??= (int) array_key_first($years);
if (! isset($years[$year])) {
abort(404);
}
$releases = PressRelease::query()
->withoutGlobalScope(PortalScope::class)
->forPortalValues($portalValues)
->forLanguage($language)
->visibleInArchive()
->whereIn('category_id', $categoryIds)
->whereBetween('published_at', [
now()->setDate($year, 1, 1)->startOfDay(),
now()->setDate($year, 12, 31)->endOfDay(),
])
->with([
'company:id,name,slug',
'category.translations' => fn ($query) => $query->where('locale', $language),
])
->orderByDesc('published_at')
->paginate(self::PerPage, ['id', 'portal', 'language', 'slug', 'title', 'category_id', 'company_id', 'published_at', 'content_score'], 'page', $page);
if ($page > 1 && $releases->isEmpty()) {
abort(404);
}
return [
'category' => $category,
'categoryAlternateSlugs' => $category->translations()->whereIn('locale', ['de', 'en'])->pluck('slug', 'locale')->all(),
'year' => $year,
'years' => $years,
'releases' => $releases,
'totalReleases' => array_sum($years),
];
}
}

View file

@ -293,6 +293,8 @@ class CategoryPageData
'topNewsrooms' => $topNewsrooms,
'subCategories' => $subCategories,
'relatedCategories' => $relatedCategories,
// Jahresleiste ins Archiv (2.3) — eine gruppierte Query.
'archiveYears' => CategoryArchivePageData::yearsWithCounts($categoryIds, $portalValues, $language),
];
}
}