Commit inicial - upload de todos os arquivos da pasta

This commit is contained in:
2026-07-24 15:51:10 -03:00
commit efa06def71
44 changed files with 3604 additions and 0 deletions
+169
View File
@@ -0,0 +1,169 @@
"""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)