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,153 @@
|
||||
"""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 pathlib import Path
|
||||
|
||||
from .config import 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."
|
||||
)
|
||||
|
||||
_USER_PROMPT = """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.
|
||||
- "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 _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)
|
||||
if uncertain and legible and len(uncertain) >= 2:
|
||||
legible = False
|
||||
results.append(
|
||||
RawExtraction(
|
||||
supplier_name=supplier,
|
||||
purchase_date_raw=date_raw,
|
||||
total_paid=total,
|
||||
legible=legible,
|
||||
uncertain_fields=uncertain,
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
def extract_with_ai(image_paths: list[Path]) -> list[RawExtraction] | None:
|
||||
"""Extrai via OpenAI. Retorna None se IA indisponível ou em erro (=> fallback)."""
|
||||
settings = get_settings()
|
||||
if not settings.openai_enabled:
|
||||
return None
|
||||
|
||||
uris = [uri for p in image_paths[: settings.openai_max_pages] if (uri := _data_uri(p))]
|
||||
if not uris:
|
||||
return None
|
||||
|
||||
try:
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(api_key=settings.openai_api_key)
|
||||
content: list[dict] = [{"type": "text", "text": _USER_PROMPT}]
|
||||
for uri in uris:
|
||||
content.append({"type": "image_url", "image_url": {"url": uri}})
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model=settings.openai_model,
|
||||
messages=[
|
||||
{"role": "system", "content": _SYSTEM_PROMPT},
|
||||
{"role": "user", "content": content},
|
||||
],
|
||||
response_format={"type": "json_object"},
|
||||
temperature=0,
|
||||
)
|
||||
message = response.choices[0].message.content or "{}"
|
||||
return _parse_response(message)
|
||||
except Exception as exc: # rede, cota, parsing, modelo indisponível...
|
||||
logger.warning("Extração OpenAI falhou, usando fallback local: %s", exc)
|
||||
return None
|
||||
+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,81 @@
|
||||
"""Configuração central lida de variáveis de ambiente (com suporte a .env)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import secrets
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
try: # carregamento opcional do .env
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
except Exception: # pragma: no cover - dotenv é opcional
|
||||
pass
|
||||
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
DATA_DIR = BASE_DIR / "data"
|
||||
UPLOAD_DIR = DATA_DIR / "uploads"
|
||||
PREVIEW_DIR = DATA_DIR / "previews"
|
||||
|
||||
ALLOWED_EXTENSIONS = {".pdf", ".jpg", ".jpeg", ".png"}
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool) -> bool:
|
||||
raw = os.environ.get(name)
|
||||
if raw is None:
|
||||
return default
|
||||
return raw.strip().lower() in {"1", "true", "yes", "on", "sim"}
|
||||
|
||||
|
||||
class Settings:
|
||||
"""Configuração resolvida uma vez no start do processo."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.base_dir = BASE_DIR
|
||||
self.data_dir = DATA_DIR
|
||||
self.upload_dir = UPLOAD_DIR
|
||||
self.preview_dir = PREVIEW_DIR
|
||||
|
||||
self.db_path = Path(os.environ.get("DB_PATH", DATA_DIR / "app.sqlite3"))
|
||||
|
||||
# Segredo da sessão. Em produção DEVE vir do ambiente; sem ele geramos um
|
||||
# efêmero (as sessões caem a cada restart) e avisamos no log.
|
||||
self.secret_key = os.environ.get("SECRET_KEY", "")
|
||||
self.secret_key_is_ephemeral = not self.secret_key
|
||||
if self.secret_key_is_ephemeral:
|
||||
self.secret_key = secrets.token_urlsafe(48)
|
||||
|
||||
# OpenAI (opcional — sem chave o app cai no OCR/heurística local).
|
||||
self.openai_api_key = os.environ.get("OPENAI_API_KEY", "").strip()
|
||||
self.openai_model = os.environ.get("OPENAI_MODEL", "gpt-4o").strip() or "gpt-4o"
|
||||
self.openai_max_pages = int(os.environ.get("OPENAI_MAX_PAGES", "8"))
|
||||
|
||||
# Admin semeado na primeira execução.
|
||||
self.admin_username = os.environ.get("ADMIN_USERNAME", "admin").strip() or "admin"
|
||||
self.admin_password = os.environ.get("ADMIN_PASSWORD", "").strip()
|
||||
|
||||
self.max_upload_bytes = int(os.environ.get("MAX_UPLOAD_BYTES", str(12 * 1024 * 1024)))
|
||||
self.allowed_extensions = set(ALLOWED_EXTENSIONS)
|
||||
|
||||
self.session_cookie = os.environ.get("SESSION_COOKIE", "lnf_session")
|
||||
self.session_https_only = _env_bool("SESSION_HTTPS_ONLY", False)
|
||||
self.session_max_age = int(os.environ.get("SESSION_MAX_AGE", str(60 * 60 * 12)))
|
||||
|
||||
@property
|
||||
def openai_enabled(self) -> bool:
|
||||
return bool(self.openai_api_key)
|
||||
|
||||
def ensure_storage(self) -> None:
|
||||
self.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.preview_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings() -> Settings:
|
||||
settings = Settings()
|
||||
settings.ensure_storage()
|
||||
return settings
|
||||
+508
@@ -0,0 +1,508 @@
|
||||
"""Acesso ao SQLite: esquema, migrações leves e helpers de consulta.
|
||||
|
||||
Estende o esquema original (uploaded_files -> detected_documents -> fiscal_documents)
|
||||
com autenticação (users), lotes de importação (import_batches) e os campos de
|
||||
legibilidade usados pela extração por IA.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterator
|
||||
|
||||
from .config import get_settings
|
||||
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS import_batches (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
confirmed_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uploaded_files (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
batch_id INTEGER REFERENCES import_batches(id) ON DELETE SET NULL,
|
||||
original_name TEXT NOT NULL,
|
||||
stored_path TEXT NOT NULL,
|
||||
content_type TEXT NOT NULL,
|
||||
size_bytes INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
detected_count INTEGER NOT NULL DEFAULT 0,
|
||||
message TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS detected_documents (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
upload_id INTEGER NOT NULL REFERENCES uploaded_files(id) ON DELETE CASCADE,
|
||||
batch_id INTEGER REFERENCES import_batches(id) ON DELETE SET NULL,
|
||||
source_file_name TEXT NOT NULL,
|
||||
source_page INTEGER,
|
||||
source_location TEXT NOT NULL,
|
||||
raw_text TEXT NOT NULL DEFAULT '',
|
||||
purchase_date TEXT,
|
||||
supplier_name TEXT,
|
||||
total_paid REAL,
|
||||
confidence TEXT NOT NULL DEFAULT 'low',
|
||||
field_confidence_json TEXT NOT NULL DEFAULT '{}',
|
||||
legible INTEGER NOT NULL DEFAULT 1,
|
||||
uncertain_fields TEXT NOT NULL DEFAULT '[]',
|
||||
extractor TEXT NOT NULL DEFAULT 'local',
|
||||
status TEXT NOT NULL DEFAULT 'staged',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fiscal_documents (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
detected_document_id INTEGER REFERENCES detected_documents(id) ON DELETE SET NULL,
|
||||
source_file_name TEXT NOT NULL DEFAULT '',
|
||||
source_location TEXT NOT NULL DEFAULT '',
|
||||
purchase_date TEXT NOT NULL,
|
||||
supplier_name TEXT NOT NULL,
|
||||
total_paid REAL NOT NULL,
|
||||
confidence TEXT NOT NULL DEFAULT 'high',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_fiscal_date ON fiscal_documents(purchase_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_detected_batch ON detected_documents(batch_id, status);
|
||||
"""
|
||||
|
||||
|
||||
def connect(db_path: Path | None = None) -> sqlite3.Connection:
|
||||
settings = get_settings()
|
||||
settings.ensure_storage()
|
||||
conn = sqlite3.connect(db_path or settings.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
conn.execute("PRAGMA journal_mode = WAL")
|
||||
return conn
|
||||
|
||||
|
||||
def init_db(conn: sqlite3.Connection) -> None:
|
||||
conn.executescript(SCHEMA)
|
||||
conn.commit()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def session(db_path: Path | None = None) -> Iterator[sqlite3.Connection]:
|
||||
conn = connect(db_path)
|
||||
try:
|
||||
init_db(conn)
|
||||
yield conn
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Usuários
|
||||
# --------------------------------------------------------------------------- #
|
||||
def count_users(conn: sqlite3.Connection) -> int:
|
||||
return int(conn.execute("SELECT COUNT(*) FROM users").fetchone()[0])
|
||||
|
||||
|
||||
def get_user(conn: sqlite3.Connection, username: str) -> sqlite3.Row | None:
|
||||
return conn.execute(
|
||||
"SELECT * FROM users WHERE username = ?", (username,)
|
||||
).fetchone()
|
||||
|
||||
|
||||
def create_user(conn: sqlite3.Connection, username: str, password_hash: str) -> int:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO users (username, password_hash) VALUES (?, ?)",
|
||||
(username, password_hash),
|
||||
)
|
||||
conn.commit()
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def update_password(conn: sqlite3.Connection, username: str, password_hash: str) -> None:
|
||||
conn.execute(
|
||||
"UPDATE users SET password_hash = ? WHERE username = ?",
|
||||
(password_hash, username),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Lotes de importação
|
||||
# --------------------------------------------------------------------------- #
|
||||
def create_batch(conn: sqlite3.Connection) -> int:
|
||||
cur = conn.execute("INSERT INTO import_batches (status) VALUES ('open')")
|
||||
conn.commit()
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def get_batch(conn: sqlite3.Connection, batch_id: int) -> sqlite3.Row | None:
|
||||
return conn.execute(
|
||||
"SELECT * FROM import_batches WHERE id = ?", (batch_id,)
|
||||
).fetchone()
|
||||
|
||||
|
||||
def set_batch_status(conn: sqlite3.Connection, batch_id: int, status: str) -> None:
|
||||
if status == "confirmed":
|
||||
conn.execute(
|
||||
"UPDATE import_batches SET status = ?, confirmed_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
(status, batch_id),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE import_batches SET status = ? WHERE id = ?", (status, batch_id)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Uploads
|
||||
# --------------------------------------------------------------------------- #
|
||||
def insert_upload(
|
||||
conn: sqlite3.Connection,
|
||||
batch_id: int,
|
||||
original_name: str,
|
||||
stored_path: Path,
|
||||
content_type: str,
|
||||
size_bytes: int,
|
||||
) -> int:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO uploaded_files (batch_id, original_name, stored_path, content_type, size_bytes)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(batch_id, original_name, str(stored_path), content_type, size_bytes),
|
||||
)
|
||||
conn.commit()
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def update_upload_status(
|
||||
conn: sqlite3.Connection,
|
||||
upload_id: int,
|
||||
status: str,
|
||||
detected_count: int,
|
||||
message: str | None = None,
|
||||
) -> None:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE uploaded_files
|
||||
SET status = ?, detected_count = ?, message = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""",
|
||||
(status, detected_count, message, upload_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def get_upload(conn: sqlite3.Connection, upload_id: int) -> sqlite3.Row | None:
|
||||
return conn.execute(
|
||||
"SELECT * FROM uploaded_files WHERE id = ?", (upload_id,)
|
||||
).fetchone()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Documentos detectados (staging)
|
||||
# --------------------------------------------------------------------------- #
|
||||
def insert_detected(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
upload_id: int,
|
||||
batch_id: int,
|
||||
source_file_name: str,
|
||||
source_page: int | None,
|
||||
source_location: str,
|
||||
raw_text: str,
|
||||
purchase_date: str | None,
|
||||
supplier_name: str | None,
|
||||
total_paid: float | None,
|
||||
confidence: str,
|
||||
field_confidence: dict[str, str],
|
||||
legible: bool,
|
||||
uncertain_fields: list[str],
|
||||
extractor: str,
|
||||
) -> int:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO detected_documents (
|
||||
upload_id, batch_id, source_file_name, source_page, source_location,
|
||||
raw_text, purchase_date, supplier_name, total_paid, confidence,
|
||||
field_confidence_json, legible, uncertain_fields, extractor, status
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'staged')
|
||||
""",
|
||||
(
|
||||
upload_id,
|
||||
batch_id,
|
||||
source_file_name,
|
||||
source_page,
|
||||
source_location,
|
||||
raw_text,
|
||||
purchase_date,
|
||||
supplier_name,
|
||||
total_paid,
|
||||
confidence,
|
||||
json.dumps(field_confidence, ensure_ascii=False),
|
||||
1 if legible else 0,
|
||||
json.dumps(uncertain_fields, ensure_ascii=False),
|
||||
extractor,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def get_detected(conn: sqlite3.Connection, detected_id: int) -> sqlite3.Row | None:
|
||||
return conn.execute(
|
||||
"SELECT * FROM detected_documents WHERE id = ?", (detected_id,)
|
||||
).fetchone()
|
||||
|
||||
|
||||
def staged_documents(conn: sqlite3.Connection, batch_id: int) -> list[sqlite3.Row]:
|
||||
return list(
|
||||
conn.execute(
|
||||
"""
|
||||
SELECT * FROM detected_documents
|
||||
WHERE batch_id = ? AND status = 'staged'
|
||||
ORDER BY id ASC
|
||||
""",
|
||||
(batch_id,),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def batch_summary(conn: sqlite3.Connection, batch_id: int) -> dict[str, Any]:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS n,
|
||||
COALESCE(SUM(total_paid), 0) AS total,
|
||||
SUM(CASE WHEN legible = 0 THEN 1 ELSE 0 END) AS ilegiveis,
|
||||
SUM(CASE WHEN supplier_name IS NULL OR TRIM(supplier_name) = ''
|
||||
OR total_paid IS NULL THEN 1 ELSE 0 END) AS incompletos
|
||||
FROM detected_documents
|
||||
WHERE batch_id = ? AND status = 'staged'
|
||||
""",
|
||||
(batch_id,),
|
||||
).fetchone()
|
||||
# "pendentes" = tudo que impede importar (ilegível OU faltando fornecedor/valor).
|
||||
ilegiveis = int(row["ilegiveis"] or 0)
|
||||
incompletos = int(row["incompletos"] or 0)
|
||||
pendentes = conn.execute(
|
||||
"""
|
||||
SELECT COUNT(*) FROM detected_documents
|
||||
WHERE batch_id = ? AND status = 'staged'
|
||||
AND (legible = 0 OR supplier_name IS NULL OR TRIM(supplier_name) = '' OR total_paid IS NULL)
|
||||
""",
|
||||
(batch_id,),
|
||||
).fetchone()[0]
|
||||
return {
|
||||
"count": int(row["n"]),
|
||||
"total": float(row["total"]),
|
||||
"ilegiveis": ilegiveis,
|
||||
"incompletos": incompletos,
|
||||
"pendentes": int(pendentes),
|
||||
}
|
||||
|
||||
|
||||
def update_staged(
|
||||
conn: sqlite3.Connection,
|
||||
detected_id: int,
|
||||
*,
|
||||
purchase_date: str,
|
||||
supplier_name: str,
|
||||
total_paid: float,
|
||||
legible: bool = True,
|
||||
) -> None:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE detected_documents
|
||||
SET purchase_date = ?, supplier_name = ?, total_paid = ?,
|
||||
legible = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND status = 'staged'
|
||||
""",
|
||||
(purchase_date, supplier_name, total_paid, 1 if legible else 0, detected_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def discard_staged(conn: sqlite3.Connection, detected_id: int) -> None:
|
||||
conn.execute(
|
||||
"UPDATE detected_documents SET status = 'discarded', updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
(detected_id,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def confirm_batch(conn: sqlite3.Connection, batch_id: int) -> int:
|
||||
"""Promove todos os detectados 'staged' do lote para fiscal_documents."""
|
||||
rows = staged_documents(conn, batch_id)
|
||||
inserted = 0
|
||||
for row in rows:
|
||||
if not row["purchase_date"] or not row["supplier_name"] or row["total_paid"] is None:
|
||||
# Sem os três campos essenciais não confirma — permanece staged.
|
||||
continue
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO fiscal_documents (
|
||||
detected_document_id, source_file_name, source_location,
|
||||
purchase_date, supplier_name, total_paid, confidence
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
row["id"],
|
||||
row["source_file_name"],
|
||||
row["source_location"],
|
||||
row["purchase_date"],
|
||||
row["supplier_name"],
|
||||
float(row["total_paid"]),
|
||||
row["confidence"],
|
||||
),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE detected_documents SET status = 'confirmed', updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
(row["id"],),
|
||||
)
|
||||
inserted += 1
|
||||
set_batch_status(conn, batch_id, "confirmed")
|
||||
conn.commit()
|
||||
return inserted
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# CRUD de fiscal_documents
|
||||
# --------------------------------------------------------------------------- #
|
||||
def create_fiscal(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
purchase_date: str,
|
||||
supplier_name: str,
|
||||
total_paid: float,
|
||||
source_file_name: str = "lançamento manual",
|
||||
source_location: str = "manual",
|
||||
confidence: str = "high",
|
||||
detected_document_id: int | None = None,
|
||||
) -> int:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO fiscal_documents (
|
||||
detected_document_id, source_file_name, source_location,
|
||||
purchase_date, supplier_name, total_paid, confidence
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
detected_document_id,
|
||||
source_file_name,
|
||||
source_location,
|
||||
purchase_date,
|
||||
supplier_name,
|
||||
total_paid,
|
||||
confidence,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def get_fiscal(conn: sqlite3.Connection, doc_id: int) -> sqlite3.Row | None:
|
||||
return conn.execute(
|
||||
"SELECT * FROM fiscal_documents WHERE id = ?", (doc_id,)
|
||||
).fetchone()
|
||||
|
||||
|
||||
def update_fiscal(
|
||||
conn: sqlite3.Connection,
|
||||
doc_id: int,
|
||||
*,
|
||||
purchase_date: str,
|
||||
supplier_name: str,
|
||||
total_paid: float,
|
||||
) -> None:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE fiscal_documents
|
||||
SET purchase_date = ?, supplier_name = ?, total_paid = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""",
|
||||
(purchase_date, supplier_name, total_paid, doc_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def delete_fiscal(conn: sqlite3.Connection, doc_id: int) -> None:
|
||||
conn.execute("DELETE FROM fiscal_documents WHERE id = ?", (doc_id,))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def list_fiscal(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
start: str | None = None,
|
||||
end: str | None = None,
|
||||
supplier: str | None = None,
|
||||
) -> list[sqlite3.Row]:
|
||||
clauses: list[str] = []
|
||||
params: list[Any] = []
|
||||
if start:
|
||||
clauses.append("purchase_date >= ?")
|
||||
params.append(start)
|
||||
if end:
|
||||
clauses.append("purchase_date <= ?")
|
||||
params.append(end)
|
||||
if supplier:
|
||||
clauses.append("supplier_name LIKE ?")
|
||||
params.append(f"%{supplier}%")
|
||||
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||
return list(
|
||||
conn.execute(
|
||||
f"SELECT * FROM fiscal_documents {where} ORDER BY purchase_date DESC, id DESC",
|
||||
params,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def monthly_totals(conn: sqlite3.Connection) -> list[sqlite3.Row]:
|
||||
return list(
|
||||
conn.execute(
|
||||
"""
|
||||
SELECT substr(purchase_date, 1, 7) AS month, SUM(total_paid) AS total, COUNT(*) AS count
|
||||
FROM fiscal_documents
|
||||
GROUP BY substr(purchase_date, 1, 7)
|
||||
ORDER BY month ASC
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def supplier_totals(conn: sqlite3.Connection, limit: int = 10) -> list[sqlite3.Row]:
|
||||
return list(
|
||||
conn.execute(
|
||||
"""
|
||||
SELECT supplier_name, SUM(total_paid) AS total, COUNT(*) AS count
|
||||
FROM fiscal_documents
|
||||
GROUP BY supplier_name
|
||||
ORDER BY total DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def overall_totals(conn: sqlite3.Connection) -> dict[str, Any]:
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) AS n, COALESCE(SUM(total_paid), 0) AS total FROM fiscal_documents"
|
||||
).fetchone()
|
||||
n = int(row["n"])
|
||||
total = float(row["total"])
|
||||
return {"count": n, "total": total, "avg": (total / n) if n else 0.0}
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Resolução de datas de compra com fallback único e compartilhado.
|
||||
|
||||
Requisito: quando a data do documento está ilegível/ausente, atribuir o
|
||||
**primeiro dia do mês corrente** (o mês da importação/lançamento). Tanto a
|
||||
extração por IA quanto o cadastro manual passam por aqui, para nunca divergirem.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import date
|
||||
|
||||
|
||||
_ISO_RE = re.compile(r"^(\d{4})-(\d{2})-(\d{2})$")
|
||||
_BR_RE = re.compile(r"^([0-3]?\d)[/.\-]([01]?\d)[/.\-]((?:19|20)?\d{2})$")
|
||||
|
||||
|
||||
def first_of_current_month(reference: date | None = None) -> str:
|
||||
ref = reference or date.today()
|
||||
return f"{ref.year:04d}-{ref.month:02d}-01"
|
||||
|
||||
|
||||
def _valid_iso(year: int, month: int, day: int) -> str | None:
|
||||
try:
|
||||
return date(year, month, day).isoformat()
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def parse_date(value: str | None) -> str | None:
|
||||
"""Tenta interpretar uma data em ISO (YYYY-MM-DD) ou BR (dd/mm/aaaa).
|
||||
|
||||
Retorna a data normalizada em ISO, ou None se não for uma data válida.
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
text = value.strip()
|
||||
m = _ISO_RE.match(text)
|
||||
if m:
|
||||
return _valid_iso(int(m.group(1)), int(m.group(2)), int(m.group(3)))
|
||||
m = _BR_RE.match(text)
|
||||
if m:
|
||||
day, month, year = m.groups()
|
||||
year_i = int(year)
|
||||
if year_i < 100:
|
||||
year_i += 2000
|
||||
return _valid_iso(year_i, int(month), int(day))
|
||||
return None
|
||||
|
||||
|
||||
def resolve_purchase_date(value: str | None, *, reference: date | None = None) -> str:
|
||||
"""Data válida em ISO, ou o 1º dia do mês corrente quando ilegível/ausente."""
|
||||
parsed = parse_date(value)
|
||||
if parsed:
|
||||
return parsed
|
||||
return first_of_current_month(reference)
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Orquestra a extração de um arquivo enviado: OpenAI Vision -> fallback local.
|
||||
|
||||
Reutiliza `lernotafiscal.extraction` (normalização de PDF/imagem, render de
|
||||
páginas via PyMuPDF, OCR Tesseract e a detecção heurística) como caminho de
|
||||
contingência quando a IA está indisponível ou falha.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from lernotafiscal.extraction import (
|
||||
DetectedDocumentCandidate,
|
||||
detect_documents,
|
||||
normalize_file,
|
||||
)
|
||||
|
||||
from .ai_extraction import RawExtraction, extract_with_ai
|
||||
|
||||
|
||||
def _candidate_to_raw(candidate: DetectedDocumentCandidate) -> RawExtraction:
|
||||
fields = candidate.field_confidence or {}
|
||||
uncertain = [name for name, level in fields.items() if level == "low"]
|
||||
for name, val in (
|
||||
("fornecedor", candidate.supplier_name),
|
||||
("data_compra", candidate.purchase_date),
|
||||
("valor_pago", candidate.total_paid),
|
||||
):
|
||||
if val is None and name not in uncertain:
|
||||
uncertain.append(name)
|
||||
legible = candidate.confidence != "low" and len(uncertain) < 2
|
||||
return RawExtraction(
|
||||
supplier_name=candidate.supplier_name,
|
||||
purchase_date_raw=candidate.purchase_date,
|
||||
total_paid=candidate.total_paid,
|
||||
legible=legible,
|
||||
uncertain_fields=uncertain,
|
||||
extractor="local",
|
||||
raw_text=(candidate.raw_text or "")[:4000],
|
||||
)
|
||||
|
||||
|
||||
def extract_file(stored_path: Path, original_name: str) -> list[RawExtraction]:
|
||||
"""Retorna os documentos extraídos de um arquivo (>=0). Nunca levanta exceção
|
||||
de extração para o chamador — em último caso devolve lista vazia."""
|
||||
pages = normalize_file(stored_path, original_name)
|
||||
|
||||
# Caminho preferencial: enviar as imagens (páginas renderizadas / imagem) à IA.
|
||||
# Só aceitamos o resultado da IA quando ela retorna ao menos um documento.
|
||||
# Uma resposta vazia (nada encontrado OU shape inesperado) cai no fallback
|
||||
# local — evita "zero documentos" silencioso por uma resposta malformada.
|
||||
image_paths = [p.image_path for p in pages if p.image_path is not None]
|
||||
if image_paths:
|
||||
ai_result = extract_with_ai(image_paths)
|
||||
if ai_result:
|
||||
return ai_result
|
||||
|
||||
# Fallback: OCR/heurística sobre o texto já normalizado.
|
||||
candidates = detect_documents(pages, original_name)
|
||||
return [_candidate_to_raw(c) for c in candidates]
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
"""Ponto de entrada FastAPI do Lernotafiscal."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import RedirectResponse
|
||||
|
||||
from . import auth, database
|
||||
from .config import get_settings
|
||||
from .routes import (
|
||||
auth_routes,
|
||||
dashboard_routes,
|
||||
documents_routes,
|
||||
files_routes,
|
||||
upload_routes,
|
||||
)
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||
logger = logging.getLogger("lernotafiscal")
|
||||
|
||||
# Caminhos liberados sem sessão (login e assets da própria UI).
|
||||
PUBLIC_PREFIXES = ("/login", "/static/", "/favicon.ico", "/healthz")
|
||||
|
||||
|
||||
class AuthGuardMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
path = request.url.path
|
||||
if any(path == p or path.startswith(p) for p in PUBLIC_PREFIXES):
|
||||
return await call_next(request)
|
||||
if not request.session.get("user"):
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
settings = get_settings()
|
||||
with database.session() as conn:
|
||||
database.init_db(conn)
|
||||
if settings.secret_key_is_ephemeral:
|
||||
logger.warning("SECRET_KEY não definido: sessões não sobrevivem a reinícios. Defina SECRET_KEY em produção.")
|
||||
note = auth.seed_admin()
|
||||
if note:
|
||||
logger.warning(note)
|
||||
logger.info("OpenAI %s.", "habilitado (" + settings.openai_model + ")" if settings.openai_enabled else "desabilitado — usando OCR/heurística local")
|
||||
yield
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
settings = get_settings()
|
||||
app = FastAPI(title="Lernotafiscal", lifespan=lifespan)
|
||||
|
||||
# Ordem importa: SessionMiddleware é adicionado por último para ser o mais
|
||||
# externo e popular request.session ANTES da guarda de autenticação.
|
||||
app.add_middleware(AuthGuardMiddleware)
|
||||
app.add_middleware(
|
||||
SessionMiddleware,
|
||||
secret_key=settings.secret_key,
|
||||
session_cookie=settings.session_cookie,
|
||||
https_only=settings.session_https_only,
|
||||
max_age=settings.session_max_age,
|
||||
same_site="lax",
|
||||
)
|
||||
|
||||
static_dir = Path(__file__).resolve().parent / "static"
|
||||
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
||||
|
||||
app.include_router(auth_routes.router)
|
||||
app.include_router(dashboard_routes.router)
|
||||
app.include_router(upload_routes.router)
|
||||
app.include_router(documents_routes.router)
|
||||
app.include_router(files_routes.router)
|
||||
|
||||
@app.get("/healthz")
|
||||
def healthz():
|
||||
return {"status": "ok"}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
@@ -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,48 @@
|
||||
"""Dashboard: KPIs, gastos por mês e por fornecedor, últimas notas."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
|
||||
from .. import database as db
|
||||
from ..storage import money
|
||||
from ..templating import render
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_MESES = ["jan", "fev", "mar", "abr", "mai", "jun", "jul", "ago", "set", "out", "nov", "dez"]
|
||||
|
||||
|
||||
def _mes_label(ym: str) -> str:
|
||||
try:
|
||||
year, month = ym.split("-")
|
||||
return f"{_MESES[int(month) - 1]}/{year}"
|
||||
except (ValueError, IndexError):
|
||||
return ym
|
||||
|
||||
|
||||
@router.get("/")
|
||||
def dashboard(request: Request):
|
||||
with db.session() as conn:
|
||||
totals = db.overall_totals(conn)
|
||||
months = [dict(r) for r in db.monthly_totals(conn)]
|
||||
suppliers = [dict(r) for r in db.supplier_totals(conn, limit=8)]
|
||||
recent = [dict(r) for r in db.list_fiscal(conn)][:10]
|
||||
|
||||
max_month = max((m["total"] for m in months), default=0) or 1
|
||||
for m in months:
|
||||
m["label"] = _mes_label(m["month"])
|
||||
m["pct"] = round(m["total"] / max_month * 100, 1)
|
||||
max_sup = max((s["total"] for s in suppliers), default=0) or 1
|
||||
for s in suppliers:
|
||||
s["pct"] = round(s["total"] / max_sup * 100, 1)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"dashboard.html",
|
||||
totals=totals,
|
||||
months=months,
|
||||
suppliers=suppliers,
|
||||
recent=recent,
|
||||
money=money,
|
||||
)
|
||||
@@ -0,0 +1,137 @@
|
||||
"""CRUD de documentos fiscais confirmados."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Form, Query, Request
|
||||
from starlette.responses import RedirectResponse
|
||||
|
||||
from .. import auth
|
||||
from .. import database as db
|
||||
from ..dates import resolve_purchase_date
|
||||
from ..storage import money
|
||||
from ..templating import flash, render
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _parse_money(value: str) -> float | None:
|
||||
text = (value or "").strip().replace("R$", "").replace(" ", "")
|
||||
if not text:
|
||||
return None
|
||||
if "," in text and "." in text:
|
||||
text = text.replace(".", "").replace(",", ".")
|
||||
elif "," in text:
|
||||
text = text.replace(",", ".")
|
||||
try:
|
||||
return round(float(text), 2)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/documents")
|
||||
def list_documents(
|
||||
request: Request,
|
||||
start: str = Query(""),
|
||||
end: str = Query(""),
|
||||
supplier: str = Query(""),
|
||||
):
|
||||
with db.session() as conn:
|
||||
rows = [dict(r) for r in db.list_fiscal(conn, start=start or None, end=end or None, supplier=supplier or None)]
|
||||
total = sum(r["total_paid"] for r in rows)
|
||||
return render(
|
||||
request,
|
||||
"documents_list.html",
|
||||
rows=rows,
|
||||
total=total,
|
||||
filters={"start": start, "end": end, "supplier": supplier},
|
||||
money=money,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/documents/new")
|
||||
def new_form(request: Request):
|
||||
return render(request, "document_form.html", doc=None, mode="new")
|
||||
|
||||
|
||||
@router.post("/documents/new")
|
||||
def create_document(
|
||||
request: Request,
|
||||
csrf_token: str = Form(""),
|
||||
purchase_date: str = Form(""),
|
||||
supplier_name: str = Form(""),
|
||||
total_paid: str = Form(""),
|
||||
):
|
||||
if not auth.check_csrf(request, csrf_token):
|
||||
flash(request, "Sessão expirada.", "error")
|
||||
return RedirectResponse("/documents/new", status_code=303)
|
||||
|
||||
total = _parse_money(total_paid)
|
||||
supplier = supplier_name.strip()
|
||||
if not supplier or total is None:
|
||||
flash(request, "Informe fornecedor e valor válidos.", "error")
|
||||
return RedirectResponse("/documents/new", status_code=303)
|
||||
|
||||
with db.session() as conn:
|
||||
db.create_fiscal(
|
||||
conn,
|
||||
purchase_date=resolve_purchase_date(purchase_date),
|
||||
supplier_name=supplier,
|
||||
total_paid=total,
|
||||
)
|
||||
flash(request, "Documento lançado.", "success")
|
||||
return RedirectResponse("/documents", status_code=303)
|
||||
|
||||
|
||||
@router.get("/documents/{doc_id}/edit")
|
||||
def edit_form(request: Request, doc_id: int):
|
||||
with db.session() as conn:
|
||||
doc = db.get_fiscal(conn, doc_id)
|
||||
if doc is None:
|
||||
flash(request, "Documento não encontrado.", "error")
|
||||
return RedirectResponse("/documents", status_code=303)
|
||||
return render(request, "document_form.html", doc=dict(doc), mode="edit")
|
||||
|
||||
|
||||
@router.post("/documents/{doc_id}/edit")
|
||||
def update_document(
|
||||
request: Request,
|
||||
doc_id: int,
|
||||
csrf_token: str = Form(""),
|
||||
purchase_date: str = Form(""),
|
||||
supplier_name: str = Form(""),
|
||||
total_paid: str = Form(""),
|
||||
):
|
||||
if not auth.check_csrf(request, csrf_token):
|
||||
flash(request, "Sessão expirada.", "error")
|
||||
return RedirectResponse(f"/documents/{doc_id}/edit", status_code=303)
|
||||
|
||||
total = _parse_money(total_paid)
|
||||
supplier = supplier_name.strip()
|
||||
if not supplier or total is None:
|
||||
flash(request, "Informe fornecedor e valor válidos.", "error")
|
||||
return RedirectResponse(f"/documents/{doc_id}/edit", status_code=303)
|
||||
|
||||
with db.session() as conn:
|
||||
if db.get_fiscal(conn, doc_id) is None:
|
||||
flash(request, "Documento não encontrado.", "error")
|
||||
return RedirectResponse("/documents", status_code=303)
|
||||
db.update_fiscal(
|
||||
conn,
|
||||
doc_id,
|
||||
purchase_date=resolve_purchase_date(purchase_date),
|
||||
supplier_name=supplier,
|
||||
total_paid=total,
|
||||
)
|
||||
flash(request, "Documento atualizado.", "success")
|
||||
return RedirectResponse("/documents", status_code=303)
|
||||
|
||||
|
||||
@router.post("/documents/{doc_id}/delete")
|
||||
def delete_document(request: Request, doc_id: int, csrf_token: str = Form("")):
|
||||
if not auth.check_csrf(request, csrf_token):
|
||||
flash(request, "Sessão expirada.", "error")
|
||||
return RedirectResponse("/documents", status_code=303)
|
||||
with db.session() as conn:
|
||||
db.delete_fiscal(conn, doc_id)
|
||||
flash(request, "Documento excluído.", "info")
|
||||
return RedirectResponse("/documents", status_code=303)
|
||||
@@ -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,169 @@
|
||||
"""Fluxo de importação: upload -> staging (revisão) -> confirmação em lote."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, File, Form, Request, UploadFile
|
||||
from starlette.responses import RedirectResponse
|
||||
|
||||
from .. import auth
|
||||
from .. import database as db
|
||||
from ..config import get_settings
|
||||
from ..dates import resolve_purchase_date
|
||||
from ..ingestion import extract_file
|
||||
from ..storage import money, safe_original_name, unique_storage_name
|
||||
from ..templating import flash, render
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _confidence(legible: bool, uncertain: list[str]) -> str:
|
||||
if legible and not uncertain:
|
||||
return "high"
|
||||
if len(uncertain) >= 2 or not legible:
|
||||
return "low"
|
||||
return "medium"
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload(request: Request, csrf_token: str = Form(""), files: list[UploadFile] = File(...)):
|
||||
if not auth.check_csrf(request, csrf_token):
|
||||
flash(request, "Sessão expirada. Tente novamente.", "error")
|
||||
return RedirectResponse("/", status_code=303)
|
||||
|
||||
settings = get_settings()
|
||||
accepted = 0
|
||||
rejected = 0
|
||||
|
||||
with db.session() as conn:
|
||||
batch_id = db.create_batch(conn)
|
||||
for upload_file in files:
|
||||
original = safe_original_name(upload_file.filename or "upload")
|
||||
suffix = Path(original).suffix.lower()
|
||||
content = await upload_file.read()
|
||||
if suffix not in settings.allowed_extensions or len(content) > settings.max_upload_bytes:
|
||||
rejected += 1
|
||||
continue
|
||||
|
||||
stored_name = unique_storage_name(original)
|
||||
stored_path = settings.upload_dir / stored_name
|
||||
stored_path.write_bytes(content)
|
||||
content_type = upload_file.content_type or "application/octet-stream"
|
||||
upload_id = db.insert_upload(conn, batch_id, original, stored_path, content_type, len(content))
|
||||
|
||||
try:
|
||||
extractions = extract_file(stored_path, original)
|
||||
for raw in extractions:
|
||||
purchase_date = resolve_purchase_date(raw.purchase_date_raw)
|
||||
uncertain = list(raw.uncertain_fields)
|
||||
db.insert_detected(
|
||||
conn,
|
||||
upload_id=upload_id,
|
||||
batch_id=batch_id,
|
||||
source_file_name=original,
|
||||
source_page=None,
|
||||
source_location=original,
|
||||
raw_text=raw.raw_text,
|
||||
purchase_date=purchase_date,
|
||||
supplier_name=raw.supplier_name,
|
||||
total_paid=raw.total_paid,
|
||||
confidence=_confidence(raw.legible, uncertain),
|
||||
field_confidence={},
|
||||
legible=raw.legible,
|
||||
uncertain_fields=uncertain,
|
||||
extractor=raw.extractor,
|
||||
)
|
||||
accepted += 1
|
||||
status = "processed" if extractions else "needs_attention"
|
||||
message = None if extractions else "Nenhum documento fiscal detectado."
|
||||
db.update_upload_status(conn, upload_id, status, len(extractions), message)
|
||||
except Exception as exc: # nunca deixa um arquivo derrubar o lote
|
||||
db.update_upload_status(conn, upload_id, "failed", 0, str(exc)[:300])
|
||||
|
||||
if rejected:
|
||||
flash(request, f"{rejected} arquivo(s) recusado(s). Use PDF/JPG/PNG até {settings.max_upload_bytes // (1024*1024)} MB.", "error")
|
||||
if accepted == 0:
|
||||
flash(request, "Nenhum documento foi extraído dos arquivos enviados.", "error")
|
||||
return RedirectResponse("/", status_code=303)
|
||||
return RedirectResponse(f"/import/{batch_id}/review", status_code=303)
|
||||
|
||||
|
||||
@router.get("/import/{batch_id}/review")
|
||||
def review(request: Request, batch_id: int):
|
||||
with db.session() as conn:
|
||||
batch = db.get_batch(conn, batch_id)
|
||||
if batch is None:
|
||||
flash(request, "Lote de importação não encontrado.", "error")
|
||||
return RedirectResponse("/", status_code=303)
|
||||
rows = [dict(r) for r in db.staged_documents(conn, batch_id)]
|
||||
summary = db.batch_summary(conn, batch_id)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"staging.html",
|
||||
batch=dict(batch),
|
||||
rows=rows,
|
||||
summary=summary,
|
||||
money=money,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/import/{batch_id}/update/{detected_id}")
|
||||
def update_row(
|
||||
request: Request,
|
||||
batch_id: int,
|
||||
detected_id: int,
|
||||
csrf_token: str = Form(""),
|
||||
purchase_date: str = Form(""),
|
||||
supplier_name: str = Form(""),
|
||||
total_paid: str = Form(""),
|
||||
):
|
||||
if not auth.check_csrf(request, csrf_token):
|
||||
flash(request, "Sessão expirada.", "error")
|
||||
return RedirectResponse(f"/import/{batch_id}/review", status_code=303)
|
||||
|
||||
try:
|
||||
total = float(total_paid.replace(".", "").replace(",", ".")) if "," in total_paid else float(total_paid or 0)
|
||||
except ValueError:
|
||||
flash(request, "Valor inválido na correção.", "error")
|
||||
return RedirectResponse(f"/import/{batch_id}/review", status_code=303)
|
||||
|
||||
resolved_date = resolve_purchase_date(purchase_date)
|
||||
with db.session() as conn:
|
||||
db.update_staged(
|
||||
conn,
|
||||
detected_id,
|
||||
purchase_date=resolved_date,
|
||||
supplier_name=supplier_name.strip(),
|
||||
total_paid=round(total, 2),
|
||||
legible=True,
|
||||
)
|
||||
flash(request, "Documento corrigido.", "success")
|
||||
return RedirectResponse(f"/import/{batch_id}/review", status_code=303)
|
||||
|
||||
|
||||
@router.post("/import/{batch_id}/discard/{detected_id}")
|
||||
def discard_row(request: Request, batch_id: int, detected_id: int, csrf_token: str = Form("")):
|
||||
if auth.check_csrf(request, csrf_token):
|
||||
with db.session() as conn:
|
||||
db.discard_staged(conn, detected_id)
|
||||
flash(request, "Documento descartado do lote.", "info")
|
||||
return RedirectResponse(f"/import/{batch_id}/review", status_code=303)
|
||||
|
||||
|
||||
@router.post("/import/{batch_id}/confirm")
|
||||
def confirm(request: Request, batch_id: int, csrf_token: str = Form("")):
|
||||
if not auth.check_csrf(request, csrf_token):
|
||||
flash(request, "Sessão expirada.", "error")
|
||||
return RedirectResponse(f"/import/{batch_id}/review", status_code=303)
|
||||
|
||||
with db.session() as conn:
|
||||
summary = db.batch_summary(conn, batch_id)
|
||||
if summary["pendentes"] > 0:
|
||||
flash(request, f"Ainda há {summary['pendentes']} documento(s) com problema (ilegível ou sem fornecedor/valor). Corrija ou descarte antes de importar.", "error")
|
||||
return RedirectResponse(f"/import/{batch_id}/review", status_code=303)
|
||||
inserted = db.confirm_batch(conn, batch_id)
|
||||
|
||||
flash(request, f"{inserted} documento(s) importado(s) com sucesso.", "success")
|
||||
return RedirectResponse("/documents", status_code=303)
|
||||
@@ -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,206 @@
|
||||
:root {
|
||||
--bg: #f3f5f4;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #f7faf8;
|
||||
--border: #e1e7e3;
|
||||
--text: #16211c;
|
||||
--muted: #5c6b63;
|
||||
--faint: #8a978f;
|
||||
--accent: #0f7a5a;
|
||||
--accent-ink: #ffffff;
|
||||
--accent-2: #2f6f8f;
|
||||
--danger: #b23b3b;
|
||||
--warn-bg: #fff4e0;
|
||||
--warn-border: #e6bd76;
|
||||
--warn-ink: #7a4d10;
|
||||
--ok-bg: #e2f3ea;
|
||||
--ok-ink: #14603f;
|
||||
--err-bg: #fbe4e4;
|
||||
--err-ink: #8f2626;
|
||||
--shadow: 0 1px 2px rgba(16,33,26,.05), 0 10px 26px -18px rgba(16,33,26,.22);
|
||||
--radius: 14px;
|
||||
--radius-sm: 9px;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #0e1512; --surface: #141d19; --surface-2: #18231e; --border: #26332c;
|
||||
--text: #e8efeb; --muted: #9db0a6; --faint: #6c7d74; --accent: #35c491;
|
||||
--accent-ink: #06231a; --accent-2: #62b4d6; --danger: #e06a6a;
|
||||
--warn-bg: #33270f; --warn-border: #6b5220; --warn-ink: #f0cd8a;
|
||||
--ok-bg: #123528; --ok-ink: #7fe0b4; --err-bg: #3a1d1d; --err-ink: #f0a8a8;
|
||||
--shadow: 0 1px 2px rgba(0,0,0,.3), 0 14px 30px -20px rgba(0,0,0,.7);
|
||||
}
|
||||
}
|
||||
:root[data-theme="light"] {
|
||||
--bg: #f3f5f4; --surface: #ffffff; --surface-2: #f7faf8; --border: #e1e7e3;
|
||||
--text: #16211c; --muted: #5c6b63; --faint: #8a978f; --accent: #0f7a5a;
|
||||
--accent-ink: #ffffff; --accent-2: #2f6f8f; --danger: #b23b3b;
|
||||
--warn-bg: #fff4e0; --warn-border: #e6bd76; --warn-ink: #7a4d10;
|
||||
--ok-bg: #e2f3ea; --ok-ink: #14603f; --err-bg: #fbe4e4; --err-ink: #8f2626;
|
||||
--shadow: 0 1px 2px rgba(16,33,26,.05), 0 10px 26px -18px rgba(16,33,26,.22);
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--bg: #0e1512; --surface: #141d19; --surface-2: #18231e; --border: #26332c;
|
||||
--text: #e8efeb; --muted: #9db0a6; --faint: #6c7d74; --accent: #35c491;
|
||||
--accent-ink: #06231a; --accent-2: #62b4d6; --danger: #e06a6a;
|
||||
--warn-bg: #33270f; --warn-border: #6b5220; --warn-ink: #f0cd8a;
|
||||
--ok-bg: #123528; --ok-ink: #7fe0b4; --err-bg: #3a1d1d; --err-ink: #f0a8a8;
|
||||
--shadow: 0 1px 2px rgba(0,0,0,.3), 0 14px 30px -20px rgba(0,0,0,.7);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-variant-numeric: tabular-nums;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
h1, h2, h3 { text-wrap: balance; margin: 0; }
|
||||
a { color: var(--accent); }
|
||||
.money { font-variant-numeric: tabular-nums; }
|
||||
.muted { color: var(--muted); }
|
||||
.small { font-size: .82rem; }
|
||||
.nowrap { white-space: nowrap; }
|
||||
.r { text-align: right; }
|
||||
|
||||
/* Topbar */
|
||||
.topbar {
|
||||
display: flex; align-items: center; gap: 18px;
|
||||
padding: 12px 22px; background: var(--surface); border-bottom: 1px solid var(--border);
|
||||
position: sticky; top: 0; z-index: 10;
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 9px; font-weight: 680; text-decoration: none; color: var(--text); font-size: 1.05rem; }
|
||||
.brand-mark { font-size: 1.2rem; }
|
||||
.brand-mark.lg { font-size: 2.2rem; }
|
||||
.nav { display: flex; align-items: center; gap: 6px; margin-right: auto; }
|
||||
.nav a { padding: 7px 12px; border-radius: var(--radius-sm); text-decoration: none; color: var(--muted); font-weight: 550; font-size: .92rem; }
|
||||
.nav a:hover { background: var(--surface-2); color: var(--text); }
|
||||
.nav a.active { color: var(--accent); background: var(--surface-2); }
|
||||
.topbar-right { display: flex; align-items: center; gap: 10px; }
|
||||
.user { font-size: .85rem; color: var(--muted); }
|
||||
.theme-toggle { background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px; width: 36px; height: 36px; cursor: pointer; color: var(--text); font-size: 1rem; }
|
||||
.theme-toggle:hover { border-color: var(--accent); }
|
||||
.inline { display: inline; }
|
||||
|
||||
/* Layout */
|
||||
.main { max-width: 1080px; margin: 0 auto; padding: 26px 20px 70px; display: flex; flex-direction: column; gap: 20px; }
|
||||
.main-centered { min-height: 100vh; display: grid; place-items: center; padding: 24px; }
|
||||
.page-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; }
|
||||
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 18px 20px; }
|
||||
.card-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
|
||||
.card h3 { font-size: 1rem; margin-bottom: 12px; }
|
||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
|
||||
|
||||
/* Buttons */
|
||||
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; border: 1px solid transparent; border-radius: var(--radius-sm); padding: 9px 15px; font: inherit; font-weight: 600; font-size: .9rem; cursor: pointer; text-decoration: none; }
|
||||
.btn-primary { background: var(--accent); color: var(--accent-ink); }
|
||||
.btn-primary:hover { filter: brightness(1.06); }
|
||||
.btn-primary:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.btn-ghost { background: transparent; border-color: var(--border); color: var(--text); }
|
||||
.btn-ghost:hover { background: var(--surface-2); }
|
||||
.btn-danger { background: transparent; border-color: var(--danger); color: var(--danger); }
|
||||
.btn-danger:hover { background: var(--danger); color: #fff; }
|
||||
.btn-sm { padding: 6px 11px; font-size: .84rem; }
|
||||
.btn-lg { padding: 12px 20px; font-size: 1rem; }
|
||||
.btn-block { width: 100%; }
|
||||
.link { color: var(--accent); text-decoration: none; font-weight: 550; }
|
||||
.link:hover { text-decoration: underline; }
|
||||
.linkbtn { background: none; border: 0; padding: 0 0 0 10px; color: var(--accent); font: inherit; font-weight: 550; cursor: pointer; }
|
||||
.linkbtn.danger { color: var(--danger); }
|
||||
|
||||
/* Forms */
|
||||
.form { display: flex; flex-direction: column; gap: 14px; }
|
||||
.field { display: flex; flex-direction: column; gap: 5px; }
|
||||
.field > span { font-size: .78rem; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .03em; }
|
||||
.field.grow { flex: 1; }
|
||||
input[type=text], input[type=password], input[type=date], input[type=file] {
|
||||
font: inherit; color: var(--text); background: var(--surface-2);
|
||||
border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 9px 11px; min-height: 40px; width: 100%;
|
||||
}
|
||||
input:focus-visible, button:focus-visible, a:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
.form-actions, .filters-actions { display: flex; gap: 10px; align-items: center; }
|
||||
.form-card { max-width: 520px; }
|
||||
|
||||
/* Login */
|
||||
.login-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 30px; width: 100%; max-width: 380px; }
|
||||
.login-head { text-align: center; margin-bottom: 20px; display: flex; flex-direction: column; align-items: center; gap: 2px; }
|
||||
.login-head h1 { font-size: 1.4rem; }
|
||||
|
||||
/* Flash */
|
||||
.flash-stack { display: flex; flex-direction: column; gap: 8px; }
|
||||
.flash { padding: 11px 14px; border-radius: var(--radius-sm); font-size: .9rem; border: 1px solid transparent; }
|
||||
.flash-success { background: var(--ok-bg); color: var(--ok-ink); }
|
||||
.flash-error { background: var(--err-bg); color: var(--err-ink); }
|
||||
.flash-info { background: var(--surface-2); color: var(--muted); border-color: var(--border); }
|
||||
|
||||
/* Upload panel */
|
||||
.upload-panel { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 20px; display: grid; grid-template-columns: 1fr auto; gap: 18px; align-items: center; }
|
||||
.upload-panel h2 { font-size: 1.1rem; margin-bottom: 4px; }
|
||||
.upload-form { display: flex; gap: 12px; align-items: center; }
|
||||
.filedrop { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; border: 1.5px dashed var(--border); border-radius: var(--radius-sm); padding: 14px 22px; cursor: pointer; background: var(--surface-2); color: var(--muted); min-width: 240px; text-align: center; }
|
||||
.filedrop.drag { border-color: var(--accent); color: var(--accent); }
|
||||
.filedrop-icon { font-size: 1.2rem; }
|
||||
.filedrop-text { font-size: .85rem; }
|
||||
|
||||
/* KPIs */
|
||||
.kpis { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
|
||||
.kpi { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 16px 18px; }
|
||||
.kpi-label { font-size: .7rem; text-transform: uppercase; letter-spacing: .06em; color: var(--faint); font-weight: 600; }
|
||||
.kpi-value { display: block; font-size: 1.55rem; font-weight: 680; margin-top: 6px; letter-spacing: -.02em; }
|
||||
.kpi-value.money { color: var(--accent); }
|
||||
.kpi-foot { font-size: .78rem; color: var(--muted); }
|
||||
|
||||
/* Bar lists */
|
||||
.barlist { display: flex; flex-direction: column; }
|
||||
.barrow { display: grid; grid-template-columns: 92px 1fr auto; gap: 12px; align-items: center; padding: 7px 0; }
|
||||
.barrow + .barrow { border-top: 1px solid var(--surface-2); }
|
||||
.barlabel { font-size: .84rem; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.bartrack { height: 10px; background: var(--surface-2); border-radius: 999px; overflow: hidden; }
|
||||
.barfill { display: block; height: 100%; border-radius: 999px; background: linear-gradient(90deg, var(--accent-2), var(--accent)); }
|
||||
.barfill.alt { background: var(--accent); }
|
||||
.barval { font-size: .84rem; font-weight: 600; white-space: nowrap; }
|
||||
|
||||
/* Tables */
|
||||
.table-scroll { overflow-x: auto; }
|
||||
.table { width: 100%; border-collapse: collapse; font-size: .9rem; }
|
||||
.table thead th { text-align: left; font-size: .7rem; text-transform: uppercase; letter-spacing: .05em; color: var(--faint); font-weight: 600; padding: 10px 12px; border-bottom: 1px solid var(--border); }
|
||||
.table tbody td { padding: 11px 12px; border-bottom: 1px solid var(--surface-2); }
|
||||
.table tbody tr:hover td { background: var(--surface-2); }
|
||||
.table tfoot td { padding: 11px 12px; font-weight: 650; border-top: 2px solid var(--border); }
|
||||
|
||||
/* Filters */
|
||||
.filters { display: flex; gap: 12px; align-items: flex-end; flex-wrap: wrap; }
|
||||
|
||||
/* Staging */
|
||||
.summary-banner { display: flex; gap: 28px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 18px 22px; }
|
||||
.summary-item { display: flex; flex-direction: column; gap: 2px; }
|
||||
.summary-label { font-size: .72rem; text-transform: uppercase; letter-spacing: .06em; color: var(--faint); font-weight: 600; }
|
||||
.summary-value { font-size: 1.5rem; font-weight: 700; }
|
||||
.summary-value.money { color: var(--accent); }
|
||||
.summary-item.warn .summary-value { color: var(--warn-ink); }
|
||||
.staging-list { display: flex; flex-direction: column; gap: 12px; }
|
||||
.staging-row { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 14px 16px; display: grid; grid-template-columns: 1fr auto; gap: 8px 14px; align-items: end; }
|
||||
.staging-row.row-warn { border-color: var(--warn-border); background: var(--warn-bg); }
|
||||
.staging-badges { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.badge { font-size: .68rem; font-weight: 600; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--border); color: var(--muted); background: var(--surface-2); text-transform: uppercase; letter-spacing: .03em; }
|
||||
.badge-warn { background: var(--warn-bg); color: var(--warn-ink); border-color: var(--warn-border); }
|
||||
.badge-high { color: var(--ok-ink); }
|
||||
.badge-low { color: var(--err-ink); }
|
||||
.badge-fields { text-transform: none; letter-spacing: 0; }
|
||||
.staging-form { grid-column: 1; display: flex; gap: 12px; align-items: end; flex-wrap: wrap; }
|
||||
.staging-form .field { min-width: 130px; }
|
||||
.staging-actions { display: flex; gap: 8px; align-items: center; }
|
||||
.staging-discard { grid-column: 2; }
|
||||
.staging-source { grid-column: 1 / -1; font-size: .74rem; color: var(--faint); }
|
||||
.confirm-bar { position: sticky; bottom: 0; display: flex; align-items: center; justify-content: space-between; gap: 16px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 14px 18px; }
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.grid-2, .kpis { grid-template-columns: 1fr; }
|
||||
.upload-panel { grid-template-columns: 1fr; }
|
||||
.staging-row { grid-template-columns: 1fr; }
|
||||
.staging-discard { grid-column: 1; }
|
||||
.nav a:not(.btn) { display: none; }
|
||||
}
|
||||
@@ -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,61 @@
|
||||
<!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="/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,97 @@
|
||||
{% 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="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-2">
|
||||
<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>
|
||||
</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>Data</th><th>Fornecedor</th><th class="r">Valor</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for d in recent %}
|
||||
<tr>
|
||||
<td class="nowrap">{{ d.purchase_date }}</td>
|
||||
<td>{{ d.supplier_name }}</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,36 @@
|
||||
{% 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>Data da compra</span>
|
||||
<input type="date" name="purchase_date" value="{{ doc.purchase_date if doc else '' }}">
|
||||
<small class="muted">Se deixar em branco, será usado o 1º dia do mês 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>
|
||||
<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,60 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Documentos{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h2>Documentos</h2>
|
||||
<p class="muted">{{ rows | length }} 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</span><input type="date" name="start" value="{{ filters.start }}"></label>
|
||||
<label class="field"><span>Até</span><input type="date" name="end" value="{{ filters.end }}"></label>
|
||||
<label class="field grow"><span>Fornecedor</span><input type="text" name="supplier" value="{{ filters.supplier }}" placeholder="contém…"></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 %}
|
||||
<div class="table-scroll">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>Data</th><th>Fornecedor</th><th class="r">Valor</th><th>Origem</th><th class="r">Ações</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for d in rows %}
|
||||
<tr>
|
||||
<td class="nowrap">{{ d.purchase_date }}</td>
|
||||
<td>{{ d.supplier_name }}</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="2">Total filtrado</td><td class="r money">{{ total | money }}</td><td colspan="2"></td></tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
{% 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,92 @@
|
||||
{% 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) 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) %}<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>Data</span>
|
||||
<input type="date" name="purchase_date" value="{{ r.purchase_date }}">
|
||||
</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,41 @@
|
||||
"""Configuração do Jinja2 e helpers de renderização/flash."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
from . import auth
|
||||
from .config import get_settings
|
||||
from .storage import money
|
||||
|
||||
TEMPLATES_DIR = Path(__file__).resolve().parent / "templates"
|
||||
|
||||
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
||||
templates.env.filters["money"] = money
|
||||
|
||||
|
||||
def flash(request: Request, message: str, level: str = "info") -> None:
|
||||
bucket = request.session.setdefault("_flash", [])
|
||||
bucket.append({"message": message, "level": level})
|
||||
|
||||
|
||||
def pop_flash(request: Request) -> list[dict[str, str]]:
|
||||
return request.session.pop("_flash", [])
|
||||
|
||||
|
||||
def render(request: Request, name: str, status_code: int = 200, **context) -> HTMLResponse:
|
||||
settings = get_settings()
|
||||
base = {
|
||||
"request": request,
|
||||
"current_user": auth.current_user(request),
|
||||
"csrf_token": auth.get_csrf_token(request),
|
||||
"flashes": pop_flash(request),
|
||||
"openai_enabled": settings.openai_enabled,
|
||||
"app_name": "Lernotafiscal",
|
||||
}
|
||||
base.update(context)
|
||||
return templates.TemplateResponse(request, name, base, status_code=status_code)
|
||||
Reference in New Issue
Block a user