Commit inicial - upload de todos os arquivos da pasta
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
"""Lernotafiscal — aplicativo web de controle de despesas fiscais."""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
@@ -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
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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}"
|
||||
@@ -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
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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…";
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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", ".")
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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">« Anterior</span>
|
||||
{% else %}
|
||||
<a href="{{ page_url(page - 1) }}" class="btn btn-ghost btn-sm">« 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 »</span>
|
||||
{% else %}
|
||||
<a href="{{ page_url(page + 1) }}" class="btn btn-ghost btn-sm">Próxima »</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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user