Product Requirements Document · solcom

Personio-Vacancy-Integration: Stellenausschreibungen aus dem Personio-XML-Feed spiegeln

TL;DR — Drupal spiegelt den öffentlichen Personio-Career-Feed (solcom-gmbh.jobs.personio.de/xml) stündlich via migrate_plus. Vacancies erscheinen automatisch unter /karriere mit Canvas-Detail-Page, JSON-LD (schema.org/JobPosting) und Sitemap-Coverage. Verschwundene Jobs werden hart gelöscht. Apply-CTA verlinkt extern auf Personio. Status: draft · Erstellt: 2026-05-02 · ADRs: 0012, 0013, 0014, 0015

Problem Statement

Recruiting-/Marketing-Verantwortliche pflegen Stellen vollständig in Personio. Auf der Solcom-Website gibt es keine automatische Karriere-Übersicht — Bewerbende müssen direkt zur Personio-Career-Page springen (Journey-Bruch) oder Stellen werden redundant in Drupal gepflegt (Drift, Pflege-Aufwand). Außerdem fehlt JSON-LD nach schema.org/JobPosting, wodurch Google-for-Jobs-Rich-Results nicht erreichbar sind.

Stimme aus Recruiting

„Unsere offenen Stellen pflegen wir vollständig in Personio. Auf der Solcom-Website gibt es aktuell keine Karriere-Übersicht, die diesen Bestand automatisch zeigt — Bewerbende müssen entweder direkt zur Personio-Career-Page springen (das fühlt sich wie ein Bruch in der Customer-Journey an) oder wir pflegen Stellen redundant in Drupal nach. Wir wollen, dass Solcom-Besuchende die offenen Stellen auf solcom.de selbst sehen — gefiltert nach Standort und Bereich, mit brand-konformer Detail-Page, in Google für JobPosting-Rich-Results indexierbar — ohne dass jemand Inhalte pflegen muss."

Solution

Eigenes Drupal-Modul solcom_vacancy unter web/modules/custom/: Bundle, Vocabularies, Migrate-Plugins, JSON-LD-Service, hook_node_view-Adapter, Drush-Command-Wrapper, Logger-Channel. Render-Schicht via Canvas-Content-Templates (vacancy.full, vacancy.card). Ingestion via migrate_plus mit XML-Source-Plugin, Cron + Drush-Trigger. Listing: View vacancies_listing unter /karriere mit better_exposed_filters.

Sobald eine Stelle in Personio freigegeben wird, erscheint sie spätestens nach einer Stunde unter /karriere. Inaktive Stellen verschwinden automatisch. Google findet Detail-Pages über Sitemap und versteht sie als JobPosting.

Goals (V1)

  1. Content-Type vacancy in config/sync/ definiert, über drush cim reproducible deploybar.
  2. 10 Vocabularies (vacancy_office, vacancy_department, vacancy_subcompany, vacancy_recruiting_category, vacancy_employment_type, vacancy_seniority, vacancy_schedule, vacancy_years_experience, vacancy_keyword, vacancy_occupation_category) via entity_generate automatisch befüllt.
  3. Modul solcom_vacancy im Default-Sync aktiviert.
  4. Cron-getriggerte Migration solcom_vacancy läuft stündlich, importiert beide Subcompanies, idempotent über Personio-id.
  5. Hard-Delete-Lifecycle: Jobs die nicht mehr im Feed erscheinen werden hart gelöscht. Bei Empty-Feed oder HTTP-Fehler: Abbruch ohne Delete, Watchdog-Error.
  6. Listing-Page /karriere als Canvas-Page mit Views-Block und better_exposed_filters (Office, Department, Subcompany, Employment Type, Seniority).
  7. Detail-Page als Canvas-Content-Template node.vacancy.full (Hero, Badges, Body, Apply-CTA, Salary-Info).
  8. JSON-LD (schema.org/JobPosting) als <script type="application/ld+json"> im <head> jeder Detail-Page.
  9. Sitemap-Coverage via simple_sitemap; metatag liefert <meta description>.
  10. Apply-CTA verlinkt auf https://solcom-gmbh.jobs.personio.de/job/{personio_id} (target=_blank, rel=noopener).
  11. 4 ADRs (0012–0015) dokumentieren Architektur-Entscheidungen.
  12. Tests: Migrate-Source-Plugin (Kernel), Custom-Process-Plugin (Unit), JSON-LD-Service (Kernel), Listing-Page (Functional), Filter-AJAX (FunctionalJavascript).

User Stories

  1. Als Recruiterin will ich eine Stelle in Personio freigeben, damit sie spätestens eine Stunde später auf /karriere erscheint.
  2. Als Recruiterin will ich einen manuellen Sofort-Refresh per Drush auslösen können (ddev drush migrate:import solcom_vacancy --update).
  3. Als Recruiterin will ich eine Stelle offline nehmen, damit sie nach dem nächsten Import vollständig verschwindet (Listing + Detail + Sitemap + JSON-LD).
  4. Als Bewerber:in will ich auf /karriere alle offenen Solcom-Stellen sehen (inkl. House of SOLCOM).
  5. Als Bewerber:in will ich nach Office filtern (Hamburg, Stuttgart, Reutlingen, München, Karlsruhe).
  6. Als Bewerber:in will ich Filter kombinieren können (z. B. „Stuttgart × Vertrieb × Festanstellung").
  7. Als Bewerber:in will ich auf der Detail-Page den vollständigen Stellentext in lesbarer Sektionierung sehen.
  8. Als Bewerber:in will ich einen prominenten „Jetzt bewerben"-Button sehen, der mich auf Personio führt.
  9. Als Bewerber:in via Google will ich, dass Solcom-Stellen in den Google-for-Jobs-Rich-Results erscheinen.
  10. Als Solcom-Ops will ich Importfehler in Watchdog nachsehen und sicher sein, dass ein Empty-Feed nicht alle Stellen löscht.

Implementation Decisions

Datenmodell — Content-Type vacancy

FeldTypPersonio-QuellePflicht
titleString<name>ja
field_personio_idString, indexed<id>ja
field_apply_urlLinkkonstruiert aus idja
field_bodyLong-Text (full_html)<jobDescriptions> konkateniertja
field_officeTerm-Reference (vacancy_office)<office>ja
field_departmentTerm-Reference (vacancy_department)<department>ja
field_employment_typeTerm-Reference<employmentType>ja
field_seniorityTerm-Reference<seniority>ja
field_salary_min/_max/_currency/_typeDecimal/List<salaryInformation>nein

Vocabulary vacancy_office erhält zusätzlich field_image (Media-Reference) für Hero-Bilder. Alle Taxonomies via entity_generate-Process.

Migration-Mechanik

Hard-Delete + Edge Cases

Listing-Page /karriere

Canvas-Page mit eingehängtem Views-Block vacancies_listing:block_listing. View: Bundle vacancy, Status published=1, Sort created DESC, 20 Items per Page. BEF als Multi-Select-Pillen. Views-AJAX. Kein HTMX (Skill-Verbot).

Detail-Page

Canvas-Content-Template node.vacancy.full: Breadcrumb → Hero (SDC mit Badges) → Body (text-SDC) → Salary-Box (conditional) → Apply-CTA. Topologie via customer-lens-reviewer zu validieren vor Code.

JSON-LD-Builder

Service-Klasse VacancyJsonLdBuilder::build(NodeInterface). Output: schema.org/JobPosting mit Pflichtfeldern inkl. directApply: false. Injection via hook_node_view#attached.html_head. Cache-Tags: node:<nid> + Term-Tags.

Modul-Layout solcom_vacancy

web/modules/custom/solcom_vacancy/
├── solcom_vacancy.info.yml
├── solcom_vacancy.module
├── solcom_vacancy.services.yml
├── src/
│   ├── Service/VacancyJsonLdBuilder.php
│   ├── EventSubscriber/VacancyHardDeleteSubscriber.php
│   └── Plugin/migrate/process/VacancyDescriptionsConcat.php
└── tests/src/
    ├── Unit/VacancyDescriptionsConcatTest.php
    └── Kernel/
        ├── VacancyMigrationTest.php
        ├── VacancyHardDeleteTest.php
        └── VacancyJsonLdBuilderTest.php

Customer-lens-reviewer (Pflicht-Schritt vor Implementation)

Offen: (1) Listing-Card-SDC: card adaptiert vs. dedizierte vacancy-card. (2) Detail-Hero-SDC: hero-blog adaptiert vs. neue vacancy-hero. (3) Apply-CTA: cta vs. dedizierter SDC. (4) Salary-Box: eigene SDC vs. generischer Formatter. Review vor erstem Code-Touch Pflicht.


Testing Decisions

TestTypCoverage-Ziel
VacancyDescriptionsConcatTestUnitSektions-Slug, HTML-Wrapping, leere Inputs
VacancyMigrationTestKernelGuzzle-MockHandler, Nodes mit title, field_personio_id, field_office
VacancyHardDeleteTestKernelItem verschwindet → gelöscht; Empty-Feed → kein Delete; HTTP-Fehler → kein Delete
VacancyJsonLdBuilderTestKernelJSON-LD-Struktur, directApply: false, baseSalary conditional
VacancyListingTestFunctional/karriere lädt, Filter funktioniert, Pagination
VacancyListingAjaxTestFunctionalJavascriptBEF-AJAX, URL-Querystring synchron
VacancyDetailTestFunctionalTitle, Body, Apply-CTA, JSON-LD-Script-Tag im Head

Test-Befehl: ddev drush phpunit web/modules/custom/solcom_vacancy/

ADRs

ADR-0012 — Personio-Vacancy-Spiegel-Modell + Hard-Delete

Entscheidung: Personio = Source-of-Truth, Drupal = Read-only-Spiegel. Hard-Delete bei Verschwinden. Empty-Feed-Schutz verhindert Total-Wipe. Rejected: Drupal editierbar (Konflikt-Logik), Hybrid-Schema, Soft-Archive mit validThrough. Consequences: Drupal-Editor:innen können Vacancies nicht ändern; verschwundene Jobs → 404.

ADR-0013 — Migrate API + migrate_plus als Ingestion-Pattern

Entscheidung: Migrate API + migrate_plus (XML-Source) + migrate_tools (Cron+Drush). Cron 3600s. Rejected: Custom-QueueWorker (Idempotency selbst bauen), Feeds-Modul (D11 weniger gepflegt). Consequences: Setzt Standard für zukünftige externe Quellen.

ADR-0014 — Vacancy-Klassifizierung als Taxonomy

Entscheidung: Alle Klassifizierungs-Achsen als Taxonomy. entity_generate legt fehlende Terms automatisch an. Rejected: Hybrid Taxonomy+List-String, Alles List-String. Consequences: Konsistente Filter-UX, Translatability, Erweiterbarkeit ohne Code-Change.

ADR-0015 — JSON-LD via Custom-Service (statt schema_metatag)

Entscheidung: Custom-Service VacancyJsonLdBuilder + hook_node_view-Adapter. Rejected: schema_metatag (zu großer Footprint), SDC-Komponente (JSON-Building in Twig klobig). Consequences: Voller Test-Coverage via Kernel-Test.

Out of Scope (V1)

Open Implementation Questions

  1. SDC-Topologie:customer-lens-reviewer-Konsultation als erster Implementation-Schritt (Pflicht).
  2. Pathauto-Pattern: Vorschlag: flach /karriere/[node:title].
  3. employment_type zu schema.org-Enum: full-timeFULL_TIME, part-timePART_TIME, sonst OTHER.
  4. Cache-Tags-Granularität: node_list:vacancy für Listing; taxonomy_term:<tid> für Detail-Page.

FAQ

Was passiert, wenn der Personio-Feed down ist?
Die Migration bricht mit Status failed ab. Watchdog-Error. Bestehende Nodes bleiben unverändert — kein Delete-Sweep ohne erfolgreichen Import.
Kann ein Drupal-Editor eine Vacancy manuell bearbeiten?
Nein. Personio ist Source-of-Truth. Drupal-seitige Änderungen würden beim nächsten Import überschrieben.
Wie schnell erscheinen neue Stellen auf /karriere?
Spätestens nach 60 Minuten (Cron-Intervall). Bei Bedarf manuell via ddev drush migrate:import solcom_vacancy --update sofort.
Werden Stellen von House of SOLCOM auch importiert?
Ja. Der Feed enthält beide Subcompanies; field_subcompany unterscheidet sie. Der Listing-Filter zeigt beide, lässt aber nach Subcompany filtern.