[15]
GENERATOR OFERT Z CRM W SZABLONIE FIRMOWYM
▓ TIER B · ROK 2026 · STATUS: DZIAŁA · JĘZYKI: TYPESCRIPT
Deterministyczna generacja dokumentów na surowym OOXML, wsadowo
[FIG. 1] MISJA
Świadomie nie używam LLM tam, gdzie powtarzalność bije kreatywność, i umiem tę decyzję uzasadnić: oferta handlowa ma wyglądać identycznie za każdym razem, więc generuję ją deterministycznie. Handlowcy firmy szkoleniowej przepisywali wcześniej oferty z arkusza do Worda ręcznie i doklejali własne stopki — żmudnie, z błędami i bez szans na skalę. Zbudowałem im SPA bez backendu (React + Vite), bo backend byłby tu kosztem bez wartości: arkusz parsuję biblioteką xlsx z odpornym mapowaniem danych — kolumny po indeksie z fallbackiem po nagłówku, bo arkusz w realnym użyciu bywa przestawiony — i pokazuję wyszukiwalną listę produktów. Eksport wykonuję chirurgicznie na surowym OOXML (PizZip), podmieniając fragmenty zamiast generować dokument od zera: stopka handlowca to pełnostronicowy design z tłem PNG kotwiczonym do strony i pływającymi tabelami kotwiczonymi do marginesu, więc zamiast re-renderować dokument podmieniam jego sectPr na zerowo-marginesowy sectPr stopki i wszystko ląduje co do piksela. Zamiast operacji pojedynczej buduję eksport wsadowy: zaznaczone produkty pakują się w osobne pliki .docx wewnątrz jednego ZIP-a, a całość odpala się plikiem .vbs/.ps1 — nietechniczny użytkownik nigdy nie widzi terminala.
[FIG. 2] ARCHITEKTURA
najedź na blok, by zobaczyć opis
[FIG. 3] WYZWANIA
[+][CH-01]
Stopka handlowca nie była zwykłą stopką, tylko pełnostronicowym designem: tło PNG kotwiczone do strony i pływające tabele kotwiczone do marginesu. Każda próba re-renderowania dokumentu gubiła pozycjonowanie — pływające elementy lądowały gdzie indziej, bo współrzędne tblpX/tblpY liczą się względem marginesów sekcji. Zszedłem więc na poziom surowego XML wewnątrz .docx (PizZip): podmieniam końcowe sectPr dokumentu głównego na zerowo-marginesowy sectPr stopki, dzięki czemu współrzędne pływających tabel rozwiązują się jako poprawne pozycje absolutne. To chirurgia na OOXML, nie eksport HTML — i dlatego wynik trafia co do piksela.
[+][CH-02]
Arkusz źródłowy żył własnym życiem: kolumny bywały przestawiane, nagłówki zmieniane, a część danych dodatkowych pojawiała się tylko czasem. Mapuję więc kolumny po indeksie z fallbackiem po słowach-kluczach nagłówka (produkt/nazwa, opis, zakres), pomijam kolumny techniczne A/B, a dane dodatkowe z X–AC wciągam tylko wtedy, gdy są niepuste. Narzędzie przeżywa reorganizacje arkusza bez mojej interwencji — co przy narzędziu dla nietechnicznych użytkowników jest warunkiem przetrwania.
[+][CH-03]
Naiwna podmiana placeholdera {{Title}} w szablonie nie działała — i długo nie było widać dlaczego. Word przy zapisie tnie ciągły tekst na osobne „runy” (<w:r>), często przez sprawdzanie pisowni albo ślady formatowania, więc ciągu {{Title}} fizycznie nie ma w XML-u w jednym kawałku. Zdiagnozowałem to, zaglądając nodem prosto w document.xml szablonu i wypisując kontekst wokół indeksu „Title” — a naprawa to regex, który dopasowuje całą sekwencję od runa z „{{” przez dowolne runy pośrednie aż do runa z „}}” i zastępuje ją jednym runem z wyescape'owanym tytułem. Klasyczna pułapka WordprocessingML, którą trzeba raz w życiu przeżyć osobiście, żeby ją potem rozpoznawać od progu.
[+][CH-04]
Dane szkoleń przychodzą z Excela spiętego z CRM przez zewnętrzne zapytanie z logowaniem — aplikacja potrzebuje świeżego pliku. Pierwsza wersja launchera sterowała Excelem przez automatyzację COM z PowerShella i przegrała z platformą: Excel otwierany programowo zachowuje się inaczej niż przy dwukliku i auto-refresh z CRM po prostu nie startował. Zamiast brnąć w obejścia, cofnąłem automatyzację w całości: skrypt VBS otwiera plik dokładnie jak dwuklik, pokazuje okno z instrukcją (zaloguj się, poczekaj na odświeżenie, zapisz, kliknij OK), a dopiero po potwierdzeniu kopiuje świeży plik i startuje ukryty serwer. Do tego launcher sam szuka wolnego portu w puli 4000–4004, bo u handlowców porty bywają zajęte. Człowiek w pętli okazał się bardziej niezawodny niż krucha automatyzacja — i tę lekcję zapisałem sobie na stałe.
[FIG. 4] WARSTWA AI
Sama konwersja jest w pełni deterministyczna — przy dokumentach ofertowych powtarzalność bije kreatywność, więc LLM celowo nie dotyka treści. AI pracowało w procesie: projekt prowadziłem spec-first w Claude Code (specyfikacja batch-exportu powstała przed implementacją), a mapowanie kolumn arkusza jest opisane w CLAUDE.md jako kontrakt.
[FIG. 2A] CHIRURGIA XML
oferta.docx → word/document.xml · PizZip · PACJENT OTWARTY
<w:body>
<w:p>…treść produktu z arkusza…</w:p>
− <w:sectPr>
− <w:pgMar w:top="1440" w:bottom="1440"/>
− </w:sectPr>
+ <w:sectPr>
+ <w:pgMar w:top="0" w:bottom="0"/>
+ <w:footerReference r:id="rId7"/>
+ </w:sectPr>
</w:body>
→ najedź na linię diffu, by zobaczyć, czemu to chirurgia, a nie re-render
[FIG. 5] GALERIA
