Commit inicial - upload de todos os arquivos da pasta

This commit is contained in:
2026-07-24 23:55:21 -03:00
commit 9d4d395881
88 changed files with 9484 additions and 0 deletions
View File
+45
View File
@@ -0,0 +1,45 @@
"""Rotas de login/logout."""
from __future__ import annotations
from fastapi import APIRouter, Form, Request
from starlette.responses import RedirectResponse
from .. import auth
from ..templating import flash, render
router = APIRouter()
@router.get("/login")
def login_form(request: Request):
if auth.current_user(request):
return RedirectResponse("/", status_code=303)
return render(request, "login.html", hide_nav=True)
@router.post("/login")
def login_submit(
request: Request,
username: str = Form(...),
password: str = Form(...),
csrf_token: str = Form(""),
):
if not auth.check_csrf(request, csrf_token):
flash(request, "Sessão expirada. Tente novamente.", "error")
return RedirectResponse("/login", status_code=303)
if auth.authenticate(username.strip(), password):
auth.login_session(request, username.strip())
flash(request, "Bem-vindo de volta.", "success")
return RedirectResponse("/", status_code=303)
flash(request, "Usuário ou senha inválidos.", "error")
return RedirectResponse("/login", status_code=303)
@router.post("/logout")
def logout(request: Request, csrf_token: str = Form("")):
if auth.check_csrf(request, csrf_token):
auth.logout_session(request)
return RedirectResponse("/login", status_code=303)
+99
View File
@@ -0,0 +1,99 @@
"""CRUD administrativo da tabela `categoria` (categorias e palavras-chave)."""
from __future__ import annotations
from fastapi import APIRouter, Form, Request
from starlette.responses import RedirectResponse
from .. import auth
from .. import database as db
from ..templating import flash, render
router = APIRouter()
@router.get("/categorias")
def list_categorias(request: Request):
with db.session() as conn:
rows = [dict(r) for r in db.list_categorias_por_nome(conn)]
return render(request, "categorias_list.html", rows=rows, reserved_id=db.RESERVED_CATEGORIA_ID)
@router.get("/categorias/new")
def new_form(request: Request):
return render(request, "categoria_form.html", categoria=None, mode="new")
@router.post("/categorias/new")
def create_categoria(
request: Request,
csrf_token: str = Form(""),
categoria: str = Form(""),
palavra_chave: str = Form(""),
):
if not auth.check_csrf(request, csrf_token):
flash(request, "Sessão expirada.", "error")
return RedirectResponse("/categorias/new", status_code=303)
nome = categoria.strip()
keyword = palavra_chave.strip()
if not nome or not keyword:
flash(request, "Informe categoria e palavra-chave válidas.", "error")
return RedirectResponse("/categorias/new", status_code=303)
with db.session() as conn:
db.create_categoria(conn, nome, keyword)
flash(request, "Categoria criada.", "success")
return RedirectResponse("/categorias", status_code=303)
@router.get("/categorias/{categoria_id}/edit")
def edit_form(request: Request, categoria_id: int):
with db.session() as conn:
row = db.get_categoria(conn, categoria_id)
if row is None:
flash(request, "Categoria não encontrada.", "error")
return RedirectResponse("/categorias", status_code=303)
return render(request, "categoria_form.html", categoria=dict(row), mode="edit")
@router.post("/categorias/{categoria_id}/edit")
def update_categoria(
request: Request,
categoria_id: int,
csrf_token: str = Form(""),
categoria: str = Form(""),
palavra_chave: str = Form(""),
):
if not auth.check_csrf(request, csrf_token):
flash(request, "Sessão expirada.", "error")
return RedirectResponse(f"/categorias/{categoria_id}/edit", status_code=303)
nome = categoria.strip()
keyword = palavra_chave.strip()
if not nome or not keyword:
flash(request, "Informe categoria e palavra-chave válidas.", "error")
return RedirectResponse(f"/categorias/{categoria_id}/edit", status_code=303)
with db.session() as conn:
if db.get_categoria(conn, categoria_id) is None:
flash(request, "Categoria não encontrada.", "error")
return RedirectResponse("/categorias", status_code=303)
db.update_categoria(conn, categoria_id, nome, keyword)
flash(request, "Categoria atualizada.", "success")
return RedirectResponse("/categorias", status_code=303)
@router.post("/categorias/{categoria_id}/delete")
def delete_categoria(request: Request, categoria_id: int, csrf_token: str = Form("")):
if not auth.check_csrf(request, csrf_token):
flash(request, "Sessão expirada.", "error")
return RedirectResponse("/categorias", status_code=303)
with db.session() as conn:
deleted = db.delete_categoria(conn, categoria_id)
if deleted:
flash(request, "Categoria excluída.", "info")
else:
flash(request, "A categoria \"Não Encontrado\" é reservada e não pode ser excluída.", "error")
return RedirectResponse("/categorias", status_code=303)
+48
View File
@@ -0,0 +1,48 @@
"""Dashboard: KPIs, gastos por mês e por fornecedor, últimas notas."""
from __future__ import annotations
from fastapi import APIRouter, Query, Request
from .. import database as db
from ..dates import format_competencia
from ..storage import money
from ..templating import render
router = APIRouter()
@router.get("/")
def dashboard(request: Request, category: str = Query("")):
category = category or ""
with db.session() as conn:
categorias = [dict(r) for r in db.list_categorias(conn)]
totals = db.overall_totals(conn, category=category or None)
months = [dict(r) for r in db.monthly_totals(conn, category=category or None)]
suppliers = [dict(r) for r in db.supplier_totals(conn, limit=8, category=category or None)]
categories = [dict(r) for r in db.category_totals(conn)]
recent = [dict(r) for r in db.list_fiscal(conn, category=category or None)][:10]
max_month = max((m["total"] for m in months), default=0) or 1
for m in months:
m["label"] = format_competencia(m["mes"], m["ano"])
m["pct"] = round(m["total"] / max_month * 100, 1)
max_sup = max((s["total"] for s in suppliers), default=0) or 1
for s in suppliers:
s["pct"] = round(s["total"] / max_sup * 100, 1)
max_cat = max((c["total"] for c in categories), default=0) or 1
for c in categories:
c["pct"] = round(c["total"] / max_cat * 100, 1)
return render(
request,
"dashboard.html",
totals=totals,
months=months,
suppliers=suppliers,
categories=categories,
categorias=categorias,
recent=recent,
selected_category=category,
money=money,
)
+263
View File
@@ -0,0 +1,263 @@
"""CRUD de documentos fiscais confirmados."""
from __future__ import annotations
from fastapi import APIRouter, Form, Query, Request
from starlette.responses import RedirectResponse
from .. import auth
from .. import database as db
from ..dates import current_competencia
from ..storage import money
from ..templating import flash, render
router = APIRouter()
PAGE_SIZE = 10
def _page_window(page: int, total_pages: int, span: int = 2) -> list[int | None]:
"""Monta a lista de números de página com None para reticências."""
pages = {1, total_pages, *range(page - span, page + span + 1)}
pages = sorted(p for p in pages if 1 <= p <= total_pages)
windowed: list[int | None] = []
prev = None
for p in pages:
if prev is not None and p - prev > 1:
windowed.append(None)
windowed.append(p)
prev = p
return windowed
def _parse_money(value: str) -> float | None:
text = (value or "").strip().replace("R$", "").replace(" ", "")
if not text:
return None
if "," in text and "." in text:
text = text.replace(".", "").replace(",", ".")
elif "," in text:
text = text.replace(",", ".")
try:
return round(float(text), 2)
except ValueError:
return None
def _parse_competencia_filter(mes: str, ano: str) -> tuple[int, int] | None:
"""(mês, ano) a partir dos `<select>` de filtro "De"/"Até", ou None se o
par não foi informado (filtro de competência é opcional na listagem)."""
mes = (mes or "").strip()
ano = (ano or "").strip()
if not mes or not ano:
return None
try:
mes_val, ano_val = int(mes), int(ano)
except ValueError:
return None
if not (1 <= mes_val <= 12):
return None
return mes_val, ano_val
@router.get("/documents")
def list_documents(
request: Request,
start_mes: str = Query(""),
start_ano: str = Query(""),
end_mes: str = Query(""),
end_ano: str = Query(""),
supplier: str = Query(""),
category: str = Query(""),
sort: str = Query("competencia"),
order: str = Query("desc"),
page: int = Query(1, ge=1),
):
if sort not in db.FISCAL_SORT_COLUMNS:
sort = "competencia"
order = "asc" if order.lower() == "asc" else "desc"
start = _parse_competencia_filter(start_mes, start_ano)
end = _parse_competencia_filter(end_mes, end_ano)
with db.session() as conn:
filter_kwargs = dict(
start=start,
end=end,
supplier=supplier or None,
category=category or None,
)
total_count, total = db.fiscal_summary(conn, **filter_kwargs)
total_pages = max(1, -(-total_count // PAGE_SIZE))
page = min(page, total_pages)
rows = [
dict(r)
for r in db.list_fiscal(
conn,
**filter_kwargs,
sort=sort,
order=order,
limit=PAGE_SIZE,
offset=(page - 1) * PAGE_SIZE,
)
]
categorias = [dict(r) for r in db.list_categorias_por_nome(conn)]
return render(
request,
"documents_list.html",
rows=rows,
total=total,
total_count=total_count,
page=page,
total_pages=total_pages,
page_numbers=_page_window(page, total_pages),
filters={
"start_mes": start_mes,
"start_ano": start_ano,
"end_mes": end_mes,
"end_ano": end_ano,
"supplier": supplier,
"category": category,
},
sort=sort,
order=order,
categorias=categorias,
money=money,
)
def _parse_mes_ano(mes: str, ano: str) -> tuple[int, int]:
"""(mês, ano) a partir dos `<select>` do formulário, com o mesmo fallback
"mês/ano corrente" que o antigo `<input type="date">` tinha para valor em
branco."""
fallback_mes, fallback_ano = current_competencia()
try:
mes_val = int(mes)
except (TypeError, ValueError):
mes_val = None
if mes_val is None or not (1 <= mes_val <= 12):
mes_val = fallback_mes
try:
ano_val = int(ano)
except (TypeError, ValueError):
ano_val = fallback_ano
return mes_val, ano_val
def _parse_categoria_id(value: str) -> int | None:
value = (value or "").strip()
if not value:
return None
try:
return int(value)
except ValueError:
return None
@router.get("/documents/new")
def new_form(request: Request):
with db.session() as conn:
categorias = [dict(r) for r in db.list_categorias_por_nome(conn)]
default_mes, default_ano = current_competencia()
return render(
request,
"document_form.html",
doc=None,
mode="new",
categorias=categorias,
default_mes=default_mes,
default_ano=default_ano,
)
@router.post("/documents/new")
def create_document(
request: Request,
csrf_token: str = Form(""),
mes: str = Form(""),
ano: str = Form(""),
supplier_name: str = Form(""),
total_paid: str = Form(""),
categoria_id: str = Form(""),
):
if not auth.check_csrf(request, csrf_token):
flash(request, "Sessão expirada.", "error")
return RedirectResponse("/documents/new", status_code=303)
total = _parse_money(total_paid)
supplier = supplier_name.strip()
if not supplier or total is None:
flash(request, "Informe fornecedor e valor válidos.", "error")
return RedirectResponse("/documents/new", status_code=303)
mes_val, ano_val = _parse_mes_ano(mes, ano)
with db.session() as conn:
db.create_fiscal(
conn,
mes=mes_val,
ano=ano_val,
supplier_name=supplier,
total_paid=total,
categoria_id=_parse_categoria_id(categoria_id),
)
flash(request, "Documento lançado.", "success")
return RedirectResponse("/documents", status_code=303)
@router.get("/documents/{doc_id}/edit")
def edit_form(request: Request, doc_id: int):
with db.session() as conn:
doc = db.get_fiscal(conn, doc_id)
categorias = [dict(r) for r in db.list_categorias_por_nome(conn)]
if doc is None:
flash(request, "Documento não encontrado.", "error")
return RedirectResponse("/documents", status_code=303)
return render(request, "document_form.html", doc=dict(doc), mode="edit", categorias=categorias)
@router.post("/documents/{doc_id}/edit")
def update_document(
request: Request,
doc_id: int,
csrf_token: str = Form(""),
mes: str = Form(""),
ano: str = Form(""),
supplier_name: str = Form(""),
total_paid: str = Form(""),
categoria_id: str = Form(""),
):
if not auth.check_csrf(request, csrf_token):
flash(request, "Sessão expirada.", "error")
return RedirectResponse(f"/documents/{doc_id}/edit", status_code=303)
total = _parse_money(total_paid)
supplier = supplier_name.strip()
if not supplier or total is None:
flash(request, "Informe fornecedor e valor válidos.", "error")
return RedirectResponse(f"/documents/{doc_id}/edit", status_code=303)
mes_val, ano_val = _parse_mes_ano(mes, ano)
with db.session() as conn:
if db.get_fiscal(conn, doc_id) is None:
flash(request, "Documento não encontrado.", "error")
return RedirectResponse("/documents", status_code=303)
db.update_fiscal(
conn,
doc_id,
mes=mes_val,
ano=ano_val,
supplier_name=supplier,
total_paid=total,
categoria_id=_parse_categoria_id(categoria_id),
)
flash(request, "Documento atualizado.", "success")
return RedirectResponse("/documents", status_code=303)
@router.post("/documents/{doc_id}/delete")
def delete_document(request: Request, doc_id: int, csrf_token: str = Form("")):
if not auth.check_csrf(request, csrf_token):
flash(request, "Sessão expirada.", "error")
return RedirectResponse("/documents", status_code=303)
with db.session() as conn:
db.delete_fiscal(conn, doc_id)
flash(request, "Documento excluído.", "info")
return RedirectResponse("/documents", status_code=303)
+39
View File
@@ -0,0 +1,39 @@
"""Servir arquivos enviados SOMENTE via rota autenticada.
Documentos fiscais contêm CPF/CNPJ, endereços e valores — nunca ficam num
static mount público. O acesso passa pela guarda de sessão do middleware.
"""
from __future__ import annotations
from pathlib import Path
from fastapi import APIRouter, Request
from starlette.responses import FileResponse, Response
from .. import database as db
from ..config import get_settings
router = APIRouter()
@router.get("/files/{upload_id}")
def serve_file(request: Request, upload_id: int):
settings = get_settings()
with db.session() as conn:
row = db.get_upload(conn, upload_id)
if row is None:
return Response("Arquivo não encontrado.", status_code=404)
stored = Path(row["stored_path"]).resolve()
upload_root = settings.upload_dir.resolve()
# trava contra path traversal: o arquivo precisa estar dentro de data/uploads
if upload_root not in stored.parents or not stored.is_file():
return Response("Arquivo indisponível.", status_code=404)
return FileResponse(
str(stored),
media_type=row["content_type"] or "application/octet-stream",
filename=row["original_name"],
content_disposition_type="inline",
)
+203
View File
@@ -0,0 +1,203 @@
"""Fluxo de importação: upload -> staging (revisão) -> confirmação em lote."""
from __future__ import annotations
from pathlib import Path
from fastapi import APIRouter, File, Form, Request, UploadFile
from starlette.responses import RedirectResponse
from .. import auth
from .. import database as db
from ..config import get_settings
from ..dates import parse_competencia
from ..ingestion import extract_file
from ..storage import money, safe_original_name, unique_storage_name
from ..templating import flash, render
router = APIRouter()
def _confidence(legible: bool, uncertain: list[str]) -> str:
if legible and not uncertain:
return "high"
if len(uncertain) >= 2 or not legible:
return "low"
return "medium"
@router.post("/upload")
def upload(request: Request, csrf_token: str = Form(""), files: list[UploadFile] = File(...)):
# Rota síncrona (não `async def`) de propósito: o processamento de cada
# arquivo (render de PDF, OCR de fallback, chamada à IA) é bloqueante e
# pode levar vários segundos. Starlette roda rotas síncronas numa
# threadpool, então isso libera o event loop único do processo para
# continuar atendendo outras requisições (dashboard, login etc.)
# enquanto este upload é processado.
if not auth.check_csrf(request, csrf_token):
flash(request, "Sessão expirada. Tente novamente.", "error")
return RedirectResponse("/", status_code=303)
settings = get_settings()
accepted = 0
rejected = 0
with db.session() as conn:
batch_id = db.create_batch(conn)
for upload_file in files:
original = safe_original_name(upload_file.filename or "upload")
suffix = Path(original).suffix.lower()
content = upload_file.file.read()
if suffix not in settings.allowed_extensions or len(content) > settings.max_upload_bytes:
rejected += 1
continue
stored_name = unique_storage_name(original)
stored_path = settings.upload_dir / stored_name
stored_path.write_bytes(content)
content_type = upload_file.content_type or "application/octet-stream"
upload_id = db.insert_upload(
conn, batch_id, original, stored_path, content_type, len(content), commit=False
)
try:
extractions = extract_file(stored_path, original)
for raw in extractions:
# Sem fallback aqui: se a extração não identificou uma
# competência plausível, mes/ano ficam nulos até o usuário
# confirmar na tela de revisão (competência é obrigatória
# antes de importar, não pode ser preenchida silenciosamente).
parsed = parse_competencia(raw.purchase_date_raw)
mes, ano = parsed if parsed else (None, None)
uncertain = list(raw.uncertain_fields)
db.insert_detected(
conn,
upload_id=upload_id,
batch_id=batch_id,
source_file_name=original,
source_page=None,
source_location=original,
raw_text=raw.raw_text,
mes=mes,
ano=ano,
supplier_name=raw.supplier_name,
total_paid=raw.total_paid,
confidence=_confidence(raw.legible, uncertain),
field_confidence={},
legible=raw.legible,
uncertain_fields=uncertain,
extractor=raw.extractor,
commit=False,
)
accepted += 1
status = "processed" if extractions else "needs_attention"
message = None if extractions else "Nenhum documento fiscal detectado."
db.update_upload_status(conn, upload_id, status, len(extractions), message, commit=False)
except Exception as exc: # nunca deixa um arquivo derrubar o lote
db.update_upload_status(conn, upload_id, "failed", 0, str(exc)[:300], commit=False)
conn.commit()
if rejected:
flash(request, f"{rejected} arquivo(s) recusado(s). Use PDF/JPG/PNG até {settings.max_upload_bytes // (1024*1024)} MB.", "error")
if accepted == 0:
flash(request, "Nenhum documento foi extraído dos arquivos enviados.", "error")
return RedirectResponse("/", status_code=303)
return RedirectResponse(f"/import/{batch_id}/review", status_code=303)
@router.get("/import/{batch_id}/review")
def review(request: Request, batch_id: int):
with db.session() as conn:
batch = db.get_batch(conn, batch_id)
if batch is None:
flash(request, "Lote de importação não encontrado.", "error")
return RedirectResponse("/", status_code=303)
rows = [dict(r) for r in db.staged_documents(conn, batch_id)]
summary = db.batch_summary(conn, batch_id)
return render(
request,
"staging.html",
batch=dict(batch),
rows=rows,
summary=summary,
money=money,
)
def _parse_form_int(value: str, *, min_value: int | None = None, max_value: int | None = None) -> int | None:
value = (value or "").strip()
if not value.isdigit():
return None
parsed = int(value)
if min_value is not None and parsed < min_value:
return None
if max_value is not None and parsed > max_value:
return None
return parsed
@router.post("/import/{batch_id}/update/{detected_id}")
def update_row(
request: Request,
batch_id: int,
detected_id: int,
csrf_token: str = Form(""),
mes: str = Form(""),
ano: str = Form(""),
supplier_name: str = Form(""),
total_paid: str = Form(""),
):
if not auth.check_csrf(request, csrf_token):
flash(request, "Sessão expirada.", "error")
return RedirectResponse(f"/import/{batch_id}/review", status_code=303)
try:
total = float(total_paid.replace(".", "").replace(",", ".")) if "," in total_paid else float(total_paid or 0)
except ValueError:
flash(request, "Valor inválido na correção.", "error")
return RedirectResponse(f"/import/{batch_id}/review", status_code=303)
# Sem fallback automático: mes/ano só ficam preenchidos se o usuário
# realmente selecionou um valor nos <select> — competência ausente
# continua bloqueando a confirmação do lote (ver `batch_summary`).
mes_val = _parse_form_int(mes, min_value=1, max_value=12)
ano_val = _parse_form_int(ano, min_value=1900)
with db.session() as conn:
db.update_staged(
conn,
detected_id,
mes=mes_val,
ano=ano_val,
supplier_name=supplier_name.strip(),
total_paid=round(total, 2),
legible=True,
)
flash(request, "Documento corrigido.", "success")
return RedirectResponse(f"/import/{batch_id}/review", status_code=303)
@router.post("/import/{batch_id}/discard/{detected_id}")
def discard_row(request: Request, batch_id: int, detected_id: int, csrf_token: str = Form("")):
if auth.check_csrf(request, csrf_token):
with db.session() as conn:
db.discard_staged(conn, detected_id)
flash(request, "Documento descartado do lote.", "info")
return RedirectResponse(f"/import/{batch_id}/review", status_code=303)
@router.post("/import/{batch_id}/confirm")
def confirm(request: Request, batch_id: int, csrf_token: str = Form("")):
if not auth.check_csrf(request, csrf_token):
flash(request, "Sessão expirada.", "error")
return RedirectResponse(f"/import/{batch_id}/review", status_code=303)
with db.session() as conn:
summary = db.batch_summary(conn, batch_id)
if summary["pendentes"] > 0:
flash(request, f"Ainda há {summary['pendentes']} documento(s) com problema (ilegível ou sem fornecedor/valor). Corrija ou descarte antes de importar.", "error")
return RedirectResponse(f"/import/{batch_id}/review", status_code=303)
inserted = db.confirm_batch(conn, batch_id)
flash(request, f"{inserted} documento(s) importado(s) com sucesso.", "success")
return RedirectResponse("/documents", status_code=303)