Implementation plan · solcom · P2503-106 · F-280 · superseded

Personio-REST-Import: authentifizierte Spiegelung der Stellenanzeigen · superseded

Verworfen am 2026-05-15. Der hier geplante Refactor wurde nach Live-Spike (siehe Spike-Findings) verworfen. Kanonischer Sync-Pfad bleibt Migrate+XML (Story F-280-personio-xml-feed.html). Architektur-Begründung: ADR-0035. Inhalt unten bleibt als historisches Plan-Artefakt erhalten.
PRD Aus PRD 2026-05-15-personio-rest-import-prd.html in sechs tracer-bullet-Slices zerlegt. Story-Hub: F-280-personio-rest-import.html. MVP-Termin 2026-07-08. Architektur-Cut: Service + hook_cron + Drupal Queue-API, kein Migrate-API, drupal/key-Modul für Credentials.
Slices
6
HITL / AFK
2 HITL · 4 AFK
Begleit-ADRs
0031, 0032, 0029-Update
Tests-Pyramide
Unit · Kernel · Functional
01

Slice-Übersicht

Sechs vertikale Slices, jede ist ein eigener PR. Slice 1 ist die Decision-/Spike-Phase; Slices 2–4 + 6 sind agentenfähig (AFK); Slice 5 ist destruktiv und wird atomar mit Slice 4 gemerged.

Phase / Slice Title Type Blocked by
Slice 1 Spike + ADR-Trio (Personio-API, key-Pattern, Service+Queue) HITL
Slice 2 PersonioApiClient + drupal/key mit Env-Provider AFK Slice 1
Slice 3 PersonioPositionMapper (JSON → Drupal-Field-Werte) AFK Slice 1
Slice 4 Syncer + QueueWorker + hook_cron + Sweep AFK Slice 2, Slice 3
Slice 5 Cleanup Migrate-Legacy (atomar mit Slice 4 mergen) HITL Slice 4
Slice 6 Drush-Commands + Watchdog-Channel + State-Tracking AFK Slice 4
02

Slice-Details

Pro Slice: was gebaut wird, welche PRD-User-Stories abgedeckt sind, und 3–5 Akzeptanzkriterien als Checkbox-Liste.

Slice 1 — Spike + ADR-Trio (Personio-API, key-Pattern, Service+Queue)

Type: HITL Blocked by: User Stories: #10 (Spike-Pre-Condition), #11 (Pattern-ADRs)

Live-Spike gegen Personio REST API v1: echter Token-Fetch via POST /v1/auth mit den vorliegenden papi-…-Credentials, anschließend echter GET /v1/recruiting/positions-Call gegen die SOLCOM-Personio-Org. Die Response wird inspiziert auf (a) Field-Schema vs. den 18 bestehenden XPath-Selectors, (b) apply_url-Feldname oder Konstruktions-Notwendigkeit, (c) Description-Section-Struktur, (d) Paging-Mechanik bei größerer Position-Anzahl. Parallel: ADR-0031 (key-Modul-Pattern für externe API-Credentials), ADR-0032 (Service+Queue-Pattern für authentifizierte REST-Syncs ohne Migrate-API), ADR-0029 in-place Update auf das key-Modul-Pattern. Spike-Findings fließen als Open-Items-Resolutions zurück ins PRD.

Akzeptanzkriterien
  • Token-Fetch gegen /v1/auth mit echten Credentials erfolgreich; JWT-Format und Token-Lifetime dokumentiert
  • Positions-Response-Schema dokumentiert (apply_url-Frage, description-Sections, paging) — alle drei PRD-Open-Items resolved
  • ADR-0031 + ADR-0032 als proposed in docs/adr/ angelegt; ADR-0029 in-place Update gemerged
  • PRD Open Items-Section in 2026-05-15-personio-rest-import-prd.html resolved oder explizit verschoben
  • CONTEXT.md-Glossar-Eintrag „Vacancy" wird vorbereitet (Diff im PR-Body skizziert, atomar mit Slice 5 angewendet)

Slice 2 — PersonioApiClient + drupal/key mit Env-Provider

Type: AFK Blocked by: Slice 1 User Stories: #5 (Credentials nicht im Repo), #8 (Compliance/Service-Identity)

Composer-Dependency drupal/key hinzufügen und im Default-Sync (core.extension.yml) aktivieren. Zwei Key-Entities (personio_client_id, personio_client_secret) als config/sync/key.key.*.yml mit Provider env — die Config-Files enthalten nur die Provider-Konfiguration, nicht die Werte. Service PersonioApiClient in src/Service/PersonioApiClient.php: Token-Fetch von POST /v1/auth, JWT-Cache in cache.default mit TTL jwt.exp - 60s, generischer request($method, $path, $options)-Wrapper mit automatischem Authorization: Bearer-Header und einmaligem Refresh-on-401. Watchdog-Logging für alle Auth-Events.

Akzeptanzkriterien
  • drupal/key ist in composer.json + composer.lock, im core.extension.yml aktiviert, zwei Key-Entities in config/sync/
  • PersonioApiClient::request() liefert valid Response gegen Personio (lokal mit Mock-HTTP, Kernel-Test grün)
  • Token wird in cache.default mit TTL gecached; zweiter request()-Call löst keinen zweiten Auth-Call aus
  • Bei 401-Response wird Token erneuert und der Request einmal retried; Logging auf Watchdog-Channel solcom_vacancy sichtbar
  • Unit-Test für Token-Cache-Logik, Kernel-Test mit Mock-HTTP für Token-Fetch + Refresh-on-401

Slice 3 — PersonioPositionMapper (JSON → Drupal-Field-Werte)

Type: AFK Blocked by: Slice 1 User Stories: #1–3 (Spiegelung Anlegen/Schließen/Ändern), #9 (Term-Auto-Create Warning)

Service PersonioPositionMapper in src/Service/PersonioPositionMapper.php mit Methode map(array $position): array. Übersetzt ein Personio-Position-JSON-Object zu einem Drupal-Field-Array für den Bundle vacancy. Term-Auto-Create-Logik für die zehn Vocabularies (vacancy_office, vacancy_department, vacancy_keyword, vacancy_recruiting_category, vacancy_subcompany, vacancy_employment_type, vacancy_seniority, vacancy_schedule, vacancy_years_experience, vacancy_occupation_category) mit Watchdog-Warning pro Auto-Create. Description-Aggregation über die strukturierten Job-Sections gemäß Spike-Output (Sub-Task 1), inklusive Ausblendung von „Team" / „Ansprechpartner/in"-Sections (carry-over). Apply-URL-Strategie: Spike-Output entscheidet, ob Personio ein eigenes apply_url-Feld liefert oder apply_base + position.id konstruiert wird.

Akzeptanzkriterien
  • map($position) liefert valid Drupal-Field-Array für Bundle vacancy (alle 18 Felder + computed field_salary_display)
  • Auto-Create-on-Miss in jedem Vocabulary funktioniert mit Watchdog-Warning Severity warning
  • Description-Aggregation excludes „Team" und „Ansprechpartner/in"-Sections
  • Unit-Tests gegen Fixture-JSON: happy path, fehlende optional-Fields, mehrere Job-Sections, leere Salary-Range, Term-Picklist-Drift
  • Mapper hat keine Drupal-Render-/Database-Side-Effects (pure Funktion außer Term-Storage-Lookup)

Slice 4 — Syncer + QueueWorker + hook_cron + Sweep

Type: AFK Blocked by: Slice 2, Slice 3 User Stories: #1–3 (60-Min-Lifecycle), #6 (Empty-Response-Schutz)

Service PersonioVacancySyncer in src/Service/PersonioVacancySyncer.php mit Methode syncFromCron(): (1) Pull GET /v1/recruiting/positions via PersonioApiClient, (2) synchroner Sweep via Drupal-EntityQuery field_personio_id NOT IN $current_ids + EntityStorage::delete(), (3) Empty-Response-Schutz mit Watchdog-Warning, (4) Queue-Befüllung pro Position in queue.factory.get('personio_position_import'). @QueueWorker('personio_position_import') in src/Plugin/QueueWorker/PersonioPositionWorker.php mit Annotation cron => ['time' => 60]: arbeitet Items asynchron ab, Lookup-or-Create Vacancy-Node anhand field_personio_id, Mapping via PersonioPositionMapper, save. Neuer hook_cron in solcom_vacancy.module mit State-Gating (3600s).

Akzeptanzkriterien
  • End-to-End: syncFromCron() in lokalem DDEV gegen Mock-HTTP führt drei Operationen aus (Insert, Update, Delete) auf Drupal-DB
  • Empty-Response-Schutz: bei 0 Personio-IDs wird der Sweep übersprungen + Watchdog-Warning Severity warning
  • hook_cron ist State-gated (3600s); kein zweiter Run innerhalb der TTL
  • QueueWorker verarbeitet ein Item korrekt: Lookup-or-Create + Mapper + save + State-Update
  • Kernel-Tests: Insert mit 1 Position, Update bei Field-Change, Sweep mit 3 Test-Nodes, Empty-Response-Schutz, State-Tracking-Schreibvorgang
  • Transient 5xx-Response → re-throw → Drupal-Queue-Retry-Mechanismus greift

Slice 5 — Cleanup Migrate-Legacy (atomar mit Slice 4 mergen)

Type: HITL Blocked by: Slice 4 User Stories: #11 (Pattern-Konsistenz im Repo)

Destruktiver Schritt: composer remove drupal/migrate_plus drupal/migrate_tools, solcom_vacancy.info.yml-Dependencies entfernen. Files löschen: src/Plugin/migrate/process/VacancyDescriptionsConcat.php, src/Plugin/migrate/process/VacancyTermGenerate.php, src/EventSubscriber/VacancyHardDeleteSubscriber.php, tests/src/Unit/Plugin/migrate/process/VacancyDescriptionsConcatTest.php. Config-Files löschen: config/sync/migrate_plus.migration.solcom_vacancy.yml, config/sync/migrate_plus.migration_group.solcom_vacancy.yml. core.extension.yml aktualisieren (key in, migrate_plus + migrate_tools out). Alter MigrateExecutable-basierter solcom_vacancy_cron-Code-Pfad weg, neuer hook_cron aus Slice 4 ist drin. CONTEXT.md-Update: Glossar-Eintrag „Vacancy" wird vom Migrate-Pattern auf Service+Queue umgeschrieben. Atomarität: dieser Slice darf nicht ohne Slice 4 gemerged werden — beide gehören in einen einzigen PR oder in einen Squash-Commit. Sonst entsteht ein Zeitfenster, in dem weder alter noch neuer Sync-Pfad funktioniert.

Akzeptanzkriterien
  • composer.json + composer.lock zeigen migrate_plus und migrate_tools nicht mehr als Dependencies
  • solcom_vacancy.info.yml hat keine migrate_plus/migrate_tools-Dependencies; drupal/key ist drin
  • Alle gelisteten Files unter src/Plugin/migrate/, src/EventSubscriber/, tests/.../Plugin/migrate/ sind gelöscht
  • config/sync/migrate_plus.migration*.solcom_vacancy.yml gelöscht; core.extension.yml aktualisiert
  • CONTEXT.md „Vacancy"-Glossar-Eintrag spiegelt das Service+Queue-Pattern
  • PR-Header zeigt explizit „atomar mit Slice 4" — Reviewer prüft, dass beide Slices im selben Merge enthalten sind

Slice 6 — Drush-Commands + Watchdog-Channel + State-Tracking

Type: AFK Blocked by: Slice 4 User Stories: #7 (System-Operator-Manual-Sync + Status)

Drush-Commands in src/Commands/SolcomVacancyCommands.php: solcom_vacancy:sync (Manual-Run, triggert PersonioVacancySyncer::syncFromCron() synchron und arbeitet die Queue komplett ab, liefert strukturiertes Result als JSON) und solcom_vacancy:status (Read-only, zeigt letzten erfolgreichen Pull-Run-Timestamp, Token-Cache-State, Queue-Depth, Anzahl Vacancy-Nodes). Watchdog-Channel solcom_vacancy in solcom_vacancy.services.yml (war bereits vorhanden, jetzt für alle Sync-Events genutzt). State-Tracking-Felder solcom_vacancy.last_sync (Timestamp) und solcom_vacancy.last_sync_result (Result-Array) in Drupal::state().

Akzeptanzkriterien
  • drush solcom_vacancy:sync liefert strukturiertes JSON-Output mit synced, deleted, errors, last_token_refresh, queue_depth
  • drush solcom_vacancy:status zeigt aktuellen Sync-Zustand ohne Side-Effects
  • Watchdog-Einträge sichtbar via drush wd-show --type=solcom_vacancy
  • State-Tracking in Drupal::state() persistiert über Cron-Runs
  • Optional: README oder docs/ops-Kurz-Eintrag mit Operations-Cheatsheet (sync, status, manuelle Token-Invalidierung)
03

Daten- und Kontroll-Fluss

Stündlicher Cron-Cycle: Pull, synchroner Sweep, Queue-Befüllung, asynchrone Worker-Verarbeitung. Token-Cache hält den JWT zwischen den Cycles.

hook_cron (3600s) solcom_vacancy.module PersonioVacancySyncer syncFromCron() PersonioApiClient request() · Token-Cache Personio REST v1 /v1/auth + /v1/recruiting/positions Sweep (synchron) EntityStorage::delete() Drupal Queue personio_position_import QueueWorker Mapper + Node save drupal/key · env PERSONIO_CLIENT_ID/SECRET tick pull positions HTTPS · Bearer JWT 1) sweep first 2) enqueue items 3) worker pro Item credentials

Durchgezogen: Daten/Trigger-Pfad. Gestrichelt clay: Credential-Read aus dem drupal/key-Modul. Sweep läuft synchron im selben Cron-Tick wie der Pull — Insert/Update läuft asynchron über die Queue.

04

Schlüssel-Code

Die zwei Code-Stellen, die am ehesten falsch gebaut werden: Token-Cache-Logik (Race-Conditions, TTL-Off-by-One) und Sweep-Reihenfolge (vor Queue-Befüllung, nicht danach).

src/Service/PersonioApiClient.php (Token-Cache + Refresh)
public function request(string $method, string $path, array $options = []): ResponseInterface
{
  $token = $this->getValidToken();
  $options['headers']['Authorization'] = "Bearer {$token}";

  try {
    return $this->httpClient->request($method, $this->baseUrl . $path, $options);
  } catch (ClientException $e) {
    if ($e->getResponse()->getStatusCode() !== 401) {
      throw $e;
    }
    // Token expired or rotated — invalidate + retry once.
    $this->cache->delete('solcom_vacancy.personio_jwt');
    $this->logger->notice('Personio JWT refresh triggered by 401');
    $token = $this->getValidToken();
    $options['headers']['Authorization'] = "Bearer {$token}";
    return $this->httpClient->request($method, $this->baseUrl . $path, $options);
  }
}

private function getValidToken(): string
{
  $cached = $this->cache->get('solcom_vacancy.personio_jwt');
  if ($cached !== false && isset($cached->data)) {
    return $cached->data;
  }
  return $this->fetchAndCacheToken();
}
src/Service/PersonioVacancySyncer.php (Sweep vor Queue)
public function syncFromCron(): array
{
  $positions = $this->apiClient->fetchPositions();
  $currentIds = array_column($positions, 'id');

  // Empty-feed guard — never sweep on empty.
  if ($currentIds === []) {
    $this->logger->warning('Personio returned 0 positions, skipping sweep');
    return ['synced' => 0, 'deleted' => 0, 'skipped_sweep' => true];
  }

  // 1) Synchronous sweep — must run BEFORE enqueue,
  //    otherwise workers and sweep race for the same nodes.
  $deleted = $this->deleteOrphans($currentIds);

  // 2) Enqueue one item per remaining position.
  $queue = $this->queueFactory->get('personio_position_import');
  foreach ($positions as $position) {
    $queue->createItem([
      'personio_id' => $position['id'],
      'payload'     => $position,
    ]);
  }

  $this->state->set('solcom_vacancy.last_sync', $this->time->getRequestTime());
  return ['synced' => count($positions), 'deleted' => $deleted, 'skipped_sweep' => false];
}
05

Risiken & Mitigations

Risiko
Sev
Mitigation
Deployment-Race: alter MigrateExecutable-Cron läuft parallel zum neuen Service-Cron während Stage-Deploy — schreibt auf dieselben Vacancy-Nodes mit inkompatibler ID-Map.
HIGH
Slice 4 und Slice 5 müssen in einem einzigen Merge atomar gehen. Beide PRs werden im Stack gelinked und im PR-Body explizit markiert. CI-Pipeline blockt isolierten Merge von Slice 4 ohne Slice 5 (siehe AK 5/6).
Sweep löscht versehentlich gesamten Vacancy-Bestand bei Personio-Outage (5xx) — Empty-Response-Schutz greift nur bei echtem Empty, nicht bei 5xx.
HIGH
5xx im PersonioApiClient wirft Exception statt leeres Array zurückzugeben. syncFromCron() catched die Exception, logged Severity error, gibt early-return ohne Sweep. Kernel-Test deckt diesen Pfad explizit.
JWT-Cache wird zwischen zwei parallelen Cron-Threads (Multi-Worker-Cron) inkonsistent — beide refreshen den Token, beide schreiben in cache.default.
MED
In Praxis vermeidet das State-Gating (3600s) gleichzeitige hook_cron-Runs. Bei Manual-Drush-Trigger + Cron parallel: Personio's Auth-Endpoint hat kein Rate-Limit für unsere Volumen — zweifacher Token-Fetch ist verzeihlich. Kein expliziter Lock nötig.
Term-Auto-Create explodiert bei Personio-Picklist-Drift (z. B. „Werkstudent" + „Werkstudentin" als zwei Office-Werte) — Drupal-Vocabularies wachsen unkontrolliert.
MED
Watchdog-Warning pro Auto-Create. Slice 6 zeigt im solcom_vacancy:status-Output die Anzahl Auto-Created-Terms seit letztem Pull. Editor:innen müssen die DE-Labels pflegen — operativer Prozess, kein Code-Fix.
ADR-0029-Update bricht laufende SF-Publication-Story (P2503-108), weil dort das alte getenv()-Pattern dokumentiert ist.
LOW
SF-Publication-Story ist noch nicht in Arbeit (Status „Idee"). ADR-0029-Update wird im Slice 1 fertig, bevor SF-Story refined wird. Cross-Story-Note ist im SF-Story-Hub als „Pre-Implementation-Gate" geführt.
Personio-Response-Schema weicht von Annahme ab (z. B. Description-Sections in unerwartetem Format) — Mapper-Code muss nachträglich angepasst werden, Tests werden rot.
LOW
Slice 1 (Spike) ist explizit Pre-Condition für Slice 3 (Mapper). Spike-Output fließt in die Test-Fixtures. Mapper-Spec wird nicht spekulativ vor dem Spike geschrieben.
06

Offene Fragen

Apply-URL-Strategie
Liefert Personios /v1/recruiting/positions-Response ein eigenes apply_url-Feld, oder bleibt es bei der konstruierten Form apply_base + position.id? Beeinflusst die Mapper-Implementation in Slice 3.
Resolve in · Slice 1 (Spike)
Description-Section-Struktur
Sind die Job-Sections weiterhin als {name, value}-Paare strukturiert (so wie im XML-Vorgänger-Schema) oder hat REST eine andere Form (z. B. sections: [{title, content}])? Direkt-Einfluss auf PersonioPositionMapper::aggregateDescription().
Resolve in · Slice 1 (Spike)
Paging
Bei welcher Position-Anzahl greift Personios Paging (Limit/Offset oder Cursor)? Bei SOLCOMs erwartetem Volumen (< 100 Stellen) ist Single-Request-Response wahrscheinlich — falls Paging nötig wird, erweitert Slice 4 um eine Pagination-Loop in PersonioApiClient::fetchPositions().
Resolve in · Slice 1 (Spike)
SF-Publication-Adoption des key-Modul-Patterns
ADR-0029-Update setzt das key-Modul-Pattern auch für SF-Publication (P2503-108) als verbindlich. Wer baut die Migration der SF-Story um — als Sub-Task in dieser Story (F-280) oder im SF-Bau-Sprint? Vorschlag: SF-Sprint, weil die SF-Story noch nicht in Arbeit ist.
Decide with · Marc, vor SF-Story-Start
Client-Secret-Bereitstellung
Client-ID papi-… ist da. Client-Secret muss vom Personio-Admin angefragt und vor Slice 2 in den drei Env-Layers hinterlegt sein (lokal in settings.local.php, Mittwald-Stage-App, Mittwald-Prod-App).
Owner · SOLCOM-IT / Personio-Admin, blockt Slice 2