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
+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)