From efa06def71bc6db05719186db552688baf83ba99 Mon Sep 17 00:00:00 2001 From: WanderMotta Date: Fri, 24 Jul 2026 15:51:10 -0300 Subject: [PATCH] Commit inicial - upload de todos os arquivos da pasta --- .env.example | 24 ++ .gitignore | 23 ++ DEPLOY.md | 100 ++++++ Dockerfile | 39 +++ README.md | 69 ++++ Skill/dados/lernotafiscal.db | Bin 0 -> 16384 bytes app.py | 16 + app.zip | Bin 0 -> 45897 bytes app/__init__.py | 3 + app/ai_extraction.py | 153 +++++++++ app/auth.py | 90 ++++++ app/config.py | 81 +++++ app/database.py | 508 ++++++++++++++++++++++++++++++ app/dates.py | 56 ++++ app/ingestion.py | 60 ++++ app/main.py | 89 ++++++ app/routes/__init__.py | 0 app/routes/auth_routes.py | 45 +++ app/routes/dashboard_routes.py | 48 +++ app/routes/documents_routes.py | 137 ++++++++ app/routes/files_routes.py | 39 +++ app/routes/upload_routes.py | 169 ++++++++++ app/static/app.js | 59 ++++ app/static/styles.css | 206 ++++++++++++ app/storage.py | 33 ++ app/templates/base.html | 61 ++++ app/templates/dashboard.html | 97 ++++++ app/templates/document_form.html | 36 +++ app/templates/documents_list.html | 60 ++++ app/templates/login.html | 23 ++ app/templates/staging.html | 92 ++++++ app/templating.py | 41 +++ deploy/lernotafiscal.service | 21 ++ deploy/nginx.conf | 21 ++ docker-compose.yml | 37 +++ github.bat | 30 ++ lernotafiscal/__init__.py | 2 + lernotafiscal/db.py | 317 +++++++++++++++++++ lernotafiscal/extraction.py | 369 ++++++++++++++++++++++ requirements.txt | 15 + scripts/migrate_notas.py | 79 +++++ tests/__init__.py | 1 + tests/test_app.py | 141 +++++++++ tests/test_ingestion.py | 114 +++++++ 44 files changed, 3604 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 DEPLOY.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 Skill/dados/lernotafiscal.db create mode 100644 app.py create mode 100644 app.zip create mode 100644 app/__init__.py create mode 100644 app/ai_extraction.py create mode 100644 app/auth.py create mode 100644 app/config.py create mode 100644 app/database.py create mode 100644 app/dates.py create mode 100644 app/ingestion.py create mode 100644 app/main.py create mode 100644 app/routes/__init__.py create mode 100644 app/routes/auth_routes.py create mode 100644 app/routes/dashboard_routes.py create mode 100644 app/routes/documents_routes.py create mode 100644 app/routes/files_routes.py create mode 100644 app/routes/upload_routes.py create mode 100644 app/static/app.js create mode 100644 app/static/styles.css create mode 100644 app/storage.py create mode 100644 app/templates/base.html create mode 100644 app/templates/dashboard.html create mode 100644 app/templates/document_form.html create mode 100644 app/templates/documents_list.html create mode 100644 app/templates/login.html create mode 100644 app/templates/staging.html create mode 100644 app/templating.py create mode 100644 deploy/lernotafiscal.service create mode 100644 deploy/nginx.conf create mode 100644 docker-compose.yml create mode 100644 github.bat create mode 100644 lernotafiscal/__init__.py create mode 100644 lernotafiscal/db.py create mode 100644 lernotafiscal/extraction.py create mode 100644 requirements.txt create mode 100644 scripts/migrate_notas.py create mode 100644 tests/__init__.py create mode 100644 tests/test_app.py create mode 100644 tests/test_ingestion.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..79beaae --- /dev/null +++ b/.env.example @@ -0,0 +1,24 @@ +# Copie para .env e ajuste. NUNCA versione o .env real. + +# --- Sessão / segurança --- +# Gere com: python -c "import secrets; print(secrets.token_urlsafe(48))" +SECRET_KEY=troque-por-um-valor-aleatorio-longo +# Em produção (HTTPS) marque o cookie como seguro: +SESSION_HTTPS_ONLY=true + +# --- Login admin (semeado na 1a execucao) --- +ADMIN_USERNAME=admin +ADMIN_PASSWORD=defina-uma-senha-forte + +# --- OpenAI (opcional; sem chave o app usa OCR/heuristica local) --- +OPENAI_API_KEY= +OPENAI_MODEL=gpt-4o +OPENAI_MAX_PAGES=8 + +# --- Banco / uploads --- +DB_PATH=data/app.sqlite3 +MAX_UPLOAD_BYTES=12582912 + +# --- Servidor (dev) --- +HOST=127.0.0.1 +PORT=8000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1cbcf91 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# Segredos e ambiente +.env +*.env.local + +# Banco e dados de runtime +data/*.sqlite3 +data/*.sqlite3-wal +data/*.sqlite3-shm +data/uploads/ +data/previews/ +data/*.log + +# Python +__pycache__/ +*.py[cod] +.venv/ +venv/ +*.egg-info/ + +# Editores / SO +.DS_Store +.idea/ +.vscode/ diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..2c8e296 --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,100 @@ +# Deploy — VPS Ubuntu + systemd + Nginx + HTTPS + +Guia para publicar o Lernotafiscal em uma VPS Ubuntu (22.04+). O app roda em +`127.0.0.1:8000` via Uvicorn sob systemd; o Nginx faz proxy reverso e TLS. + +## 1. Pacotes do sistema + +```bash +sudo apt update +sudo apt install -y python3-venv python3-pip nginx \ + tesseract-ocr tesseract-ocr-por \ + certbot python3-certbot-nginx +``` + +> `tesseract-ocr-por` habilita o OCR em português usado no fallback local. +> `PyMuPDF` (render de PDF) vem via pip, não precisa de pacote do sistema. + +## 2. Usuário e código + +```bash +sudo useradd --system --create-home --home-dir /opt/lernotafiscal lernotafiscal +sudo -u lernotafiscal -H bash +cd /opt/lernotafiscal +git clone . # ou copie os arquivos do projeto para cá +python3 -m venv .venv +.venv/bin/pip install -r requirements.txt +``` + +## 3. Configuração (`.env`) + +```bash +cp .env.example .env +# gere um SECRET_KEY forte: +python3 -c "import secrets; print(secrets.token_urlsafe(48))" +nano .env +``` + +Defina no mínimo: `SECRET_KEY`, `ADMIN_USERNAME`, `ADMIN_PASSWORD`, +`SESSION_HTTPS_ONLY=true` e, se for usar IA, `OPENAI_API_KEY` (+ `OPENAI_MODEL`). +Sem `OPENAI_API_KEY` o app funciona com OCR/heurística local. + +## 4. Dados iniciais (opcional) + +Para migrar o histórico existente (21 notas da skill) para o banco do app: + +```bash +.venv/bin/python scripts/migrate_notas.py +``` + +O banco é criado em `data/app.sqlite3` na primeira execução do app de qualquer forma. + +## 5. Serviço systemd + +```bash +sudo cp deploy/lernotafiscal.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now lernotafiscal +sudo systemctl status lernotafiscal +# a senha temporária (se ADMIN_PASSWORD estiver vazio) aparece no log: +sudo journalctl -u lernotafiscal -n 30 +``` + +## 6. Nginx + HTTPS + +```bash +sudo cp deploy/nginx.conf /etc/nginx/sites-available/lernotafiscal +sudo nano /etc/nginx/sites-available/lernotafiscal # ajuste server_name +sudo ln -s /etc/nginx/sites-available/lernotafiscal /etc/nginx/sites-enabled/ +sudo nginx -t && sudo systemctl reload nginx +sudo certbot --nginx -d seu.dominio.com # emite e configura o TLS +``` + +Após o certbot, confirme que o `.env` tem `SESSION_HTTPS_ONLY=true` e reinicie: +`sudo systemctl restart lernotafiscal`. + +## 7. Atualizações + +```bash +sudo -u lernotafiscal -H bash -c 'cd /opt/lernotafiscal && git pull && .venv/bin/pip install -r requirements.txt' +sudo systemctl restart lernotafiscal +``` + +## 8. Backup + +O estado vive em dois lugares — faça backup dos dois (ex.: cron diário): + +```bash +# banco (checkpoint do WAL antes de copiar) +sqlite3 /opt/lernotafiscal/data/app.sqlite3 "PRAGMA wal_checkpoint(TRUNCATE);" +cp /opt/lernotafiscal/data/app.sqlite3 /backup/app-$(date +%F).sqlite3 +# arquivos enviados +tar czf /backup/uploads-$(date +%F).tgz -C /opt/lernotafiscal/data uploads +``` + +## Notas de segurança + +- Os arquivos enviados **nunca** são servidos por static mount; o download passa + pela rota autenticada `GET /files/{id}`. +- Segredos ficam só no `.env` (fora do git via `.gitignore`). +- Rode o Uvicorn apenas em `127.0.0.1` — a exposição pública é só via Nginx/TLS. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3d1607d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,39 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + HOST=0.0.0.0 \ + PORT=8000 + +WORKDIR /app + +# Dependências de sistema para OCR em português +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + tesseract-ocr \ + tesseract-ocr-por \ + && rm -rf /var/lib/apt/lists/* + +# Instala dependências Python primeiro para aproveitar cache de build +COPY requirements.txt ./ + +RUN pip install --upgrade pip \ + && pip install -r requirements.txt + +# Copia o restante da aplicação +COPY . . + +# Prepara diretórios persistentes e usuário sem privilégios +RUN mkdir -p /app/data /app/data/uploads \ + && useradd --system --uid 10001 --create-home \ + --home-dir /home/lernotafiscal lernotafiscal \ + && chown -R lernotafiscal:lernotafiscal /app + +USER lernotafiscal + +EXPOSE 8000 + +# APP_MODULE pode ser sobrescrito pelo Coolify/docker-compose. +# Padrão assumido: app.main:app +CMD ["sh", "-c", "uvicorn ${APP_MODULE:-app.main:app} --host ${HOST:-0.0.0.0} --port ${PORT:-8000} --proxy-headers --forwarded-allow-ips='*'"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..e7b59af --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ +# Lernotafiscal + +Aplicativo web para importar notas e cupons fiscais (PDF/imagem), revisar a +extração e acompanhar despesas confirmadas. A leitura dos documentos usa +**OpenAI Vision** quando há chave configurada, com **fallback** para OCR/heurística +local. Feito para rodar numa VPS. + +## Recursos + +- Login simples (usuário/senha, hash bcrypt, sessão por cookie assinado). +- Upload de PDF/JPG/PNG com **confirmação em lote** antes de gravar: mostra o + número de documentos e o valor total, e destaca itens ilegíveis para correção. +- Data ilegível/ausente → **1º dia do mês corrente** (regra única compartilhada + por IA e cadastro manual). +- **CRUD** completo de documentos (criar, editar, excluir, listar/filtrar). +- Dashboard com KPIs, gastos por mês e por fornecedor. +- Tema claro/escuro (persistido no navegador). +- Banco **SQLite** (WAL), sem serviço externo. + +## Stack + +FastAPI + Uvicorn · Jinja2 (server-rendered) · SQLite · OpenAI (Vision) com +fallback OCR (PyMuPDF + Tesseract) · bcrypt. + +## Rodar localmente + +```bash +python -m venv .venv +.venv/Scripts/activate # Windows; no Linux/Mac: source .venv/bin/activate +pip install -r requirements.txt +cp .env.example .env # defina ADMIN_PASSWORD e SECRET_KEY +python app.py # http://127.0.0.1:8000 +``` + +Sem `OPENAI_API_KEY` o app usa OCR/heurística local (o Tesseract precisa estar +instalado no sistema para OCR de imagens). Com a chave, a extração usa a IA. + +### Migrar histórico (opcional) + +```bash +python scripts/migrate_notas.py # importa Skill/dados/lernotafiscal.db -> app +``` + +## Testes + +```bash +python -m unittest +``` + +## Deploy em VPS + +Guia completo (Ubuntu + systemd + Nginx + HTTPS) em [DEPLOY.md](DEPLOY.md). + +## Estrutura + +``` +app/ aplicação FastAPI (config, database, auth, rotas, templates) +app/ai_extraction extração via OpenAI Vision +app/ingestion orquestra IA -> fallback OCR local +lernotafiscal/ motor de extração heurística/OCR reutilizado no fallback +scripts/ utilitários (migrate_notas.py) +deploy/ unit systemd + config nginx +``` + +## Variáveis de ambiente + +Veja [.env.example](.env.example). Principais: `SECRET_KEY`, `ADMIN_USERNAME`, +`ADMIN_PASSWORD`, `OPENAI_API_KEY`, `OPENAI_MODEL`, `DB_PATH`, `MAX_UPLOAD_BYTES`, +`SESSION_HTTPS_ONLY`. diff --git a/Skill/dados/lernotafiscal.db b/Skill/dados/lernotafiscal.db new file mode 100644 index 0000000000000000000000000000000000000000..0df98633046c793bd6fdc9b726e46ee1244b9584 GIT binary patch literal 16384 zcmeI2PfXiZ7{Kj>LJ7nMN)&0*wug2pqXye)VxVFTBpCye07;-kRHCo3OT8wsaS~Q> zLe+ZMsnQP9w#%e-f22+8WztmbIJEAx%l`FTZI@}&4xM`6X9LAfXmgshRo_z_`;*`M z<$FKBXZ(co%W1u?kt>E(p>+~r`WTjF;)E~^6Naq=w!`9s19!|Go_NYW%#2=`4?+b$ zCotR(+%D{}paC?12G9T+Km%w14WI!ufCkXOf8D^czcV}>X1DaJs$JLB>l;<0PHP(~ ztyAZyBaur@7gA&{n@p_{Zw0cLB~B%B+OEG*GAgD;hnIYv;gJ!xSf`iET5YQgi_wjm zw$;$8rRLCoKrc+sq*J7+?&&J6XlJ11@Qo3+D;y59Z$=%a^PA3>&(r=OmjHpHtB`Ub zHJi$jrQBR*I#(nMsUn$PDJ;%qp;ac8Eu3*`x>6^F)LMaL7hzjTr_b0ND1tzWP=riI zNG6>ou(YmfB~3M~rWVfGZCW<04U=vf1l*;Mc+OdBZ8h|5W5clYO|9azcjrM>TB}=z zm-{%6x?a(0by_jWTz-+L8rf>ls%pSmUniRu-KMG$7`E5uq3#bE{w}}Hhq&*!8{A0n z$KcK2%e}w%zTK>J{zHV;5G_hY_5u`{# zmM5diq%^)T2&^#|OO9rZZAxY>T7`#6lCG;Xej#!{FlBz^fsG#nOPsP3V~-M(<#dBu zLe75bYyc7dujGL!$3PYanlh3mS~Dt6PMNpw2RyJ?D@s7r31Hq_l>nc6x zVaTni#1cW9;YvlESf%BPZrMwOywTX9%*P&x)C!3fdjT@nHS#z~QOY+=&8lct3Dy-g zGG|rxZo@upm|BhGr{mgUIEYb2|^K??Tbf1o|b zUjMWFBL+TL&;S}h184vZpaC?12G9T+Km%w14WNN%$v`*j53|k-5TO9{E5zMn_*eLT zp73MbJ??Mb$Nk3r%zwpS<1g|V{#*WC{#X7d{w@9?|0%!oEQ!FrpaC?12G9T+Km%w1 z4WI!ufCkXO|H}a9A7b}kC9z+R3b{i$I_L@IQ|?fj7;uNO-0u#hNS`M>%X`8Y=MKe* z;8CauqSWgRd%R)59ZFKSI~0|Zo-o$s4kfYE9g5NkPZ;U&go@uA`hxzU?!(t^#PNRs Dh8(2U literal 0 HcmV?d00001 diff --git a/app.py b/app.py new file mode 100644 index 0000000..aa42f48 --- /dev/null +++ b/app.py @@ -0,0 +1,16 @@ +"""Entrypoint de desenvolvimento: sobe o app FastAPI com Uvicorn. + +Produção usa `uvicorn app.main:app` via systemd (veja DEPLOY.md). +""" + +import os + +import uvicorn + +if __name__ == "__main__": + uvicorn.run( + "app.main:app", + host=os.environ.get("HOST", "127.0.0.1"), + port=int(os.environ.get("PORT", "8000")), + reload=bool(os.environ.get("RELOAD")), + ) diff --git a/app.zip b/app.zip new file mode 100644 index 0000000000000000000000000000000000000000..196029da4bf73041e0048d3c48fe4b726b871128 GIT binary patch literal 45897 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 str | None: + mime = _MIME.get(path.suffix.lower()) + if not mime: + return None + try: + encoded = base64.b64encode(path.read_bytes()).decode("ascii") + except OSError: + return None + return f"data:{mime};base64,{encoded}" + + +def _coerce_float(value: object) -> float | None: + if value is None: + return None + if isinstance(value, (int, float)): + return round(float(value), 2) + text = str(value).strip().replace("R$", "").replace(" ", "") + if not text: + return None + # aceita "1.234,56" e "1234.56" + if "," in text and "." in text: + text = text.replace(".", "").replace(",", ".") + elif "," in text: + text = text.replace(",", ".") + try: + return round(float(text), 2) + except ValueError: + return None + + +def _parse_response(content: str) -> list[RawExtraction]: + data = json.loads(content) + docs = data.get("documentos") if isinstance(data, dict) else None + if not isinstance(docs, list): + return [] + results: list[RawExtraction] = [] + for item in docs: + if not isinstance(item, dict): + continue + supplier = item.get("fornecedor") + supplier = str(supplier).strip()[:200] if supplier else None + date_raw = item.get("data_compra") + date_raw = str(date_raw).strip() if date_raw else None + total = _coerce_float(item.get("valor_pago")) + legible = bool(item.get("legivel", True)) + uncertain = item.get("campos_incertos") or [] + uncertain = [str(x) for x in uncertain if x] if isinstance(uncertain, list) else [] + # segurança: campos faltando são inerentemente incertos + for name, val in (("fornecedor", supplier), ("data_compra", date_raw), ("valor_pago", total)): + if val is None and name not in uncertain: + uncertain.append(name) + if uncertain and legible and len(uncertain) >= 2: + legible = False + results.append( + RawExtraction( + supplier_name=supplier, + purchase_date_raw=date_raw, + total_paid=total, + legible=legible, + uncertain_fields=uncertain, + ) + ) + return results + + +def extract_with_ai(image_paths: list[Path]) -> list[RawExtraction] | None: + """Extrai via OpenAI. Retorna None se IA indisponível ou em erro (=> fallback).""" + settings = get_settings() + if not settings.openai_enabled: + return None + + uris = [uri for p in image_paths[: settings.openai_max_pages] if (uri := _data_uri(p))] + if not uris: + return None + + try: + from openai import OpenAI + + client = OpenAI(api_key=settings.openai_api_key) + content: list[dict] = [{"type": "text", "text": _USER_PROMPT}] + for uri in uris: + content.append({"type": "image_url", "image_url": {"url": uri}}) + + response = client.chat.completions.create( + model=settings.openai_model, + messages=[ + {"role": "system", "content": _SYSTEM_PROMPT}, + {"role": "user", "content": content}, + ], + response_format={"type": "json_object"}, + temperature=0, + ) + message = response.choices[0].message.content or "{}" + return _parse_response(message) + except Exception as exc: # rede, cota, parsing, modelo indisponível... + logger.warning("Extração OpenAI falhou, usando fallback local: %s", exc) + return None diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..04d0a80 --- /dev/null +++ b/app/auth.py @@ -0,0 +1,90 @@ +"""Autenticação: hash de senha (bcrypt), seed do admin, CSRF e guarda de sessão.""" + +from __future__ import annotations + +import secrets +import time + +import bcrypt +from starlette.requests import Request + +from . import database as db +from .config import get_settings + + +# --------------------------------------------------------------------------- # +# Senhas +# --------------------------------------------------------------------------- # +def hash_password(password: str) -> str: + return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("ascii") + + +def verify_password(password: str, password_hash: str) -> bool: + try: + return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("ascii")) + except (ValueError, TypeError): + return False + + +def seed_admin() -> str | None: + """Cria o usuário admin na 1ª execução. Retorna uma mensagem de aviso, se houver.""" + settings = get_settings() + with db.session() as conn: + if db.count_users(conn) > 0: + return None + password = settings.admin_password + note = None + if not password: + # Sem ADMIN_PASSWORD definido: gera uma senha aleatória e a expõe UMA vez + # no log para o operador. Em produção defina ADMIN_PASSWORD no ambiente. + password = secrets.token_urlsafe(12) + note = ( + f"[AVISO] Nenhum ADMIN_PASSWORD definido. Usuário '{settings.admin_username}' " + f"criado com senha temporária: {password} (defina ADMIN_PASSWORD e reinicie)" + ) + db.create_user(conn, settings.admin_username, hash_password(password)) + return note + + +def authenticate(username: str, password: str) -> bool: + with db.session() as conn: + row = db.get_user(conn, username) + if row is None: + # Compara mesmo sem usuário para não vazar tempo (mitiga user enumeration). + verify_password(password, hash_password("dummy")) + return False + return verify_password(password, row["password_hash"]) + + +# --------------------------------------------------------------------------- # +# Sessão / guarda +# --------------------------------------------------------------------------- # +def current_user(request: Request) -> str | None: + return request.session.get("user") + + +def login_session(request: Request, username: str) -> None: + request.session["user"] = username + request.session["logged_at"] = int(time.time()) + # rotaciona o token CSRF a cada login + request.session["csrf"] = secrets.token_urlsafe(32) + + +def logout_session(request: Request) -> None: + request.session.clear() + + +# --------------------------------------------------------------------------- # +# CSRF +# --------------------------------------------------------------------------- # +def get_csrf_token(request: Request) -> str: + token = request.session.get("csrf") + if not token: + token = secrets.token_urlsafe(32) + request.session["csrf"] = token + return token + + +def check_csrf(request: Request, submitted: str | None) -> bool: + expected = request.session.get("csrf") + return bool(expected) and bool(submitted) and secrets.compare_digest(expected, submitted) diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..a5a574f --- /dev/null +++ b/app/config.py @@ -0,0 +1,81 @@ +"""Configuração central lida de variáveis de ambiente (com suporte a .env).""" + +from __future__ import annotations + +import os +import secrets +from functools import lru_cache +from pathlib import Path + +try: # carregamento opcional do .env + from dotenv import load_dotenv + + load_dotenv() +except Exception: # pragma: no cover - dotenv é opcional + pass + + +BASE_DIR = Path(__file__).resolve().parent.parent +DATA_DIR = BASE_DIR / "data" +UPLOAD_DIR = DATA_DIR / "uploads" +PREVIEW_DIR = DATA_DIR / "previews" + +ALLOWED_EXTENSIONS = {".pdf", ".jpg", ".jpeg", ".png"} + + +def _env_bool(name: str, default: bool) -> bool: + raw = os.environ.get(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "on", "sim"} + + +class Settings: + """Configuração resolvida uma vez no start do processo.""" + + def __init__(self) -> None: + self.base_dir = BASE_DIR + self.data_dir = DATA_DIR + self.upload_dir = UPLOAD_DIR + self.preview_dir = PREVIEW_DIR + + self.db_path = Path(os.environ.get("DB_PATH", DATA_DIR / "app.sqlite3")) + + # Segredo da sessão. Em produção DEVE vir do ambiente; sem ele geramos um + # efêmero (as sessões caem a cada restart) e avisamos no log. + self.secret_key = os.environ.get("SECRET_KEY", "") + self.secret_key_is_ephemeral = not self.secret_key + if self.secret_key_is_ephemeral: + self.secret_key = secrets.token_urlsafe(48) + + # OpenAI (opcional — sem chave o app cai no OCR/heurística local). + self.openai_api_key = os.environ.get("OPENAI_API_KEY", "").strip() + self.openai_model = os.environ.get("OPENAI_MODEL", "gpt-4o").strip() or "gpt-4o" + self.openai_max_pages = int(os.environ.get("OPENAI_MAX_PAGES", "8")) + + # Admin semeado na primeira execução. + self.admin_username = os.environ.get("ADMIN_USERNAME", "admin").strip() or "admin" + self.admin_password = os.environ.get("ADMIN_PASSWORD", "").strip() + + self.max_upload_bytes = int(os.environ.get("MAX_UPLOAD_BYTES", str(12 * 1024 * 1024))) + self.allowed_extensions = set(ALLOWED_EXTENSIONS) + + self.session_cookie = os.environ.get("SESSION_COOKIE", "lnf_session") + self.session_https_only = _env_bool("SESSION_HTTPS_ONLY", False) + self.session_max_age = int(os.environ.get("SESSION_MAX_AGE", str(60 * 60 * 12))) + + @property + def openai_enabled(self) -> bool: + return bool(self.openai_api_key) + + def ensure_storage(self) -> None: + self.data_dir.mkdir(parents=True, exist_ok=True) + self.upload_dir.mkdir(parents=True, exist_ok=True) + self.preview_dir.mkdir(parents=True, exist_ok=True) + + +@lru_cache(maxsize=1) +def get_settings() -> Settings: + settings = Settings() + settings.ensure_storage() + return settings diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..63541c7 --- /dev/null +++ b/app/database.py @@ -0,0 +1,508 @@ +"""Acesso ao SQLite: esquema, migrações leves e helpers de consulta. + +Estende o esquema original (uploaded_files -> detected_documents -> fiscal_documents) +com autenticação (users), lotes de importação (import_batches) e os campos de +legibilidade usados pela extração por IA. +""" + +from __future__ import annotations + +import json +import sqlite3 +from contextlib import contextmanager +from pathlib import Path +from typing import Any, Iterator + +from .config import get_settings + + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS import_batches ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + status TEXT NOT NULL DEFAULT 'open', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + confirmed_at TEXT +); + +CREATE TABLE IF NOT EXISTS uploaded_files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + batch_id INTEGER REFERENCES import_batches(id) ON DELETE SET NULL, + original_name TEXT NOT NULL, + stored_path TEXT NOT NULL, + content_type TEXT NOT NULL, + size_bytes INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + detected_count INTEGER NOT NULL DEFAULT 0, + message TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS detected_documents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + upload_id INTEGER NOT NULL REFERENCES uploaded_files(id) ON DELETE CASCADE, + batch_id INTEGER REFERENCES import_batches(id) ON DELETE SET NULL, + source_file_name TEXT NOT NULL, + source_page INTEGER, + source_location TEXT NOT NULL, + raw_text TEXT NOT NULL DEFAULT '', + purchase_date TEXT, + supplier_name TEXT, + total_paid REAL, + confidence TEXT NOT NULL DEFAULT 'low', + field_confidence_json TEXT NOT NULL DEFAULT '{}', + legible INTEGER NOT NULL DEFAULT 1, + uncertain_fields TEXT NOT NULL DEFAULT '[]', + extractor TEXT NOT NULL DEFAULT 'local', + status TEXT NOT NULL DEFAULT 'staged', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS fiscal_documents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + detected_document_id INTEGER REFERENCES detected_documents(id) ON DELETE SET NULL, + source_file_name TEXT NOT NULL DEFAULT '', + source_location TEXT NOT NULL DEFAULT '', + purchase_date TEXT NOT NULL, + supplier_name TEXT NOT NULL, + total_paid REAL NOT NULL, + confidence TEXT NOT NULL DEFAULT 'high', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_fiscal_date ON fiscal_documents(purchase_date); +CREATE INDEX IF NOT EXISTS idx_detected_batch ON detected_documents(batch_id, status); +""" + + +def connect(db_path: Path | None = None) -> sqlite3.Connection: + settings = get_settings() + settings.ensure_storage() + conn = sqlite3.connect(db_path or settings.db_path) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + conn.execute("PRAGMA journal_mode = WAL") + return conn + + +def init_db(conn: sqlite3.Connection) -> None: + conn.executescript(SCHEMA) + conn.commit() + + +@contextmanager +def session(db_path: Path | None = None) -> Iterator[sqlite3.Connection]: + conn = connect(db_path) + try: + init_db(conn) + yield conn + finally: + conn.close() + + +# --------------------------------------------------------------------------- # +# Usuários +# --------------------------------------------------------------------------- # +def count_users(conn: sqlite3.Connection) -> int: + return int(conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]) + + +def get_user(conn: sqlite3.Connection, username: str) -> sqlite3.Row | None: + return conn.execute( + "SELECT * FROM users WHERE username = ?", (username,) + ).fetchone() + + +def create_user(conn: sqlite3.Connection, username: str, password_hash: str) -> int: + cur = conn.execute( + "INSERT INTO users (username, password_hash) VALUES (?, ?)", + (username, password_hash), + ) + conn.commit() + return int(cur.lastrowid) + + +def update_password(conn: sqlite3.Connection, username: str, password_hash: str) -> None: + conn.execute( + "UPDATE users SET password_hash = ? WHERE username = ?", + (password_hash, username), + ) + conn.commit() + + +# --------------------------------------------------------------------------- # +# Lotes de importação +# --------------------------------------------------------------------------- # +def create_batch(conn: sqlite3.Connection) -> int: + cur = conn.execute("INSERT INTO import_batches (status) VALUES ('open')") + conn.commit() + return int(cur.lastrowid) + + +def get_batch(conn: sqlite3.Connection, batch_id: int) -> sqlite3.Row | None: + return conn.execute( + "SELECT * FROM import_batches WHERE id = ?", (batch_id,) + ).fetchone() + + +def set_batch_status(conn: sqlite3.Connection, batch_id: int, status: str) -> None: + if status == "confirmed": + conn.execute( + "UPDATE import_batches SET status = ?, confirmed_at = CURRENT_TIMESTAMP WHERE id = ?", + (status, batch_id), + ) + else: + conn.execute( + "UPDATE import_batches SET status = ? WHERE id = ?", (status, batch_id) + ) + conn.commit() + + +# --------------------------------------------------------------------------- # +# Uploads +# --------------------------------------------------------------------------- # +def insert_upload( + conn: sqlite3.Connection, + batch_id: int, + original_name: str, + stored_path: Path, + content_type: str, + size_bytes: int, +) -> int: + cur = conn.execute( + """ + INSERT INTO uploaded_files (batch_id, original_name, stored_path, content_type, size_bytes) + VALUES (?, ?, ?, ?, ?) + """, + (batch_id, original_name, str(stored_path), content_type, size_bytes), + ) + conn.commit() + return int(cur.lastrowid) + + +def update_upload_status( + conn: sqlite3.Connection, + upload_id: int, + status: str, + detected_count: int, + message: str | None = None, +) -> None: + conn.execute( + """ + UPDATE uploaded_files + SET status = ?, detected_count = ?, message = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + (status, detected_count, message, upload_id), + ) + conn.commit() + + +def get_upload(conn: sqlite3.Connection, upload_id: int) -> sqlite3.Row | None: + return conn.execute( + "SELECT * FROM uploaded_files WHERE id = ?", (upload_id,) + ).fetchone() + + +# --------------------------------------------------------------------------- # +# Documentos detectados (staging) +# --------------------------------------------------------------------------- # +def insert_detected( + conn: sqlite3.Connection, + *, + upload_id: int, + batch_id: int, + source_file_name: str, + source_page: int | None, + source_location: str, + raw_text: str, + purchase_date: str | None, + supplier_name: str | None, + total_paid: float | None, + confidence: str, + field_confidence: dict[str, str], + legible: bool, + uncertain_fields: list[str], + extractor: str, +) -> int: + cur = conn.execute( + """ + INSERT INTO detected_documents ( + upload_id, batch_id, source_file_name, source_page, source_location, + raw_text, purchase_date, supplier_name, total_paid, confidence, + field_confidence_json, legible, uncertain_fields, extractor, status + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'staged') + """, + ( + upload_id, + batch_id, + source_file_name, + source_page, + source_location, + raw_text, + purchase_date, + supplier_name, + total_paid, + confidence, + json.dumps(field_confidence, ensure_ascii=False), + 1 if legible else 0, + json.dumps(uncertain_fields, ensure_ascii=False), + extractor, + ), + ) + conn.commit() + return int(cur.lastrowid) + + +def get_detected(conn: sqlite3.Connection, detected_id: int) -> sqlite3.Row | None: + return conn.execute( + "SELECT * FROM detected_documents WHERE id = ?", (detected_id,) + ).fetchone() + + +def staged_documents(conn: sqlite3.Connection, batch_id: int) -> list[sqlite3.Row]: + return list( + conn.execute( + """ + SELECT * FROM detected_documents + WHERE batch_id = ? AND status = 'staged' + ORDER BY id ASC + """, + (batch_id,), + ) + ) + + +def batch_summary(conn: sqlite3.Connection, batch_id: int) -> dict[str, Any]: + row = conn.execute( + """ + SELECT COUNT(*) AS n, + COALESCE(SUM(total_paid), 0) AS total, + SUM(CASE WHEN legible = 0 THEN 1 ELSE 0 END) AS ilegiveis, + SUM(CASE WHEN supplier_name IS NULL OR TRIM(supplier_name) = '' + OR total_paid IS NULL THEN 1 ELSE 0 END) AS incompletos + FROM detected_documents + WHERE batch_id = ? AND status = 'staged' + """, + (batch_id,), + ).fetchone() + # "pendentes" = tudo que impede importar (ilegível OU faltando fornecedor/valor). + ilegiveis = int(row["ilegiveis"] or 0) + incompletos = int(row["incompletos"] or 0) + pendentes = conn.execute( + """ + SELECT COUNT(*) FROM detected_documents + WHERE batch_id = ? AND status = 'staged' + AND (legible = 0 OR supplier_name IS NULL OR TRIM(supplier_name) = '' OR total_paid IS NULL) + """, + (batch_id,), + ).fetchone()[0] + return { + "count": int(row["n"]), + "total": float(row["total"]), + "ilegiveis": ilegiveis, + "incompletos": incompletos, + "pendentes": int(pendentes), + } + + +def update_staged( + conn: sqlite3.Connection, + detected_id: int, + *, + purchase_date: str, + supplier_name: str, + total_paid: float, + legible: bool = True, +) -> None: + conn.execute( + """ + UPDATE detected_documents + SET purchase_date = ?, supplier_name = ?, total_paid = ?, + legible = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? AND status = 'staged' + """, + (purchase_date, supplier_name, total_paid, 1 if legible else 0, detected_id), + ) + conn.commit() + + +def discard_staged(conn: sqlite3.Connection, detected_id: int) -> None: + conn.execute( + "UPDATE detected_documents SET status = 'discarded', updated_at = CURRENT_TIMESTAMP WHERE id = ?", + (detected_id,), + ) + conn.commit() + + +def confirm_batch(conn: sqlite3.Connection, batch_id: int) -> int: + """Promove todos os detectados 'staged' do lote para fiscal_documents.""" + rows = staged_documents(conn, batch_id) + inserted = 0 + for row in rows: + if not row["purchase_date"] or not row["supplier_name"] or row["total_paid"] is None: + # Sem os três campos essenciais não confirma — permanece staged. + continue + conn.execute( + """ + INSERT INTO fiscal_documents ( + detected_document_id, source_file_name, source_location, + purchase_date, supplier_name, total_paid, confidence + ) VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + row["id"], + row["source_file_name"], + row["source_location"], + row["purchase_date"], + row["supplier_name"], + float(row["total_paid"]), + row["confidence"], + ), + ) + conn.execute( + "UPDATE detected_documents SET status = 'confirmed', updated_at = CURRENT_TIMESTAMP WHERE id = ?", + (row["id"],), + ) + inserted += 1 + set_batch_status(conn, batch_id, "confirmed") + conn.commit() + return inserted + + +# --------------------------------------------------------------------------- # +# CRUD de fiscal_documents +# --------------------------------------------------------------------------- # +def create_fiscal( + conn: sqlite3.Connection, + *, + purchase_date: str, + supplier_name: str, + total_paid: float, + source_file_name: str = "lançamento manual", + source_location: str = "manual", + confidence: str = "high", + detected_document_id: int | None = None, +) -> int: + cur = conn.execute( + """ + INSERT INTO fiscal_documents ( + detected_document_id, source_file_name, source_location, + purchase_date, supplier_name, total_paid, confidence + ) VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + detected_document_id, + source_file_name, + source_location, + purchase_date, + supplier_name, + total_paid, + confidence, + ), + ) + conn.commit() + return int(cur.lastrowid) + + +def get_fiscal(conn: sqlite3.Connection, doc_id: int) -> sqlite3.Row | None: + return conn.execute( + "SELECT * FROM fiscal_documents WHERE id = ?", (doc_id,) + ).fetchone() + + +def update_fiscal( + conn: sqlite3.Connection, + doc_id: int, + *, + purchase_date: str, + supplier_name: str, + total_paid: float, +) -> None: + conn.execute( + """ + UPDATE fiscal_documents + SET purchase_date = ?, supplier_name = ?, total_paid = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + (purchase_date, supplier_name, total_paid, doc_id), + ) + conn.commit() + + +def delete_fiscal(conn: sqlite3.Connection, doc_id: int) -> None: + conn.execute("DELETE FROM fiscal_documents WHERE id = ?", (doc_id,)) + conn.commit() + + +def list_fiscal( + conn: sqlite3.Connection, + *, + start: str | None = None, + end: str | None = None, + supplier: str | None = None, +) -> list[sqlite3.Row]: + clauses: list[str] = [] + params: list[Any] = [] + if start: + clauses.append("purchase_date >= ?") + params.append(start) + if end: + clauses.append("purchase_date <= ?") + params.append(end) + if supplier: + clauses.append("supplier_name LIKE ?") + params.append(f"%{supplier}%") + where = f"WHERE {' AND '.join(clauses)}" if clauses else "" + return list( + conn.execute( + f"SELECT * FROM fiscal_documents {where} ORDER BY purchase_date DESC, id DESC", + params, + ) + ) + + +def monthly_totals(conn: sqlite3.Connection) -> list[sqlite3.Row]: + return list( + conn.execute( + """ + SELECT substr(purchase_date, 1, 7) AS month, SUM(total_paid) AS total, COUNT(*) AS count + FROM fiscal_documents + GROUP BY substr(purchase_date, 1, 7) + ORDER BY month ASC + """ + ) + ) + + +def supplier_totals(conn: sqlite3.Connection, limit: int = 10) -> list[sqlite3.Row]: + return list( + conn.execute( + """ + SELECT supplier_name, SUM(total_paid) AS total, COUNT(*) AS count + FROM fiscal_documents + GROUP BY supplier_name + ORDER BY total DESC + LIMIT ? + """, + (limit,), + ) + ) + + +def overall_totals(conn: sqlite3.Connection) -> dict[str, Any]: + row = conn.execute( + "SELECT COUNT(*) AS n, COALESCE(SUM(total_paid), 0) AS total FROM fiscal_documents" + ).fetchone() + n = int(row["n"]) + total = float(row["total"]) + return {"count": n, "total": total, "avg": (total / n) if n else 0.0} diff --git a/app/dates.py b/app/dates.py new file mode 100644 index 0000000..ed8166f --- /dev/null +++ b/app/dates.py @@ -0,0 +1,56 @@ +"""Resolução de datas de compra com fallback único e compartilhado. + +Requisito: quando a data do documento está ilegível/ausente, atribuir o +**primeiro dia do mês corrente** (o mês da importação/lançamento). Tanto a +extração por IA quanto o cadastro manual passam por aqui, para nunca divergirem. +""" + +from __future__ import annotations + +import re +from datetime import date + + +_ISO_RE = re.compile(r"^(\d{4})-(\d{2})-(\d{2})$") +_BR_RE = re.compile(r"^([0-3]?\d)[/.\-]([01]?\d)[/.\-]((?:19|20)?\d{2})$") + + +def first_of_current_month(reference: date | None = None) -> str: + ref = reference or date.today() + return f"{ref.year:04d}-{ref.month:02d}-01" + + +def _valid_iso(year: int, month: int, day: int) -> str | None: + try: + return date(year, month, day).isoformat() + except ValueError: + return None + + +def parse_date(value: str | None) -> str | None: + """Tenta interpretar uma data em ISO (YYYY-MM-DD) ou BR (dd/mm/aaaa). + + Retorna a data normalizada em ISO, ou None se não for uma data válida. + """ + if not value: + return None + text = value.strip() + m = _ISO_RE.match(text) + if m: + return _valid_iso(int(m.group(1)), int(m.group(2)), int(m.group(3))) + m = _BR_RE.match(text) + if m: + day, month, year = m.groups() + year_i = int(year) + if year_i < 100: + year_i += 2000 + return _valid_iso(year_i, int(month), int(day)) + return None + + +def resolve_purchase_date(value: str | None, *, reference: date | None = None) -> str: + """Data válida em ISO, ou o 1º dia do mês corrente quando ilegível/ausente.""" + parsed = parse_date(value) + if parsed: + return parsed + return first_of_current_month(reference) diff --git a/app/ingestion.py b/app/ingestion.py new file mode 100644 index 0000000..abc4dc1 --- /dev/null +++ b/app/ingestion.py @@ -0,0 +1,60 @@ +"""Orquestra a extração de um arquivo enviado: OpenAI Vision -> fallback local. + +Reutiliza `lernotafiscal.extraction` (normalização de PDF/imagem, render de +páginas via PyMuPDF, OCR Tesseract e a detecção heurística) como caminho de +contingência quando a IA está indisponível ou falha. +""" + +from __future__ import annotations + +from pathlib import Path + +from lernotafiscal.extraction import ( + DetectedDocumentCandidate, + detect_documents, + normalize_file, +) + +from .ai_extraction import RawExtraction, extract_with_ai + + +def _candidate_to_raw(candidate: DetectedDocumentCandidate) -> RawExtraction: + fields = candidate.field_confidence or {} + uncertain = [name for name, level in fields.items() if level == "low"] + for name, val in ( + ("fornecedor", candidate.supplier_name), + ("data_compra", candidate.purchase_date), + ("valor_pago", candidate.total_paid), + ): + if val is None and name not in uncertain: + uncertain.append(name) + legible = candidate.confidence != "low" and len(uncertain) < 2 + return RawExtraction( + supplier_name=candidate.supplier_name, + purchase_date_raw=candidate.purchase_date, + total_paid=candidate.total_paid, + legible=legible, + uncertain_fields=uncertain, + extractor="local", + raw_text=(candidate.raw_text or "")[:4000], + ) + + +def extract_file(stored_path: Path, original_name: str) -> list[RawExtraction]: + """Retorna os documentos extraídos de um arquivo (>=0). Nunca levanta exceção + de extração para o chamador — em último caso devolve lista vazia.""" + pages = normalize_file(stored_path, original_name) + + # Caminho preferencial: enviar as imagens (páginas renderizadas / imagem) à IA. + # Só aceitamos o resultado da IA quando ela retorna ao menos um documento. + # Uma resposta vazia (nada encontrado OU shape inesperado) cai no fallback + # local — evita "zero documentos" silencioso por uma resposta malformada. + image_paths = [p.image_path for p in pages if p.image_path is not None] + if image_paths: + ai_result = extract_with_ai(image_paths) + if ai_result: + return ai_result + + # Fallback: OCR/heurística sobre o texto já normalizado. + candidates = detect_documents(pages, original_name) + return [_candidate_to_raw(c) for c in candidates] diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..cc58ebe --- /dev/null +++ b/app/main.py @@ -0,0 +1,89 @@ +"""Ponto de entrada FastAPI do Lernotafiscal.""" + +from __future__ import annotations + +import logging +from contextlib import asynccontextmanager +from pathlib import Path + +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.middleware.sessions import SessionMiddleware +from starlette.requests import Request +from starlette.responses import RedirectResponse + +from . import auth, database +from .config import get_settings +from .routes import ( + auth_routes, + dashboard_routes, + documents_routes, + files_routes, + upload_routes, +) + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") +logger = logging.getLogger("lernotafiscal") + +# Caminhos liberados sem sessão (login e assets da própria UI). +PUBLIC_PREFIXES = ("/login", "/static/", "/favicon.ico", "/healthz") + + +class AuthGuardMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + path = request.url.path + if any(path == p or path.startswith(p) for p in PUBLIC_PREFIXES): + return await call_next(request) + if not request.session.get("user"): + return RedirectResponse("/login", status_code=303) + return await call_next(request) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + settings = get_settings() + with database.session() as conn: + database.init_db(conn) + if settings.secret_key_is_ephemeral: + logger.warning("SECRET_KEY não definido: sessões não sobrevivem a reinícios. Defina SECRET_KEY em produção.") + note = auth.seed_admin() + if note: + logger.warning(note) + logger.info("OpenAI %s.", "habilitado (" + settings.openai_model + ")" if settings.openai_enabled else "desabilitado — usando OCR/heurística local") + yield + + +def create_app() -> FastAPI: + settings = get_settings() + app = FastAPI(title="Lernotafiscal", lifespan=lifespan) + + # Ordem importa: SessionMiddleware é adicionado por último para ser o mais + # externo e popular request.session ANTES da guarda de autenticação. + app.add_middleware(AuthGuardMiddleware) + app.add_middleware( + SessionMiddleware, + secret_key=settings.secret_key, + session_cookie=settings.session_cookie, + https_only=settings.session_https_only, + max_age=settings.session_max_age, + same_site="lax", + ) + + static_dir = Path(__file__).resolve().parent / "static" + app.mount("/static", StaticFiles(directory=str(static_dir)), name="static") + + app.include_router(auth_routes.router) + app.include_router(dashboard_routes.router) + app.include_router(upload_routes.router) + app.include_router(documents_routes.router) + app.include_router(files_routes.router) + + @app.get("/healthz") + def healthz(): + return {"status": "ok"} + + return app + + +app = create_app() diff --git a/app/routes/__init__.py b/app/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routes/auth_routes.py b/app/routes/auth_routes.py new file mode 100644 index 0000000..a7648df --- /dev/null +++ b/app/routes/auth_routes.py @@ -0,0 +1,45 @@ +"""Rotas de login/logout.""" + +from __future__ import annotations + +from fastapi import APIRouter, Form, Request +from starlette.responses import RedirectResponse + +from .. import auth +from ..templating import flash, render + +router = APIRouter() + + +@router.get("/login") +def login_form(request: Request): + if auth.current_user(request): + return RedirectResponse("/", status_code=303) + return render(request, "login.html", hide_nav=True) + + +@router.post("/login") +def login_submit( + request: Request, + username: str = Form(...), + password: str = Form(...), + csrf_token: str = Form(""), +): + if not auth.check_csrf(request, csrf_token): + flash(request, "Sessão expirada. Tente novamente.", "error") + return RedirectResponse("/login", status_code=303) + + if auth.authenticate(username.strip(), password): + auth.login_session(request, username.strip()) + flash(request, "Bem-vindo de volta.", "success") + return RedirectResponse("/", status_code=303) + + flash(request, "Usuário ou senha inválidos.", "error") + return RedirectResponse("/login", status_code=303) + + +@router.post("/logout") +def logout(request: Request, csrf_token: str = Form("")): + if auth.check_csrf(request, csrf_token): + auth.logout_session(request) + return RedirectResponse("/login", status_code=303) diff --git a/app/routes/dashboard_routes.py b/app/routes/dashboard_routes.py new file mode 100644 index 0000000..60705cb --- /dev/null +++ b/app/routes/dashboard_routes.py @@ -0,0 +1,48 @@ +"""Dashboard: KPIs, gastos por mês e por fornecedor, últimas notas.""" + +from __future__ import annotations + +from fastapi import APIRouter, Request + +from .. import database as db +from ..storage import money +from ..templating import render + +router = APIRouter() + +_MESES = ["jan", "fev", "mar", "abr", "mai", "jun", "jul", "ago", "set", "out", "nov", "dez"] + + +def _mes_label(ym: str) -> str: + try: + year, month = ym.split("-") + return f"{_MESES[int(month) - 1]}/{year}" + except (ValueError, IndexError): + return ym + + +@router.get("/") +def dashboard(request: Request): + with db.session() as conn: + totals = db.overall_totals(conn) + months = [dict(r) for r in db.monthly_totals(conn)] + suppliers = [dict(r) for r in db.supplier_totals(conn, limit=8)] + recent = [dict(r) for r in db.list_fiscal(conn)][:10] + + max_month = max((m["total"] for m in months), default=0) or 1 + for m in months: + m["label"] = _mes_label(m["month"]) + m["pct"] = round(m["total"] / max_month * 100, 1) + max_sup = max((s["total"] for s in suppliers), default=0) or 1 + for s in suppliers: + s["pct"] = round(s["total"] / max_sup * 100, 1) + + return render( + request, + "dashboard.html", + totals=totals, + months=months, + suppliers=suppliers, + recent=recent, + money=money, + ) diff --git a/app/routes/documents_routes.py b/app/routes/documents_routes.py new file mode 100644 index 0000000..dada051 --- /dev/null +++ b/app/routes/documents_routes.py @@ -0,0 +1,137 @@ +"""CRUD de documentos fiscais confirmados.""" + +from __future__ import annotations + +from fastapi import APIRouter, Form, Query, Request +from starlette.responses import RedirectResponse + +from .. import auth +from .. import database as db +from ..dates import resolve_purchase_date +from ..storage import money +from ..templating import flash, render + +router = APIRouter() + + +def _parse_money(value: str) -> float | None: + text = (value or "").strip().replace("R$", "").replace(" ", "") + if not text: + return None + if "," in text and "." in text: + text = text.replace(".", "").replace(",", ".") + elif "," in text: + text = text.replace(",", ".") + try: + return round(float(text), 2) + except ValueError: + return None + + +@router.get("/documents") +def list_documents( + request: Request, + start: str = Query(""), + end: str = Query(""), + supplier: str = Query(""), +): + with db.session() as conn: + rows = [dict(r) for r in db.list_fiscal(conn, start=start or None, end=end or None, supplier=supplier or None)] + total = sum(r["total_paid"] for r in rows) + return render( + request, + "documents_list.html", + rows=rows, + total=total, + filters={"start": start, "end": end, "supplier": supplier}, + money=money, + ) + + +@router.get("/documents/new") +def new_form(request: Request): + return render(request, "document_form.html", doc=None, mode="new") + + +@router.post("/documents/new") +def create_document( + request: Request, + csrf_token: str = Form(""), + purchase_date: str = Form(""), + supplier_name: str = Form(""), + total_paid: str = Form(""), +): + if not auth.check_csrf(request, csrf_token): + flash(request, "Sessão expirada.", "error") + return RedirectResponse("/documents/new", status_code=303) + + total = _parse_money(total_paid) + supplier = supplier_name.strip() + if not supplier or total is None: + flash(request, "Informe fornecedor e valor válidos.", "error") + return RedirectResponse("/documents/new", status_code=303) + + with db.session() as conn: + db.create_fiscal( + conn, + purchase_date=resolve_purchase_date(purchase_date), + supplier_name=supplier, + total_paid=total, + ) + flash(request, "Documento lançado.", "success") + return RedirectResponse("/documents", status_code=303) + + +@router.get("/documents/{doc_id}/edit") +def edit_form(request: Request, doc_id: int): + with db.session() as conn: + doc = db.get_fiscal(conn, doc_id) + if doc is None: + flash(request, "Documento não encontrado.", "error") + return RedirectResponse("/documents", status_code=303) + return render(request, "document_form.html", doc=dict(doc), mode="edit") + + +@router.post("/documents/{doc_id}/edit") +def update_document( + request: Request, + doc_id: int, + csrf_token: str = Form(""), + purchase_date: str = Form(""), + supplier_name: str = Form(""), + total_paid: str = Form(""), +): + if not auth.check_csrf(request, csrf_token): + flash(request, "Sessão expirada.", "error") + return RedirectResponse(f"/documents/{doc_id}/edit", status_code=303) + + total = _parse_money(total_paid) + supplier = supplier_name.strip() + if not supplier or total is None: + flash(request, "Informe fornecedor e valor válidos.", "error") + return RedirectResponse(f"/documents/{doc_id}/edit", status_code=303) + + with db.session() as conn: + if db.get_fiscal(conn, doc_id) is None: + flash(request, "Documento não encontrado.", "error") + return RedirectResponse("/documents", status_code=303) + db.update_fiscal( + conn, + doc_id, + purchase_date=resolve_purchase_date(purchase_date), + supplier_name=supplier, + total_paid=total, + ) + flash(request, "Documento atualizado.", "success") + return RedirectResponse("/documents", status_code=303) + + +@router.post("/documents/{doc_id}/delete") +def delete_document(request: Request, doc_id: int, csrf_token: str = Form("")): + if not auth.check_csrf(request, csrf_token): + flash(request, "Sessão expirada.", "error") + return RedirectResponse("/documents", status_code=303) + with db.session() as conn: + db.delete_fiscal(conn, doc_id) + flash(request, "Documento excluído.", "info") + return RedirectResponse("/documents", status_code=303) diff --git a/app/routes/files_routes.py b/app/routes/files_routes.py new file mode 100644 index 0000000..251adbe --- /dev/null +++ b/app/routes/files_routes.py @@ -0,0 +1,39 @@ +"""Servir arquivos enviados SOMENTE via rota autenticada. + +Documentos fiscais contêm CPF/CNPJ, endereços e valores — nunca ficam num +static mount público. O acesso passa pela guarda de sessão do middleware. +""" + +from __future__ import annotations + +from pathlib import Path + +from fastapi import APIRouter, Request +from starlette.responses import FileResponse, Response + +from .. import database as db +from ..config import get_settings + +router = APIRouter() + + +@router.get("/files/{upload_id}") +def serve_file(request: Request, upload_id: int): + settings = get_settings() + with db.session() as conn: + row = db.get_upload(conn, upload_id) + if row is None: + return Response("Arquivo não encontrado.", status_code=404) + + stored = Path(row["stored_path"]).resolve() + upload_root = settings.upload_dir.resolve() + # trava contra path traversal: o arquivo precisa estar dentro de data/uploads + if upload_root not in stored.parents or not stored.is_file(): + return Response("Arquivo indisponível.", status_code=404) + + return FileResponse( + str(stored), + media_type=row["content_type"] or "application/octet-stream", + filename=row["original_name"], + content_disposition_type="inline", + ) diff --git a/app/routes/upload_routes.py b/app/routes/upload_routes.py new file mode 100644 index 0000000..c3126df --- /dev/null +++ b/app/routes/upload_routes.py @@ -0,0 +1,169 @@ +"""Fluxo de importação: upload -> staging (revisão) -> confirmação em lote.""" + +from __future__ import annotations + +from pathlib import Path + +from fastapi import APIRouter, File, Form, Request, UploadFile +from starlette.responses import RedirectResponse + +from .. import auth +from .. import database as db +from ..config import get_settings +from ..dates import resolve_purchase_date +from ..ingestion import extract_file +from ..storage import money, safe_original_name, unique_storage_name +from ..templating import flash, render + +router = APIRouter() + + +def _confidence(legible: bool, uncertain: list[str]) -> str: + if legible and not uncertain: + return "high" + if len(uncertain) >= 2 or not legible: + return "low" + return "medium" + + +@router.post("/upload") +async def upload(request: Request, csrf_token: str = Form(""), files: list[UploadFile] = File(...)): + if not auth.check_csrf(request, csrf_token): + flash(request, "Sessão expirada. Tente novamente.", "error") + return RedirectResponse("/", status_code=303) + + settings = get_settings() + accepted = 0 + rejected = 0 + + with db.session() as conn: + batch_id = db.create_batch(conn) + for upload_file in files: + original = safe_original_name(upload_file.filename or "upload") + suffix = Path(original).suffix.lower() + content = await upload_file.read() + if suffix not in settings.allowed_extensions or len(content) > settings.max_upload_bytes: + rejected += 1 + continue + + stored_name = unique_storage_name(original) + stored_path = settings.upload_dir / stored_name + stored_path.write_bytes(content) + content_type = upload_file.content_type or "application/octet-stream" + upload_id = db.insert_upload(conn, batch_id, original, stored_path, content_type, len(content)) + + try: + extractions = extract_file(stored_path, original) + for raw in extractions: + purchase_date = resolve_purchase_date(raw.purchase_date_raw) + uncertain = list(raw.uncertain_fields) + db.insert_detected( + conn, + upload_id=upload_id, + batch_id=batch_id, + source_file_name=original, + source_page=None, + source_location=original, + raw_text=raw.raw_text, + purchase_date=purchase_date, + supplier_name=raw.supplier_name, + total_paid=raw.total_paid, + confidence=_confidence(raw.legible, uncertain), + field_confidence={}, + legible=raw.legible, + uncertain_fields=uncertain, + extractor=raw.extractor, + ) + accepted += 1 + status = "processed" if extractions else "needs_attention" + message = None if extractions else "Nenhum documento fiscal detectado." + db.update_upload_status(conn, upload_id, status, len(extractions), message) + except Exception as exc: # nunca deixa um arquivo derrubar o lote + db.update_upload_status(conn, upload_id, "failed", 0, str(exc)[:300]) + + if rejected: + flash(request, f"{rejected} arquivo(s) recusado(s). Use PDF/JPG/PNG até {settings.max_upload_bytes // (1024*1024)} MB.", "error") + if accepted == 0: + flash(request, "Nenhum documento foi extraído dos arquivos enviados.", "error") + return RedirectResponse("/", status_code=303) + return RedirectResponse(f"/import/{batch_id}/review", status_code=303) + + +@router.get("/import/{batch_id}/review") +def review(request: Request, batch_id: int): + with db.session() as conn: + batch = db.get_batch(conn, batch_id) + if batch is None: + flash(request, "Lote de importação não encontrado.", "error") + return RedirectResponse("/", status_code=303) + rows = [dict(r) for r in db.staged_documents(conn, batch_id)] + summary = db.batch_summary(conn, batch_id) + + return render( + request, + "staging.html", + batch=dict(batch), + rows=rows, + summary=summary, + money=money, + ) + + +@router.post("/import/{batch_id}/update/{detected_id}") +def update_row( + request: Request, + batch_id: int, + detected_id: int, + csrf_token: str = Form(""), + purchase_date: str = Form(""), + supplier_name: str = Form(""), + total_paid: str = Form(""), +): + if not auth.check_csrf(request, csrf_token): + flash(request, "Sessão expirada.", "error") + return RedirectResponse(f"/import/{batch_id}/review", status_code=303) + + try: + total = float(total_paid.replace(".", "").replace(",", ".")) if "," in total_paid else float(total_paid or 0) + except ValueError: + flash(request, "Valor inválido na correção.", "error") + return RedirectResponse(f"/import/{batch_id}/review", status_code=303) + + resolved_date = resolve_purchase_date(purchase_date) + with db.session() as conn: + db.update_staged( + conn, + detected_id, + purchase_date=resolved_date, + supplier_name=supplier_name.strip(), + total_paid=round(total, 2), + legible=True, + ) + flash(request, "Documento corrigido.", "success") + return RedirectResponse(f"/import/{batch_id}/review", status_code=303) + + +@router.post("/import/{batch_id}/discard/{detected_id}") +def discard_row(request: Request, batch_id: int, detected_id: int, csrf_token: str = Form("")): + if auth.check_csrf(request, csrf_token): + with db.session() as conn: + db.discard_staged(conn, detected_id) + flash(request, "Documento descartado do lote.", "info") + return RedirectResponse(f"/import/{batch_id}/review", status_code=303) + + +@router.post("/import/{batch_id}/confirm") +def confirm(request: Request, batch_id: int, csrf_token: str = Form("")): + if not auth.check_csrf(request, csrf_token): + flash(request, "Sessão expirada.", "error") + return RedirectResponse(f"/import/{batch_id}/review", status_code=303) + + with db.session() as conn: + summary = db.batch_summary(conn, batch_id) + if summary["pendentes"] > 0: + flash(request, f"Ainda há {summary['pendentes']} documento(s) com problema (ilegível ou sem fornecedor/valor). Corrija ou descarte antes de importar.", "error") + return RedirectResponse(f"/import/{batch_id}/review", status_code=303) + inserted = db.confirm_batch(conn, batch_id) + + flash(request, f"{inserted} documento(s) importado(s) com sucesso.", "success") + return RedirectResponse("/documents", status_code=303) diff --git a/app/static/app.js b/app/static/app.js new file mode 100644 index 0000000..bd98f6a --- /dev/null +++ b/app/static/app.js @@ -0,0 +1,59 @@ +(function () { + "use strict"; + + // ---- Alternância de tema (persistida em localStorage) ---- + function currentTheme() { + var explicit = document.documentElement.getAttribute("data-theme"); + if (explicit) return explicit; + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + } + var toggle = document.getElementById("themeToggle"); + if (toggle) { + toggle.addEventListener("click", function () { + var next = currentTheme() === "dark" ? "light" : "dark"; + document.documentElement.setAttribute("data-theme", next); + try { localStorage.setItem("theme", next); } catch (e) {} + }); + } + + // ---- Área de upload (clique + arrastar/soltar + nome dos arquivos) ---- + var drop = document.getElementById("filedrop"); + var input = document.getElementById("fileInput"); + var label = document.getElementById("filedropText"); + var form = document.getElementById("uploadForm"); + var btn = document.getElementById("uploadBtn"); + + function describe(files) { + if (!files || !files.length) return "Clique ou arraste arquivos aqui"; + if (files.length === 1) return files[0].name; + return files.length + " arquivos selecionados"; + } + + if (input && label) { + input.addEventListener("change", function () { + label.textContent = describe(input.files); + }); + } + if (drop && input) { + ["dragenter", "dragover"].forEach(function (ev) { + drop.addEventListener(ev, function (e) { e.preventDefault(); drop.classList.add("drag"); }); + }); + ["dragleave", "drop"].forEach(function (ev) { + drop.addEventListener(ev, function (e) { e.preventDefault(); drop.classList.remove("drag"); }); + }); + drop.addEventListener("drop", function (e) { + if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length) { + input.files = e.dataTransfer.files; + if (label) label.textContent = describe(input.files); + } + }); + } + if (form && btn) { + form.addEventListener("submit", function () { + if (input && input.files && input.files.length) { + btn.disabled = true; + btn.textContent = "Processando…"; + } + }); + } +})(); diff --git a/app/static/styles.css b/app/static/styles.css new file mode 100644 index 0000000..a0bb41a --- /dev/null +++ b/app/static/styles.css @@ -0,0 +1,206 @@ +:root { + --bg: #f3f5f4; + --surface: #ffffff; + --surface-2: #f7faf8; + --border: #e1e7e3; + --text: #16211c; + --muted: #5c6b63; + --faint: #8a978f; + --accent: #0f7a5a; + --accent-ink: #ffffff; + --accent-2: #2f6f8f; + --danger: #b23b3b; + --warn-bg: #fff4e0; + --warn-border: #e6bd76; + --warn-ink: #7a4d10; + --ok-bg: #e2f3ea; + --ok-ink: #14603f; + --err-bg: #fbe4e4; + --err-ink: #8f2626; + --shadow: 0 1px 2px rgba(16,33,26,.05), 0 10px 26px -18px rgba(16,33,26,.22); + --radius: 14px; + --radius-sm: 9px; +} +@media (prefers-color-scheme: dark) { + :root { + --bg: #0e1512; --surface: #141d19; --surface-2: #18231e; --border: #26332c; + --text: #e8efeb; --muted: #9db0a6; --faint: #6c7d74; --accent: #35c491; + --accent-ink: #06231a; --accent-2: #62b4d6; --danger: #e06a6a; + --warn-bg: #33270f; --warn-border: #6b5220; --warn-ink: #f0cd8a; + --ok-bg: #123528; --ok-ink: #7fe0b4; --err-bg: #3a1d1d; --err-ink: #f0a8a8; + --shadow: 0 1px 2px rgba(0,0,0,.3), 0 14px 30px -20px rgba(0,0,0,.7); + } +} +:root[data-theme="light"] { + --bg: #f3f5f4; --surface: #ffffff; --surface-2: #f7faf8; --border: #e1e7e3; + --text: #16211c; --muted: #5c6b63; --faint: #8a978f; --accent: #0f7a5a; + --accent-ink: #ffffff; --accent-2: #2f6f8f; --danger: #b23b3b; + --warn-bg: #fff4e0; --warn-border: #e6bd76; --warn-ink: #7a4d10; + --ok-bg: #e2f3ea; --ok-ink: #14603f; --err-bg: #fbe4e4; --err-ink: #8f2626; + --shadow: 0 1px 2px rgba(16,33,26,.05), 0 10px 26px -18px rgba(16,33,26,.22); +} +:root[data-theme="dark"] { + --bg: #0e1512; --surface: #141d19; --surface-2: #18231e; --border: #26332c; + --text: #e8efeb; --muted: #9db0a6; --faint: #6c7d74; --accent: #35c491; + --accent-ink: #06231a; --accent-2: #62b4d6; --danger: #e06a6a; + --warn-bg: #33270f; --warn-border: #6b5220; --warn-ink: #f0cd8a; + --ok-bg: #123528; --ok-ink: #7fe0b4; --err-bg: #3a1d1d; --err-ink: #f0a8a8; + --shadow: 0 1px 2px rgba(0,0,0,.3), 0 14px 30px -20px rgba(0,0,0,.7); +} + +* { box-sizing: border-box; } +html, body { margin: 0; padding: 0; } +body { + background: var(--bg); + color: var(--text); + font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + line-height: 1.5; + font-variant-numeric: tabular-nums; + -webkit-font-smoothing: antialiased; +} +h1, h2, h3 { text-wrap: balance; margin: 0; } +a { color: var(--accent); } +.money { font-variant-numeric: tabular-nums; } +.muted { color: var(--muted); } +.small { font-size: .82rem; } +.nowrap { white-space: nowrap; } +.r { text-align: right; } + +/* Topbar */ +.topbar { + display: flex; align-items: center; gap: 18px; + padding: 12px 22px; background: var(--surface); border-bottom: 1px solid var(--border); + position: sticky; top: 0; z-index: 10; +} +.brand { display: flex; align-items: center; gap: 9px; font-weight: 680; text-decoration: none; color: var(--text); font-size: 1.05rem; } +.brand-mark { font-size: 1.2rem; } +.brand-mark.lg { font-size: 2.2rem; } +.nav { display: flex; align-items: center; gap: 6px; margin-right: auto; } +.nav a { padding: 7px 12px; border-radius: var(--radius-sm); text-decoration: none; color: var(--muted); font-weight: 550; font-size: .92rem; } +.nav a:hover { background: var(--surface-2); color: var(--text); } +.nav a.active { color: var(--accent); background: var(--surface-2); } +.topbar-right { display: flex; align-items: center; gap: 10px; } +.user { font-size: .85rem; color: var(--muted); } +.theme-toggle { background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px; width: 36px; height: 36px; cursor: pointer; color: var(--text); font-size: 1rem; } +.theme-toggle:hover { border-color: var(--accent); } +.inline { display: inline; } + +/* Layout */ +.main { max-width: 1080px; margin: 0 auto; padding: 26px 20px 70px; display: flex; flex-direction: column; gap: 20px; } +.main-centered { min-height: 100vh; display: grid; place-items: center; padding: 24px; } +.page-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; } +.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 18px 20px; } +.card-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; } +.card h3 { font-size: 1rem; margin-bottom: 12px; } +.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; } + +/* Buttons */ +.btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; border: 1px solid transparent; border-radius: var(--radius-sm); padding: 9px 15px; font: inherit; font-weight: 600; font-size: .9rem; cursor: pointer; text-decoration: none; } +.btn-primary { background: var(--accent); color: var(--accent-ink); } +.btn-primary:hover { filter: brightness(1.06); } +.btn-primary:disabled { opacity: .5; cursor: not-allowed; } +.btn-ghost { background: transparent; border-color: var(--border); color: var(--text); } +.btn-ghost:hover { background: var(--surface-2); } +.btn-danger { background: transparent; border-color: var(--danger); color: var(--danger); } +.btn-danger:hover { background: var(--danger); color: #fff; } +.btn-sm { padding: 6px 11px; font-size: .84rem; } +.btn-lg { padding: 12px 20px; font-size: 1rem; } +.btn-block { width: 100%; } +.link { color: var(--accent); text-decoration: none; font-weight: 550; } +.link:hover { text-decoration: underline; } +.linkbtn { background: none; border: 0; padding: 0 0 0 10px; color: var(--accent); font: inherit; font-weight: 550; cursor: pointer; } +.linkbtn.danger { color: var(--danger); } + +/* Forms */ +.form { display: flex; flex-direction: column; gap: 14px; } +.field { display: flex; flex-direction: column; gap: 5px; } +.field > span { font-size: .78rem; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .03em; } +.field.grow { flex: 1; } +input[type=text], input[type=password], input[type=date], input[type=file] { + font: inherit; color: var(--text); background: var(--surface-2); + border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 9px 11px; min-height: 40px; width: 100%; +} +input:focus-visible, button:focus-visible, a:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } +.form-actions, .filters-actions { display: flex; gap: 10px; align-items: center; } +.form-card { max-width: 520px; } + +/* Login */ +.login-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 30px; width: 100%; max-width: 380px; } +.login-head { text-align: center; margin-bottom: 20px; display: flex; flex-direction: column; align-items: center; gap: 2px; } +.login-head h1 { font-size: 1.4rem; } + +/* Flash */ +.flash-stack { display: flex; flex-direction: column; gap: 8px; } +.flash { padding: 11px 14px; border-radius: var(--radius-sm); font-size: .9rem; border: 1px solid transparent; } +.flash-success { background: var(--ok-bg); color: var(--ok-ink); } +.flash-error { background: var(--err-bg); color: var(--err-ink); } +.flash-info { background: var(--surface-2); color: var(--muted); border-color: var(--border); } + +/* Upload panel */ +.upload-panel { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 20px; display: grid; grid-template-columns: 1fr auto; gap: 18px; align-items: center; } +.upload-panel h2 { font-size: 1.1rem; margin-bottom: 4px; } +.upload-form { display: flex; gap: 12px; align-items: center; } +.filedrop { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; border: 1.5px dashed var(--border); border-radius: var(--radius-sm); padding: 14px 22px; cursor: pointer; background: var(--surface-2); color: var(--muted); min-width: 240px; text-align: center; } +.filedrop.drag { border-color: var(--accent); color: var(--accent); } +.filedrop-icon { font-size: 1.2rem; } +.filedrop-text { font-size: .85rem; } + +/* KPIs */ +.kpis { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; } +.kpi { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 16px 18px; } +.kpi-label { font-size: .7rem; text-transform: uppercase; letter-spacing: .06em; color: var(--faint); font-weight: 600; } +.kpi-value { display: block; font-size: 1.55rem; font-weight: 680; margin-top: 6px; letter-spacing: -.02em; } +.kpi-value.money { color: var(--accent); } +.kpi-foot { font-size: .78rem; color: var(--muted); } + +/* Bar lists */ +.barlist { display: flex; flex-direction: column; } +.barrow { display: grid; grid-template-columns: 92px 1fr auto; gap: 12px; align-items: center; padding: 7px 0; } +.barrow + .barrow { border-top: 1px solid var(--surface-2); } +.barlabel { font-size: .84rem; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.bartrack { height: 10px; background: var(--surface-2); border-radius: 999px; overflow: hidden; } +.barfill { display: block; height: 100%; border-radius: 999px; background: linear-gradient(90deg, var(--accent-2), var(--accent)); } +.barfill.alt { background: var(--accent); } +.barval { font-size: .84rem; font-weight: 600; white-space: nowrap; } + +/* Tables */ +.table-scroll { overflow-x: auto; } +.table { width: 100%; border-collapse: collapse; font-size: .9rem; } +.table thead th { text-align: left; font-size: .7rem; text-transform: uppercase; letter-spacing: .05em; color: var(--faint); font-weight: 600; padding: 10px 12px; border-bottom: 1px solid var(--border); } +.table tbody td { padding: 11px 12px; border-bottom: 1px solid var(--surface-2); } +.table tbody tr:hover td { background: var(--surface-2); } +.table tfoot td { padding: 11px 12px; font-weight: 650; border-top: 2px solid var(--border); } + +/* Filters */ +.filters { display: flex; gap: 12px; align-items: flex-end; flex-wrap: wrap; } + +/* Staging */ +.summary-banner { display: flex; gap: 28px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 18px 22px; } +.summary-item { display: flex; flex-direction: column; gap: 2px; } +.summary-label { font-size: .72rem; text-transform: uppercase; letter-spacing: .06em; color: var(--faint); font-weight: 600; } +.summary-value { font-size: 1.5rem; font-weight: 700; } +.summary-value.money { color: var(--accent); } +.summary-item.warn .summary-value { color: var(--warn-ink); } +.staging-list { display: flex; flex-direction: column; gap: 12px; } +.staging-row { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 14px 16px; display: grid; grid-template-columns: 1fr auto; gap: 8px 14px; align-items: end; } +.staging-row.row-warn { border-color: var(--warn-border); background: var(--warn-bg); } +.staging-badges { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 6px; } +.badge { font-size: .68rem; font-weight: 600; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--border); color: var(--muted); background: var(--surface-2); text-transform: uppercase; letter-spacing: .03em; } +.badge-warn { background: var(--warn-bg); color: var(--warn-ink); border-color: var(--warn-border); } +.badge-high { color: var(--ok-ink); } +.badge-low { color: var(--err-ink); } +.badge-fields { text-transform: none; letter-spacing: 0; } +.staging-form { grid-column: 1; display: flex; gap: 12px; align-items: end; flex-wrap: wrap; } +.staging-form .field { min-width: 130px; } +.staging-actions { display: flex; gap: 8px; align-items: center; } +.staging-discard { grid-column: 2; } +.staging-source { grid-column: 1 / -1; font-size: .74rem; color: var(--faint); } +.confirm-bar { position: sticky; bottom: 0; display: flex; align-items: center; justify-content: space-between; gap: 16px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 14px 18px; } + +@media (max-width: 820px) { + .grid-2, .kpis { grid-template-columns: 1fr; } + .upload-panel { grid-template-columns: 1fr; } + .staging-row { grid-template-columns: 1fr; } + .staging-discard { grid-column: 1; } + .nav a:not(.btn) { display: none; } +} diff --git a/app/storage.py b/app/storage.py new file mode 100644 index 0000000..4bbebb9 --- /dev/null +++ b/app/storage.py @@ -0,0 +1,33 @@ +"""Helpers de armazenamento seguro dos arquivos enviados.""" + +from __future__ import annotations + +import os +import re +from pathlib import Path + +from .config import get_settings + + +def safe_original_name(name: str) -> str: + return os.path.basename(name or "").replace("\x00", "") or "upload" + + +def unique_storage_name(original: str) -> str: + upload_dir = get_settings().upload_dir + stem = re.sub(r"[^A-Za-z0-9_.-]+", "_", Path(original).stem).strip("._") or "upload" + suffix = Path(original).suffix.lower() + index = 0 + while True: + candidate = f"{stem}{'-' + str(index) if index else ''}{suffix}" + if not (upload_dir / candidate).exists(): + return candidate + index += 1 + + +def money(value: object) -> str: + try: + amount = float(value or 0) + except (TypeError, ValueError): + amount = 0.0 + return f"R$ {amount:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".") diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..dd02bac --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,61 @@ + + + + + + {% block title %}{{ app_name }}{% endblock %} · {{ app_name }} + + + + + + {% if not hide_nav %} +
+ + 🧾 + {{ app_name }} + + +
+ + {% if current_user %} + {{ current_user }} +
+ + +
+ {% endif %} +
+
+ {% endif %} + +
+ {% if flashes %} +
+ {% for f in flashes %} +
{{ f.message }}
+ {% endfor %} +
+ {% endif %} + + {% block content %}{% endblock %} +
+ + + {% block scripts %}{% endblock %} + + diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html new file mode 100644 index 0000000..e4d14c2 --- /dev/null +++ b/app/templates/dashboard.html @@ -0,0 +1,97 @@ +{% extends "base.html" %} +{% block title %}Dashboard{% endblock %} +{% block content %} + +
+
+

Importar documentos

+

Envie PDFs ou imagens de notas e cupons fiscais. A leitura é feita + {% if openai_enabled %}por IA (OpenAI){% else %}por OCR/heurística local{% endif %}, + e você confirma antes de gravar.

+
+
+ + + +
+
+ +
+
+ Total gasto + {{ totals.total | money }} + {{ totals.count }} documento(s) +
+
+ Ticket médio + {{ totals.avg | money }} + por documento +
+
+ Meses com registro + {{ months | length }} + {{ suppliers | length }} fornecedor(es) no top +
+
+ +
+
+

Gastos por mês

+ {% if months %} +
+ {% for m in months %} +
+ {{ m.label }} + + {{ m.total | money }} +
+ {% endfor %} +
+ {% else %}

Sem dados ainda.

{% endif %} +
+ +
+

Gastos por fornecedor

+ {% if suppliers %} +
+ {% for s in suppliers %} +
+ {{ s.supplier_name }} + + {{ s.total | money }} +
+ {% endfor %} +
+ {% else %}

Sem dados ainda.

{% endif %} +
+
+ +
+
+

Últimos documentos

+ Ver todos +
+ {% if recent %} +
+ + + + {% for d in recent %} + + + + + + + {% endfor %} + +
DataFornecedorValor
{{ d.purchase_date }}{{ d.supplier_name }}{{ d.total_paid | money }}editar
+
+ {% else %}

Nenhum documento confirmado. Comece importando acima.

{% endif %} +
+ +{% endblock %} diff --git a/app/templates/document_form.html b/app/templates/document_form.html new file mode 100644 index 0000000..2798618 --- /dev/null +++ b/app/templates/document_form.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} +{% block title %}{{ 'Editar documento' if mode == 'edit' else 'Novo lançamento' }}{% endblock %} +{% block content %} + +
+
+

{{ 'Editar documento' if mode == 'edit' else 'Novo lançamento' }}

+

{{ 'Ajuste os campos e salve.' if mode == 'edit' else 'Lançamento manual de uma despesa fiscal.' }}

+
+ Voltar +
+ +
+
+ + + + +
+ + Cancelar +
+
+
+ +{% endblock %} diff --git a/app/templates/documents_list.html b/app/templates/documents_list.html new file mode 100644 index 0000000..93256fd --- /dev/null +++ b/app/templates/documents_list.html @@ -0,0 +1,60 @@ +{% extends "base.html" %} +{% block title %}Documentos{% endblock %} +{% block content %} + +
+
+

Documentos

+

{{ rows | length }} registro(s) · total {{ total | money }}

+
+ + Novo lançamento +
+ +
+
+ + + +
+ + Limpar +
+
+
+ +
+ {% if rows %} +
+ + + + + + {% for d in rows %} + + + + + + + + {% endfor %} + + + + +
DataFornecedorValorOrigemAções
{{ d.purchase_date }}{{ d.supplier_name }}{{ d.total_paid | money }}{{ d.source_location }} + Editar +
+ + +
+
Total filtrado{{ total | money }}
+
+ {% else %} +

Nenhum documento encontrado. Lançar manualmente ou importar.

+ {% endif %} +
+ +{% endblock %} diff --git a/app/templates/login.html b/app/templates/login.html new file mode 100644 index 0000000..a777dbb --- /dev/null +++ b/app/templates/login.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} +{% block title %}Entrar{% endblock %} +{% block content %} + +{% endblock %} diff --git a/app/templates/staging.html b/app/templates/staging.html new file mode 100644 index 0000000..33bc7eb --- /dev/null +++ b/app/templates/staging.html @@ -0,0 +1,92 @@ +{% extends "base.html" %} +{% block title %}Revisar importação{% endblock %} +{% block content %} + +
+
+

Revisar antes de importar

+

Confira os documentos extraídos. Corrija os ilegíveis e confirme.

+
+ Cancelar +
+ +
+
+ Documentos + {{ summary.count }} +
+
+ Valor total + {{ summary.total | money }} +
+ {% if summary.pendentes %} +
+ Precisam de correção + {{ summary.pendentes }} +
+ {% endif %} +
+ +{% if not rows %} +

Nenhum documento neste lote. Voltar.

+{% else %} + +
+ {% for r in rows %} +
+
+ {% if not r.legible %}Ilegível — revise{% endif %} + {% if r.legible and (not r.supplier_name or r.total_paid is none) %}Incompleto — preencha{% endif %} + {{ r.confidence }} + {{ 'IA' if r.extractor == 'openai' else 'OCR local' }} + {% if r.uncertain_fields and r.uncertain_fields != '[]' %} + incertos: {{ r.uncertain_fields | replace('[','') | replace(']','') | replace('"','') }} + {% endif %} +
+
+ + + + +
+ + Ver arquivo +
+
+
+ + +
+
{{ r.source_file_name }}
+
+ {% endfor %} +
+ +
+
+ {% if summary.pendentes %} + Corrija os {{ summary.pendentes }} documento(s) pendente(s) antes de importar. + {% else %} + Tudo pronto para importar. + {% endif %} +
+
+ + +
+
+ +{% endif %} +{% endblock %} diff --git a/app/templating.py b/app/templating.py new file mode 100644 index 0000000..70bb359 --- /dev/null +++ b/app/templating.py @@ -0,0 +1,41 @@ +"""Configuração do Jinja2 e helpers de renderização/flash.""" + +from __future__ import annotations + +from pathlib import Path + +from fastapi.templating import Jinja2Templates +from starlette.requests import Request +from starlette.responses import HTMLResponse + +from . import auth +from .config import get_settings +from .storage import money + +TEMPLATES_DIR = Path(__file__).resolve().parent / "templates" + +templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) +templates.env.filters["money"] = money + + +def flash(request: Request, message: str, level: str = "info") -> None: + bucket = request.session.setdefault("_flash", []) + bucket.append({"message": message, "level": level}) + + +def pop_flash(request: Request) -> list[dict[str, str]]: + return request.session.pop("_flash", []) + + +def render(request: Request, name: str, status_code: int = 200, **context) -> HTMLResponse: + settings = get_settings() + base = { + "request": request, + "current_user": auth.current_user(request), + "csrf_token": auth.get_csrf_token(request), + "flashes": pop_flash(request), + "openai_enabled": settings.openai_enabled, + "app_name": "Lernotafiscal", + } + base.update(context) + return templates.TemplateResponse(request, name, base, status_code=status_code) diff --git a/deploy/lernotafiscal.service b/deploy/lernotafiscal.service new file mode 100644 index 0000000..210681a --- /dev/null +++ b/deploy/lernotafiscal.service @@ -0,0 +1,21 @@ +[Unit] +Description=Lernotafiscal (FastAPI/Uvicorn) +After=network.target + +[Service] +Type=simple +User=lernotafiscal +Group=lernotafiscal +WorkingDirectory=/opt/lernotafiscal +EnvironmentFile=/opt/lernotafiscal/.env +ExecStart=/opt/lernotafiscal/.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000 --workers 2 +Restart=on-failure +RestartSec=3 +# Hardening básico +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=full +ReadWritePaths=/opt/lernotafiscal/data + +[Install] +WantedBy=multi-user.target diff --git a/deploy/nginx.conf b/deploy/nginx.conf new file mode 100644 index 0000000..c731161 --- /dev/null +++ b/deploy/nginx.conf @@ -0,0 +1,21 @@ +# Nginx como proxy reverso do Lernotafiscal. +# Copie para /etc/nginx/sites-available/lernotafiscal, ajuste server_name e +# habilite com: ln -s .../sites-available/lernotafiscal /etc/nginx/sites-enabled/ +# Depois rode `certbot --nginx -d seu.dominio.com` para gerar o bloco TLS/443. + +server { + listen 80; + server_name seu.dominio.com; + + # Precisa acomodar MAX_UPLOAD_BYTES (12 MB por padrão) + folga do multipart. + client_max_body_size 16m; + + location / { + proxy_pass http://127.0.0.1:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 120s; + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1c1555c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,37 @@ +services: + app: + build: + context: . + dockerfile: Dockerfile + + restart: unless-stopped + + environment: + SECRET_KEY: ${SECRET_KEY:?SECRET_KEY obrigatoria} + SESSION_HTTPS_ONLY: ${SESSION_HTTPS_ONLY:-true} + + ADMIN_USERNAME: ${ADMIN_USERNAME:?ADMIN_USERNAME obrigatorio} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD obrigatoria} + + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + OPENAI_MODEL: ${OPENAI_MODEL:-gpt-4o} + OPENAI_MAX_PAGES: ${OPENAI_MAX_PAGES:-8} + + DB_PATH: ${DB_PATH:-data/app.sqlite3} + MAX_UPLOAD_BYTES: ${MAX_UPLOAD_BYTES:-12582912} + + HOST: ${HOST:-0.0.0.0} + PORT: ${PORT:-8000} + + # Se o Dockerfile já define o comando correto, esta variável pode + # permanecer no padrão ou ser removida se não for utilizada por ele. + APP_MODULE: ${APP_MODULE:-app.main:app} + + volumes: + - lernotafiscal-data:/app/data + + expose: + - "8000" + +volumes: + lernotafiscal-data: diff --git a/github.bat b/github.bat new file mode 100644 index 0000000..acc6a08 --- /dev/null +++ b/github.bat @@ -0,0 +1,30 @@ +@echo off +echo === INICIANDO UPLOAD PARA GITHUB === + +REM Inicializar repositório Git +echo Inicializando repositorio Git... +git init + +REM Adicionar todos os arquivos +echo Adicionando todos os arquivos... +git add . + +REM Fazer commit inicial +echo Realizando commit inicial... +git commit -m "Commit inicial - upload de todos os arquivos da pasta" + +REM Adicionar repositório remoto +echo Conectando ao repositorio remoto... +git remote add origin https://gitea.aplicativopro.com/wander/LerNotaFiscal.git + +REM Definir branch principal +echo Definindo branch principal como 'main'... +git branch -M main + +REM Fazer push para o GitHub +echo Fazendo upload para o GitHub... +git push -u origin main + +echo === UPLOAD CONCLUIDO COM SUCESSO! === + +pause \ No newline at end of file diff --git a/lernotafiscal/__init__.py b/lernotafiscal/__init__.py new file mode 100644 index 0000000..930eac8 --- /dev/null +++ b/lernotafiscal/__init__.py @@ -0,0 +1,2 @@ +"""Lernotafiscal application package.""" + diff --git a/lernotafiscal/db.py b/lernotafiscal/db.py new file mode 100644 index 0000000..b9067e0 --- /dev/null +++ b/lernotafiscal/db.py @@ -0,0 +1,317 @@ +from __future__ import annotations + +import json +import sqlite3 +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parent.parent +DATA_DIR = ROOT / "data" +UPLOAD_DIR = DATA_DIR / "uploads" +PREVIEW_DIR = DATA_DIR / "previews" +DB_PATH = DATA_DIR / "lernotafiscal.sqlite3" + + +def ensure_storage() -> None: + DATA_DIR.mkdir(exist_ok=True) + UPLOAD_DIR.mkdir(exist_ok=True) + PREVIEW_DIR.mkdir(exist_ok=True) + + +def connect(db_path: Path = DB_PATH) -> sqlite3.Connection: + ensure_storage() + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + return conn + + +def init_db(conn: sqlite3.Connection) -> None: + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS uploaded_files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + original_name TEXT NOT NULL, + stored_path TEXT NOT NULL, + content_type TEXT NOT NULL, + size_bytes INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + detected_count INTEGER NOT NULL DEFAULT 0, + message TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS detected_documents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + upload_id INTEGER NOT NULL REFERENCES uploaded_files(id) ON DELETE CASCADE, + source_file_name TEXT NOT NULL, + source_page INTEGER, + source_location TEXT NOT NULL, + raw_text TEXT NOT NULL, + purchase_date TEXT, + supplier_name TEXT, + total_paid REAL, + confidence TEXT NOT NULL, + field_confidence_json TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS fiscal_documents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + detected_document_id INTEGER REFERENCES detected_documents(id) ON DELETE SET NULL, + source_file_name TEXT NOT NULL, + source_location TEXT NOT NULL, + purchase_date TEXT NOT NULL, + supplier_name TEXT NOT NULL, + total_paid REAL NOT NULL, + confidence TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + """ + ) + conn.commit() + + +def insert_upload( + conn: sqlite3.Connection, + original_name: str, + stored_path: Path, + content_type: str, + size_bytes: int, +) -> int: + cur = conn.execute( + """ + INSERT INTO uploaded_files (original_name, stored_path, content_type, size_bytes) + VALUES (?, ?, ?, ?) + """, + (original_name, str(stored_path), content_type, size_bytes), + ) + conn.commit() + return int(cur.lastrowid) + + +def update_upload_status( + conn: sqlite3.Connection, + upload_id: int, + status: str, + detected_count: int, + message: str | None = None, +) -> None: + conn.execute( + """ + UPDATE uploaded_files + SET status = ?, detected_count = ?, message = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + (status, detected_count, message, upload_id), + ) + conn.commit() + + +def insert_detected_document( + conn: sqlite3.Connection, + upload_id: int, + candidate: Any, +) -> int: + cur = conn.execute( + """ + INSERT INTO detected_documents ( + upload_id, source_file_name, source_page, source_location, raw_text, + purchase_date, supplier_name, total_paid, confidence, field_confidence_json + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + upload_id, + candidate.source_file_name, + candidate.source_page, + candidate.source_location, + candidate.raw_text, + candidate.purchase_date, + candidate.supplier_name, + candidate.total_paid, + candidate.confidence, + json.dumps(candidate.field_confidence, ensure_ascii=True), + ), + ) + conn.commit() + return int(cur.lastrowid) + + +def pending_documents(conn: sqlite3.Connection) -> list[sqlite3.Row]: + return list( + conn.execute( + """ + SELECT d.*, u.original_name + FROM detected_documents d + JOIN uploaded_files u ON u.id = d.upload_id + WHERE d.status = 'pending' + ORDER BY d.created_at DESC, d.id DESC + """ + ) + ) + + +def uploads_summary(conn: sqlite3.Connection) -> list[sqlite3.Row]: + return list( + conn.execute( + """ + SELECT * + FROM uploaded_files + ORDER BY created_at DESC, id DESC + LIMIT 20 + """ + ) + ) + + +def attention_uploads(conn: sqlite3.Connection) -> list[sqlite3.Row]: + return list( + conn.execute( + """ + SELECT * + FROM uploaded_files + WHERE status IN ('needs_attention', 'failed') + ORDER BY updated_at DESC, id DESC + LIMIT 20 + """ + ) + ) + + +def review_counts(conn: sqlite3.Connection) -> dict[str, int]: + rows = conn.execute( + """ + SELECT status, COUNT(*) AS total + FROM detected_documents + GROUP BY status + """ + ).fetchall() + counts = {"pending": 0, "confirmed": 0, "ignored": 0} + for row in rows: + counts[row["status"]] = int(row["total"]) + return counts + + +def get_detected(conn: sqlite3.Connection, detected_id: int) -> sqlite3.Row | None: + return conn.execute( + "SELECT * FROM detected_documents WHERE id = ?", + (detected_id,), + ).fetchone() + + +def confirm_document( + conn: sqlite3.Connection, + detected_id: int, + purchase_date: str, + supplier_name: str, + total_paid: float, +) -> None: + row = get_detected(conn, detected_id) + if row is None: + raise ValueError("Documento detectado nao encontrado.") + + conn.execute( + """ + UPDATE detected_documents + SET purchase_date = ?, supplier_name = ?, total_paid = ?, + status = 'confirmed', updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + (purchase_date, supplier_name, total_paid, detected_id), + ) + conn.execute( + """ + INSERT INTO fiscal_documents ( + detected_document_id, source_file_name, source_location, + purchase_date, supplier_name, total_paid, confidence + ) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + detected_id, + row["source_file_name"], + row["source_location"], + purchase_date, + supplier_name, + total_paid, + row["confidence"], + ), + ) + conn.commit() + + +def ignore_document(conn: sqlite3.Connection, detected_id: int) -> None: + conn.execute( + """ + UPDATE detected_documents + SET status = 'ignored', updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + (detected_id,), + ) + conn.commit() + + +def dashboard_documents( + conn: sqlite3.Connection, + start: str | None = None, + end: str | None = None, + supplier: str | None = None, +) -> list[sqlite3.Row]: + clauses = [] + params: list[Any] = [] + if start: + clauses.append("purchase_date >= ?") + params.append(start) + if end: + clauses.append("purchase_date <= ?") + params.append(end) + if supplier: + clauses.append("supplier_name LIKE ?") + params.append(f"%{supplier}%") + + where = f"WHERE {' AND '.join(clauses)}" if clauses else "" + return list( + conn.execute( + f""" + SELECT * + FROM fiscal_documents + {where} + ORDER BY purchase_date DESC, id DESC + """, + params, + ) + ) + + +def monthly_totals(conn: sqlite3.Connection) -> list[sqlite3.Row]: + return list( + conn.execute( + """ + SELECT substr(purchase_date, 1, 7) AS month, SUM(total_paid) AS total, COUNT(*) AS count + FROM fiscal_documents + GROUP BY substr(purchase_date, 1, 7) + ORDER BY month DESC + """ + ) + ) + + +def supplier_totals(conn: sqlite3.Connection) -> list[sqlite3.Row]: + return list( + conn.execute( + """ + SELECT supplier_name, SUM(total_paid) AS total, COUNT(*) AS count + FROM fiscal_documents + GROUP BY supplier_name + ORDER BY total DESC + LIMIT 10 + """ + ) + ) diff --git a/lernotafiscal/extraction.py b/lernotafiscal/extraction.py new file mode 100644 index 0000000..ef8e0ec --- /dev/null +++ b/lernotafiscal/extraction.py @@ -0,0 +1,369 @@ +from __future__ import annotations + +import re +import zlib +from dataclasses import dataclass +from pathlib import Path + +from .db import PREVIEW_DIR + + +PDF_RENDER_SCALE = 3.0 +MAX_BLOCKS_PER_PAGE = 12 +DATE_RE = re.compile(r"\b([0-3]?\d)[/-]([01]?\d)[/-]((?:20)?\d{2})\b") +MONEY_RE = re.compile(r"(? list[PageSource]: + suffix = path.suffix.lower() + if suffix == ".pdf": + return normalize_pdf(path) + if suffix in {".jpg", ".jpeg", ".png"}: + return [normalize_image(path, page_number=1)] + return [PageSource(1, "", None, "unsupported")] + + +def normalize_pdf(path: Path) -> list[PageSource]: + pages = extract_pdf_text_with_pypdf(path) + if not pages: + pages = extract_pdf_text_naive(path) + + preview_images = render_pdf_pages_to_images(path) + page_count = max(len(pages), len(preview_images), count_pdf_pages(path), 1) + normalized: list[PageSource] = [] + for index in range(page_count): + text = pages[index] if index < len(pages) else "" + if not has_usable_text(text) and index < len(preview_images): + ocr_text = extract_image_text(preview_images[index]) + if has_usable_text(ocr_text): + text = ocr_text + normalized.append( + PageSource( + page_number=index + 1, + text=text if has_usable_text(text) else "", + image_path=preview_images[index] if index < len(preview_images) else None, + source_kind="pdf", + ) + ) + return normalized + + +def normalize_image(path: Path, page_number: int) -> PageSource: + text = extract_image_text(path) + return PageSource(page_number, text, path, "image") + + +def extract_pdf_text_with_pypdf(path: Path) -> list[str]: + try: + from pypdf import PdfReader # type: ignore + except Exception: + return [] + + try: + reader = PdfReader(str(path)) + return [ + text if has_usable_text(text) else "" + for text in ((page.extract_text() or "") for page in reader.pages) + ] + except Exception: + return [] + + + +def extract_pdf_text_naive(path: Path) -> list[str]: + data = path.read_bytes() + chunks: list[bytes] = [] + for match in re.finditer(rb"stream\r?\n(.*?)\r?\nendstream", data, re.S): + stream = match.group(1) + try: + chunks.append(zlib.decompress(stream)) + except Exception: + chunks.append(stream) + + decoded = "\n".join(chunk.decode("latin-1", errors="ignore") for chunk in chunks) + if not decoded: + decoded = data.decode("latin-1", errors="ignore") + + text_tokens = re.findall(r"\(([^()]*)\)", decoded) + text = "\n".join(token.replace(r"\)", ")").replace(r"\(", "(") for token in text_tokens) + + page_count = count_pdf_pages(path) + if not has_usable_text(text): + return ["" for _ in range(max(page_count, 1))] + if "\f" in text and page_count > 1: + parts = [clean_text(part) for part in text.split("\f")] + if len(parts) <= page_count * 2 and any(has_usable_text(part) for part in parts): + return [part if has_usable_text(part) else "" for part in parts] + if page_count <= 1: + return [clean_text(text)] + return [clean_text(text)] + ["" for _ in range(max(page_count - 1, 0))] + +def render_pdf_pages_to_images(path: Path) -> list[Path]: + try: + import fitz # type: ignore + except Exception: + return [] + + rendered: list[Path] = [] + try: + PREVIEW_DIR.mkdir(exist_ok=True) + document = fitz.open(path) + for index, page in enumerate(document): + pix = page.get_pixmap(matrix=fitz.Matrix(PDF_RENDER_SCALE, PDF_RENDER_SCALE), alpha=False) + output = PREVIEW_DIR / f"{path.stem}-page-{index + 1}.png" + pix.save(output) + rendered.append(output) + except Exception: + return rendered + return rendered + + +def extract_image_text(path: Path) -> str: + try: + from PIL import Image # type: ignore + import pytesseract # type: ignore + except Exception: + return "" + + try: + image = Image.open(path) + for language in ("por+eng", "eng"): + try: + text = pytesseract.image_to_string(image, lang=language) + except Exception: + continue + if has_usable_text(text): + return text + return "" + except Exception: + return "" + + +def count_pdf_pages(path: Path) -> int: + try: + data = path.read_bytes() + except OSError: + return 0 + matches = re.findall(rb"/Type\s*/Page\b", data) + return len(matches) + + +def detect_documents(pages: list[PageSource], original_name: str) -> list[DetectedDocumentCandidate]: + candidates: list[DetectedDocumentCandidate] = [] + for page in pages: + blocks = segment_blocks(page.text) + if not blocks: + if page.source_kind == "pdf" and not page.text.strip(): + continue + location = f"pagina {page.page_number}" + if page.image_path: + location += f" ({page.image_path.name})" + candidates.append( + build_candidate( + original_name, + page.page_number, + location, + "", + forced_confidence="low", + ) + ) + continue + + for block_index, block in enumerate(blocks, start=1): + location = f"pagina {page.page_number}, bloco {block_index}" + candidates.append(build_candidate(original_name, page.page_number, location, block)) + return candidates + + +def segment_blocks(text: str) -> list[str]: + cleaned = clean_text(text) + if not has_usable_text(cleaned): + return [] + + lines = [line.strip() for line in cleaned.splitlines() if line.strip()] + anchor_indexes = [ + index for index, line in enumerate(lines) + if START_ANCHOR_RE.search(line) or CNPJ_RE.search(line) + ] + + starts: list[int] = [] + for index in anchor_indexes: + start = max(index - 1, 0) if CNPJ_RE.search(lines[index]) else index + if not starts or start - starts[-1] > 3: + starts.append(start) + + if len(starts) <= 1: + return [cleaned] + + blocks: list[str] = [] + for position, start in enumerate(starts): + end = starts[position + 1] if position + 1 < len(starts) else len(lines) + block = "\n".join(lines[start:end]).strip() + if block: + blocks.append(block) + return blocks[:MAX_BLOCKS_PER_PAGE] + + +def build_candidate( + original_name: str, + page_number: int | None, + location: str, + raw_text: str, + forced_confidence: str | None = None, +) -> DetectedDocumentCandidate: + purchase_date = extract_date(raw_text) + supplier = extract_supplier(raw_text) + total = extract_total(raw_text) + + fields = { + "purchase_date": "high" if purchase_date else "low", + "supplier_name": "medium" if supplier else "low", + "total_paid": "high" if total is not None and has_total_label(raw_text) else ("medium" if total is not None else "low"), + } + confidence = forced_confidence or overall_confidence(fields) + return DetectedDocumentCandidate( + source_file_name=original_name, + source_page=page_number, + source_location=location, + raw_text=raw_text, + purchase_date=purchase_date, + supplier_name=supplier, + total_paid=total, + confidence=confidence, + field_confidence=fields, + ) + + +def extract_date(text: str) -> str | None: + match = DATE_RE.search(text) + if not match: + return None + day, month, year = match.groups() + if len(year) == 2: + year = f"20{year}" + return f"{int(year):04d}-{int(month):02d}-{int(day):02d}" + + +def extract_supplier(text: str) -> str | None: + for line in clean_text(text).splitlines()[:8]: + value = line.strip(" :-") + if not value: + continue + if should_skip_supplier_line(value): + continue + if any(ch.isalpha() for ch in value): + return value[:120] + return None + + +def should_skip_supplier_line(line: str) -> bool: + upper = line.upper() + if CNPJ_RE.search(line) or DATE_RE.search(line) or MONEY_RE.search(line): + return True + blocked = ["CUPOM", "NFC", "SAT", "DANFE", "EXTRATO", "VALOR", "TOTAL", "CHAVE", "ENDERECO"] + return any(token in upper for token in blocked) + + +def extract_total(text: str) -> float | None: + lines = [line.strip() for line in clean_text(text).splitlines() if line.strip()] + labelled: list[float] = [] + all_values: list[float] = [] + for line in lines: + values = [parse_money(match.group(1)) for match in MONEY_RE.finditer(line)] + values = [value for value in values if value is not None] + all_values.extend(values) + if LABEL_RE.search(line): + labelled.extend(values) + + if labelled: + return labelled[-1] + if all_values: + return all_values[-1] + return None + + +def parse_money(value: str) -> float | None: + normalized = value.strip() + if "," in normalized: + normalized = normalized.replace(".", "").replace(",", ".") + try: + return round(float(normalized), 2) + except ValueError: + return None + + +def has_total_label(text: str) -> bool: + return LABEL_RE.search(text) is not None + + +def overall_confidence(fields: dict[str, str]) -> str: + values = list(fields.values()) + if all(value == "high" for value in values): + return "high" + if values.count("low") >= 2: + return "low" + return "medium" + + +def clean_text(text: str) -> str: + text = text.replace("\x00", " ") + lines = [re.sub(r"\s+", " ", line).strip() for line in text.splitlines()] + return "\n".join(line for line in lines if line) + + +def has_usable_text(text: str) -> bool: + cleaned = clean_text(text) + if len(cleaned) < 12: + return False + + allowed_extra = set("???????????????????????????????????????????????????$??") + ordinary = sum( + 1 + for ch in cleaned + if ch.isascii() or ch in allowed_extra + ) + printable = sum(1 for ch in cleaned if ch.isprintable()) + alnum = sum(1 for ch in cleaned if ch.isalnum()) + letters = sum(1 for ch in cleaned if ch.isalpha()) + length = max(len(cleaned), 1) + if ordinary / length < 0.85: + return False + if printable / length < 0.9: + return False + if alnum / length < 0.35: + return False + if letters < 3 and not (DATE_RE.search(cleaned) or MONEY_RE.search(cleaned) or CNPJ_RE.search(cleaned)): + return False + + return True diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..75effbf --- /dev/null +++ b/requirements.txt @@ -0,0 +1,15 @@ +# Web app +fastapi +uvicorn[standard] +jinja2 +python-multipart +itsdangerous +bcrypt +python-dotenv + +# Extração +openai +pypdf +PyMuPDF +Pillow +pytesseract diff --git a/scripts/migrate_notas.py b/scripts/migrate_notas.py new file mode 100644 index 0000000..dfe9666 --- /dev/null +++ b/scripts/migrate_notas.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Migra as notas da skill (tabela `notas`) para `fiscal_documents` do app. + +Idempotente: deduplica por (purchase_date, supplier_name, total_paid). + +Uso: + python scripts/migrate_notas.py [caminho_do_banco_origem] + +Origem padrão: Skill/dados/lernotafiscal.db +Destino: o banco do app (DB_PATH / data/app.sqlite3). +""" + +from __future__ import annotations + +import sqlite3 +import sys +from pathlib import Path + +# permite rodar de qualquer lugar +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from app import database as db # noqa: E402 + +DEFAULT_SOURCE = Path(__file__).resolve().parent.parent / "Skill" / "dados" / "lernotafiscal.db" + + +def read_notas(source: Path) -> list[tuple]: + conn = sqlite3.connect(source) + conn.row_factory = sqlite3.Row + try: + rows = conn.execute( + "SELECT data_compra, fornecedor, valor_pago, arquivo_origem FROM notas" + ).fetchall() + finally: + conn.close() + return [(r["data_compra"], r["fornecedor"], float(r["valor_pago"]), r["arquivo_origem"]) for r in rows] + + +def main() -> int: + source = Path(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_SOURCE + if not source.exists(): + print(f"Banco de origem não encontrado: {source}") + return 1 + + notas = read_notas(source) + inserted = skipped = 0 + with db.session() as conn: + for data_compra, fornecedor, valor, arquivo in notas: + exists = conn.execute( + """ + SELECT 1 FROM fiscal_documents + WHERE purchase_date = ? AND supplier_name = ? AND ROUND(total_paid, 2) = ROUND(?, 2) + LIMIT 1 + """, + (data_compra, fornecedor, valor), + ).fetchone() + if exists: + skipped += 1 + continue + db.create_fiscal( + conn, + purchase_date=data_compra, + supplier_name=fornecedor, + total_paid=valor, + source_file_name=arquivo or "migrado", + source_location="migrado", + confidence="high", + ) + inserted += 1 + totals = db.overall_totals(conn) + + print(f"Origem: {source}") + print(f"Inseridos: {inserted} · Ignorados (duplicados): {skipped}") + print(f"Total em fiscal_documents: {totals['count']} documento(s) · R$ {totals['total']:.2f}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e8d8fe6 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for Lernotafiscal.""" diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..0ff5344 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,141 @@ +"""Testes do app: fallback de data, autenticação e CRUD de documentos.""" + +import os +import tempfile +import unittest +from datetime import date +from pathlib import Path + + +class DateFallbackTests(unittest.TestCase): + def test_iso_date_is_kept(self): + from app.dates import resolve_purchase_date + + self.assertEqual(resolve_purchase_date("2026-06-09"), "2026-06-09") + + def test_br_date_is_normalized(self): + from app.dates import resolve_purchase_date + + self.assertEqual(resolve_purchase_date("09/06/2026"), "2026-06-09") + + def test_illegible_date_falls_back_to_first_of_month(self): + from app.dates import resolve_purchase_date + + ref = date(2026, 7, 24) + self.assertEqual(resolve_purchase_date("ilegível", reference=ref), "2026-07-01") + self.assertEqual(resolve_purchase_date(None, reference=ref), "2026-07-01") + self.assertEqual(resolve_purchase_date("", reference=ref), "2026-07-01") + + def test_invalid_calendar_date_falls_back(self): + from app.dates import resolve_purchase_date + + ref = date(2026, 7, 24) + self.assertEqual(resolve_purchase_date("2026-13-40", reference=ref), "2026-07-01") + + +class PasswordTests(unittest.TestCase): + def test_hash_and_verify(self): + from app import auth + + h = auth.hash_password("segredo-forte") + self.assertTrue(auth.verify_password("segredo-forte", h)) + self.assertFalse(auth.verify_password("errada", h)) + + +class CrudTests(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + os.environ["DB_PATH"] = str(Path(self._tmp.name) / "crud.sqlite3") + # zera o cache de settings para pegar o DB_PATH novo + from app.config import get_settings + + get_settings.cache_clear() + + def tearDown(self): + os.environ.pop("DB_PATH", None) + from app.config import get_settings + + get_settings.cache_clear() + self._tmp.cleanup() + + def test_create_update_delete_list(self): + from app import database as db + + with db.session() as conn: + doc_id = db.create_fiscal( + conn, purchase_date="2026-07-01", supplier_name="Padaria X", total_paid=10.0 + ) + self.assertEqual(db.overall_totals(conn)["count"], 1) + + db.update_fiscal( + conn, doc_id, purchase_date="2026-07-02", supplier_name="Padaria Y", total_paid=12.5 + ) + row = db.get_fiscal(conn, doc_id) + self.assertEqual(row["supplier_name"], "Padaria Y") + self.assertEqual(row["total_paid"], 12.5) + + rows = db.list_fiscal(conn, supplier="Padaria") + self.assertEqual(len(rows), 1) + + db.delete_fiscal(conn, doc_id) + self.assertEqual(db.overall_totals(conn)["count"], 0) + + +class StagingGateTests(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + os.environ["DB_PATH"] = str(Path(self._tmp.name) / "stage.sqlite3") + from app.config import get_settings + + get_settings.cache_clear() + + def tearDown(self): + os.environ.pop("DB_PATH", None) + from app.config import get_settings + + get_settings.cache_clear() + self._tmp.cleanup() + + def _stage(self, conn, batch_id, upload_id, **over): + base = dict( + upload_id=upload_id, batch_id=batch_id, source_file_name="f.pdf", + source_page=None, source_location="f.pdf", raw_text="", + purchase_date="2026-07-01", supplier_name="Loja", total_paid=10.0, + confidence="high", field_confidence={}, legible=True, + uncertain_fields=[], extractor="local", + ) + base.update(over) + from app import database as db + + return db.insert_detected(conn, **base) + + def test_incomplete_row_counts_as_pending_and_is_not_imported(self): + from app import database as db + + with db.session() as conn: + batch = db.create_batch(conn) + up = db.insert_upload(conn, batch, "f.pdf", Path("f.pdf"), "application/pdf", 1) + self._stage(conn, batch, up) # completo + # legível mas SEM fornecedor -> incompleto, deve contar como pendente + self._stage(conn, batch, up, supplier_name=None, legible=True) + + summary = db.batch_summary(conn, batch) + self.assertEqual(summary["count"], 2) + self.assertEqual(summary["incompletos"], 1) + self.assertEqual(summary["pendentes"], 1) + + def test_confirm_only_promotes_complete_rows(self): + from app import database as db + + with db.session() as conn: + batch = db.create_batch(conn) + up = db.insert_upload(conn, batch, "f.pdf", Path("f.pdf"), "application/pdf", 1) + self._stage(conn, batch, up, supplier_name="Loja A", total_paid=10.0) + self._stage(conn, batch, up, supplier_name="Loja B", total_paid=20.0) + inserted = db.confirm_batch(conn, batch) + self.assertEqual(inserted, 2) + self.assertEqual(db.overall_totals(conn)["total"], 30.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_ingestion.py b/tests/test_ingestion.py new file mode 100644 index 0000000..35eec8f --- /dev/null +++ b/tests/test_ingestion.py @@ -0,0 +1,114 @@ +import tempfile +import unittest +from pathlib import Path + +from lernotafiscal import db +from lernotafiscal.extraction import detect_documents, extract_pdf_text_naive, segment_blocks +from lernotafiscal.extraction import PageSource + + +SINGLE_DOC = """ +MERCADO CENTRAL LTDA +CNPJ 12.345.678/0001-90 +DATA 13/07/2026 +ITEM ARROZ 10,00 +VALOR TOTAL R$ 42,50 +""" + + +MULTI_DOC = """ +MERCADO CENTRAL LTDA +CNPJ 12.345.678/0001-90 +DATA 13/07/2026 +VALOR TOTAL R$ 42,50 + +FARMACIA SAUDE +CNPJ 98.765.432/0001-10 +DATA 14/07/2026 +TOTAL A PAGAR R$ 18,90 +""" + + +class ExtractionTests(unittest.TestCase): + def test_single_image_text_creates_one_candidate(self) -> None: + pages = [PageSource(1, SINGLE_DOC, None, "image")] + docs = detect_documents(pages, "cupom.png") + + self.assertEqual(len(docs), 1) + self.assertEqual(docs[0].purchase_date, "2026-07-13") + self.assertEqual(docs[0].supplier_name, "MERCADO CENTRAL LTDA") + self.assertEqual(docs[0].total_paid, 42.50) + + def test_pdf_page_with_multiple_blocks_creates_multiple_candidates(self) -> None: + blocks = segment_blocks(MULTI_DOC) + self.assertEqual(len(blocks), 2) + + docs = detect_documents([PageSource(1, MULTI_DOC, None, "pdf")], "lote.pdf") + self.assertEqual(len(docs), 2) + self.assertEqual(docs[0].supplier_name, "MERCADO CENTRAL LTDA") + self.assertEqual(docs[1].supplier_name, "FARMACIA SAUDE") + + def test_pdf_with_one_document_per_page_creates_review_item_per_page(self) -> None: + pages = [ + PageSource(1, SINGLE_DOC, None, "pdf"), + PageSource(2, SINGLE_DOC.replace("MERCADO CENTRAL LTDA", "POSTO AVENIDA"), None, "pdf"), + ] + docs = detect_documents(pages, "duas-paginas.pdf") + + self.assertEqual(len(docs), 2) + self.assertEqual(docs[0].source_location, "pagina 1, bloco 1") + self.assertEqual(docs[1].source_location, "pagina 2, bloco 1") + self.assertEqual(docs[1].supplier_name, "POSTO AVENIDA") + + def test_naive_pdf_extraction_rejects_binary_streams_without_fake_pages(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + pdf_path = Path(tmp) / "scan.pdf" + pdf_path.write_bytes( + b"%PDF-1.4\n" + b"1 0 obj << /Type /Page >> endobj\n" + b"2 0 obj << /Length 28 >> stream\n" + b"\x01\x02binary\x0cnoise\x0cnot text\xff\n" + b"endstream\n%%EOF" + ) + + pages = extract_pdf_text_naive(pdf_path) + + self.assertEqual(pages, [""]) + + def test_low_confidence_candidate_can_be_corrected_and_confirmed(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + conn = db.connect(Path(tmp) / "test.sqlite3") + try: + db.init_db(conn) + upload_id = db.insert_upload(conn, "scan.png", Path(tmp) / "scan.png", "image/png", 12) + candidate = detect_documents([PageSource(1, "", None, "image")], "scan.png")[0] + detected_id = db.insert_detected_document(conn, upload_id, candidate) + + db.confirm_document(conn, detected_id, "2026-07-15", "PADARIA BOA", 9.75) + docs = db.dashboard_documents(conn) + + self.assertEqual(len(docs), 1) + self.assertEqual(docs[0]["supplier_name"], "PADARIA BOA") + self.assertEqual(docs[0]["total_paid"], 9.75) + finally: + conn.close() + + def test_ignored_documents_do_not_reach_dashboard(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + conn = db.connect(Path(tmp) / "test.sqlite3") + try: + db.init_db(conn) + upload_id = db.insert_upload(conn, "cupom.png", Path(tmp) / "cupom.png", "image/png", 12) + candidate = detect_documents([PageSource(1, SINGLE_DOC, None, "image")], "cupom.png")[0] + detected_id = db.insert_detected_document(conn, upload_id, candidate) + + db.ignore_document(conn, detected_id) + docs = db.dashboard_documents(conn) + + self.assertEqual(docs, []) + finally: + conn.close() + + +if __name__ == "__main__": + unittest.main()