Commit inicial - upload de todos os arquivos da pasta

This commit is contained in:
2026-07-24 23:55:21 -03:00
commit 9d4d395881
88 changed files with 9484 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
"""Lernotafiscal — aplicativo web de controle de despesas fiscais."""
__version__ = "1.0.0"
+187
View File
@@ -0,0 +1,187 @@
"""Extração de documentos fiscais via OpenAI Vision, com fallback local.
`extract_with_ai` recebe as imagens de um arquivo enviado (páginas de PDF já
renderizadas em PNG, ou a própria imagem) e devolve uma lista de `RawExtraction`
— ou `None` para sinalizar que o chamador deve cair no OCR/heurística local.
A superfície da SDK usada (chat.completions + response_format json_object +
entrada de imagem por data URI) foi verificada contra `openai` 2.x.
"""
from __future__ import annotations
import base64
import json
import logging
from dataclasses import dataclass, field
from datetime import date
from pathlib import Path
from .config import get_openai_client, get_settings
logger = logging.getLogger("lernotafiscal.ai")
@dataclass
class RawExtraction:
"""Resultado bruto de UM documento fiscal, antes de normalizar a data."""
supplier_name: str | None
purchase_date_raw: str | None
total_paid: float | None
legible: bool
uncertain_fields: list[str] = field(default_factory=list)
extractor: str = "openai"
raw_text: str = ""
_MIME = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg"}
_SYSTEM_PROMPT = (
"Você é um extrator de dados de notas e cupons fiscais brasileiros. "
"Responda SEMPRE em JSON válido, sem texto fora do JSON."
)
# Tolerância de plausibilidade da data extraída (ver `_is_plausible_purchase_date`).
# A checagem opera em granularidade de mês/ano — é isso que de fato importa
# para a competência persistida, o dia é descartado na normalização.
_DATE_TOLERANCE_YEARS_PAST = 2
_DATE_TOLERANCE_MONTHS_FUTURE = 1
def _user_prompt() -> str:
today = date.today().isoformat()
return f"""A data de hoje é {today}. As imagens a seguir são documentos fiscais (notas, cupons, recibos). Podem conter mais de um documento.
Para CADA documento distinto, extraia:
- "fornecedor": razão social ou nome fantasia do emissor (o mais destacado no cabeçalho), ou null.
- "data_compra": data da compra no formato "YYYY-MM-DD", ou null se ilegível/ausente. Esses documentos são quase sempre recentes (deste ano ou do ano anterior a {today[:4]}) — releia com cuidado os dois últimos dígitos do ano antes de responder, para não confundir dígitos parecidos (ex.: não troque "26" por "22").
- "valor_pago": número (ponto decimal) do TOTAL efetivamente pago. Use o "VALOR TOTAL"/"TOTAL A PAGAR"/"VALOR PAGO". NUNCA use "TROCO" nem "DINHEIRO RECEBIDO". Se parcelado, use o total da compra. Null se ilegível.
- "legivel": true se você leu os campos com confiança; false se o documento está borrado, cortado ou ilegível.
- "campos_incertos": lista dos campos que ficaram duvidosos (ex.: ["data_compra","valor_pago"]).
Responda exatamente neste formato:
{{"documentos": [{{"fornecedor": ..., "data_compra": ..., "valor_pago": ..., "legivel": ..., "campos_incertos": [...]}}]}}
Se nenhum documento fiscal for identificável, responda {{"documentos": []}}."""
def _is_plausible_purchase_date(date_raw: str) -> bool:
"""Sanidade sobre a competência (mês/ano) da data devolvida pela IA: notas
fiscais são quase sempre recentes, então uma competência muito no passado
ou no futuro é sinal de erro de leitura de dígito (ex.: "26" lido como
"22") — melhor mandar para revisão manual do que aceitar silenciosamente."""
try:
year, month, day = (int(part) for part in date_raw.split("-"))
date(year, month, day) # valida o calendário
except (ValueError, TypeError):
return True # formato inesperado já é tratado como campo incerto à parte
today = date.today()
months_ahead = (year * 12 + month) - (today.year * 12 + today.month)
if months_ahead > _DATE_TOLERANCE_MONTHS_FUTURE:
return False
if year < today.year - _DATE_TOLERANCE_YEARS_PAST:
return False
return True
def _data_uri(path: Path) -> 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)
# segurança: data implausível (ano muito no passado/futuro) força revisão
# manual, mesmo que a IA tenha respondido "legivel": true — ver
# `_is_plausible_purchase_date` para o porquê (erro de leitura de dígito).
if date_raw is not None and not _is_plausible_purchase_date(date_raw):
if "data_compra" not in uncertain:
uncertain.append("data_compra")
legible = False
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:
client = get_openai_client()
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
+90
View File
@@ -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)
+130
View File
@@ -0,0 +1,130 @@
"""Categorização automática de notas fiscais por fornecedor.
Ordem de resolução, sempre retornando um `categoria.id` válido (nunca `None`):
cache em memória -> limite de chamadas de IA por lote -> IA -> palavra-chave
-> categoria reservada `1` ("Não Encontrado").
Sem retry: qualquer falha na chamada de IA é tratada como "sem categoria da IA"
e o fluxo cai para o próximo passo, sem nunca impedir a criação do documento.
"""
from __future__ import annotations
import json
import logging
import sqlite3
from . import database as db
from .config import get_openai_client, get_settings
logger = logging.getLogger("lernotafiscal.categorization")
# Cache em memória (por fornecedor normalizado) e contador de chamadas de IA
# por lote — ambos módulo-level, aceitável pois degradam de forma segura
# (pior caso: recategoriza após restart, ainda limitado pelo teto por lote).
_supplier_cache: dict[str, int] = {}
_batch_ai_calls: dict[int, int] = {}
def reset_cache() -> None:
"""Limpa cache e contadores em memória. Uso principal: isolamento em testes."""
_supplier_cache.clear()
_batch_ai_calls.clear()
def _normalize(supplier_name: str) -> str:
return (supplier_name or "").strip().upper()
def _call_ai(supplier_name: str, known_categories: list[str]) -> str | None:
"""Chamada única (sem retry) à IA para sugerir uma categoria dentre as
conhecidas. Qualquer erro ou valor fora do conjunto conhecido -> None."""
settings = get_settings()
if not settings.openai_enabled or not known_categories:
return None
try:
client = get_openai_client()
prompt = (
"Classifique o fornecedor de uma nota fiscal brasileira em UMA das "
"categorias a seguir (responda exatamente como escrito na lista): "
+ ", ".join(known_categories)
+ f'\nFornecedor: "{supplier_name}"\n'
'Responda em JSON: {"categoria": "<categoria da lista>"} '
'ou {"categoria": null} se nenhuma categoria se aplicar claramente.'
)
response = client.chat.completions.create(
model=settings.openai_model,
messages=[
{
"role": "system",
"content": "Você classifica fornecedores de notas fiscais brasileiras por categoria. Responda SEMPRE em JSON válido.",
},
{"role": "user", "content": prompt},
],
response_format={"type": "json_object"},
temperature=0,
)
content = response.choices[0].message.content or "{}"
data = json.loads(content)
category = data.get("categoria") if isinstance(data, dict) else None
if isinstance(category, str) and category.strip() in known_categories:
return category.strip()
return None
except Exception as exc: # rede, cota, parsing, modelo indisponível...
logger.warning("Categorização por IA falhou para '%s': %s", supplier_name, exc)
return None
def categorize_supplier(supplier_name: str, conn: sqlite3.Connection, batch_id: int) -> int:
"""Determina o `categoria.id` de um fornecedor. Nunca levanta exceção para o
chamador nem retorna `None` — em último caso devolve a categoria reservada."""
try:
normalized = _normalize(supplier_name)
cached = _supplier_cache.get(normalized)
if cached is not None:
logger.debug(
"Categorização de '%s' pulada (cache hit) -> categoria_id=%s", supplier_name, cached
)
return cached
settings = get_settings()
categorias = db.list_categorias(conn)
known_names: list[str] = []
id_by_name: dict[str, int] = {}
for row in categorias:
if row["id"] == db.RESERVED_CATEGORIA_ID:
continue
known_names.append(row["categoria"])
id_by_name.setdefault(row["categoria"], int(row["id"]))
categoria_id: int | None = None
calls_so_far = _batch_ai_calls.get(batch_id, 0)
limit = settings.categorization_max_ai_calls_per_batch
if not settings.openai_enabled:
pass # sem IA configurada: cai direto para palavra-chave/reservado
elif calls_so_far >= limit:
logger.info(
"Categorização de '%s' pulada (limite de %s chamadas de IA por lote atingido no lote %s)",
supplier_name, limit, batch_id,
)
else:
_batch_ai_calls[batch_id] = calls_so_far + 1
ai_category = _call_ai(supplier_name, known_names)
if ai_category is not None:
categoria_id = id_by_name.get(ai_category)
if categoria_id is None:
match = db.find_categoria_by_keyword(conn, supplier_name)
if match is not None:
categoria_id = int(match["id"])
if categoria_id is None:
categoria_id = db.RESERVED_CATEGORIA_ID
_supplier_cache[normalized] = categoria_id
return categoria_id
except Exception as exc: # nunca impede a criação do fiscal_documents
logger.warning("Categorização falhou inesperadamente para '%s': %s", supplier_name, exc)
return db.RESERVED_CATEGORIA_ID
+97
View File
@@ -0,0 +1,97 @@
"""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)))
# Limite de segurança: máximo de chamadas de IA de categorização por lote de importação.
self.categorization_max_ai_calls_per_batch = int(
os.environ.get("CATEGORIZATION_MAX_AI_CALLS_PER_BATCH", "50")
)
@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
@lru_cache(maxsize=1)
def get_openai_client():
"""Client OpenAI reaproveitado entre chamadas (evita recriar o pool HTTP a cada request).
Só deve ser chamado quando `settings.openai_enabled` for True.
"""
from openai import OpenAI
return OpenAI(api_key=get_settings().openai_api_key)
+876
View File
@@ -0,0 +1,876 @@
"""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 '',
mes INTEGER,
ano INTEGER,
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 categoria (
id INTEGER PRIMARY KEY AUTOINCREMENT,
categoria TEXT NOT NULL,
palavra_chave TEXT NOT NULL
);
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 '',
mes INTEGER NOT NULL,
ano INTEGER NOT NULL,
supplier_name TEXT NOT NULL,
total_paid REAL NOT NULL,
confidence TEXT NOT NULL DEFAULT 'high',
categoria_id INTEGER REFERENCES categoria(id),
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_fiscal_competencia ON fiscal_documents(ano, mes);
CREATE INDEX IF NOT EXISTS idx_detected_batch ON detected_documents(batch_id, status);
"""
_RESERVED_CATEGORIA_SEED = (
"INSERT OR IGNORE INTO categoria (id, categoria, palavra_chave) VALUES (1, 'Não Encontrado', '')"
)
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 _table_columns(conn: sqlite3.Connection, table: str) -> set[str]:
return {row["name"] for row in conn.execute(f"PRAGMA table_info({table})")}
def _rebuild_fiscal_documents(conn: sqlite3.Connection, old_columns: set[str]) -> None:
"""Recria `fiscal_documents` com `mes`/`ano` no lugar de `purchase_date`.
SQLite não suporta `DROP COLUMN` em todas as versões-alvo, então o rebuild
(tabela nova + `INSERT ... SELECT` + `DROP` + `RENAME`) é a técnica
portável recomendada pela própria documentação do SQLite.
"""
categoria_expr = "categoria_id" if "categoria_id" in old_columns else "NULL"
conn.execute("DROP TABLE IF EXISTS fiscal_documents_new")
conn.execute(
"""
CREATE TABLE fiscal_documents_new (
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 '',
mes INTEGER NOT NULL,
ano INTEGER NOT NULL,
supplier_name TEXT NOT NULL,
total_paid REAL NOT NULL,
confidence TEXT NOT NULL DEFAULT 'high',
categoria_id INTEGER REFERENCES categoria(id),
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
f"""
INSERT INTO fiscal_documents_new (
id, detected_document_id, source_file_name, source_location,
mes, ano, supplier_name, total_paid, confidence, categoria_id,
created_at, updated_at
)
SELECT id, detected_document_id, source_file_name, source_location,
CAST(substr(purchase_date, 6, 2) AS INTEGER),
CAST(substr(purchase_date, 1, 4) AS INTEGER),
supplier_name, total_paid, confidence, {categoria_expr},
created_at, updated_at
FROM fiscal_documents
"""
)
conn.execute("DROP TABLE fiscal_documents")
conn.execute("ALTER TABLE fiscal_documents_new RENAME TO fiscal_documents")
def _rebuild_detected_documents(conn: sqlite3.Connection) -> None:
"""Mesmo rebuild que `_rebuild_fiscal_documents`, mas `mes`/`ano` ficam
nullable — mesmo comportamento opcional que `purchase_date` tinha."""
conn.execute("DROP TABLE IF EXISTS detected_documents_new")
conn.execute(
"""
CREATE TABLE detected_documents_new (
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 '',
mes INTEGER,
ano INTEGER,
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
)
"""
)
conn.execute(
"""
INSERT INTO detected_documents_new (
id, upload_id, batch_id, source_file_name, source_page, source_location,
raw_text, mes, ano, supplier_name, total_paid, confidence,
field_confidence_json, legible, uncertain_fields, extractor, status,
created_at, updated_at
)
SELECT id, upload_id, batch_id, source_file_name, source_page, source_location,
raw_text,
CASE WHEN purchase_date IS NOT NULL THEN CAST(substr(purchase_date, 6, 2) AS INTEGER) END,
CASE WHEN purchase_date IS NOT NULL THEN CAST(substr(purchase_date, 1, 4) AS INTEGER) END,
supplier_name, total_paid, confidence,
field_confidence_json, legible, uncertain_fields, extractor, status,
created_at, updated_at
FROM detected_documents
"""
)
conn.execute("DROP TABLE detected_documents")
conn.execute("ALTER TABLE detected_documents_new RENAME TO detected_documents")
def _migrate_competencia(conn: sqlite3.Connection) -> None:
"""Migra `fiscal_documents`/`detected_documents` de `purchase_date` para
`mes`/`ano`, uma única vez. Guardado por `PRAGMA table_info`: só roda se
`purchase_date` ainda existir (tabela de instalação anterior a esta
mudança); em uma instalação nova, ou já migrada, é um no-op."""
fiscal_columns = _table_columns(conn, "fiscal_documents")
detected_columns = _table_columns(conn, "detected_documents")
needs_fiscal = "purchase_date" in fiscal_columns
needs_detected = "purchase_date" in detected_columns
if not needs_fiscal and not needs_detected:
return
conn.execute("BEGIN")
try:
if needs_fiscal:
_rebuild_fiscal_documents(conn, fiscal_columns)
if needs_detected:
_rebuild_detected_documents(conn)
except Exception:
conn.rollback()
raise
else:
conn.commit()
def init_db(conn: sqlite3.Connection) -> None:
_migrate_competencia(conn)
conn.executescript(SCHEMA)
columns = {row["name"] for row in conn.execute("PRAGMA table_info(fiscal_documents)")}
if "categoria_id" not in columns:
conn.execute("ALTER TABLE fiscal_documents ADD COLUMN categoria_id INTEGER REFERENCES categoria(id)")
conn.execute(_RESERVED_CATEGORIA_SEED)
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,
*,
commit: bool = True,
) -> 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),
)
if commit:
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,
*,
commit: bool = True,
) -> None:
conn.execute(
"""
UPDATE uploaded_files
SET status = ?, detected_count = ?, message = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""",
(status, detected_count, message, upload_id),
)
if commit:
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,
mes: int | None,
ano: int | None,
supplier_name: str | None,
total_paid: float | None,
confidence: str,
field_confidence: dict[str, str],
legible: bool,
uncertain_fields: list[str],
extractor: str,
commit: bool = True,
) -> int:
cur = conn.execute(
"""
INSERT INTO detected_documents (
upload_id, batch_id, source_file_name, source_page, source_location,
raw_text, mes, ano, 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,
mes,
ano,
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,
),
)
if commit:
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 OR mes IS NULL OR ano 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/competência).
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
OR mes IS NULL OR ano 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,
*,
mes: int | None,
ano: int | None,
supplier_name: str,
total_paid: float,
legible: bool = True,
) -> None:
conn.execute(
"""
UPDATE detected_documents
SET mes = ?, ano = ?, supplier_name = ?, total_paid = ?,
legible = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND status = 'staged'
""",
(mes, ano, 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."""
# Import local para evitar ciclo: categorization.py importa este módulo no topo.
from .categorization import categorize_supplier
rows = staged_documents(conn, batch_id)
inserted = 0
for row in rows:
mes, ano = row["mes"], row["ano"]
if (
mes is None or ano is None or not (1 <= mes <= 12)
or not row["supplier_name"] or row["total_paid"] is None
):
# Sem os campos essenciais (competência, fornecedor, valor) não confirma — permanece staged.
continue
categoria_id = categorize_supplier(row["supplier_name"], conn, batch_id)
conn.execute(
"""
INSERT INTO fiscal_documents (
detected_document_id, source_file_name, source_location,
mes, ano, supplier_name, total_paid, confidence, categoria_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
row["id"],
row["source_file_name"],
row["source_location"],
mes,
ano,
row["supplier_name"],
float(row["total_paid"]),
row["confidence"],
categoria_id,
),
)
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,
*,
mes: int,
ano: int,
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,
categoria_id: int | None = None,
) -> int:
cur = conn.execute(
"""
INSERT INTO fiscal_documents (
detected_document_id, source_file_name, source_location,
mes, ano, supplier_name, total_paid, confidence, categoria_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
detected_document_id,
source_file_name,
source_location,
mes,
ano,
supplier_name,
total_paid,
confidence,
categoria_id,
),
)
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,
*,
mes: int,
ano: int,
supplier_name: str,
total_paid: float,
categoria_id: int | None = None,
) -> None:
conn.execute(
"""
UPDATE fiscal_documents
SET mes = ?, ano = ?, supplier_name = ?, total_paid = ?, categoria_id = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""",
(mes, ano, supplier_name, total_paid, categoria_id, 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 _category_clause(category: str | None) -> tuple[str | None, Any]:
"""Traduz o filtro de categoria (id, 'none' para Sem categoria, ou None/''
para nenhum filtro) numa clausula SQL + parâmetro."""
if not category:
return None, None
if category == "none":
return "categoria_id IS NULL", None
try:
return "categoria_id = ?", int(category)
except ValueError:
return None, None
def _fiscal_where(
*,
start: tuple[int, int] | None = None,
end: tuple[int, int] | None = None,
supplier: str | None = None,
category: str | None = None,
) -> tuple[str, list[Any]]:
"""`start`/`end` são tuplas `(mes, ano)` delimitando o intervalo de
competência (inclusive), comparadas via a chave `ano * 12 + mes`."""
clauses: list[str] = []
params: list[Any] = []
if start:
start_mes, start_ano = start
clauses.append("(ano * 12 + mes) >= ?")
params.append(start_ano * 12 + start_mes)
if end:
end_mes, end_ano = end
clauses.append("(ano * 12 + mes) <= ?")
params.append(end_ano * 12 + end_mes)
if supplier:
clauses.append("supplier_name LIKE ?")
params.append(f"%{supplier}%")
cat_clause, cat_param = _category_clause(category)
if cat_clause:
clauses.append(cat_clause)
if cat_param is not None:
params.append(cat_param)
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
return where, params
FISCAL_SORT_COLUMNS = {
"competencia": "f.ano, f.mes",
"supplier_name": "f.supplier_name COLLATE NOCASE",
"categoria": "categoria_nome COLLATE NOCASE",
"total_paid": "f.total_paid",
}
def list_fiscal(
conn: sqlite3.Connection,
*,
start: tuple[int, int] | None = None,
end: tuple[int, int] | None = None,
supplier: str | None = None,
category: str | None = None,
sort: str | None = None,
order: str = "desc",
limit: int | None = None,
offset: int = 0,
) -> list[sqlite3.Row]:
where, params = _fiscal_where(start=start, end=end, supplier=supplier, category=category)
limit_sql = ""
if limit is not None:
limit_sql = "LIMIT ? OFFSET ?"
params = [*params, limit, offset]
sort_col = FISCAL_SORT_COLUMNS.get(sort or "competencia", FISCAL_SORT_COLUMNS["competencia"])
direction = "ASC" if (order or "").lower() == "asc" else "DESC"
order_sql = ", ".join(f"{col} {direction}" for col in sort_col.split(", ")) + f", f.id {direction}"
if sort_col != FISCAL_SORT_COLUMNS["competencia"]:
order_sql += ", f.ano DESC, f.mes DESC"
return list(
conn.execute(
f"""
SELECT f.*, c.categoria AS categoria_nome
FROM fiscal_documents f
LEFT JOIN categoria c ON c.id = f.categoria_id
{where}
ORDER BY {order_sql}
{limit_sql}
""",
params,
)
)
def fiscal_summary(
conn: sqlite3.Connection,
*,
start: tuple[int, int] | None = None,
end: tuple[int, int] | None = None,
supplier: str | None = None,
category: str | None = None,
) -> tuple[int, float]:
"""Retorna (quantidade, soma de total_paid) para o filtro informado, sem paginação."""
where, params = _fiscal_where(start=start, end=end, supplier=supplier, category=category)
row = conn.execute(
f"SELECT COUNT(*) AS n, COALESCE(SUM(total_paid), 0) AS total FROM fiscal_documents {where}",
params,
).fetchone()
return row["n"], row["total"]
def monthly_totals(conn: sqlite3.Connection, category: str | None = None) -> list[sqlite3.Row]:
cat_clause, cat_param = _category_clause(category)
where = f"WHERE {cat_clause}" if cat_clause else ""
params = [cat_param] if cat_param is not None else []
return list(
conn.execute(
f"""
SELECT ano, mes, SUM(total_paid) AS total, COUNT(*) AS count
FROM fiscal_documents
{where}
GROUP BY ano, mes
ORDER BY ano ASC, mes ASC
""",
params,
)
)
def supplier_totals(conn: sqlite3.Connection, limit: int = 10, category: str | None = None) -> list[sqlite3.Row]:
cat_clause, cat_param = _category_clause(category)
where = f"WHERE {cat_clause}" if cat_clause else ""
params: list[Any] = [cat_param] if cat_param is not None else []
params.append(limit)
return list(
conn.execute(
f"""
SELECT supplier_name, SUM(total_paid) AS total, COUNT(*) AS count
FROM fiscal_documents
{where}
GROUP BY supplier_name
ORDER BY total DESC
LIMIT ?
""",
params,
)
)
def category_totals(
conn: sqlite3.Connection,
start: tuple[int, int] | None = None,
end: tuple[int, int] | None = None,
) -> list[sqlite3.Row]:
"""Totais agrupados por categoria (LEFT JOIN, inclui 'Não Encontrado'), mais
um grupo 'Sem categoria' à parte para categoria_id IS NULL (legado).
`start`/`end` são tuplas `(mes, ano)` delimitando o intervalo de competência.
"""
clauses: list[str] = []
params: list[Any] = []
if start:
start_mes, start_ano = start
clauses.append("(f.ano * 12 + f.mes) >= ?")
params.append(start_ano * 12 + start_mes)
if end:
end_mes, end_ano = end
clauses.append("(f.ano * 12 + f.mes) <= ?")
params.append(end_ano * 12 + end_mes)
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
return list(
conn.execute(
f"""
SELECT COALESCE(c.categoria, 'Sem categoria') AS categoria,
f.categoria_id AS categoria_id,
SUM(f.total_paid) AS total, COUNT(*) AS count
FROM fiscal_documents f
LEFT JOIN categoria c ON c.id = f.categoria_id
{where}
GROUP BY COALESCE(c.categoria, 'Sem categoria'), f.categoria_id
ORDER BY total DESC
""",
params,
)
)
def overall_totals(conn: sqlite3.Connection, category: str | None = None) -> dict[str, Any]:
cat_clause, cat_param = _category_clause(category)
where = f"WHERE {cat_clause}" if cat_clause else ""
params = [cat_param] if cat_param is not None else []
row = conn.execute(
f"SELECT COUNT(*) AS n, COALESCE(SUM(total_paid), 0) AS total FROM fiscal_documents {where}",
params,
).fetchone()
n = int(row["n"])
total = float(row["total"])
return {"count": n, "total": total, "avg": (total / n) if n else 0.0}
# --------------------------------------------------------------------------- #
# CRUD de categoria
# --------------------------------------------------------------------------- #
RESERVED_CATEGORIA_ID = 1
def create_categoria(conn: sqlite3.Connection, categoria: str, palavra_chave: str) -> int:
cur = conn.execute(
"INSERT INTO categoria (categoria, palavra_chave) VALUES (?, ?)",
(categoria, palavra_chave),
)
conn.commit()
return int(cur.lastrowid)
def get_categoria(conn: sqlite3.Connection, categoria_id: int) -> sqlite3.Row | None:
return conn.execute(
"SELECT * FROM categoria WHERE id = ?", (categoria_id,)
).fetchone()
def list_categorias(conn: sqlite3.Connection) -> list[sqlite3.Row]:
return list(conn.execute("SELECT * FROM categoria ORDER BY id ASC"))
def list_categorias_por_nome(conn: sqlite3.Connection) -> list[sqlite3.Row]:
return list(conn.execute("SELECT * FROM categoria ORDER BY categoria ASC"))
def update_categoria(conn: sqlite3.Connection, categoria_id: int, categoria: str, palavra_chave: str) -> None:
conn.execute(
"UPDATE categoria SET categoria = ?, palavra_chave = ? WHERE id = ?",
(categoria, palavra_chave, categoria_id),
)
conn.commit()
def delete_categoria(conn: sqlite3.Connection, categoria_id: int) -> bool:
"""Exclui uma categoria, reatribuindo documentos referenciados para a
categoria reservada (id=1). Rejeita a exclusão da própria linha reservada."""
if categoria_id == RESERVED_CATEGORIA_ID:
return False
conn.execute(
"UPDATE fiscal_documents SET categoria_id = ? WHERE categoria_id = ?",
(RESERVED_CATEGORIA_ID, categoria_id),
)
conn.execute("DELETE FROM categoria WHERE id = ?", (categoria_id,))
conn.commit()
return True
def find_categoria_by_keyword(conn: sqlite3.Connection, supplier_name: str) -> sqlite3.Row | None:
"""Primeira categoria (id ASC, excluindo a reservada id=1) cuja palavra_chave
é substring case-insensitive de supplier_name."""
supplier = (supplier_name or "").upper()
rows = conn.execute(
"SELECT * FROM categoria WHERE id != ? ORDER BY id ASC",
(RESERVED_CATEGORIA_ID,),
)
for row in rows:
keyword = (row["palavra_chave"] or "").strip()
if keyword and keyword.upper() in supplier:
return row
return None
+74
View File
@@ -0,0 +1,74 @@
"""Resolução de competência (mês/ano) com fallback único e compartilhado.
Requisito: quando a competência do documento está ilegível/ausente, atribuir o
**mês/ano corrente** (o mês da importação/lançamento) — mesma semântica que o
antigo "primeiro dia do mês corrente", um passo mais simples (não precisa mais
inventar um dia fictício). Tanto a extração por IA/OCR 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})$")
MESES = ["Jan", "Fev", "Mar", "Abr", "Mai", "Jun", "Jul", "Ago", "Set", "Out", "Nov", "Dez"]
# Intervalo de anos oferecido nos seletores de competência da UI.
ANOS_COMPETENCIA = list(range(2026, 2031))
def current_competencia(reference: date | None = None) -> tuple[int, int]:
ref = reference or date.today()
return ref.month, ref.year
def _valid_date(year: int, month: int, day: int) -> tuple[int, int] | None:
try:
parsed = date(year, month, day)
except ValueError:
return None
return parsed.month, parsed.year
def parse_competencia(value: str | None) -> tuple[int, int] | None:
"""Tenta interpretar uma data em ISO (YYYY-MM-DD) ou BR (dd/mm/aaaa) e
devolve apenas o (mês, ano) correspondente, descartando o dia.
Retorna None (sem fallback) se o valor não for uma data válida — usado
quando a ausência de competência deve ficar visível (ex. staging).
"""
if not value:
return None
text = value.strip()
m = _ISO_RE.match(text)
if m:
return _valid_date(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_date(year_i, int(month), int(day))
return None
def resolve_competencia(value: str | None, *, reference: date | None = None) -> tuple[int, int]:
"""(mês, ano) válidos a partir de `value`, ou o mês/ano corrente quando
ilegível/ausente."""
parsed = parse_competencia(value)
if parsed:
return parsed
return current_competencia(reference)
def format_competencia(mes: int | None, ano: int | None) -> str:
"""Formata mês/ano para exibição, ex. `(6, 2026)` -> "Jun/26"."""
if not mes or not ano or not (1 <= mes <= 12):
return ""
return f"{MESES[mes - 1]}/{ano % 100:02d}"
+65
View File
@@ -0,0 +1,65 @@
"""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,
apply_ocr_fallback,
detect_documents,
normalize_file,
)
from .ai_extraction import RawExtraction, extract_with_ai
from .config import get_settings
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."""
settings = get_settings()
pages = normalize_file(stored_path, original_name, max_render_pages=settings.openai_max_pages)
# 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: só agora rodamos o OCR (Tesseract) sobre as páginas sem texto
# utilizável — evita o custo do OCR quando a IA já resolveu a extração.
pages = apply_ocr_fallback(pages)
candidates = detect_documents(pages, original_name)
return [_candidate_to_raw(c) for c in candidates]
+91
View File
@@ -0,0 +1,91 @@
"""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,
categoria_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(categoria_routes.router)
app.include_router(files_routes.router)
@app.get("/healthz")
def healthz():
return {"status": "ok"}
return app
app = create_app()
View File
+45
View File
@@ -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)
+99
View File
@@ -0,0 +1,99 @@
"""CRUD administrativo da tabela `categoria` (categorias e palavras-chave)."""
from __future__ import annotations
from fastapi import APIRouter, Form, Request
from starlette.responses import RedirectResponse
from .. import auth
from .. import database as db
from ..templating import flash, render
router = APIRouter()
@router.get("/categorias")
def list_categorias(request: Request):
with db.session() as conn:
rows = [dict(r) for r in db.list_categorias_por_nome(conn)]
return render(request, "categorias_list.html", rows=rows, reserved_id=db.RESERVED_CATEGORIA_ID)
@router.get("/categorias/new")
def new_form(request: Request):
return render(request, "categoria_form.html", categoria=None, mode="new")
@router.post("/categorias/new")
def create_categoria(
request: Request,
csrf_token: str = Form(""),
categoria: str = Form(""),
palavra_chave: str = Form(""),
):
if not auth.check_csrf(request, csrf_token):
flash(request, "Sessão expirada.", "error")
return RedirectResponse("/categorias/new", status_code=303)
nome = categoria.strip()
keyword = palavra_chave.strip()
if not nome or not keyword:
flash(request, "Informe categoria e palavra-chave válidas.", "error")
return RedirectResponse("/categorias/new", status_code=303)
with db.session() as conn:
db.create_categoria(conn, nome, keyword)
flash(request, "Categoria criada.", "success")
return RedirectResponse("/categorias", status_code=303)
@router.get("/categorias/{categoria_id}/edit")
def edit_form(request: Request, categoria_id: int):
with db.session() as conn:
row = db.get_categoria(conn, categoria_id)
if row is None:
flash(request, "Categoria não encontrada.", "error")
return RedirectResponse("/categorias", status_code=303)
return render(request, "categoria_form.html", categoria=dict(row), mode="edit")
@router.post("/categorias/{categoria_id}/edit")
def update_categoria(
request: Request,
categoria_id: int,
csrf_token: str = Form(""),
categoria: str = Form(""),
palavra_chave: str = Form(""),
):
if not auth.check_csrf(request, csrf_token):
flash(request, "Sessão expirada.", "error")
return RedirectResponse(f"/categorias/{categoria_id}/edit", status_code=303)
nome = categoria.strip()
keyword = palavra_chave.strip()
if not nome or not keyword:
flash(request, "Informe categoria e palavra-chave válidas.", "error")
return RedirectResponse(f"/categorias/{categoria_id}/edit", status_code=303)
with db.session() as conn:
if db.get_categoria(conn, categoria_id) is None:
flash(request, "Categoria não encontrada.", "error")
return RedirectResponse("/categorias", status_code=303)
db.update_categoria(conn, categoria_id, nome, keyword)
flash(request, "Categoria atualizada.", "success")
return RedirectResponse("/categorias", status_code=303)
@router.post("/categorias/{categoria_id}/delete")
def delete_categoria(request: Request, categoria_id: int, csrf_token: str = Form("")):
if not auth.check_csrf(request, csrf_token):
flash(request, "Sessão expirada.", "error")
return RedirectResponse("/categorias", status_code=303)
with db.session() as conn:
deleted = db.delete_categoria(conn, categoria_id)
if deleted:
flash(request, "Categoria excluída.", "info")
else:
flash(request, "A categoria \"Não Encontrado\" é reservada e não pode ser excluída.", "error")
return RedirectResponse("/categorias", status_code=303)
+48
View File
@@ -0,0 +1,48 @@
"""Dashboard: KPIs, gastos por mês e por fornecedor, últimas notas."""
from __future__ import annotations
from fastapi import APIRouter, Query, Request
from .. import database as db
from ..dates import format_competencia
from ..storage import money
from ..templating import render
router = APIRouter()
@router.get("/")
def dashboard(request: Request, category: str = Query("")):
category = category or ""
with db.session() as conn:
categorias = [dict(r) for r in db.list_categorias(conn)]
totals = db.overall_totals(conn, category=category or None)
months = [dict(r) for r in db.monthly_totals(conn, category=category or None)]
suppliers = [dict(r) for r in db.supplier_totals(conn, limit=8, category=category or None)]
categories = [dict(r) for r in db.category_totals(conn)]
recent = [dict(r) for r in db.list_fiscal(conn, category=category or None)][:10]
max_month = max((m["total"] for m in months), default=0) or 1
for m in months:
m["label"] = format_competencia(m["mes"], m["ano"])
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)
max_cat = max((c["total"] for c in categories), default=0) or 1
for c in categories:
c["pct"] = round(c["total"] / max_cat * 100, 1)
return render(
request,
"dashboard.html",
totals=totals,
months=months,
suppliers=suppliers,
categories=categories,
categorias=categorias,
recent=recent,
selected_category=category,
money=money,
)
+263
View File
@@ -0,0 +1,263 @@
"""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 current_competencia
from ..storage import money
from ..templating import flash, render
router = APIRouter()
PAGE_SIZE = 10
def _page_window(page: int, total_pages: int, span: int = 2) -> list[int | None]:
"""Monta a lista de números de página com None para reticências."""
pages = {1, total_pages, *range(page - span, page + span + 1)}
pages = sorted(p for p in pages if 1 <= p <= total_pages)
windowed: list[int | None] = []
prev = None
for p in pages:
if prev is not None and p - prev > 1:
windowed.append(None)
windowed.append(p)
prev = p
return windowed
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
def _parse_competencia_filter(mes: str, ano: str) -> tuple[int, int] | None:
"""(mês, ano) a partir dos `<select>` de filtro "De"/"Até", ou None se o
par não foi informado (filtro de competência é opcional na listagem)."""
mes = (mes or "").strip()
ano = (ano or "").strip()
if not mes or not ano:
return None
try:
mes_val, ano_val = int(mes), int(ano)
except ValueError:
return None
if not (1 <= mes_val <= 12):
return None
return mes_val, ano_val
@router.get("/documents")
def list_documents(
request: Request,
start_mes: str = Query(""),
start_ano: str = Query(""),
end_mes: str = Query(""),
end_ano: str = Query(""),
supplier: str = Query(""),
category: str = Query(""),
sort: str = Query("competencia"),
order: str = Query("desc"),
page: int = Query(1, ge=1),
):
if sort not in db.FISCAL_SORT_COLUMNS:
sort = "competencia"
order = "asc" if order.lower() == "asc" else "desc"
start = _parse_competencia_filter(start_mes, start_ano)
end = _parse_competencia_filter(end_mes, end_ano)
with db.session() as conn:
filter_kwargs = dict(
start=start,
end=end,
supplier=supplier or None,
category=category or None,
)
total_count, total = db.fiscal_summary(conn, **filter_kwargs)
total_pages = max(1, -(-total_count // PAGE_SIZE))
page = min(page, total_pages)
rows = [
dict(r)
for r in db.list_fiscal(
conn,
**filter_kwargs,
sort=sort,
order=order,
limit=PAGE_SIZE,
offset=(page - 1) * PAGE_SIZE,
)
]
categorias = [dict(r) for r in db.list_categorias_por_nome(conn)]
return render(
request,
"documents_list.html",
rows=rows,
total=total,
total_count=total_count,
page=page,
total_pages=total_pages,
page_numbers=_page_window(page, total_pages),
filters={
"start_mes": start_mes,
"start_ano": start_ano,
"end_mes": end_mes,
"end_ano": end_ano,
"supplier": supplier,
"category": category,
},
sort=sort,
order=order,
categorias=categorias,
money=money,
)
def _parse_mes_ano(mes: str, ano: str) -> tuple[int, int]:
"""(mês, ano) a partir dos `<select>` do formulário, com o mesmo fallback
"mês/ano corrente" que o antigo `<input type="date">` tinha para valor em
branco."""
fallback_mes, fallback_ano = current_competencia()
try:
mes_val = int(mes)
except (TypeError, ValueError):
mes_val = None
if mes_val is None or not (1 <= mes_val <= 12):
mes_val = fallback_mes
try:
ano_val = int(ano)
except (TypeError, ValueError):
ano_val = fallback_ano
return mes_val, ano_val
def _parse_categoria_id(value: str) -> int | None:
value = (value or "").strip()
if not value:
return None
try:
return int(value)
except ValueError:
return None
@router.get("/documents/new")
def new_form(request: Request):
with db.session() as conn:
categorias = [dict(r) for r in db.list_categorias_por_nome(conn)]
default_mes, default_ano = current_competencia()
return render(
request,
"document_form.html",
doc=None,
mode="new",
categorias=categorias,
default_mes=default_mes,
default_ano=default_ano,
)
@router.post("/documents/new")
def create_document(
request: Request,
csrf_token: str = Form(""),
mes: str = Form(""),
ano: str = Form(""),
supplier_name: str = Form(""),
total_paid: str = Form(""),
categoria_id: 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)
mes_val, ano_val = _parse_mes_ano(mes, ano)
with db.session() as conn:
db.create_fiscal(
conn,
mes=mes_val,
ano=ano_val,
supplier_name=supplier,
total_paid=total,
categoria_id=_parse_categoria_id(categoria_id),
)
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)
categorias = [dict(r) for r in db.list_categorias_por_nome(conn)]
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", categorias=categorias)
@router.post("/documents/{doc_id}/edit")
def update_document(
request: Request,
doc_id: int,
csrf_token: str = Form(""),
mes: str = Form(""),
ano: str = Form(""),
supplier_name: str = Form(""),
total_paid: str = Form(""),
categoria_id: 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)
mes_val, ano_val = _parse_mes_ano(mes, ano)
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,
mes=mes_val,
ano=ano_val,
supplier_name=supplier,
total_paid=total,
categoria_id=_parse_categoria_id(categoria_id),
)
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)
+39
View File
@@ -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",
)
+203
View File
@@ -0,0 +1,203 @@
"""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 parse_competencia
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")
def upload(request: Request, csrf_token: str = Form(""), files: list[UploadFile] = File(...)):
# Rota síncrona (não `async def`) de propósito: o processamento de cada
# arquivo (render de PDF, OCR de fallback, chamada à IA) é bloqueante e
# pode levar vários segundos. Starlette roda rotas síncronas numa
# threadpool, então isso libera o event loop único do processo para
# continuar atendendo outras requisições (dashboard, login etc.)
# enquanto este upload é processado.
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 = upload_file.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), commit=False
)
try:
extractions = extract_file(stored_path, original)
for raw in extractions:
# Sem fallback aqui: se a extração não identificou uma
# competência plausível, mes/ano ficam nulos até o usuário
# confirmar na tela de revisão (competência é obrigatória
# antes de importar, não pode ser preenchida silenciosamente).
parsed = parse_competencia(raw.purchase_date_raw)
mes, ano = parsed if parsed else (None, None)
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,
mes=mes,
ano=ano,
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,
commit=False,
)
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, commit=False)
except Exception as exc: # nunca deixa um arquivo derrubar o lote
db.update_upload_status(conn, upload_id, "failed", 0, str(exc)[:300], commit=False)
conn.commit()
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,
)
def _parse_form_int(value: str, *, min_value: int | None = None, max_value: int | None = None) -> int | None:
value = (value or "").strip()
if not value.isdigit():
return None
parsed = int(value)
if min_value is not None and parsed < min_value:
return None
if max_value is not None and parsed > max_value:
return None
return parsed
@router.post("/import/{batch_id}/update/{detected_id}")
def update_row(
request: Request,
batch_id: int,
detected_id: int,
csrf_token: str = Form(""),
mes: str = Form(""),
ano: 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)
# Sem fallback automático: mes/ano só ficam preenchidos se o usuário
# realmente selecionou um valor nos <select> — competência ausente
# continua bloqueando a confirmação do lote (ver `batch_summary`).
mes_val = _parse_form_int(mes, min_value=1, max_value=12)
ano_val = _parse_form_int(ano, min_value=1900)
with db.session() as conn:
db.update_staged(
conn,
detected_id,
mes=mes_val,
ano=ano_val,
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)
+59
View File
@@ -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…";
}
});
}
})();
+223
View File
@@ -0,0 +1,223 @@
: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;
--stripe: #e7ece8;
--stripe-hover: #d8e0da;
--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;
--stripe: #24322b; --stripe-hover: #324338;
--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;
--stripe: #e7ece8; --stripe-hover: #d8e0da;
--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;
--stripe: #24322b; --stripe-hover: #324338;
--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; }
.grid-3 { display: grid; grid-template-columns: 1fr 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], select {
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; }
.field-group { display: flex; gap: 8px; }
.field-group select { min-width: 90px; }
/* 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:nth-child(even) td { background: var(--stripe); }
.table tbody tr:hover td { background: var(--stripe-hover); }
.table tfoot td { padding: 11px 12px; font-weight: 650; border-top: 2px solid var(--border); }
/* Pagination */
.pagination { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; padding: 14px 12px 4px; }
.pagination-page { display: inline-flex; align-items: center; justify-content: center; min-width: 32px; height: 32px; padding: 0 8px; border-radius: var(--radius-sm, 6px); font-size: .85rem; color: var(--text); text-decoration: none; }
.pagination-page:hover { background: var(--surface-2); }
.pagination-page.current { background: var(--accent); color: var(--surface); font-weight: 650; }
.pagination-ellipsis { color: var(--faint); padding: 0 4px; }
.pagination .disabled { opacity: .45; pointer-events: none; }
/* 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, .grid-3, .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; }
}
+33
View File
@@ -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", ".")
+62
View File
@@ -0,0 +1,62 @@
<!doctype html>
<html lang="pt-BR" data-theme="">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{{ app_name }}{% endblock %} · {{ app_name }}</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><text y='14' font-size='14'>🧾</text></svg>">
<link rel="stylesheet" href="/static/styles.css">
<script>
// Aplica o tema salvo antes da pintura para evitar flash.
(function () {
try {
var t = localStorage.getItem('theme');
if (t) document.documentElement.setAttribute('data-theme', t);
} catch (e) {}
})();
</script>
</head>
<body>
{% if not hide_nav %}
<header class="topbar">
<a class="brand" href="/">
<span class="brand-mark">🧾</span>
<span>{{ app_name }}</span>
</a>
<nav class="nav">
<a href="/" class="{{ 'active' if request.url.path == '/' else '' }}">Dashboard</a>
<a href="/documents" class="{{ 'active' if request.url.path.startswith('/documents') and 'new' not in request.url.path else '' }}">Documentos</a>
<a href="/categorias" class="{{ 'active' if request.url.path.startswith('/categorias') else '' }}">Categorias</a>
<a href="/documents/new" class="btn btn-primary btn-sm">+ Novo</a>
</nav>
<div class="topbar-right">
<button type="button" class="theme-toggle" id="themeToggle" title="Alternar tema" aria-label="Alternar tema">
<span class="theme-icon"></span>
</button>
{% if current_user %}
<span class="user">{{ current_user }}</span>
<form method="post" action="/logout" class="inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn-ghost btn-sm">Sair</button>
</form>
{% endif %}
</div>
</header>
{% endif %}
<main class="{{ 'main-centered' if hide_nav else 'main' }}">
{% if flashes %}
<div class="flash-stack">
{% for f in flashes %}
<div class="flash flash-{{ f.level }}">{{ f.message }}</div>
{% endfor %}
</div>
{% endif %}
{% block content %}{% endblock %}
</main>
<script src="/static/app.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>
+32
View File
@@ -0,0 +1,32 @@
{% extends "base.html" %}
{% block title %}{{ 'Editar categoria' if mode == 'edit' else 'Nova categoria' }}{% endblock %}
{% block content %}
<div class="page-head">
<div>
<h2>{{ 'Editar categoria' if mode == 'edit' else 'Nova categoria' }}</h2>
<p class="muted">{{ 'Ajuste os campos e salve.' if mode == 'edit' else 'Cadastre uma categoria e a palavra-chave usada para reconhecê-la.' }}</p>
</div>
<a href="/categorias" class="btn btn-ghost btn-sm">Voltar</a>
</div>
<section class="card form-card">
<form method="post" action="{{ '/categorias/' ~ categoria.id ~ '/edit' if mode == 'edit' else '/categorias/new' }}" class="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label class="field">
<span>Categoria</span>
<input type="text" name="categoria" value="{{ categoria.categoria if categoria else '' }}" required placeholder="Ex.: Alimentação">
</label>
<label class="field">
<span>Palavra-chave</span>
<input type="text" name="palavra_chave" value="{{ categoria.palavra_chave if categoria else '' }}" required placeholder="Ex.: MERCADO">
<small class="muted">Usada para reconhecer o fornecedor por correspondência (contém, sem diferenciar maiúsculas/minúsculas).</small>
</label>
<div class="form-actions">
<button type="submit" class="btn btn-primary">{{ 'Salvar alterações' if mode == 'edit' else 'Criar categoria' }}</button>
<a href="/categorias" class="btn btn-ghost">Cancelar</a>
</div>
</form>
</section>
{% endblock %}
+45
View File
@@ -0,0 +1,45 @@
{% extends "base.html" %}
{% block title %}Categorias{% endblock %}
{% block content %}
<div class="page-head">
<div>
<h2>Categorias</h2>
<p class="muted">{{ rows | length }} categoria(s)</p>
</div>
<a href="/categorias/new" class="btn btn-primary btn-sm">+ Nova</a>
</div>
<section class="card">
{% if rows %}
<div class="table-scroll">
<table class="table">
<thead>
<tr><th>Categoria</th><th>Palavra-chave</th><th class="r">Ações</th></tr>
</thead>
<tbody>
{% for c in rows %}
<tr>
<td>{{ c.categoria }}</td>
<td class="muted small">{{ c.palavra_chave }}</td>
<td class="r nowrap">
<a href="/categorias/{{ c.id }}/edit" class="link">Editar</a>
{% if c.id != reserved_id %}
<form method="post" action="/categorias/{{ c.id }}/delete" class="inline"
onsubmit="return confirm('Excluir esta categoria?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="linkbtn danger">Excluir</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="muted">Nenhuma categoria cadastrada. <a href="/categorias/new" class="link">Criar a primeira</a>.</p>
{% endif %}
</section>
{% endblock %}
+132
View File
@@ -0,0 +1,132 @@
{% extends "base.html" %}
{% block title %}Dashboard{% endblock %}
{% block content %}
<section class="upload-panel">
<div>
<h2>Importar documentos</h2>
<p class="muted">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.</p>
</div>
<form method="post" action="/upload" enctype="multipart/form-data" class="upload-form" id="uploadForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label class="filedrop" id="filedrop">
<input type="file" name="files" id="fileInput" multiple accept=".pdf,.jpg,.jpeg,.png" hidden>
<span class="filedrop-icon"></span>
<span class="filedrop-text" id="filedropText">Clique ou arraste arquivos aqui</span>
</label>
<button type="submit" class="btn btn-primary" id="uploadBtn">Enviar e revisar</button>
</form>
</section>
<section class="card">
<form method="get" action="/" class="filters">
<label class="field grow">
<span>Categoria</span>
<select name="category" onchange="this.form.submit()">
<option value="">Todas</option>
{% for c in categorias %}
<option value="{{ c.id }}" {{ 'selected' if selected_category == c.id | string else '' }}>{{ c.categoria }}</option>
{% endfor %}
<option value="none" {{ 'selected' if selected_category == 'none' else '' }}>Sem categoria</option>
</select>
</label>
<div class="filters-actions">
<button type="submit" class="btn btn-primary btn-sm">Filtrar</button>
<a href="/" class="btn btn-ghost btn-sm">Limpar</a>
</div>
</form>
</section>
<section class="kpis">
<div class="kpi">
<span class="kpi-label">Total gasto</span>
<strong class="kpi-value money">{{ totals.total | money }}</strong>
<span class="kpi-foot">{{ totals.count }} documento(s)</span>
</div>
<div class="kpi">
<span class="kpi-label">Ticket médio</span>
<strong class="kpi-value">{{ totals.avg | money }}</strong>
<span class="kpi-foot">por documento</span>
</div>
<div class="kpi">
<span class="kpi-label">Meses com registro</span>
<strong class="kpi-value">{{ months | length }}</strong>
<span class="kpi-foot">{{ suppliers | length }} fornecedor(es) no top</span>
</div>
</section>
<section class="grid-3">
<div class="card">
<h3>Gastos por mês</h3>
{% if months %}
<div class="barlist">
{% for m in months %}
<div class="barrow">
<span class="barlabel">{{ m.label }}</span>
<span class="bartrack"><span class="barfill" style="width: {{ m.pct }}%"></span></span>
<span class="barval">{{ m.total | money }}</span>
</div>
{% endfor %}
</div>
{% else %}<p class="muted">Sem dados ainda.</p>{% endif %}
</div>
<div class="card">
<h3>Gastos por fornecedor</h3>
{% if suppliers %}
<div class="barlist">
{% for s in suppliers %}
<div class="barrow">
<span class="barlabel" title="{{ s.supplier_name }}">{{ s.supplier_name }}</span>
<span class="bartrack"><span class="barfill alt" style="width: {{ s.pct }}%"></span></span>
<span class="barval">{{ s.total | money }}</span>
</div>
{% endfor %}
</div>
{% else %}<p class="muted">Sem dados ainda.</p>{% endif %}
</div>
<div class="card">
<h3>Gastos por categoria</h3>
{% if categories %}
<div class="barlist">
{% for c in categories %}
<div class="barrow">
<span class="barlabel" title="{{ c.categoria }}">{{ c.categoria }}</span>
<span class="bartrack"><span class="barfill" style="width: {{ c.pct }}%"></span></span>
<span class="barval">{{ c.total | money }}</span>
</div>
{% endfor %}
</div>
{% else %}<p class="muted">Sem dados ainda.</p>{% endif %}
</div>
</section>
<section class="card">
<div class="card-head">
<h3>Últimos documentos</h3>
<a href="/documents" class="btn btn-ghost btn-sm">Ver todos</a>
</div>
{% if recent %}
<div class="table-scroll">
<table class="table">
<thead><tr><th>Competência</th><th>Fornecedor</th><th>Categoria</th><th class="r">Valor</th><th></th></tr></thead>
<tbody>
{% for d in recent %}
<tr>
<td class="nowrap">{{ d.mes | competencia(d.ano) }}</td>
<td>{{ d.supplier_name }}</td>
<td>{{ d.categoria_nome or '' }}</td>
<td class="r money nowrap">{{ d.total_paid | money }}</td>
<td class="r"><a href="/documents/{{ d.id }}/edit" class="link">editar</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}<p class="muted">Nenhum documento confirmado. Comece importando acima.</p>{% endif %}
</section>
{% endblock %}
+56
View File
@@ -0,0 +1,56 @@
{% extends "base.html" %}
{% block title %}{{ 'Editar documento' if mode == 'edit' else 'Novo lançamento' }}{% endblock %}
{% block content %}
<div class="page-head">
<div>
<h2>{{ 'Editar documento' if mode == 'edit' else 'Novo lançamento' }}</h2>
<p class="muted">{{ 'Ajuste os campos e salve.' if mode == 'edit' else 'Lançamento manual de uma despesa fiscal.' }}</p>
</div>
<a href="/documents" class="btn btn-ghost btn-sm">Voltar</a>
</div>
<section class="card form-card">
<form method="post" action="{{ '/documents/' ~ doc.id ~ '/edit' if mode == 'edit' else '/documents/new' }}" class="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label class="field">
<span>Competência</span>
<span class="field-group">
<select name="mes" required>
{% for m in meses_competencia %}
<option value="{{ loop.index }}" {{ 'selected' if (doc.mes if doc else default_mes) == loop.index else '' }}>{{ m }}</option>
{% endfor %}
</select>
<select name="ano" required>
{% for a in anos_competencia %}
<option value="{{ a }}" {{ 'selected' if (doc.ano if doc else default_ano) == a else '' }}>{{ a }}</option>
{% endfor %}
</select>
</span>
<small class="muted">Se não selecionado, será usado o mês/ano corrente.</small>
</label>
<label class="field">
<span>Fornecedor</span>
<input type="text" name="supplier_name" value="{{ doc.supplier_name if doc else '' }}" required placeholder="Nome do estabelecimento">
</label>
<label class="field">
<span>Valor pago (R$)</span>
<input type="text" inputmode="decimal" name="total_paid" value="{{ '%.2f'|format(doc.total_paid) if doc else '' }}" required placeholder="0,00">
</label>
<label class="field">
<span>Categoria</span>
<select name="categoria_id">
<option value="">Sem categoria</option>
{% for c in categorias %}
<option value="{{ c.id }}" {{ 'selected' if doc and doc.categoria_id == c.id else '' }}>{{ c.categoria }}</option>
{% endfor %}
</select>
</label>
<div class="form-actions">
<button type="submit" class="btn btn-primary">{{ 'Salvar alterações' if mode == 'edit' else 'Lançar documento' }}</button>
<a href="/documents" class="btn btn-ghost">Cancelar</a>
</div>
</form>
</section>
{% endblock %}
+144
View File
@@ -0,0 +1,144 @@
{% extends "base.html" %}
{% block title %}Documentos{% endblock %}
{% block content %}
<div class="page-head">
<div>
<h2>Documentos</h2>
<p class="muted">{{ total_count }} registro(s) · total {{ total | money }}</p>
</div>
<a href="/documents/new" class="btn btn-primary btn-sm">+ Novo lançamento</a>
</div>
<section class="card">
<form method="get" action="/documents" class="filters">
<label class="field">
<span>De (competência)</span>
<span class="field-group">
<select name="start_mes">
<option value="" {{ 'selected' if not filters.start_mes else '' }}>Mês</option>
{% for m in meses_competencia %}
<option value="{{ loop.index }}" {{ 'selected' if filters.start_mes == loop.index | string else '' }}>{{ m }}</option>
{% endfor %}
</select>
<select name="start_ano">
<option value="" {{ 'selected' if not filters.start_ano else '' }}>Ano</option>
{% for a in anos_competencia %}
<option value="{{ a }}" {{ 'selected' if filters.start_ano == a | string else '' }}>{{ a }}</option>
{% endfor %}
</select>
</span>
</label>
<label class="field">
<span>Até (competência)</span>
<span class="field-group">
<select name="end_mes">
<option value="" {{ 'selected' if not filters.end_mes else '' }}>Mês</option>
{% for m in meses_competencia %}
<option value="{{ loop.index }}" {{ 'selected' if filters.end_mes == loop.index | string else '' }}>{{ m }}</option>
{% endfor %}
</select>
<select name="end_ano">
<option value="" {{ 'selected' if not filters.end_ano else '' }}>Ano</option>
{% for a in anos_competencia %}
<option value="{{ a }}" {{ 'selected' if filters.end_ano == a | string else '' }}>{{ a }}</option>
{% endfor %}
</select>
</span>
</label>
<label class="field grow"><span>Fornecedor</span><input type="text" name="supplier" value="{{ filters.supplier }}" placeholder="contém…"></label>
<label class="field grow">
<span>Categoria</span>
<select name="category" onchange="this.form.submit()">
<option value="">Todas</option>
{% for c in categorias %}
<option value="{{ c.id }}" {{ 'selected' if filters.category == c.id | string else '' }}>{{ c.categoria }}</option>
{% endfor %}
<option value="none" {{ 'selected' if filters.category == 'none' else '' }}>Sem categoria</option>
</select>
</label>
<div class="filters-actions">
<button type="submit" class="btn btn-primary btn-sm">Filtrar</button>
<a href="/documents" class="btn btn-ghost btn-sm">Limpar</a>
</div>
</form>
</section>
<section class="card">
{% if rows %}
{% macro sort_url(key) -%}
/documents?start_mes={{ filters.start_mes | urlencode }}&start_ano={{ filters.start_ano | urlencode }}&end_mes={{ filters.end_mes | urlencode }}&end_ano={{ filters.end_ano | urlencode }}&supplier={{ filters.supplier | urlencode }}&category={{ filters.category | urlencode }}&sort={{ key }}&order={{ 'asc' if (sort == key and order == 'desc') else 'desc' }}
{%- endmacro %}
{% macro sort_arrow(key) -%}
{% if sort == key %}{{ ' ▲' if order == 'asc' else ' ▼' }}{% endif %}
{%- endmacro %}
<div class="table-scroll">
<table class="table">
<thead>
<tr>
<th><a href="{{ sort_url('competencia') }}" class="link">Competência{{ sort_arrow('competencia') }}</a></th>
<th><a href="{{ sort_url('supplier_name') }}" class="link">Fornecedor{{ sort_arrow('supplier_name') }}</a></th>
<th><a href="{{ sort_url('categoria') }}" class="link">Categoria{{ sort_arrow('categoria') }}</a></th>
<th class="r"><a href="{{ sort_url('total_paid') }}" class="link">Valor{{ sort_arrow('total_paid') }}</a></th>
<th>Origem</th>
<th class="r">Ações</th>
</tr>
</thead>
<tbody>
{% for d in rows %}
<tr>
<td class="nowrap">{{ d.mes | competencia(d.ano) }}</td>
<td>{{ d.supplier_name }}</td>
<td>{{ d.categoria_nome or '' }}</td>
<td class="r money nowrap">{{ d.total_paid | money }}</td>
<td class="muted small">{{ d.source_location }}</td>
<td class="r nowrap">
<a href="/documents/{{ d.id }}/edit" class="link">Editar</a>
<form method="post" action="/documents/{{ d.id }}/delete" class="inline"
onsubmit="return confirm('Excluir este documento?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="linkbtn danger">Excluir</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr><td colspan="3">Total registros: {{ total_count }}</td><td class="r money">{{ total | money }}</td><td colspan="2"></td></tr>
</tfoot>
</table>
</div>
{% if total_pages > 1 %}
{% macro page_url(p) -%}
/documents?start_mes={{ filters.start_mes | urlencode }}&start_ano={{ filters.start_ano | urlencode }}&end_mes={{ filters.end_mes | urlencode }}&end_ano={{ filters.end_ano | urlencode }}&supplier={{ filters.supplier | urlencode }}&category={{ filters.category | urlencode }}&sort={{ sort }}&order={{ order }}&page={{ p }}
{%- endmacro %}
<nav class="pagination" aria-label="Paginação">
{% if page <= 1 %}
<span class="btn btn-ghost btn-sm disabled" aria-disabled="true">&laquo; Anterior</span>
{% else %}
<a href="{{ page_url(page - 1) }}" class="btn btn-ghost btn-sm">&laquo; Anterior</a>
{% endif %}
{% for p in page_numbers %}
{% if p is none %}
<span class="pagination-ellipsis"></span>
{% elif p == page %}
<span class="pagination-page current" aria-current="page">{{ p }}</span>
{% else %}
<a href="{{ page_url(p) }}" class="pagination-page">{{ p }}</a>
{% endif %}
{% endfor %}
{% if page >= total_pages %}
<span class="btn btn-ghost btn-sm disabled" aria-disabled="true">Próxima &raquo;</span>
{% else %}
<a href="{{ page_url(page + 1) }}" class="btn btn-ghost btn-sm">Próxima &raquo;</a>
{% endif %}
</nav>
{% endif %}
{% else %}
<p class="muted">Nenhum documento encontrado. <a href="/documents/new" class="link">Lançar manualmente</a> ou <a href="/" class="link">importar</a>.</p>
{% endif %}
</section>
{% endblock %}
+23
View File
@@ -0,0 +1,23 @@
{% extends "base.html" %}
{% block title %}Entrar{% endblock %}
{% block content %}
<div class="login-card">
<div class="login-head">
<span class="brand-mark lg">🧾</span>
<h1>{{ app_name }}</h1>
<p class="muted">Controle de despesas fiscais</p>
</div>
<form method="post" action="/login" class="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label class="field">
<span>Usuário</span>
<input type="text" name="username" autocomplete="username" required autofocus>
</label>
<label class="field">
<span>Senha</span>
<input type="password" name="password" autocomplete="current-password" required>
</label>
<button type="submit" class="btn btn-primary btn-block">Entrar</button>
</form>
</div>
{% endblock %}
+105
View File
@@ -0,0 +1,105 @@
{% extends "base.html" %}
{% block title %}Revisar importação{% endblock %}
{% block content %}
<div class="page-head">
<div>
<h2>Revisar antes de importar</h2>
<p class="muted">Confira os documentos extraídos. Corrija os ilegíveis e confirme.</p>
</div>
<a href="/" class="btn btn-ghost btn-sm">Cancelar</a>
</div>
<section class="summary-banner">
<div class="summary-item">
<span class="summary-label">Documentos</span>
<strong class="summary-value">{{ summary.count }}</strong>
</div>
<div class="summary-item">
<span class="summary-label">Valor total</span>
<strong class="summary-value money">{{ summary.total | money }}</strong>
</div>
{% if summary.pendentes %}
<div class="summary-item warn">
<span class="summary-label">Precisam de correção</span>
<strong class="summary-value">{{ summary.pendentes }}</strong>
</div>
{% endif %}
</section>
{% if not rows %}
<div class="card"><p class="muted">Nenhum documento neste lote. <a href="/" class="link">Voltar</a>.</p></div>
{% else %}
<div class="staging-list">
{% for r in rows %}
<article class="staging-row {{ 'row-warn' if (not r.legible or not r.supplier_name or r.total_paid is none or not r.mes or not r.ano) else '' }}">
<div class="staging-badges">
{% if not r.legible %}<span class="badge badge-warn">Ilegível — revise</span>{% endif %}
{% if r.legible and (not r.supplier_name or r.total_paid is none or not r.mes or not r.ano) %}<span class="badge badge-warn">Incompleto — preencha</span>{% endif %}
<span class="badge badge-conf badge-{{ r.confidence }}">{{ r.confidence }}</span>
<span class="badge badge-src">{{ 'IA' if r.extractor == 'openai' else 'OCR local' }}</span>
{% if r.uncertain_fields and r.uncertain_fields != '[]' %}
<span class="badge badge-fields">incertos: {{ r.uncertain_fields | replace('[','') | replace(']','') | replace('"','') }}</span>
{% endif %}
</div>
<form method="post" action="/import/{{ batch.id }}/update/{{ r.id }}" class="staging-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label class="field">
<span>Competência</span>
<span class="field-group">
<select name="mes" required>
<option value="" disabled {{ 'selected' if not r.mes else '' }}>Mês</option>
{% for m in meses_competencia %}
<option value="{{ loop.index }}" {{ 'selected' if r.mes == loop.index else '' }}>{{ m }}</option>
{% endfor %}
</select>
<select name="ano" required>
<option value="" disabled {{ 'selected' if not r.ano else '' }}>Ano</option>
{% for a in anos_competencia %}
<option value="{{ a }}" {{ 'selected' if r.ano == a else '' }}>{{ a }}</option>
{% endfor %}
</select>
</span>
</label>
<label class="field grow">
<span>Fornecedor</span>
<input type="text" name="supplier_name" value="{{ r.supplier_name or '' }}" placeholder="Nome do estabelecimento">
</label>
<label class="field">
<span>Valor (R$)</span>
<input type="text" inputmode="decimal" name="total_paid" value="{{ '%.2f'|format(r.total_paid) if r.total_paid is not none else '' }}" placeholder="0,00">
</label>
<div class="staging-actions">
<button type="submit" class="btn btn-primary btn-sm">Salvar correção</button>
<a href="/files/{{ r.upload_id }}" target="_blank" rel="noopener" class="btn btn-ghost btn-sm">Ver arquivo</a>
</div>
</form>
<form method="post" action="/import/{{ batch.id }}/discard/{{ r.id }}" class="staging-discard"
onsubmit="return confirm('Descartar este documento do lote?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn-danger btn-sm">Descartar</button>
</form>
<div class="staging-source">{{ r.source_file_name }}</div>
</article>
{% endfor %}
</div>
<div class="confirm-bar">
<div class="muted">
{% if summary.pendentes %}
Corrija os {{ summary.pendentes }} documento(s) pendente(s) antes de importar.
{% else %}
Tudo pronto para importar.
{% endif %}
</div>
<form method="post" action="/import/{{ batch.id }}/confirm">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn-primary btn-lg" {{ 'disabled' if summary.pendentes else '' }}>
Confirmar importação de {{ summary.count }} documento(s) · {{ summary.total | money }}
</button>
</form>
</div>
{% endif %}
{% endblock %}
+45
View File
@@ -0,0 +1,45 @@
"""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 .dates import ANOS_COMPETENCIA, MESES, format_competencia
from .storage import money
TEMPLATES_DIR = Path(__file__).resolve().parent / "templates"
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
templates.env.filters["money"] = money
templates.env.filters["competencia"] = format_competencia
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",
"meses_competencia": MESES,
"anos_competencia": ANOS_COMPETENCIA,
}
base.update(context)
return templates.TemplateResponse(request, name, base, status_code=status_code)