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