Casos de uso y soluciones

Extraer datos de facturas y pedidos con OCR a Excel desde PDF, escaneos y fotos con un LLM local

6 pasos 18 min de lectura 20 vistas 0
Contenido

Los equipos de operaciones y finanzas dedican horas a transcribir a hojas de cálculo facturas, órdenes de compra, notas de entrega y presupuestos de proveedores. Con OCR para leer escaneos y fotos, y un modelo de lenguaje local que devuelve exactamente los campos que usted define, se puede automatizar la mayor parte de ese trabajo de transcripción. Esta guía combina Tesseract y OCRmyPDF con la salida estructurada de Ollama: cada documento se convierte en un archivo JSON con los campos extraídos, y una hoja de resumen marca los documentos cuyas líneas no cuadran con el total, de modo que las personas solo revisan las excepciones. Todo se ejecuta en su propio servidor.

Trate las cifras extraídas como borradores: compruebe los totales, los datos bancarios y los números de documento antes de usarlos para pagos, contabilidad o aduanas. Los documentos con datos personales deben procesarse conforme a sus obligaciones de privacidad; hacerlo en su propio servidor ayuda, pero no las sustituye.

Paso 1: Instalar las herramientas

# Debian / Ubuntu: PDF text, OCR engine with English and Simplified Chinese, OCR for PDFs
apt install -y python3-venv poppler-utils tesseract-ocr tesseract-ocr-eng tesseract-ocr-chi-sim ocrmypdf
python3 -m venv /opt/extract
/opt/extract/bin/pip install ollama
ollama pull qwen2.5:7b

La configuración de Ollama se explica en ejecutar un LLM local con Ollama. Para otros idiomas, instale el paquete de Tesseract correspondiente, por ejemplo tesseract-ocr-jpn, tesseract-ocr-tha o tesseract-ocr-rus.

Paso 2: Comprobar cómo se extrae el texto de sus documentos

# Digital PDF (created by software): the text can be read directly
pdftotext -layout invoice.pdf - | head -40

# Scanned PDF: add a text layer with OCR first, then read it
ocrmypdf -l eng+chi_sim --skip-text scan.pdf scan-ocr.pdf
pdftotext -layout scan-ocr.pdf - | head -40

# Photo or screenshot
tesseract photo.jpg - -l eng+chi_sim | head -40

Si el texto que obtiene aquí es legible, el modelo podrá extraer los campos. Si sale ilegible, mejore primero la entrada: escanee a 300 ppp y fotografíe los documentos planos y bien iluminados.

Paso 3: Crear el script de extracción

El SCHEMA define qué campos desea obtener. Ollama obliga a que la respuesta del modelo siga exactamente esta estructura, por lo que la salida siempre es JSON válido.

cat > /opt/extract/extract.py <<'EOF2'
import csv, json, os, subprocess, sys, tempfile
import ollama

MODEL = os.environ.get("MODEL", "qwen2.5:7b")
LANG = os.environ.get("OCR_LANG", "eng+chi_sim")
SCHEMA = {
    "type": "object",
    "properties": {
        "supplier": {"type": "string"},
        "document_number": {"type": "string"},
        "date": {"type": "string", "description": "YYYY-MM-DD"},
        "currency": {"type": "string"},
        "total": {"type": "number"},
        "tax": {"type": "number"},
        "items": {"type": "array", "items": {"type": "object", "properties": {
            "description": {"type": "string"}, "quantity": {"type": "number"},
            "unit_price": {"type": "number"}, "amount": {"type": "number"}},
            "required": ["description", "quantity", "amount"]}},
    },
    "required": ["supplier", "document_number", "date", "currency", "total", "tax", "items"],
}

def text_of(path):
    run = lambda cmd: subprocess.run(cmd, capture_output=True, text=True).stdout
    if not path.lower().endswith(".pdf"):
        return run(["tesseract", path, "-", "-l", LANG])
    text = run(["pdftotext", "-layout", path, "-"])
    if len(text.strip()) > 50:
        return text
    with tempfile.TemporaryDirectory() as tmp:           # scanned PDF: OCR it first
        ocr = os.path.join(tmp, "ocr.pdf")
        subprocess.run(["ocrmypdf", "-q", "-l", LANG, "--skip-text", path, ocr], check=True)
        return run(["pdftotext", "-layout", ocr, "-"])

rows = []
for path in sys.argv[1:]:
    if not os.path.isfile(path):
        continue
    r = ollama.chat(model=MODEL, format=SCHEMA, options={"temperature": 0, "num_ctx": 8192}, messages=[
        {"role": "system", "content": "Extract the requested fields from the document text. "
         "Copy names, numbers and IDs exactly as written. Use an empty string or 0 "
         "for anything that is not in the text. Do not guess."},
        {"role": "user", "content": text_of(path)[:15000]}])
    d = json.loads(r.message.content)
    items = round(sum(i.get("amount", 0) for i in d["items"]), 2)
    ok = d["total"] > 0 and min(abs(items - d["total"]), abs(items + d["tax"] - d["total"])) < 0.02
    with open(os.path.splitext(path)[0] + ".json", "w", encoding="utf-8") as f:
        json.dump(d, f, ensure_ascii=False, indent=2)
    rows.append([os.path.basename(path), d["supplier"], d["document_number"], d["date"],
                 d["currency"], d["total"], d["tax"], len(d["items"]), "OK" if ok else "CHECK"])
    print(path, "OK" if ok else "CHECK", flush=True)

with open("summary.csv", "w", newline="", encoding="utf-8-sig") as f:
    w = csv.writer(f)
    w.writerow(["file", "supplier", "document_number", "date", "currency", "total", "tax", "lines", "check"])
    w.writerows(rows)
EOF2

Paso 4: Procesar una carpeta de documentos

cd /data/invoices/2026-10
/opt/extract/bin/python /opt/extract/extract.py *.pdf *.jpg *.png
# For a large folder, run it in the background:
nohup /opt/extract/bin/python /opt/extract/extract.py *.pdf > extract.log 2>&1 &

Junto a cada documento se crea un archivo .json con todos los campos y líneas, y summary.csv contiene una fila por documento. Ábralo en Excel y filtre la columna check: OK significa que la suma de las líneas coincide con el total (con o sin impuestos), y CHECK, que una persona debe revisar ese documento.

Paso 5: Adaptar los campos a sus documentos

  • Órdenes de compra: añada buyer, delivery_date y campos de SKU en las líneas.
  • Documentos de envío: use campos como tracking_number, consignee, weight_kg y packages.
  • Presupuestos de proveedores: extraiga valid_until, payment_terms y los precios unitarios para comparar proveedores en una sola hoja.

Una breve description en un campo, como la de date en el ejemplo anterior, indica al modelo el formato que espera. Si procesa varios tipos de documentos, mantenga un esquema por tipo en scripts o carpetas separados.

Paso 6: Mejorar la precisión

  • Los PDF digitales dan los mejores resultados; con escaneos y fotos, el resultado depende de la calidad de la imagen.
  • Mantenga la temperatura en 0 para que el mismo documento dé siempre el mismo resultado.
  • Los modelos más grandes, como qwen2.5:14b, manejan mejor los diseños complejos y las tablas largas, a costa de velocidad.
  • Guarde el archivo original junto a su JSON para poder rastrear cada cifra durante una auditoría.

Preguntas frecuentes

¿Cuánto tarda cada documento?

En un servidor con CPU, normalmente desde unas decenas de segundos para una factura de una página hasta unos minutos para documentos largos de varias páginas, según el modelo. Ejecute los lotes grandes durante la noche.

¿Puedo enviar los documentos a una IA en la nube?

Muchas API de IA también admiten salida estructurada, pero en ese caso los documentos salen de su servidor. Revise primero sus obligaciones de privacidad, las condiciones del proveedor y sus regiones admitidas. Para facturas, contratos y datos de clientes, la configuración local suele ser la opción más segura.

¿Pueden los resultados ir directamente a nuestro sistema contable o ERP?

La mayoría de los sistemas pueden importar archivos CSV; asigne las columnas de summary.csv o convierta los archivos JSON al formato de importación. Importe automáticamente solo las filas marcadas como OK y deje las filas CHECK para revisión manual.

¿Sigue sin resolverlo después de seguir estos pasos? Abra un ticket de soporte y el equipo técnico de IMIDC, disponible 24/7, le ayudará. Incluya la IP del servidor, la versión del sistema operativo, el modelo que utiliza, la salida del error y, si es posible, un documento de ejemplo sin datos sensibles, para que podamos localizar el problema más rápido.

¿Fue útil la respuesta?

Tutoriales relacionados