"""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