Szkielet aplikacji trójwarstwowej (prezentacja / logika / dane)

Trzy niezależne usługi FastAPI komunikujące się przez HTTP/JSON, każda
zna tylko adres warstwy bezpośrednio pod nią:

- presentation (:8000) — strona WWW + formularz
- logic (:8001) — reguły biznesowe, pośrednik
- data (:8002) — wyszukiwanie danych za interfejsem DataProvider

Warstwa danych: czytanie setek plików .xlsx z wykrywaniem nagłówka i
mapowaniem układu kolumn na schemat kanoniczny, z 4-poziomowym cache
(schemat L1, Parquet L2, wyniki zapytań L3, odwrócony indeks L4) i
unieważnianiem po odcisku pliku. Gotowa ścieżka migracji do SQL
(ingest/to_sql.py + SqlDataProvider, przełączane przez DATA_PROVIDER).

Zawiera docker-compose, Makefile, generator danych przykładowych.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 17:35:09 +02:00
parent a43d1ff955
commit 16d35c16dc
52 changed files with 1491 additions and 1 deletions
View File
+52
View File
@@ -0,0 +1,52 @@
"""Wykrywanie wiersza nagłówka w arkuszu.
Pliki Excela w tej domenie nie mają nagłówka zawsze w pierwszym wierszu — bywają
puste wiersze, tytuły, metadane. Ta heurystyka skanuje pierwsze N wierszy i
wybiera ten, który "wygląda jak nagłówek": dużo niepustych, tekstowych,
unikalnych komórek, po którym następują wiersze danych o podobnym wypełnieniu.
Wynik (indeks wiersza nagłówka) jest CACHE'OWANY per plik (schema_cache), więc
ten kosztowny skan robimy raz na wersję pliku.
"""
from __future__ import annotations
import pandas as pd
def _row_score(raw: pd.DataFrame, i: int) -> float:
row = raw.iloc[i]
non_null = row.notna()
filled = float(non_null.mean()) if len(row) else 0.0
if filled == 0:
return -1.0
values = [str(v).strip() for v in row[non_null].tolist()]
text_like = sum(1 for v in values if v and not _looks_numeric(v))
text_ratio = text_like / max(len(values), 1)
uniqueness = len(set(values)) / max(len(values), 1)
# Wiersze danych pod spodem powinny mieć podobną liczbę wypełnionych kolumn.
follow_bonus = 0.0
if i + 1 < len(raw):
below = raw.iloc[i + 1].notna().mean()
follow_bonus = 1.0 - abs(filled - float(below))
return filled * 0.4 + text_ratio * 0.3 + uniqueness * 0.2 + follow_bonus * 0.1
def _looks_numeric(v: str) -> bool:
try:
float(v.replace(",", "."))
return True
except ValueError:
return False
def detect_header_row(raw: pd.DataFrame, max_scan: int = 15) -> int:
"""Zwraca indeks (0-based) wiersza, który najprawdopodobniej jest nagłówkiem."""
best_idx, best_score = 0, float("-inf")
for i in range(min(max_scan, len(raw))):
score = _row_score(raw, i)
if score > best_score:
best_idx, best_score = i, score
return best_idx
+52
View File
@@ -0,0 +1,52 @@
"""Mapowanie układu kolumn na schemat kanoniczny.
Setki plików mogą mieć te same dane pod różnymi nagłówkami i w różnej kolejności
kolumn ("Imię", "Name", "NAZWA" -> kanoniczne 'name'). Ta warstwa tłumaczy
faktyczny układ kolumn pliku na wspólny słownik pól, dzięki czemu reszta systemu
(i przyszła baza SQL) operuje na jednej, stabilnej nazwie pola.
Aliasowanie jest świadomie wydzielone i konfigurowalne — to jedyne miejsce do
edycji, gdy pojawi się nowy wariant nagłówka.
"""
from __future__ import annotations
import re
# kanoniczne_pole -> zbiór aliasów (po normalizacji)
CANONICAL_ALIASES: dict[str, set[str]] = {
"id": {"id", "identyfikator", "nr", "no", "number"},
"name": {"name", "imie", "nazwa", "nazwisko", "title", "tytul"},
"symbol": {"symbol", "znak", "sign", "glyph"},
"date": {"date", "data", "datetime", "timestamp"},
"value": {"value", "wartosc", "val", "amount", "kwota"},
"category": {"category", "kategoria", "type", "typ", "group", "grupa"},
}
def _normalize(col: str) -> str:
s = str(col).strip().lower()
s = re.sub(r"[ąàá]", "a", s)
s = s.replace("ł", "l").replace("ż", "z").replace("ź", "z").replace("ć", "c")
s = s.replace("ę", "e").replace("ó", "o").replace("ś", "s").replace("ń", "n")
s = re.sub(r"[^a-z0-9]+", "", s)
return s
def build_column_mapping(header_cells: list[str]) -> dict[str, str]:
"""Zwraca mapę pole_kanoniczne -> faktyczna_nazwa_kolumny dla danego pliku.
Kolumny nierozpoznane są zachowywane pod swoją (znormalizowaną) nazwą, więc
nic nie ginie — po prostu nie mają aliasu kanonicznego.
"""
reverse: dict[str, str] = {}
for canonical, aliases in CANONICAL_ALIASES.items():
for alias in aliases:
reverse[alias] = canonical
mapping: dict[str, str] = {}
for actual in header_cells:
norm = _normalize(actual)
canonical = reverse.get(norm, norm or "col")
# pierwsze trafienie wygrywa (stabilność przy duplikatach)
mapping.setdefault(canonical, actual)
return mapping
+43
View File
@@ -0,0 +1,43 @@
"""Wczytanie pojedynczego arkusza do znormalizowanej ramki danych.
Łączy wykrywanie nagłówka (header_detect) z mapowaniem układu kolumn (layout).
Zwraca ramkę o KANONICZNYCH nazwach kolumn — gotową do indeksowania, cache'owania
(parquet) i ewentualnego załadowania do SQL.
To jest jedyne miejsce, które "rozumie" format Excela. Reszta systemu jej nie
widzi.
"""
from __future__ import annotations
from dataclasses import dataclass
import pandas as pd
from app.excel.header_detect import detect_header_row
from app.excel.layout import build_column_mapping
@dataclass
class LoadedSheet:
frame: pd.DataFrame # dane z kanonicznymi kolumnami
header_row: int # wykryty indeks nagłówka
column_mapping: dict[str, str] # pole_kanoniczne -> oryginalna_nazwa
def load_sheet(path: str, sheet: str | int = 0, header_scan_rows: int = 15) -> LoadedSheet:
raw = pd.read_excel(path, sheet_name=sheet, header=None, dtype=object)
header_row = detect_header_row(raw, max_scan=header_scan_rows)
header_cells = [str(c) for c in raw.iloc[header_row].tolist()]
mapping = build_column_mapping(header_cells)
data = raw.iloc[header_row + 1 :].copy()
data.columns = header_cells
data = data.dropna(how="all")
# przenazwij na kanoniczne pola: {oryginał -> kanoniczne}
inverse = {orig: canon for canon, orig in mapping.items()}
data = data.rename(columns=inverse)
data = data.reset_index(drop=True)
return LoadedSheet(frame=data, header_row=header_row, column_mapping=mapping)