Files
Autoparts-DB/pos/services/invoice_ocr.py
consultoria-as a386ca5b4a
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
Mejoras de sesion: estabilizacion, IA, Nexus Yonke y marketplace B2B
- F0 blindaje: Redis/Celery, pg_hba sin trust LAN, secretos a env 600, backups, webhook auth, jobs muertos
- F1 caja: bugs POS, reembolsos al corte, apartados, CFDI, topes de descuento en cotizacion/apartado
- F2/F7 IA de catalogo: autocategorizacion, dedup de SKUs con embeddings, OCR de facturas, descripciones, claves SAT, limpieza de fitment
- F3 IA en el flujo: vision Yonke, preguntas MeLi, venta cruzada, anomalias, sinonimos, busqueda semantica (rerank/embeddings)
- F4/F8 escala y cartera: /metrics+Prometheus+Grafana, pool, score de credito, churn, metricas multi-tenant
- Nexus Yonke (M1-M6): modelo de datos 1-de-1, alta por donador, vitrina publica, MeLi usado, consignacion, garantia por dias, reportes, etiquetas
- Marketplace B2B entre yonkes (catalogo + pedidos cross-tenant)
- Seguridad: auth en /api/admin/* (35 endpoints), alertas por tenant
- Migraciones v4.27/v4.28; timers expire-demos y rollup-metrics
2026-10-12 00:57:31 +00:00

475 lines
15 KiB
Python

# /home/Autopartes/pos/services/invoice_ocr.py
"""Supplier-invoice OCR / parsing.
Accepts an image or PDF of a supplier invoice and returns structured lines:
[{part_number, name, quantity, unit_cost}]
Pipeline (first stage that yields lines wins):
1. PDF with a text layer -> ``pdftotext -layout``
2. Scanned PDF -> ``pdftoppm`` (first page) + Tesseract
3. Image -> Tesseract
4. Vision fallback -> ``ai_chat`` (QWEN / OpenRouter vision model)
Stock is NEVER touched here: the caller returns the parsed lines so the
front-end can confirm before creating a purchase order.
Public API:
parse_invoice(data, filename=None, content_type=None, use_vision=True) -> dict
"""
import base64
import io
import json
import os
import re
import shutil
import subprocess
import tempfile
_MONEY_RE = re.compile(r"^\$?\s?\d{1,3}(?:[,\d]{0,15})(?:\.\d{1,2})?$")
_INT_RE = re.compile(r"^\d{1,4}$")
_PN_RE = re.compile(r"^[A-Z0-9][A-Z0-9\-\./_]{2,}$")
# Lines that are clearly headers/totals, not invoice items.
_SKIP_TOKENS = {
"factura", "remision", "total", "subtotal", "iva", "ieps", "rfc", "fecha",
"pedido", "cliente", "proveedor", "telefono", "direccion", "calle",
"colonia", "cantidad", "cant", "descripcion", "precio", "importe",
"unidad", "folio", "orden", "pagina", "gracias", "nota", "clave",
"codigo", "producto", "articulo", "descuento", "moneda", "condiciones",
"subtotal:", "total:", "iva:", "rfc:", "fecha:", "importe:", "precio:",
}
# Any of these as a whole word marks a header/footer line (RFC, date, totals).
_HEADER_WORDS = {
"rfc", "fecha", "folio", "cliente", "proveedor", "factura", "remision",
"remisión", "pedido", "telefono", "tel", "direccion", "dirección", "calle",
"colonia", "condiciones", "moneda", "subtotal", "total", "iva", "ieps",
"clave", "descripcion", "descripción", "cantidad", "unidad", "descuento",
"importe", "precio", "page",
}
_VISION_PROMPT = (
"Eres un experto en facturas de refaccionarias mexicanas. Extrae TODAS las "
"lineas de producto de la imagen. Devuelve SOLO JSON valido, sin texto extra, "
"con esta forma exacta: "
'{"lines":[{"part_number":"...","name":"...","quantity":1,"unit_cost":0.0}]}. '
"part_number = clave/SKU/numero de parte; name = descripcion; quantity = "
"cantidad (entero); unit_cost = precio unitario (numero, sin simbolos). "
"Si no hay lineas claras devuelve {\"lines\": []}."
)
# ── Binary helpers ─────────────────────────────────────────────────────────
def _is_pdf(data, filename):
if data[:5] == b"%PDF-":
return True
return bool(filename and filename.lower().endswith(".pdf"))
def _pdftotext(data):
"""Extract the text layer of a PDF. Returns '' on failure."""
exe = shutil.which("pdftotext")
if not exe:
return ""
try:
proc = subprocess.run(
[exe, "-layout", "-", "-"],
input=data, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=60,
)
if proc.returncode == 0:
return proc.stdout.decode("utf-8", "replace")
except Exception:
pass
return ""
def _pdf_first_page_png(data, dpi=200):
"""Render the first PDF page to PNG bytes (for OCR). None on failure."""
exe = shutil.which("pdftoppm")
if not exe:
return None
try:
with tempfile.TemporaryDirectory() as td:
pdf_path = os.path.join(td, "in.pdf")
with open(pdf_path, "wb") as f:
f.write(data)
prefix = os.path.join(td, "page")
subprocess.run(
[exe, "-png", "-r", str(dpi), "-f", "1", "-l", "1", pdf_path, prefix],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=120,
)
for fn in sorted(os.listdir(td)):
if fn.startswith("page") and fn.endswith(".png"):
with open(os.path.join(td, fn), "rb") as f:
return f.read()
except Exception:
pass
return None
def _ocr_langs():
try:
import pytesseract
langs = pytesseract.get_languages(config="")
for wanted in ("spa", "eng"):
if wanted in langs:
return wanted
except Exception:
pass
return "eng"
def _ocr_image(img_bytes):
"""Run Tesseract over image bytes. Returns '' on failure."""
try:
from PIL import Image
import pytesseract
except Exception:
return ""
try:
img = Image.open(io.BytesIO(img_bytes))
if img.mode not in ("RGB", "L"):
img = img.convert("RGB")
return pytesseract.image_to_string(img, lang=_ocr_langs())
except Exception:
return ""
def _ocr_rows(img_bytes, y_tolerance=12):
"""OCR an image and rebuild table rows from word positions.
Tesseract's default layout analysis splits a monospaced invoice table into
one block per column, so ``image_to_string`` returns each column stacked
instead of each row. We regroup words by their vertical center instead.
Returns a list of row strings (falls back to plain lines on failure).
"""
try:
from PIL import Image
import pytesseract
except Exception:
return []
try:
img = Image.open(io.BytesIO(img_bytes))
if img.mode not in ("RGB", "L"):
img = img.convert("RGB")
data = pytesseract.image_to_data(
img, lang=_ocr_langs(), output_type=pytesseract.Output.DICT
)
except Exception:
text = _ocr_image(img_bytes)
return text.splitlines() if text else []
groups = {} # representative y -> list of (left, text)
for i, txt in enumerate(data.get("text", [])):
txt = (txt or "").strip()
if not txt:
continue
try:
top = int(data["top"][i])
h = int(data["height"][i] or 0)
left = int(data["left"][i])
except (KeyError, ValueError, TypeError):
continue
cy = top + h // 2
key = None
for k in groups:
if abs(k - cy) <= max(y_tolerance, h // 2):
key = k
break
groups.setdefault(cy if key is None else key, []).append((left, txt))
rows = []
for cy in sorted(groups):
rows.append(" ".join(t for _, t in sorted(groups[cy])))
return rows
def _ocr_rows_text(img_bytes):
"""Row-reconstructed OCR text (falls back to raw Tesseract output)."""
rows = _ocr_rows(img_bytes)
if rows:
return "\n".join(rows)
return _ocr_image(img_bytes)
# ── Text line parser ───────────────────────────────────────────────────────
def _is_money(tok):
if tok.startswith("$"):
return True
if not _MONEY_RE.match(tok):
return False
return ("." in tok) or ("," in tok)
def _to_money(tok):
cleaned = re.sub(r"[^\d.]", "", tok.replace(",", ""))
try:
return float(cleaned)
except ValueError:
return None
def _is_part_number(tok):
if _is_money(tok):
return False
if not _PN_RE.match(tok):
return False
has_alpha = any(c.isalpha() for c in tok)
digits = sum(c.isdigit() for c in tok)
if has_alpha:
return digits >= 1 and len(tok) >= 4
# pure numeric part numbers are accepted only if long enough to not be a qty
return len(tok) >= 4
def _skip_line(line):
low = line.lower()
if low.strip(".:-") in _SKIP_TOKENS:
return True
words = set(re.findall(r"[a-záéíóúñ]+", low))
if words & _HEADER_WORDS:
return True
if words and all(t in _SKIP_TOKENS or t in ("de", "del", "la", "el") for t in words):
return True
return False
def _parse_line(line):
tokens = line.split()
if len(tokens) < 3:
return None
money_idx = [i for i, t in enumerate(tokens) if _is_money(t)]
int_idx = [i for i, t in enumerate(tokens) if _INT_RE.match(t)]
pn_idx = [i for i, t in enumerate(tokens) if _is_part_number(t)]
if not pn_idx or (not money_idx and not int_idx):
return None
qty, qty_i = None, None
for i in int_idx:
if i in money_idx:
continue
v = int(tokens[i])
if 1 <= v <= 9999:
qty, qty_i = v, i
break
unit, unit_i = None, None
if money_idx:
unit_i = money_idx[0]
unit = _to_money(tokens[unit_i])
pn_i = pn_idx[0]
used = {i for i in (pn_i, qty_i) if i is not None} | set(money_idx)
name = " ".join(t for i, t in enumerate(tokens) if i not in used).strip(" -|")
if not name:
name = None
if qty is None and unit is None:
return None
return {
"part_number": tokens[pn_i],
"name": name,
"quantity": qty,
"unit_cost": unit,
}
def parse_lines(text):
"""Parse raw invoice text into structured lines."""
out = []
seen = set()
for raw in (text or "").splitlines():
line = raw.strip()
if len(line) < 6 or _skip_line(line):
continue
row = _parse_line(line)
if not row:
continue
key = (row["part_number"].upper(), row["quantity"], row["unit_cost"])
if key in seen:
continue
seen.add(key)
out.append(row)
return out
# ── Vision fallback ────────────────────────────────────────────────────────
def _extract_json(text):
if not text:
return None
s = text.strip()
if s.startswith("```"):
s = re.sub(r"^```[a-zA-Z]*\n?", "", s)
s = re.sub(r"\n?```$", "", s)
try:
return json.loads(s)
except Exception:
pass
m = re.search(r"\{.*\}", s, re.DOTALL)
if m:
try:
return json.loads(m.group(0))
except Exception:
return None
return None
def _coerce_lines(obj):
if not obj:
return []
raw = obj.get("lines") if isinstance(obj, dict) else None
if raw is None and isinstance(obj, list):
raw = obj
if not isinstance(raw, list):
return []
lines = []
for item in raw:
if not isinstance(item, dict):
continue
pn = item.get("part_number") or item.get("clave") or item.get("sku")
name = item.get("name") or item.get("descripcion") or item.get("description")
qty = item.get("quantity", item.get("cantidad"))
cost = item.get("unit_cost", item.get("precio", item.get("unit_price")))
try:
qty = int(qty) if qty is not None else None
except (ValueError, TypeError):
qty = None
try:
cost = float(cost) if cost is not None else None
except (ValueError, TypeError):
cost = None
if pn or name:
lines.append({
"part_number": str(pn).strip() if pn else None,
"name": str(name).strip() if name else None,
"quantity": qty, "unit_cost": cost,
})
return lines
def _vision_extract(image_bytes, hint_text=None):
"""Extract lines with the vision model. Returns (lines, error)."""
try:
from services import ai_chat
except Exception as e:
return [], f"ai_chat import: {e}"
b64 = base64.b64encode(image_bytes).decode("ascii")
prompt = _VISION_PROMPT
if hint_text:
prompt += "\n\nTexto OCR parcial (puede estar incompleto):\n" + hint_text[:1500]
# 1) reuse chat_with_image (per task requirement)
try:
res = ai_chat.chat_with_image(prompt, b64)
lines = _coerce_lines(res)
if lines:
return lines, None
msg = res.get("message") if isinstance(res, dict) else None
lines = _coerce_lines(_extract_json(msg) if msg else None)
if lines:
return lines, None
except Exception:
pass
# 2) direct vision call with an invoice-specific system prompt
try:
data_url = "data:image/jpeg;base64," + b64
messages = [
{"role": "system", "content": "Responde SOLO JSON valido."},
{"role": "user", "content": [
{"type": "image_url", "image_url": {"url": data_url}},
{"type": "text", "text": prompt},
]},
]
backends = []
if ai_chat.QWEN_ENABLED:
backends.append((ai_chat.QWEN_CHAT_URL, ai_chat.QWEN_API_KEY, ai_chat.QWEN_MODEL))
if ai_chat.OPENROUTER_API_KEY:
backends.append((ai_chat.OPENROUTER_URL, ai_chat.OPENROUTER_API_KEY, ai_chat.VISION_MODEL))
for url, key, model in backends:
result = ai_chat._post_chat_completion(
url, key, model, messages, max_tokens=1200, temperature=0.1, timeout=60
)
if not result:
continue
lines = _coerce_lines(_extract_json(result["content"]))
if lines:
return lines, None
return [], "vision sin lineas"
except Exception as e:
return [], f"vision: {e}"
# ── Public API ─────────────────────────────────────────────────────────────
def parse_invoice(data, filename=None, content_type=None, use_vision=True):
"""Parse an invoice image/PDF into structured lines.
Returns:
{
"lines": [{part_number, name, quantity, unit_cost}],
"count": int,
"method": str, # pdftotext | pdftoppm+tesseract | tesseract | ...+vision
"warnings": [str],
"text_preview": str,
}
"""
warnings = []
text = ""
method = "unknown"
vision_image = None # bytes to hand to the vision model when OCR fails
if not data:
return {"lines": [], "count": 0, "method": "empty",
"warnings": ["archivo vacío"], "text_preview": ""}
if _is_pdf(data, filename):
text = _pdftotext(data)
if len(text.strip()) >= 30:
method = "pdftotext"
else:
png = _pdf_first_page_png(data)
if png:
vision_image = png
text = _ocr_rows_text(png)
method = "pdftoppm+tesseract" if text.strip() else "pdftoppm"
if not text.strip():
warnings.append("PDF sin capa de texto y OCR vacío")
else:
method = "pdf"
warnings.append("No se pudo renderizar el PDF")
if not vision_image:
# for vision fallback on a text PDF we still need a raster copy
vision_image = _pdf_first_page_png(data)
else:
vision_image = data
text = _ocr_rows_text(data)
method = "tesseract"
if not text.strip():
warnings.append("OCR sin texto reconocible")
lines = parse_lines(text)
if not lines and use_vision and vision_image:
vlines, verr = _vision_extract(vision_image, hint_text=text)
if vlines:
lines = vlines
method += "+vision"
elif verr:
warnings.append(f"fallback visión sin resultados: {verr}")
return {
"lines": lines,
"count": len(lines),
"method": method,
"warnings": warnings,
"text_preview": text[:800],
}