Plan #7397 — Dokumenty jako pierwszoklasowy rejestr (rozszerzenie #7009)

Issue: radieu/p24-infra#7397 · Partially implements: #7009 (rozszerzenie prototypu dispo-ekstrakcji) · Źródło decyzji: brainstorm w ciele #7397 (5 decyzji potwierdzonych przez repo-ownera — „do rozwinięcia w planie, nie do zmiany bez konsultacji”).

Doc-source dla pipeline’u: docs/logistics-order-extraction-pipeline-operations.md (per CLAUDE.md: docs prowadzą, workflow JSON podąża). Ten plan jest planem, nie implementacją — migracje DB, zmiany w n8n i backfill są rozpisane jako kolejne, osobne issues; ten dokument ich nie wykonuje.


1. Problem (recap)

Dzisiejszy Stage-1 (#7009) zapisuje załączniki jako kolumnę attachments jsonb ([{filename, mimetype, wasabi_url}, …]) na wierszu maila p24_l_dispo_emails_raw. To uniemożliwia traktowanie pojedynczego dokumentu jako rekordu: nie da się go łatwo przeglądać, filtrować, kategoryzować ani wiązać z kontrahentem/szablonem per-dokument. Na obecnym etapie priorytetem jest kompletność rejestru (żaden dokument nie ginie), a nie automatyczna klasyfikacja — przez kilka dni chcemy ręcznie obserwować komunikację 5–6 kluczowych kontrahentów (DHL Freight, Deutsche Bahn, Nagel-Group, Winzler, FHD Logistik-Service — już zaseedowani w p24_l_dispo_kontrahenci, migracja 20260929120000) i na tej podstawie budować szablony rozpoznawania/przetwarzania ich typów dokumentów.

Dodatkowy problem do odwrócenia: Dispo Fan Out Attachments (#7356, na produkcji) aktywnie odrzuca inline/małe obrazki przed uploadem do Wasabi — więc sygnaturki/stopki nigdy nie trafiają do rejestru. To stoi w sprzeczności z wymogiem kompletności.

Kontekst od czasu pierwotnego planu (aktualizacja z re-queue #7397). Ścieżka ekstrakcji PDF jest już naprawiona i działa na produkcji: #7376 (dekodowanie base64 PDF) + normalizacja liczb w formacie niemieckim przed upsertem (PR #7405). Ten plan dotyczy warstwy rejestracji dokumentów, nie ekstrakcji — ale zakłada, że ścieżka PDF produkuje realne dane (nie traktuje jej jako zepsutej). Nie zmienia to żadnej z decyzji D1–D5; usuwa tylko wcześniejszą niepewność co do stanu Stage-2 dla PDF.


2. Potwierdzone decyzje (z brainstormu #7397)

#DecyzjaRozwinięcie w tym planie
D1Sygnaturki/stopki — rejestrować, nie odrzucać; tylko flagować jest_stopka. Sygnał: cid: w HTML (mocny) + docelowo wymiary pikselowe (nie waga w bajtach). Częściowy **odwrót od 7356.§4
D2Jedna centralna tabela p24_l_dispo_dokumenty (typ/kategoria/status = pola), NIE fan-out do N tabel. Zastępuje kolumnę attachments jsonb.§5
D3p24_l_dispo_szablony zostaje osobną warstwą, z rozszerzoną rolą: rejestr reguł rozpoznawania + instrukcji przetwarzania; jeden kontrahent → wiele szablonów. Bez zrywania register_candidate/match/match_body.§6
D4Backfill historii = ponowne pobranie ze skrzynki (wzorzec #7365), NIE migracja z istniejącego jsonb (sygnał cid: jest w nim bezpowrotnie utracony). Reużyć ten sam object_key (nadpis) i upsert po (gmail_message_id, filename).§7
D5Czyszczenie starej kolumny attachments — warunkowe, po walidacji, przez normalną migrację z explicit review. Autoryzowane przez ownera, ale warunkowane powodzeniem walidacji nowej tabeli.§8

3. Zakres — co jest, a czego nie ma w tym planie

W zakresie (rozpisane poniżej, każde → osobne issue implementacyjne):

  • Nowa tabela p24_l_dispo_dokumenty (migracja).
  • Rozszerzenie p24_l_dispo_szablony o kolumny reguł/instrukcji + relacja szablon_id (migracja).
  • Zmiana Stage-1 n8n: Dispo Fan Out Attachments przestaje odrzucać, zaczyna flagować jest_stopka; nowy zapis per-dokument do p24_l_dispo_dokumenty zamiast attachments jsonb.
  • Backfill ~185 istniejących gmail_message_id przez disposable-workflow re-fetch (#7365).
  • Warunkowe usunięcie kolumny attachments (osobna, późniejsza migracja).

Świadomie odłożone (NIE rozwiązywać teraz, ale nie zapominać):

  • UI do przeglądania rejestru — **et-operational-platform 2029, osobny temat.
  • Wybór dostawcy OCR/vision (Claude vs OpenAI vs OCR) dla konkretnych typów dokumentów — osobna analiza strategiczna; ten plan dotyczy rejestracji, nie ekstrakcji/klasyfikacji treści.
  • Domenowe rejestry merytoryczne poza samym dokumentem (zlecenia już są; przyszłe „zmiany na zleceniach”, „informacje o paletach” itd.) — osobne, późniejsze warstwy budowane NA tej.
  • Restrukturyzacja istniejących wierszy p24_l_dispo_szablony zbudowanych na starym modelu — do oceny (§6.4), bez zakładania z góry potrzeby zmiany danych.

4. D1 — Sygnaturki/stopki: rejestrować i flagować, nie odrzucać

4.1 Zmiana zachowania Dispo Fan Out Attachments (odwrót od #7356)

Dzisiaj (#7356) node prune’uje image/* gdy spełniony jest którykolwiek sygnał inline: (a) nazwa pliku jest cid:-referencjonowana w body_text / binarka niesie contentId / Content-Disposition: inline, lub (b) plik < ~5 KB. Prune znaczy: obraz nigdy nie trafia do Wasabi, Stage-2 ani p24_l_dispo_szablony.

Nowe zachowanie (ten plan):

  • NIGDY nie odrzucać na wejściu. Każdy załącznik (w tym inline obrazy) → zawsze upload do Wasabi + rekord w p24_l_dispo_dokumenty.
  • Zamiast prune — wyliczyć flagę jest_stopka boolean i zapisać ją na rekordzie dokumentu.
  • Warunek „every attachment pruned → email survives as marker item with has_attachments=false” znika — skoro nic nie jest prune’owane, has_attachments odzwierciedla faktyczny stan.

4.2 Sygnał do jest_stopka (kolejność od najmocniejszego)

SygnałŹródłoSiłaStatus
Referencja cid:<filename-or-contentId> w HTML/body_text mailatreść maila (dostępna w Stage-1 i w re-fetch)mocny, specyficzny✅ używać od razu
Marker contentId / Content-Disposition: inline na binarcemetadane załącznika z Gmail nodemocny✅ używać gdy dostępne
Wymiary pikselowe obrazu (małe ikonki, paski logo)wymaga zdekodowania obrazuśredni⏳ docelowo (patrz 4.3)
Waga pliku w bajtach < 5 KB—odrzucone jako jedyne kryterium❌ (jednostronicowy JPG niskiej rozdzielczości to WCIĄŻ dokument)

Reguła: jest_stopka = true gdy mimetype jest image/* i zachodzi sygnał cid:/inline. Waga pliku nie jest samodzielnym kryterium (to jawny odwrót od warunku (b) z #7356).

4.3 Wymiary pikselowe — docelowo, nie w pierwszym kroku

Dekodowanie wymiarów wymaga modułu obrazów w Code-node (np. image-size/sharp) i allowlisty NODE_FUNCTION_ALLOW_EXTERNAL (sys-admin, server op — jak xlsx,pdf-parse w #7009 Deployment §3). Decyzja planu: pierwsza iteracja flaguje tylko po cid:/inline (zero nowych zależności), wymiary pikselowe dokładamy jako osobny, późniejszy krok gdy sam sygnał cid: okaże się niewystarczający. To zgodne z priorytetem „kompletność teraz”: nawet bez wymiarów nic nie ginie — co najwyżej jakaś stopka nie dostanie flagi i będzie widoczna jako zwykły dokument do ręcznego oznaczenia.


5. D2 — Centralna tabela p24_l_dispo_dokumenty

5.1 Szkic schematu (do finalizacji w migracji)

CREATE TABLE IF NOT EXISTS public.p24_l_dispo_dokumenty (
    id               uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    email_id         bigint NOT NULL REFERENCES public.p24_l_dispo_emails_raw(id) ON DELETE CASCADE,
    gmail_message_id text NOT NULL,                       -- denormalizacja (wygodny join/filter + klucz upsertu)
    filename         text NOT NULL,
    mimetype         text,
    wasabi_url       text,                                -- s3://p24-infra/dispo-ecotrans/<gmail_message_id>/<filename>
    rozmiar_bajty    bigint,
    typ_dokumentu    text,                                -- 'pdf'|'obraz'|'excel'|'inny' — derywowane z mimetype
    jest_stopka      boolean NOT NULL DEFAULT false,      -- §4
    kategoria        text,                                -- NULL; wypełniane później przez przegląd/szablon
    status           text NOT NULL DEFAULT 'zarejestrowany',
    kontrahent_id    bigint REFERENCES public.p24_l_dispo_kontrahenci(id) ON DELETE SET NULL,
    szablon_id       bigint REFERENCES public.p24_l_dispo_szablony(id) ON DELETE SET NULL,
    created_at       timestamptz NOT NULL DEFAULT now(),
    -- klucz upsertu dla jednej ścieżki ingestu (nowe + re-fetch), patrz §7:
    UNIQUE (gmail_message_id, filename)
);

Uwagi do decyzji schematu (różnice względem szkicu w issue — do potwierdzenia, nie zmieniam bez konsultacji):

  1. email_id typ. Issue sugeruje email_id uuid references p24_l_dispo_emails_raw(id), ale p24_l_dispo_emails_raw.id to bigint GENERATED ALWAYS AS IDENTITY (migracja 20260915090926), nie uuid. Dlatego FK musi być bigint. Zostawiam PK dokumentu jako uuid (zgodnie z intencją „dokument jako pierwszoklasowy rekord z własnym stabilnym id”), ale FK do maila = bigint. Alternatywa (gdyby owner wolał spójny FK po gmail_message_id): FK po gmail_message_id zamiast po id — odrzucone, bo id jest naturalnym PK maila.
  2. UNIQUE (gmail_message_id, filename) — klucz idempotencji ingestu (D4): ponowne pobranie tego samego maila nadpisuje rekord dokumentu zamiast duplikować. Spójne z ?on_conflict= upsertem PostgREST (Prefer: resolution=merge-duplicates), tym samym wzorcem co emails_raw/zlecenia.
  3. ON DELETE CASCADE na email_id — rekordy dokumentu są podrzędne wobec maila; skasowanie maila (jeśli kiedykolwiek) kasuje jego dokumenty. (Dziś nic nie kasuje maili, ale to poprawna semantyka integralności.)
  4. status wartości: zarejestrowany (default) → do_przegladu → skategoryzowany | zignorowany. Dodać CHECK constraint na dozwolony zbiór (parytet z zlecenia.status/ szablony.status).
  5. typ_dokumentu derywowane z mimetype w n8n Code-node (nie generated column — mimetype bywa null/nietypowy): application/pdf→pdf, image/*→obraz, application/vnd.*excel*/*spreadsheetml*/.xls(x)→excel, reszta→inny.

5.2 Indeksy

CREATE INDEX IF NOT EXISTS ix_dispo_dokumenty_email       ON public.p24_l_dispo_dokumenty (email_id);
CREATE INDEX IF NOT EXISTS ix_dispo_dokumenty_gmail       ON public.p24_l_dispo_dokumenty (gmail_message_id);
CREATE INDEX IF NOT EXISTS ix_dispo_dokumenty_status      ON public.p24_l_dispo_dokumenty (status);
CREATE INDEX IF NOT EXISTS ix_dispo_dokumenty_kontrahent  ON public.p24_l_dispo_dokumenty (kontrahent_id);
-- do przeglądu „pokaż nie-stopki do kategoryzacji":
CREATE INDEX IF NOT EXISTS ix_dispo_dokumenty_przeglad
    ON public.p24_l_dispo_dokumenty (status) WHERE jest_stopka = false;

5.3 RLS + grants (konwencja #4645 — identyczna jak pozostałe p24_l_dispo_*)

ALTER TABLE … ENABLE ROW LEVEL SECURITY; policy service_role_all FOR ALL; policy grafana_readonly_select FOR SELECT + jawny GRANT SELECT TO grafana_readonly (policy i GRANT oba load-bearing, #4645); anon nic. Użyć tego samego DO-blocka co migracja 20260915090926.

5.4 Relacja do attachments jsonb

Nowa tabela zastępuje kolumnę attachments jako źródło prawdy. Kolumna nie jest usuwana w tej samej migracji — jej czyszczenie jest warunkowe i osobne (§8). W okresie przejściowym Stage-1 pisze do obu (dual-write) albo tylko do nowej tabeli — patrz §7.4 (decyzja: tylko nowa tabela; stara kolumna zostaje zamrożona z historycznymi danymi do czasu walidacji i usunięcia).

5.5 Widoki pomocnicze (zero kosztu — fold-in sugestii Value & Quick-wins z review)

Dwa SELECT-only widoki dodawane w tej samej migracji I1 (bez zmian w workflow, bez nowych zależności). Oba wspierają udokumentowaną fazę ręcznej obserwacji (§1) i walidację backfillu (§8), zanim powstanie UI (et-op #2029):

-- (a) Widok przeglądu — dokument + nazwy kontrahenta/szablonu do inspekcji ad-hoc (sugestia #1).
CREATE OR REPLACE VIEW public.p24_l_dispo_dokumenty_review AS
SELECT d.*,
       k.nazwa              AS kontrahent_nazwa,
       s.nazwa              AS szablon_nazwa,
       s.reguly_rozpoznawania
FROM   public.p24_l_dispo_dokumenty d
LEFT JOIN public.p24_l_dispo_kontrahenci k ON k.id = d.kontrahent_id
LEFT JOIN public.p24_l_dispo_szablony    s ON s.id = d.szablon_id;
 
-- (b) Widok agregujący — szybka walidacja kompletności backfillu per kontrahent+typ (sugestia #3,
--     używany jako pomocniczy sanity-check w I5, §8).
CREATE OR REPLACE VIEW public.p24_l_dispo_dokumenty_stats_by_kontrahent AS
SELECT d.kontrahent_id,
       k.nazwa AS kontrahent_nazwa,
       d.typ_dokumentu,
       d.status,
       count(*)                                        AS n,
       count(*) FILTER (WHERE d.jest_stopka) AS n_stopka
FROM   public.p24_l_dispo_dokumenty d
LEFT JOIN public.p24_l_dispo_kontrahenci k ON k.id = d.kontrahent_id
GROUP BY d.kontrahent_id, k.nazwa, d.typ_dokumentu, d.status;

Widoki dziedziczą RLS tabel bazowych (brak własnego SECURITY DEFINER); grafana_readonly dostaje GRANT SELECT na oba tym samym DO-blockiem co tabela (§5.3). To jedyne dwa widoki — nie router do N rejestrów (spójne z D2).


6. D3 — p24_l_dispo_szablony jako rozszerzona warstwa reguł

6.1 Dzisiejsza rola (zachowana)

p24_l_dispo_szablony to kolejka przeglądu kandydatów (migracja 20260914150015): powod ∈ {nieznany_nadawca, niekompletna_ekstrakcja, nowy_format_zalacznika}, status ∈ {do_przegladu, zatwierdzony, odrzucony}, zapis przez register_candidate (Stage-2 „Match Sender” → RegisterCandidate). Ta logika zostaje nietknięta — register_candidate, match, match_body w Dispo Route działają dalej bez zmian.

6.2 Rozszerzenie roli (additive)

Szablon staje się centralnym rejestrem reguł rozpoznawania + instrukcji przetwarzania per kontrahent + typ dokumentu. Jeden kontrahent ma wiele szablonów — potwierdzone realnymi danymi: Nagel-Group wysyła co najmniej 3 różne wzorce (body-only „Abholer”, raport „Übersicht”, PDF „Kundenreporting”). Kolumny dokładane (wszystkie nullable, additive — nie łamią istniejących wierszy):

ALTER TABLE public.p24_l_dispo_szablony
    ADD COLUMN IF NOT EXISTS nazwa                 text,     -- czytelna nazwa wzorca, np. 'Nagel Kundenreporting PDF'
    ADD COLUMN IF NOT EXISTS typ_dokumentu         text,     -- parytet z dokumenty.typ_dokumentu
    ADD COLUMN IF NOT EXISTS reguly_rozpoznawania  jsonb,    -- warunki dopasowania dokumentu do tego szablonu
    ADD COLUMN IF NOT EXISTS instrukcje_przetwarzania text,  -- jak przetwarzać dokument tego wzorca (prompt/kroki)
    ADD COLUMN IF NOT EXISTS aktywny               boolean NOT NULL DEFAULT true;
  • reguly_rozpoznawania jsonb — luźny, iterowalny zestaw sygnałów (np. {"mimetype":"application/pdf", "subject_regex":"Kundenreporting","filename_regex":"..."}). Celowo jsonb, nie kolumny — reguły będą dopracowywane ręcznie w fazie obserwacji.
  • instrukcje_przetwarzania text — notatka/prompt dla późniejszej ekstrakcji (poza zakresem tego planu — §3).

Konwencja nazewnictwa nazwa (fold-in sugestii #2 z review — zero kosztu). Aby faza ręcznego budowania szablonów nie rozjechała się per kontrahent, nazwa trzyma się wzorca <Kontrahent>-<Typ>-<Format>, np. Nagel-PDF-Kundenreporting, Nagel-Body-Abholer, Nagel-XLSX-Uebersicht, DHL-PDF-Frachtbrief. Zapis jako komentarz schematu w migracji I2 (COMMENT ON COLUMN public.p24_l_dispo_szablony.nazwa IS 'Konwencja: <Kontrahent>-<Typ>-<Format>') + jedno zdanie w docs/logistics-order-extraction-pipeline-operations.md §Data model. To wytyczna, nie twardy CHECK — format bywa ad-hoc w fazie obserwacji.

6.3 Połączenie dokumenty.szablon_id ↔ szablon

  • p24_l_dispo_dokumenty.szablon_id (FK, §5.1) wskazuje szablon, którym dokument został rozpoznany. Na tym etapie wypełniane ręcznie / przez przegląd (priorytet to rejestracja, nie auto-klasyfikacja).
  • Docelowo (osobny, późniejszy krok): Stage-2 dopasowuje reguly_rozpoznawania do metadanych dokumentu i ustawia szablon_id + kategoria automatycznie. Ten plan tylko projektuje relację, nie buduje auto-matcha.
  • Relacja kontrahent↔szablon pozostaje szablony.kontrahent_id (już istnieje) — jeden kontrahent, wiele wierszy szablonu.

6.3a Bezpieczeństwo Stage-2 wobec dokumentów jest_stopka=true (blokujące #4 z review)

Odwrót #7356 (§4) sprawia, że do p24_l_dispo_dokumenty zaczną trafiać stopki/loga — historycznie oraz na bieżąco. Decyzja planu: Stage-2 NIGDY nie przetwarza rekordów jest_stopka=true — filtr WHERE jest_stopka = false jest twardym, obowiązkowym warunkiem na wejściu każdej gałęzi Stage-2, która kiedykolwiek będzie czytać dokumenty (auto-match reguł, ekstrakcja, kolejka RegisterCandidate). Uzasadnienie: OCR/ekstrakcja na logo jest marnotrawstwem, a wpychanie stopek do kolejki przeglądu szablony zaśmieciłoby ją fałszywymi kandydatami.

  • Dziś (w zakresie tego planu): Stage-2 nie czyta jeszcze nowej tabeli dokumenty (czyta attachments jsonb / własny strumień — §6.1 register_candidate/match/match_body nietknięte), więc odwrót #7356 nie zmienia bieżącego zachowania Stage-2 — nie ma ścieżki, którą stopki mogłyby dziś zalać Stage-2. To usuwa ryzyko „ukrytej regresji Stage-2” zgłoszone w review: nie ma regresji, bo nie ma połączenia.
  • Docelowo (osobny, późniejszy krok — §3): gdy Stage-2 zacznie konsumować dokumenty, pierwsza linia tej integracji musi zawierać jest_stopka = false w zapytaniu/filtrze n8n. Zapisane tu jako twardy wymóg projektowy, nie jako „do rozważenia”.
  • UAT przy I3: po wdrożeniu I3 (zapis per-dokument) zweryfikować na próbce, że rekordy stopek powstają z jest_stopka=true i że żadna istniejąca gałąź Stage-2 ich nie podnosi (patrz checklista walidacji §8 / I5). Wynik odnotować w §11.

6.4 Istniejące wiersze szablony (stary model)

Do oceny w migracji, bez zakładania z góry potrzeby zmiany: dokładane kolumny są nullable z sensownymi defaultami (aktywny=true), więc istniejące wiersze „review queue” pozostają poprawne i puste w nowych polach. Rekomendacja: nie migrować/nie przepisywać istniejących wierszy; nowe pola są wypełniane dopiero przy ręcznym budowaniu szablonów rozpoznawania.


7. D4 — Backfill historii przez re-fetch (wzorzec #7365), nie z jsonb

7.1 Dlaczego re-fetch, a nie migracja z attachments jsonb

W zapisanym attachments jsonb sygnał cid: (potrzebny do jest_stopka, §4.2) jest bezpowrotnie utracony — jsonb trzyma tylko {filename, mimetype, wasabi_url}, bez treści HTML ani markerów inline. Co więcej, #7356 wyrzuciło stopki jeszcze przed zapisem, więc w jsonb ich w ogóle nie ma. Jedyna droga do kompletnego rejestru z poprawnymi flagami to ponowne przepuszczenie maili przez zaktualizowany łańcuch Stage-1.

7.2 Wzorzec (zweryfikowany w #7365)

Disposable workflow, jednorazowy:

  • n8n-nodes-base.gmail — resource: message, operation: get, options.downloadAttachments: true, batch po 1.
  • Credential gmailOAuth2 id tD8Cz6GiF3zmrtki (ecotrans.automation@gmail.com — ten sam co Stage-1).
  • Wejście: wszystkie ~185 istniejących gmail_message_id z p24_l_dispo_emails_raw (GET /rest/v1/p24_l_dispo_emails_raw?select=gmail_message_id).
  • Upload do Wasabi: ten sam object_key dispo-ecotrans/<gmail_message_id>/<filename> (nadpisuje, nie duplikuje), przez naprawiony native S3 node (#7350, credential 0bS1Y3dt7lQCmEeA).
  • Zapis dokumentu: upsert po (gmail_message_id, filename) do p24_l_dispo_dokumenty (?on_conflict=gmail_message_id,filename, Prefer: resolution=merge-duplicates) — nadpisuje, nie duplikuje. Jedna ścieżka ingestu dla nowych i „odświeżanych” maili (zero osobnego best-effort kodu).

Error-notification backfillu (blokujące #5 z review — CLAUDE.md §Error Notification Standard). Decyzja planu: backfill (I4) jest jednorazowym, nadzorowanym interaktywnie uruchomieniem przez workera infra-task na bms-4 (nie cron, nie usługa stała) — operator obserwuje bieg i reaguje na błędy na żywo. Mimo to wymaga minimalnego wiring’u błędów, bo „nadzorowany” ≠ „niemy przy awarii”: końcowy IF-node w disposable workflow sprawdza, czy liczba maili z dokumenty_backfilled_at IS NULL po biegu > 0 lub czy którykolwiek mail wyczerpał retry (§7.5), i jeśli tak — wysyła embed na P24_DISCORD_INFRA_SCRIPTS_ERRORS_WEBHOOK_URL (reużycie istniejącego webhooka; ten sam, którego używa reszta pipeline’u). Założenie pełnego GH-issue-on- error jest zbędne dla jednorazowego nadzorowanego biegu — operator zakłada issue ręcznie, jeśli trzeba. To jawnie zapisane w zakresie I4 (§9), nie „pozostawione implementacji”.

7.3 WSZYSTKIE kroki post-upload (nie tylko upload!)

Lekcja z #7365: tamten worker zapomniał zresetować processed_at. Ten plan jawnie wymienia każdy krok, by re-fetch nie zostawił bazy w niespójnym stanie.

Dla każdego maila w backfillu:

  1. gmail get (downloadAttachments) → lista załączników + treść HTML (dla sygnału cid:).
  2. Dla każdego załącznika: wylicz typ_dokumentu, rozmiar_bajty, jest_stopka (§4.2).
  3. Upload binarki → Wasabi (ten sam object_key, nadpis).
  4. Upsert rekordu → p24_l_dispo_dokumenty po (gmail_message_id, filename).
  5. Zaktualizuj p24_l_dispo_emails_raw tego maila: has_attachments = (są nie-markerowe załączniki), i — jeśli backfill ma spowodować ponowne przetworzenie Stage-2 — wyzeruj processed_at = NULL. Decyzja planu: backfill rejestru dokumentów nie wymusza re-ekstrakcji Stage-2 (to osobna warstwa, §3), więc processed_at zostaje bez zmian dla maili już przetworzonych. Jeśli jednak implementacja uzna re-ekstrakcję za potrzebną — reset processed_at musi być jawny i policzony, nie przypadkowy. (Ten punkt istnieje właśnie po to, by implementator świadomie zdecydował, a nie przeoczył — jak w #7365.)
  6. Idempotencja całości: ponowne uruchomienie backfillu nadpisuje Wasabi + rekordy po tym samym kluczu; brak duplikatów.

7.4 Dual-write czy tylko nowa tabela w „świeżym” Stage-1?

Decyzja planu: tylko nowa tabela. Stage-1 po tej zmianie pisze dokumenty wyłącznie do p24_l_dispo_dokumenty; przestaje dopisywać do attachments jsonb (kolumna zostaje zamrożona z danymi historycznymi do walidacji + usunięcia, §8). Unika to rozjazdu dwóch źródeł prawdy. Backfill (§7.2) wypełnia nową tabelę dla całej historii, więc nie ma okna, w którym część maili ma dokumenty tylko w starym jsonb.

7.5 Wznawianie po częściowej awarii + tempo/quota Gmail (blokujące #3 z review)

Idempotentne wznawianie — jak backfill wraca po śmierci w połowie (np. na mailu 92/185). Backfill jest z definicji wznawialny, bo cały stan docelowy jest kluczowany:

  1. Lista wejściowa jest deklaratywna, nie licznikowa. Wejściem jest zapytanie GET …/p24_l_dispo_emails_raw?select=id,gmail_message_id wykonywane na początku każdego biegu — nie zapamiętany offset. Ponowny bieg po crashu po prostu znów iteruje po tej samej liście.
  2. Każdy zapis jest upsertem po (gmail_message_id, filename) (§7.2) + ten sam object_key w Wasabi (nadpis). Maile 1–91 (już wgrane) przy ponownym biegu nadpisują się tą samą treścią — zero duplikatów, zero efektu ubocznego. Mail 92 (wgrany częściowo, np. 3/5 załączników) dopina brakujące 2 i nadpisuje 3 istniejące. Maile 93–185 lecą pierwszy raz.
  3. Marker postępu per mail (żeby nie zostawić maila „w połowie”). Do p24_l_dispo_emails_raw dodać lekką kolumnę dokumenty_backfilled_at timestamptz NULL (ADD COLUMN IF NOT EXISTS, additive, w migracji I1). Backfill ustawia ją dopiero po pomyślnym upsercie wszystkich załączników danego maila (krok 4 z §7.3, w jednym kroku na koniec maila). Dzięki temu:
    • „zrobione do końca” = dokumenty_backfilled_at IS NOT NULL;
    • resume może pominąć maile już oznaczone (WHERE dokumenty_backfilled_at IS NULL) albo je nadpisać (oba bezpieczne dzięki upsertowi) — rekomendacja: filtruj po IS NULL, żeby skrócić ponowny bieg;
    • mail przerwany w połowie nie dostaje markera, więc resume zawsze go dokończy. To jest jawny mechanizm odzyskiwania, którego brakowało w #7365 (tam zgubił się processed_at); dokumenty_backfilled_at jest rozłączny z processed_at (Stage-2) — §7.3 pkt 5 nadal mówi „nie ruszać processed_at”.
  4. processed_at pozostaje nietknięty niezależnie od przerwań (§7.3 pkt 5), więc częściowa awaria nie zostawia maila w stanie „udaje nieprzetworzony dla Stage-2”.

Tempo i quota Gmail API. Konto ecotrans.automation@gmail.com, Gmail API messages.get to operacja „tania” w jednostkach quota (5 units/call; dzienny limit per-user to 1 000 000 units i 250 units/user/sek), ale pobranie treści + załączników 185 maili to I/O, nie problem quota. Plan:

  • sekwencyjnie, batch po 1, ~1 mail/sek (jawny Wait/throttle node) → ~3–5 min dla 185 maili, z zapasem pod 250 units/s;
  • backoff na 429/5xx: n8n node z retryOnFail + waitBetweenTries (exponential), max 3 próby; mail, który wyczerpie próby, nie dostaje dokumenty_backfilled_at, więc kolejny bieg go dokończy (pkt 3);
  • uruchamiać poza godzinami szczytu pipeline’u (jedno przejście, jednorazowo) — nie koliduje z normalnym Stage-1, bo oba są idempotentne na tym samym kluczu upsertu.

8. D5 — Warunkowe usunięcie kolumny attachments (osobna migracja)

  • Warunek wstępny — testowalny PASS/FAIL (blokujące #2 z review). „Potwierdzona walidacja” zastąpiona konkretnym zapytaniem. Kryterium: każdy mail, który ma niepusty attachments jsonb, ma w p24_l_dispo_dokumenty co najmniej tyle rekordów, ile pozycji w jsonb (nowa tabela może mieć więcej — odzyskane stopki; to oczekiwane, dlatego >=, nie =). Próg: 0 maili z brakiem (100% — bez tolerancji; każdy brakujący mail blokuje DROP i wymaga śledztwa):

    -- PASS gdy zwróci dokładnie jeden wiersz z verdict='PASS'. Każdy inny wynik = FAIL.
    WITH braki AS (
      SELECT e.id,
             jsonb_array_length(COALESCE(e.attachments, '[]'::jsonb))      AS n_jsonb,
             (SELECT count(*) FROM public.p24_l_dispo_dokumenty d
               WHERE d.email_id = e.id)                                    AS n_dok
      FROM   public.p24_l_dispo_emails_raw e
      WHERE  jsonb_array_length(COALESCE(e.attachments, '[]'::jsonb)) > 0  -- tylko maile z załącznikami w starym jsonb
    )
    SELECT CASE WHEN count(*) FILTER (WHERE n_dok < n_jsonb) = 0
                THEN 'PASS' ELSE 'FAIL' END                               AS verdict,
           count(*)                                   AS maili_z_zalacznikami,
           count(*) FILTER (WHERE n_dok < n_jsonb)    AS maili_z_brakiem
    FROM braki;

    Pomocniczo (nie bramka, ale sanity-check skosu per kontrahent): SELECT * FROM public.p24_l_dispo_dokumenty_stats_by_kontrahent ORDER BY kontrahent_nazwa (widok z §5.5) — pozwala wychwycić kontrahenta z zerem dokumentów zanim padnie bramka.

  • Jak: osobna migracja w supabase/migrations/ (ALTER TABLE public.p24_l_dispo_emails_raw DROP COLUMN attachments;), z explicit review na PR — nie ręczny ALTER TABLE na żywej bazie (CLAUDE.md §Do NOT). Zwykły plik migracji, aplikowany przez per-repo ledger runner (#5653), nigdy apply_migration.

  • Kto autoryzuje i kiedy: DROP wykonuje się tylko po (a) verdict='PASS' z zapytania wyżej oraz (b) jawnej akceptacji repo-ownera (radieu) na PR migracji I6 — owner zaaprobował DROP warunkowo w #7397, ale ostateczne kliknięcie „approve” na I6 jest wymagane. Nie wykonywać automatycznie razem z resztą. Walidujący (I5) wkleja wynik zapytania PASS/FAIL + znacznik czasu w komentarzu I6 jako ślad audytowy (kto, kiedy, jaki wynik) — zanim owner zatwierdzi.

  • Jeśli FAIL / bramka nigdy nie zielona: DROP nie następuje; attachments jsonb zostaje jako zamrożona historia (koszt marginalny), a brakujące maile trafiają do śledztwa (re-run backfillu §7.5 domyka braki idempotentnie). Lepszy zamrożony jsonb niż przedwczesny DROP.

  • p24_l_dispo_emails_raw to TABELA (nie VIEW), więc DROP COLUMN jest tu bezpieczny — pułapka „dev_r_services to VIEW” (#4293/#4442/#4447) nie dotyczy tej tabeli.


9. Rozbicie na issues implementacyjne (kolejność)

Każdy punkt → osobne issue (dev-issue lub sys-admin, jak oznaczono). Zależności zaznaczone.

#Issue (do utworzenia)RolaZależy odTreść
I1Migracja: p24_l_dispo_dokumenty (§5) + widoki (§5.5) + marker backfilludev-issue—nowa tabela + indeksy + RLS; widoki _review/_stats_by_kontrahent (§5.5); ADD COLUMN dokumenty_backfilled_at na emails_raw (§7.5); dev_r_services opis bez zmiany (ta sama usługa)
I2Migracja: rozszerzenie p24_l_dispo_szablony (§6.2)dev-issue—additive ALTER + kolumny reguł/instrukcji + COMMENT z konwencją nazwy (§6.2)
I3n8n Stage-1: odwrót #7356 + zapis per-dokument (§4, §5.4, §7.4)sys-admin/infra-taskI1modyfikacja w miejscu żywego 1PWIIWmodYYSYJNr; model wdrożenia + rollback → §9.1
I4Backfill re-fetch ~185 maili (§7, §7.5)sys-admin/infra-taskI1, I3disposable workflow; WSZYSTKIE kroki post-upload (§7.3); resume §7.5; error-notification jak w §7.2
I5Walidacja kompletności rejestru (§8 bramka PASS/FAIL)dev-issue/sys-adminI4uruchomić zapytanie PASS/FAIL (§8) + sanity-check _stats_by_kontrahent (§5.5) + UAT stopek Stage-2 (§6.3a); wkleić wynik + znacznik czasu w komentarzu I6
I6Migracja: DROP COLUMN attachments (§8)dev-issueI5 zielonewarunkowe, explicit review

Uwaga: I3/I4 to operacje serwerowe na żywej instancji n8n (bms-4) — poza rolą dev-issue, delegować do infra-task (zgodnie z docs/logistics-order-extraction-pipeline-operations.md §Deployment i CLAUDE.md §Role Enforcement). Wymiary pikselowe jest_stopka (§4.3) + ewentualna re-ekstrakcja Stage-2 (§7.3 pkt 5) to dalsze, osobne kroki — nie blokują I1–I6.

9.1 I3 — model wdrożenia + rollback SOP (blokujące #1 z review)

Model wdrożenia: modyfikacja w miejscu żywego workflow, z deaktywacją na czas okna zmiany. 1PWIIWmodYYSYJNr jest pojedynczym, żywym workflow (Stage-1); nie klonujemy go do osobnej instancji (klon miałby ten sam trigger IMAP → podwójne przetwarzanie przychodzących maili). Procedura zero-/minimal-downtime:

  1. Punkt przywracania (restore point) PRZED importem. Operator robi świeży eksport żywego workflow do pliku z datą: n8n export:workflow --id=1PWIIWmodYYSYJNr --output /opt/p24-infra/backups/n8n/1PWIIWmodYYSYJNr-pre-I3-<YYYYMMDD-HHMM>.json (oraz commit repo-tracked kopii n8n-workflows/Dispo_Logistics_Order_Extraction_1PWIIWmodYYSYJNr.json na branchu I3 jako drugi, wersjonowany punkt przywracania). To jest nazwany punkt powrotu.
  2. Deaktywacja na czas zmiany. Ustawić workflow inactive (wyłącza trigger IMAP) → przychodzące maile czekają w skrzynce (IMAP nie kasuje nieprzeczytanych; Gmail trzyma je), nie giną. Okno zmiany to minuty (import JSON + smoke-test), więc zaległość jest mała i nadrobiona po reaktywacji.
  3. Import zmienionego workflow (odwrót #7356 + zapis per-dokument) na to samo id.
  4. Smoke-test przy inactive: ręczne „Execute Workflow” na 1–2 przykładowych mailach (w tym jeden z załącznikiem-stopką) → sprawdzić, że rekordy lądują w p24_l_dispo_dokumenty z poprawnym jest_stopka, i że żadna gałąź Stage-2 nie podnosi stopek (§6.3a UAT).
  5. Reaktywacja (active) dopiero po zielonym smoke-teście → trigger IMAP wraca, zaległe maile przetwarzają się nową ścieżką.

Rollback (jeśli smoke-test lub pierwsze godziny po reaktywacji pokażą regresję):

  • Deaktywować workflow (active=false) — natychmiast zatrzymuje nową ścieżkę (sekundy).
  • n8n import:workflow --input /opt/p24-infra/backups/n8n/1PWIIWmodYYSYJNr-pre-I3-<...>.json (lub import repo-tracked kopii z main sprzed I3) — przywraca poprzednią wersję na to samo id.
  • Reaktywować. Szacowany czas całości rollbacku: < 5 min (deaktywacja + jeden import + reaktywacja).
  • Dane: nowa tabela p24_l_dispo_dokumenty (I1) i stary attachments jsonb współistnieją aż do §8, więc rollback Stage-1 nie wymaga cofania I1 — stara ścieżka jsonb wciąż działa na przywróconym workflow. DROP (I6) jest bramkowany osobno (§8) i nie następuje przed stabilizacją I3.

10. Aktualizacja dokumentacji + compliance

  • docs/logistics-order-extraction-pipeline-operations.md — zaktualizować §Data model (dodać p24_l_dispo_dokumenty, rozszerzone szablony), §Stage 1 (odwrót #7356, zapis per-dokument), dodać §Backfill. Robione w PR-ach implementacyjnych I1–I4, nie w tym planie.
  • dev_r_services — to wciąż ta sama usługa n8n-bms4/logistics-order-extraction; opis odświeżyć w migracji I1 (guarded UPDATE, nigdy ALTER/ON CONFLICT — dev_r_services to VIEW).
  • EU AI Act / dev_r_ai_systems — bez zmian: rejestracja dokumentów nie jest systemem AI (żadnej klasyfikacji/ekstrakcji LLM w tej warstwie). Wpis AI pozostaje odłożony do aktywacji Stage-2 (jak w #7009).
  • Sekrety — zero nowych. Wasabi (WASABI_ACCESS_KEY/WASABI_SECRET_KEY, cred 0bS1Y3dt7lQCmEeA) i Gmail OAuth (tD8Cz6GiF3zmrtki) już istnieją.

11. Ryzyka regresji

  • Odwrót #7356 → wzrost objętości Wasabi/rekordów. Każda stopka/logo teraz ląduje w Wasabi i w dokumenty. Mitygacja: flaga jest_stopka + indeks ix_dispo_dokumenty_przeglad pozwalają odfiltrować stopki z przeglądu; koszt Wasabi marginalny (małe obrazy).
  • Backfill a idempotencja. Błędny klucz upsertu zduplikowałby rekordy. Mitygacja: UNIQUE (gmail_message_id, filename) + on_conflict — ponowny bieg nadpisuje.
  • processed_at (lekcja #7365). Backfill może przypadkiem wyzerować/nie wyzerować processed_at. Mitygacja: §7.3 jawnie wymusza świadomą decyzję (default: NIE ruszać).
  • Dual source of truth w oknie przejściowym. Mitygacja: decyzja „tylko nowa tabela” (§7.4) + backfill całej historii zamyka okno; attachments jsonb zostaje tylko jako zamrożona historia do §8.
  • FK typ email_id (bigint vs uuid). Szkic w issue zakłada uuid; realny PK maila to bigint — §5.1 pkt 1 to prostuje. Pominięcie = błąd migracji.
  • Stage-2 zalany stopkami po odwrocie #7356. Ryzyko zgłoszone w review. Mitygacja: Stage-2 dziś nie czyta nowej tabeli (brak połączenia = brak regresji), a docelowa integracja ma twardy wymóg jest_stopka = false — §6.3a. UAT przy I3 potwierdza, że stopki nie trafiają do kolejki szablony.
  • Mutacja żywego Stage-1 (I3) / brak rollbacku. Ryzyko zgłoszone w review. Mitygacja: model „deaktywuj → import → smoke-test → reaktywuj” z nazwanym punktem przywracania i rollbackiem < 5 min — §9.1. Przychodzące maile czekają w skrzynce w oknie zmiany, nie giną.
  • Backfill umiera w połowie / quota Gmail. Ryzyko zgłoszone w review. Mitygacja: deklaratywna lista wejściowa + upsert + marker dokumenty_backfilled_at (resume idempotentny) i tempo ~1 mail/sek z backoff — §7.5.
  • Przedwczesny lub nieskończony DROP (D5). Ryzyko zgłoszone w review. Mitygacja: testowalna bramka PASS/FAIL (0 maili z brakiem) + akceptacja ownera na I6 — §8; FAIL zostawia jsonb zamrożony, nie blokuje reszty.

Plan autorstwa workera Claude Code (dev-coder, bms4-cw-1). Decyzje D1–D5 pochodzą z brainstormu w #7397 i nie były zmieniane — rozwinięcia oznaczone „do potwierdzenia” wymagają akceptacji ownera przed implementacją.