204 lines
8.3 KiB
Python
204 lines
8.3 KiB
Python
"""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)
|