"""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 resolve_purchase_date 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") async def upload(request: Request, csrf_token: str = Form(""), files: list[UploadFile] = File(...)): 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 = await upload_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)) try: extractions = extract_file(stored_path, original) for raw in extractions: purchase_date = resolve_purchase_date(raw.purchase_date_raw) 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, purchase_date=purchase_date, 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, ) 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) except Exception as exc: # nunca deixa um arquivo derrubar o lote db.update_upload_status(conn, upload_id, "failed", 0, str(exc)[:300]) 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, ) @router.post("/import/{batch_id}/update/{detected_id}") def update_row( request: Request, batch_id: int, detected_id: int, csrf_token: str = Form(""), purchase_date: 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) resolved_date = resolve_purchase_date(purchase_date) with db.session() as conn: db.update_staged( conn, detected_id, purchase_date=resolved_date, 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)