Commit inicial - upload de todos os arquivos da pasta
This commit is contained in:
@@ -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