170 lines
6.8 KiB
Python
170 lines
6.8 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 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)
|