hook_cron + Drupal Queue-API, kein Migrate-API, drupal/key-Modul für Credentials.
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 |
Pro Slice: was gebaut wird, welche PRD-User-Stories abgedeckt sind, und 3–5 Akzeptanzkriterien als Checkbox-Liste.
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.
/v1/auth mit echten Credentials erfolgreich; JWT-Format und Token-Lifetime dokumentiertproposed in docs/adr/ angelegt; ADR-0029 in-place Update gemergedOpen Items-Section in 2026-05-15-personio-rest-import-prd.html resolved oder explizit verschobendrupal/key mit Env-ProviderComposer-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.
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)cache.default mit TTL gecached; zweiter request()-Call löst keinen zweiten Auth-Call aussolcom_vacancy sichtbarService 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.
map($position) liefert valid Drupal-Field-Array für Bundle vacancy (alle 18 Felder + computed field_salary_display)warninghook_cron + SweepService 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).
syncFromCron() in lokalem DDEV gegen Mock-HTTP führt drei Operationen aus (Insert, Update, Delete) auf Drupal-DBwarninghook_cron ist State-gated (3600s); kein zweiter Run innerhalb der TTLDestruktiver 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.
composer.json + composer.lock zeigen migrate_plus und migrate_tools nicht mehr als Dependenciessolcom_vacancy.info.yml hat keine migrate_plus/migrate_tools-Dependencies; drupal/key ist drinsrc/Plugin/migrate/, src/EventSubscriber/, tests/.../Plugin/migrate/ sind gelöschtconfig/sync/migrate_plus.migration*.solcom_vacancy.yml gelöscht; core.extension.yml aktualisiertCONTEXT.md „Vacancy"-Glossar-Eintrag spiegelt das Service+Queue-PatternDrush-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().
drush solcom_vacancy:sync liefert strukturiertes JSON-Output mit synced, deleted, errors, last_token_refresh, queue_depthdrush solcom_vacancy:status zeigt aktuellen Sync-Zustand ohne Side-Effectsdrush wd-show --type=solcom_vacancyDrupal::state() persistiert über Cron-RunsStündlicher Cron-Cycle: Pull, synchroner Sweep, Queue-Befüllung, asynchrone Worker-Verarbeitung. Token-Cache hält den JWT zwischen den Cycles.
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.
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).
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(); }
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]; }
MigrateExecutable-Cron läuft parallel zum neuen Service-Cron während Stage-Deploy — schreibt auf dieselben Vacancy-Nodes mit inkompatibler ID-Map.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.cache.default.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.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.getenv()-Pattern dokumentiert ist./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.{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().PersonioApiClient::fetchPositions().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).