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)
| # | Decyzja | Rozwinięcie w tym planie |
|---|---|---|
| D1 | Sygnaturki/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 |
| D2 | Jedna centralna tabela p24_l_dispo_dokumenty (typ/kategoria/status = pola), NIE fan-out do N tabel. Zastępuje kolumnę attachments jsonb. | §5 |
| D3 | p24_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 |
| D4 | Backfill 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 |
| D5 | Czyszczenie 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_szablonyo kolumny reguł/instrukcji + relacjaszablon_id(migracja). - Zmiana Stage-1 n8n:
Dispo Fan Out Attachmentsprzestaje odrzucać, zaczyna flagowaćjest_stopka; nowy zapis per-dokument dop24_l_dispo_dokumentyzamiastattachmentsjsonb. - Backfill ~185 istniejących
gmail_message_idprzez 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_szablonyzbudowanych 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 booleani 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_attachmentsodzwierciedla faktyczny stan.
4.2 Sygnał do jest_stopka (kolejność od najmocniejszego)
| Sygnał | Źródło | Siła | Status |
|---|---|---|---|
Referencja cid:<filename-or-contentId> w HTML/body_text maila | treść maila (dostępna w Stage-1 i w re-fetch) | mocny, specyficzny | ✅ używać od razu |
Marker contentId / Content-Disposition: inline na binarce | metadane załącznika z Gmail node | mocny | ✅ używać gdy dostępne |
| Wymiary pikselowe obrazu (małe ikonki, paski logo) | wymaga zdekodowania obrazu | średni | ⏳ docelowo (patrz 4.3) |
| — | 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):
email_idtyp. Issue sugerujeemail_id uuid references p24_l_dispo_emails_raw(id), alep24_l_dispo_emails_raw.idtobigint GENERATED ALWAYS AS IDENTITY(migracja20260915090926), nie uuid. Dlatego FK musi byćbigint. Zostawiam PK dokumentu jakouuid(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 pogmail_message_idzamiast poid— odrzucone, boidjest naturalnym PK maila.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 coemails_raw/zlecenia.ON DELETE CASCADEnaemail_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.)statuswartości:zarejestrowany(default) →do_przegladu→skategoryzowany|zignorowany. Dodać CHECK constraint na dozwolony zbiór (parytet zzlecenia.status/szablony.status).typ_dokumentuderywowane zmimetypew n8n Code-node (nie generated column — mimetype bywanull/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_rozpoznawaniado metadanych dokumentu i ustawiaszablon_id+kategoriaautomatycznie. 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(czytaattachmentsjsonb / własny strumień — §6.1register_candidate/match/match_bodynietknię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 = falsew 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=truei ż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
gmailOAuth2idtD8Cz6GiF3zmrtki(ecotrans.automation@gmail.com — ten sam co Stage-1). - Wejście: wszystkie ~185 istniejących
gmail_message_idzp24_l_dispo_emails_raw(GET /rest/v1/p24_l_dispo_emails_raw?select=gmail_message_id). - Upload do Wasabi: ten sam
object_keydispo-ecotrans/<gmail_message_id>/<filename>(nadpisuje, nie duplikuje), przez naprawiony native S3 node (#7350, credential0bS1Y3dt7lQCmEeA). - Zapis dokumentu: upsert po
(gmail_message_id, filename)dop24_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:
gmail get(downloadAttachments) → lista załączników + treść HTML (dla sygnałucid:).- Dla każdego załącznika: wylicz
typ_dokumentu,rozmiar_bajty,jest_stopka(§4.2). - Upload binarki → Wasabi (ten sam
object_key, nadpis). - Upsert rekordu →
p24_l_dispo_dokumentypo(gmail_message_id, filename). - Zaktualizuj
p24_l_dispo_emails_rawtego maila:has_attachments= (są nie-markerowe załączniki), i — jeśli backfill ma spowodować ponowne przetworzenie Stage-2 — wyzerujprocessed_at = NULL. Decyzja planu: backfill rejestru dokumentów nie wymusza re-ekstrakcji Stage-2 (to osobna warstwa, §3), więcprocessed_atzostaje bez zmian dla maili już przetworzonych. Jeśli jednak implementacja uzna re-ekstrakcję za potrzebną — resetprocessed_atmusi być jawny i policzony, nie przypadkowy. (Ten punkt istnieje właśnie po to, by implementator świadomie zdecydował, a nie przeoczył — jak w #7365.) - 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:
- Lista wejściowa jest deklaratywna, nie licznikowa. Wejściem jest zapytanie
GET …/p24_l_dispo_emails_raw?select=id,gmail_message_idwykonywane na początku każdego biegu — nie zapamiętany offset. Ponowny bieg po crashu po prostu znów iteruje po tej samej liście. - Każdy zapis jest upsertem po
(gmail_message_id, filename)(§7.2) + ten samobject_keyw 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. - Marker postępu per mail (żeby nie zostawić maila „w połowie”). Do
p24_l_dispo_emails_rawdodać 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 poIS 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_atjest rozłączny zprocessed_at(Stage-2) — §7.3 pkt 5 nadal mówi „nie ruszaćprocessed_at”.
- „zrobione do końca” =
processed_atpozostaje 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 dostajedokumenty_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
attachmentsjsonb, ma wp24_l_dispo_dokumentyco 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ęcznyALTER TABLEna żywej bazie (CLAUDE.md §Do NOT). Zwykły plik migracji, aplikowany przez per-repo ledger runner (#5653), nigdyapply_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;
attachmentsjsonb 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_rawto TABELA (nie VIEW), więcDROP COLUMNjest tu bezpieczny — pułapka „dev_r_servicesto 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) | Rola | Zależy od | Treść |
|---|---|---|---|---|
| I1 | Migracja: p24_l_dispo_dokumenty (§5) + widoki (§5.5) + marker backfillu | dev-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) |
| I2 | Migracja: rozszerzenie p24_l_dispo_szablony (§6.2) | dev-issue | — | additive ALTER + kolumny reguł/instrukcji + COMMENT z konwencją nazwy (§6.2) |
| I3 | n8n Stage-1: odwrót #7356 + zapis per-dokument (§4, §5.4, §7.4) | sys-admin/infra-task | I1 | modyfikacja w miejscu żywego 1PWIIWmodYYSYJNr; model wdrożenia + rollback → §9.1 |
| I4 | Backfill re-fetch ~185 maili (§7, §7.5) | sys-admin/infra-task | I1, I3 | disposable workflow; WSZYSTKIE kroki post-upload (§7.3); resume §7.5; error-notification jak w §7.2 |
| I5 | Walidacja kompletności rejestru (§8 bramka PASS/FAIL) | dev-issue/sys-admin | I4 | uruchomić zapytanie PASS/FAIL (§8) + sanity-check _stats_by_kontrahent (§5.5) + UAT stopek Stage-2 (§6.3a); wkleić wynik + znacznik czasu w komentarzu I6 |
| I6 | Migracja: DROP COLUMN attachments (§8) | dev-issue | I5 zielone | warunkowe, 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:
- 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 kopiin8n-workflows/Dispo_Logistics_Order_Extraction_1PWIIWmodYYSYJNr.jsonna branchu I3 jako drugi, wersjonowany punkt przywracania). To jest nazwany punkt powrotu. - 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. - Import zmienionego workflow (odwrót #7356 + zapis per-dokument) na to samo
id. - 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ą wp24_l_dispo_dokumentyz poprawnymjest_stopka, i że żadna gałąź Stage-2 nie podnosi stopek (§6.3a UAT). - 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 zmainsprzed I3) — przywraca poprzednią wersję na to samoid.- Reaktywować. Szacowany czas całości rollbacku: < 5 min (deaktywacja + jeden import + reaktywacja).
- Dane: nowa tabela
p24_l_dispo_dokumenty(I1) i staryattachmentsjsonb 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, rozszerzoneszablony), §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ługan8n-bms4/logistics-order-extraction; opis odświeżyć w migracji I1 (guarded UPDATE, nigdy ALTER/ON CONFLICT —dev_r_servicesto 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, cred0bS1Y3dt7lQCmEeA) 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: flagajest_stopka+ indeksix_dispo_dokumenty_przegladpozwalają 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;
attachmentsjsonb 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 kolejkiszablony. - 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ą.