Skip to content

feat(export): export approved requests exactly once to the ERP mock - #36

Merged
Fluory merged 94 commits into
mainfrom
claude/feat-erp-export-9
Sep 23, 2026
Merged

Fluory merged 94 commits into
mainfrom
claude/feat-erp-export-9

Conversation

@Fluory

@Fluory Fluory commented Sep 23, 2026 •

Copy link
Copy Markdown
Owner

Warum

Fixes #9 · Teil von Epic #2 · gestapelt auf #35 (Basis claude/feat-review-ui-8).

Arbeitsstand

  • Ziel: Freigegebene Anfragen gehen über den ERP-Port (Vertrag contracts/erp-export.openapi.yaml) mit Idempotency-Key an den ERP-Mock und landen genau einmal im Status EXPORTED.
  • Nicht-Ziele: echtes ERP-Mapping, Dateiexport, Deployment.
  • Erledigt: Vertrag + generierte Typen + Drift-Test, Migrationen 0011/0012 (request_exports), REST-Adapter, Mock + Route hinter Flag, Export-Handler + drainExports() im Worker, Reprocess für Stufe export, Seite zeigt ERP-Referenz/Wiederholungen, Tests (Unit, Integration, E2E bis „Exportiert"), Doku, frischer Review inkl. Fixes.
  • Offen: Merge durch den Orchestrator nach feat(review): review fields beside their source, correct, approve or reject #35; decision-needed (Mock-Speicher im Speicher).
  • Annahmen: siehe Impact Manifest.
  • Nächster kleinster Schritt: Review durch den Orchestrator; danach feat(tenancy): guard test – every app table has forced RLS #29 (RLS-Guard) auf diesem Branch.

Was ist passiert (Klartext)

Freigegebene Anfragen werden jetzt automatisch an das (simulierte) ERP geschickt – und zwar garantiert nur einmal. Dafür sorgen drei Dinge zusammen: Jede Anfrage trägt ihren festen Schlüssel, an dem das ERP eine Wiederholung erkennt und dieselbe Referenz zurückgibt; auf unserer Seite gibt es genau einen Export-Eintrag pro Anfrage; und während des Exports ist die Anfrage gesperrt, sodass zwei Durchläufe nicht gleichzeitig exportieren. Getestet ist das mit simulierten Ausfällen (Fehler 503, Zeitüberschreitung, „Antwort verloren") und doppelt zugestellten Aufträgen: Am Ende steht immer genau ein Datensatz im ERP. Scheitert der Export dauerhaft, steht die Anfrage sichtbar auf „Fehler (Export)" und kann erneut angestoßen werden. Die Prüfseite zeigt die ERP-Referenz. Der ERP-Simulator ist nur mit ERP_MOCK_ENABLED=true erreichbar. Freigeben geht nicht mehr, solange ein Wert zu lang für das ERP ist – dann erst korrigieren. Nebenbei behoben (#7): ein Auftrag mit unbrauchbaren IDs bringt den Worker-Durchlauf nicht mehr aus dem Tritt. Compose-Änderung: Worker und Web bekommen die ERP-Variablen (lokale Standardwerte).

Plan-Pflicht (SYSTEM.md §4)

  • Kein Auslöser – keine Modulgrenze, öffentliche API, Migration, Auth/Rechte, kein Zahlungs-/Daten-/Infrapfad, höchstens zwei Module, keine Architekturvarianten, umkehrbar
  • Auslöser zutreffend – Impact Manifest ausgefüllt (Plan vor Code)

Impact Manifest

  • Betroffene Module: export (ERP-Port, REST-Adapter mit Timeout, Export-Handler, drainExports), erp-mock (idempotenter Empfänger mit Fehlerinjektion, Route hinter ERP_MOCK_ENABLED), jobs (Reprocess Stufe export; drain()-Härtung), requests (APPROVED → EXPORTED / ERROR(export)), review (currentFieldValues, Freigabe prüft ERP-Limits, Exportstatus in der Ansicht), db (Migrationen 0011/0012), config (ERP_*), Worker, compose, E2E-Smoke.
  • Schnittstellen / Datenänderungen: Vertrag POST /v1/quote-requests mit Idempotency-Key: <requestId> (201/200/400/401/409/411/413/503); Route POST /api/erp-mock/v1/quote-requests (404 ohne Flag, Bearer-Token, ≤ 64 KiB); Tabelle app.request_exports (unique request_id, Key = Request-ID per Check, FORCE RLS, Composite-FK, kein DELETE); Env ERP_BASE_URL, ERP_TOKEN, ERP_TIMEOUT_MS (≤ 20 s), ERP_MOCK_ENABLED, ERP_MOCK_FAULTS.
  • Akzeptanzkriterien: alle aus feat(export): export approved requests exactly once to the ERP mock #9 – siehe Nachweis.
  • Testplan: Unit – Mock (Replay, 409, 400/401, Fehlerinjektion, Speichergrenze), Adapter (Timeout, Fehlerklassen, Body-Lesefehler, Vertragsprüfung), Config; Vertrags-Drift-Test; Integration gegen echtes Postgres – Exactly-once mit 503/Timeout/lost, doppelte Zustellung (parallel), ERP speicherte vor unserem Commit, permanente 409 → ERROR + Reprocess, Dead-Letter → ERROR, RLS; Route-Tests (Flag an/aus); E2E bis „Exportiert".
  • Verifizierte Fakten: ADR-0001 D9; Export-Queue + transaktionales Einstellen existieren seit feat(review): review fields beside their source, correct, approve or reject #35; statement_timeout des Pools 30 s, Job-Expiry Export 15 min.
  • Offene Annahmen: Mock speichert Idempotency-Keys im Speicher (decision-needed in feat(export): export approved requests exactly once to the ERP mock #9): Neustart vergisst Keys; zwischen „gespeichert, Antwort verloren" und Retry erzeugt ein Neustart einen zweiten Mock-Datensatz – unsere Seite (unique Export-Zeile, EXPORTED) bleibt unberührt.
  • Nicht-Ziele: siehe oben.
  • Risiken und Rollback: Zeilensperre + Pool-Verbindung während des ERP-Aufrufs (≤ 20 s, bei 50/Tag unkritisch); ausgehende Daten enthalten Kontaktdaten (nur synthetisch), keine Payloads in Logs; Migrationen additiv; Rollback per Revert.

Geändert

  • contracts/erp-export.openapi.yaml, src/features/export/{contract,erp-client,payload,repository,export-job,index}.ts + generiertes erp-export.contract.ts
  • src/features/erp-mock/{mock,index}.ts, src/app/api/erp-mock/v1/quote-requests/route.ts
  • src/db/schema/app.ts, Migrationen 0011_export.sql, 0012_export_force_rls.sql
  • src/features/jobs/{drain,reprocess}.ts, src/worker.ts, src/config/env.ts
  • src/features/review/{review,index}.ts, src/app/requests/[id]/{page.tsx,messages.ts}
  • Tests: src/features/{export,erp-mock}/*.test.ts, src/config/env.test.ts, tests/architecture/contract-types.test.ts, tests/integration/{export,erp-mock-route,processing,review,upload-routes,intake}.test.ts, tests/e2e/review-smoke.spec.ts, playwright.config.ts
  • compose.yaml, .env.example, package.json (contract:types)
  • Doku: docs/technical/{api,operations,data-model,architecture}.md, CHANGELOG.md

Nachweis (SYSTEM.md §11)

  • verify:changed: typecheck + lint + betroffene Unit-/Integrationstests grün
  • verify: lokal grün (Exit 0, Node 24) gegen echtes PostgreSQL 17 + SeaweedFS – Unit 121/121, Integration 81/81, depcruise ohne Verstöße, Build, Audit (1 moderate, Schwelle high); CI check: siehe Checks dieses PRs
  • verify:full / E2E-Spec: tests/e2e/review-smoke.spec.ts lokal grün bis „Exportiert" mit ERP-Referenz (Worker, gebaute App, KI-Stub, Mock); CI per Label verify-full
  • Akzeptanzkriterien feat(export): export approved requests exactly once to the ERP mock #9: Mock speichert Keys, Wiederholung → gleiche Referenz → Unit „creates a record once…", Route-Test „201 once, 200…"; Export-Zeile unique(request_id) + APPROVED → EXPORTED unter Zeilensperre → Migration 0011/0012, Integration „duplicate delivery…" (Mutationsprobe: ohne Sperre scheitert der Test); injizierte 5xx/Timeouts + doppelte Zustellung → genau einmal EXPORTED → Integration „survives a 503, a timeout and a lost response…", „duplicate delivery…", „an ERP that stored the request before our commit…"; Timeout konfiguriert + Fehler sichtbar als ERROR (Stufe export) → Unit Adapter, Config-Test, Integration „permanent refusal…", „retries exhausted…"; Mock nur mit ERP_MOCK_ENABLED=true → Route-Test 404 (Flag aus) + Flag-an-Tests
  • Manueller Prüfnachweis: –
  • Frischer Review (P1 vor Ready-for-review; Architektur/API/DB immer): unabhängiger Subagent nach review-pr inkl. Security/API/Migrations-Regeln – 0 Blocker, 5 should-fix + 6 nits, alle behoben (PR-Kommentar)

Doku-Entscheidung (genau eine)

  • Keine langlebige Doku betroffen – Begründung: –
  • Doku betroffen und im selben PR aktualisiert:
    • Produktdoku (P0: README): –
    • Technische Doku: docs/technical/data-model.md, docs/technical/api.md, docs/technical/operations.md
    • Architekturkarte: docs/technical/architecture.md
    • ADR: –
    • CHANGELOG [Unreleased] (sichtbares Feature oder Verhalten – im selben PR, nie „später")

Entferntes oder Umbenanntes: nichts entfernt

Dateigrößen und neue Bausteine (SYSTEM.md §7)

Dateien über 500 Zeilen im Diff (Ausnahmen: generierter Code, Lockfiles, Fixtures, Migrationen, Schemas, Ressourcen, Doku, Konfiguration):

  • keine
  • bewusst belassen – Begründung: –
  • im selben PR nach fachlicher Verantwortung geteilt
  • Folge-Issue –

Über 800 Zeilen mit neuer Fachlogik oder über 1000 Zeilen (P1/P2): nicht betroffen

Neue Shared-Komponente, Utility-Datei, Adapter oder fachlicher Service:

Subagent-Einsätze

  • Frischer Review (read-only): Ergebnis und Auflösung als PR-Kommentar.

Risiken / offene Punkte


🤖 Generated with Claude Code

https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1

…ion access control

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1
…LS with integration proofs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1
…boss queues

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1
…nd download routes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1
…-only audit, composite FK

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1
- sign-up requires the invitation id (link) plus the invited e-mail – no takeover by address alone
- one company per user (unique index), deterministic membership lookup, actor from membership
- organization plugin accepts only admin/clerk roles
- configurable client-IP source for the auth rate limit; local secret refused in production
- invite page: zod input, 404 for clerks, shows the invitation link; signup needs the link
- tests: wrong/missing invitation id, foreign set-active/list-members, last admin, roles
- docs: operations (rate limit/proxy, recovery), data model, exceptions register (admin plugin)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1
…e rejection

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1
… docs for #5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1
…secret

The image sets NODE_ENV=production, so the previous check blocked the local compose stack.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1
…re script

Python 3.13 package requestflow_ai (src layout), docling/fastapi/google-genai
pinned, CPU-only torch via the PyTorch CPU index, dev tools ruff/pyright/pytest.
The synthetic PDF fixture is generated by scripts/make_fixtures.py (reportlab).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1
Test-first per ADR-0001 D8: quote normalisation (whitespace, case, NFKC,
hyphenation), German number and date formats, and the verifier rules that
turn unsupported model claims into unverified. Also adds the segment and
model-output types the tests build on; the verifier does not exist yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1
normalize_text folds NFKC, soft hyphens, line-break hyphenation, typographic
dashes/quotes, whitespace and case. values parses German/ISO numbers and
DD.MM.YYYY, D.M.YY and ISO dates. verify_field only keeps or downgrades the
model's status: a quote not in the cited segment, an unknown segment, a value
inconsistent with the quote, found without evidence or value, and missing
with a value all become unverified with a reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1
…ction (red)

EML: body lines with 1-based line locators, From/Subject header segments,
RFC 2047 and quoted-printable decoding, HTML-only bodies, header newline
collapse, no attachment payloads. PDF: textline segments with page + top-left
bbox via docling-parse (model-free); the layout-pipeline test only runs with
AI_TEST_DOCLING_MODELS=1. Detection by magic bytes; .msg rejected for now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1
EML via the standard library email package (policy=default): From/Subject
header segments and one segment per non-empty body line, HTML-only bodies
reduced to text lines. docling's EMAIL backend was checked but emits
paragraphs without provenance, so it cannot give line locators.

PDF via docling: the default textlines pipeline reads docling-parse text
lines (page + top-left bbox, no ML models, no network); the opt-in layout
pipeline uses DocumentConverter (OCR and tables off) and the heron layout
model. docling is imported lazily.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1
…ex model client (red)

The model client is exercised through the real google-genai SDK with an
httpx MockTransport replaying recorded generateContent bodies: eu multi-region
URL, bearer auth, structured-output config, token usage, fail-closed init
(no project, no credentials, dev flag without key), no silent switch to API
key mode, and schema-invalid output. Adds the env-based Settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1
…lient

Prompt extract_header_v1.md (system instruction) plus render_document, which
puts segment-id-prefixed lines between <document> delimiters and neutralises
delimiter-like tags inside the document. GeminiModelClient calls Vertex via
google-genai with response_schema = ModelExtraction, JSON mime type,
temperature 0 and no tools; schema-invalid output raises ModelOutputError.
build_model_client needs VERTEX_PROJECT and credentials, loads ADC eagerly,
refuses API-key mode for Vertex, and allows the Gemini API only with
AI_ALLOW_GEMINI_API_DEV=true plus GEMINI_API_KEY.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1
Pipeline: PDF and EML end to end with recorded model responses, a model date
contradicting its own quote, the prompt-injection mail (the recorded model
obeys and invents a quote -> unverified), and the no-text PDF that skips the
model. API: bearer auth (401 + WWW-Authenticate), response shape, request id
handling, 400/413/415/422/429/502 mapping without echoing input, JSON logs
with IDs only. Contract: the committed OpenAPI file equals the app's schema.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1
@Fluory Fluory mentioned this pull request Sep 23, 2026
7 tasks done

Fluory commented Sep 23, 2026

Copy link
Copy Markdown
Owner Author

Frischer Review (unabhängiger Subagent, read-only, review-pr + Security/API/Migrations-Regeln)

Ergebnis: 0 Blocker · 5 should-fix · 6 nits. Bestätigt: Exactly-once-Pfad (Statusprüfung, ERP-Aufruf, Übergang und Audit in einer Tenant-Transaktion unter Zeilensperre; exclusive-Queue; Replay nach „lost"/Crash; Payload über Versuche stabil), FORCE RLS + Composite-FK + kein DELETE, Logs nur IDs, Vertrag = Mock = api.md, kein Modulzyklus (Injektion von currentFieldValues), Tests können scheitern.

# Schwere Befund Auflösung
S1 should-fix ERP-Längenlimits erst beim Export geprüft → zu langer, unkorrigierter KI-Wert bleibt dauerhaft in ERROR (nach Freigabe keine Korrektur mehr) Freigabe wird abgelehnt (value_too_long), solange ein Wert die Limits verletzt; Clerk korrigiert zuerst. Integrationstest „refuses approval while a value is too long…". Betreff war schon begrenzt (Mail 300, Dateiname 200) – Test ergänzt
S2 should-fix Fehler beim Lesen des Antwort-Bodys wurde zu permanentem contract_violation Lesefehler (Reset/Timeout) → unreachable/timeout, retryable; nur gelesener, vertragswidriger Body ist permanent. Unit-Test
S3 should-fix Token-Vergleich im Mock nicht timing-safe timingSafeEqual über SHA-256-Digests
S4 should-fix ERP_MOCK_FAULTS erst beim ersten POST geprüft Prüfung in loadConfig (Start schlägt fehl), Test
S5 should-fix ERP_TIMEOUT_MS unbegrenzt, Zeilensperre + Pool-Verbindung während des Aufrufs Maximum 20 s (unter statement_timeout 30 s, weit unter Job-Expiry 15 min), Test; in operations.md begründet
N1 nit „skipped" still export.skipped mit Grundcode (nur IDs)
N2 nit Doku „5xx retried" ungenau exakte Codes (408, 429, 500, 502, 503, 504)
N3 nit 409-Zeile empfahl Reprocess Konflikt zuerst auf ERP-Seite lösen – Reprocess sendet denselben Body
N4 nit Mock-Route fehlte in der Architekturkarte ergänzt
N5 nit .env.example ERP_BASE_URL bricht Compose, wenn als .env kopiert Kommentar ergänzt (Compose setzt http://web:3000/api/erp-mock)
N6 nit Folge des In-Memory-Speichers bei Neustart nicht benannt in api.md/operations.md und der decision-needed ergänzt

Generated by Claude Code

@Fluory
Fluory marked this pull request as ready for review September 23, 2026 07:29
@Fluory
Fluory changed the base branch from claude/feat-review-ui-8 to main September 23, 2026 10:00
@Fluory
Fluory merged commit 7f7594e into main Sep 23, 2026
7 of 8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(export): export approved requests exactly once to the ERP mock

2 participants