Operations teams and back-office engineering groups still lose hundreds of hours every month manually keying data between ERP systems, customer PDF purchase orders, and messy spreadsheets.
The initial reaction across executive suites is almost always identical: Let us hook up an LLM or drop a chatbot wrapper on the inbox.
A week later, the engineering team hits reality:
- A single-prompt LLM hallucinates an invoice total (reading
128,490 USDas1,284,900 USDfrom a stray decimal or header subtotal). - Multi-line purchase orders drop SKUs or misapply tiered discounts.
- Synchronous ERP worker processes freeze while waiting 15 seconds for an external AI API response.
- Inside sales reps spend more time auditing the AI's output than they spent typing the orders manually.
You do not need another conversational chatbot. You need an engineered, deterministic data pipeline.
In this post, we break down why single-prompt AI fails on live ERP ledgers, and the exact Python architecture we use at SZG Labs to decouple document extraction, enforce mathematical invariants, and stage verified draft orders in ERPs like Odoo without blocking production workers.
1. The Anatomy of Failure: Why 95% Demo Accuracy Fails in Production
When you test an LLM on an isolated PDF invoice or a clean 5-line CSV, it looks like magic. Accuracy hovers around 95%.
In live enterprise operations, 95% is unusable. If a mid-market distributor processes 2,000 purchase orders per month averaging 40 line items each (80,000 database records), a 5% line-level error rate produces 4,000 corrupted ERP records every month.
Those failures trace back to three architectural anti-patterns:
Anti-Pattern A: Probabilistic Arithmetic
LLMs are next-token predictors, not calculators. Asking a model to extract quantities, compute tiered discounts, apply tax rules, and reconcile a grand total in one pass guarantees silent drift on edge cases.
Anti-Pattern B: Unvalidated SKU Guessing
Without strict runtime schema checks and catalog grounding against your database, an LLM will happily emit plausible-looking part numbers or match competitor SKUs to the wrong internal product ID.
Anti-Pattern C: Synchronous Worker Starvation
Monolithic ERPs (such as Odoo) run on synchronous worker pools (prefork or gevent). If an automated action or webhook invokes an external LLM call directly inside an ERP request thread, every 10-to-20 second PDF extraction locks a live worker thread and degrades UI responsiveness for your warehouse and sales staff.
2. The Architecture: Decoupled, Deterministic, Multi-Stage
To reach production reliability, we completely separate probabilistic extraction from deterministic validation and ERP writes:
[Inbound Email / PDF / EDI] | v [Async Ingestion Queue (FastAPI + Redis/Celery)] | v [Stage 1: Layout & Entity Extractor] | (Extracts raw line items & metadata only) v [Stage 2: Pydantic Math & Schema Gate] | (Deterministic Python: qty * price == line_total) | (Fails -> retry with error diff -> quarantine queue) v [Stage 3: Read-Only Catalog & Partner Matcher] | (Exact SKU + fuzzy fallback against DB replica) v [Stage 4: Idempotent Atomic ERP Bridge (XML-RPC / REST)] | (Checks PO uniqueness, stages 1-shot draft order) v [One-Click Human Approval in ERP] Notice what never happens: the AI never performs unverified math, never blocks an ERP worker thread, and never auto-confirms an order that fails an invariant check.
3. Implementation Blueprint in Python
Here is the core pattern we deploy in production.
Step 1: Hard Mathematical Invariants with Pydantic
Before any extracted payload can reach an ERP connector, Pydantic enforces decimal precision and arithmetic consistency.
from decimal import Decimal from typing import List, Optional from pydantic import BaseModel, Field, model_validator class LineItem(BaseModel): sku_raw: str = Field(..., description="Raw part number from customer PDF") matched_product_id: Optional[int] = None description: str quantity: Decimal = Field(..., gt=0) unit_price: Decimal = Field(..., ge=0) line_total: Decimal = Field(..., ge=0) @model_validator(mode="after") def verify_line_math(self): expected = (self.quantity * self.unit_price).quantize(Decimal("0.01")) if abs(self.line_total - expected) > Decimal("0.05"): raise ValueError( f"Line math mismatch on '{self.sku_raw}': " f"{self.quantity} * {self.unit_price} = {expected}, " f"extracted {self.line_total}" ) return self class PurchaseOrderDocument(BaseModel): document_id: str customer_name: str po_number: str lines: List[LineItem] = Field(..., min_length=1) subtotal: Decimal tax: Decimal = Decimal("0.00") total: Decimal @model_validator(mode="after") def verify_document_totals(self): calc_subtotal = sum(i.line_total for i in self.lines).quantize(Decimal("0.01")) if abs(self.subtotal - calc_subtotal) > Decimal("0.10"): raise ValueError( f"Subtotal mismatch: extracted {self.subtotal}, " f"sum of lines is {calc_subtotal}" ) expected_total = (self.subtotal + self.tax).quantize(Decimal("0.01")) if abs(self.total - expected_total) > Decimal("0.10"): raise ValueError( f"Grand total mismatch: extracted {self.total}, " f"expected {expected_total}" ) return self Step 2: Bounded Retry Loop with Explicit Error Diffs
If extraction fails validation, we feed the exact Python ValueError back for one targeted correction pass. If it still fails, the document goes to an exception triage queue instead of polluting the ERP.
class QuarantineException(Exception): pass def extract_with_verification(doc_text: str, max_retries: int = 2) -> PurchaseOrderDocument: feedback = "" for attempt in range(1, max_retries + 1): raw_json = run_extraction_agent(doc_text, feedback_error=feedback) try: return PurchaseOrderDocument.model_validate_json(raw_json) except Exception as exc: feedback = f"Attempt {attempt} failed validation: {exc}" raise QuarantineException(f"Routed to manual triage: {feedback}") Step 3: Idempotent, Atomic ERP Staging (No N+1 Loops)
Two details matter enormously when writing to an ERP like Odoo from an async worker:
- Idempotency: Always check
client_order_reffirst so worker retries never duplicate orders. - Atomic one-shot writes: Resolve
product_idup front and pass nested(0, 0, {...})tuples inside a singlesale.ordercreatecall so a transient failure never leaves a half-populated order.
import xmlrpc.client def stage_draft_sale_order(po: PurchaseOrderDocument, erp_cfg: dict) -> int: """ Runs inside an external async worker (Celery/FastAPI), never inside an ERP thread. Stages an idempotent draft order with real SKU links in a single atomic call. """ common = xmlrpc.client.ServerProxy(f"{erp_cfg['url']}/xmlrpc/2/common") uid = common.authenticate(erp_cfg["db"], erp_cfg["user"], erp_cfg["password"], {}) models = xmlrpc.client.ServerProxy(f"{erp_cfg['url']}/xmlrpc/2/object") # 1. Idempotency check: never duplicate an existing PO existing = models.execute_kw( erp_cfg["db"], uid, erp_cfg["password"], "sale.order", "search", [[["client_order_ref", "=", po.po_number], ["state", "!=", "cancel"]]], {"limit": 1}, ) if existing: return existing[0] # 2. Match customer partner partner_ids = models.execute_kw( erp_cfg["db"], uid, erp_cfg["password"], "res.partner", "search", [[["name", "ilike", po.customer_name], ["customer_rank", ">", 0]]], {"limit": 1}, ) partner_id = partner_ids[0] if partner_ids else erp_cfg["triage_partner_id"] # 3. Resolve exact SKU -> product_id and prepare atomic one2many commands order_lines = [] for item in po.lines: prod_ids = models.execute_kw( erp_cfg["db"], uid, erp_cfg["password"], "product.product", "search", [[["default_code", "=", item.sku_raw]]], {"limit": 1}, ) line_vals = { "name": item.description, "product_uom_qty": float(item.quantity), "price_unit": float(item.unit_price), } if prod_ids: line_vals["product_id"] = prod_ids[0] order_lines.append((0, 0, line_vals)) # 4. Atomic single-call draft creation order_id = models.execute_kw( erp_cfg["db"], uid, erp_cfg["password"], "sale.order", "create", [{ "partner_id": partner_id, "client_order_ref": po.po_number, "order_line": order_lines, "note": f"Staged via deterministic intake pipeline ({len(po.lines)} verified lines).", }], ) return order_id 4. Production Guardrails That Matter
- Let LLMs extract, let Python calculate: Never trust probabilistic tokens for line extensions, tax math, or inventory allocations.
- Keep ERP workers decoupled: Move OCR, LLM extraction, and catalog matching into async workers so ERP UI users never feel latency.
- Quarantine ambiguity instead of guessing: A system that flags 3% of messy PDFs for a 30-second human review builds total trust; a system that silently books wrong SKUs destroys it.
How Are You Handling AI-to-ERP Validation?
Are you running async intake workers outside your ERP, or hitting edge cases with messy customer PDFs and SKU matching? Drop your architecture or questions in the comments below.
At SZG Labs, we architect and deploy production AI intake pipelines, retail EDI workflows, and Odoo ERP integrations for wholesale distributors, manufacturers, and 3PL operators. If you want to review your architecture or eliminate manual order entry, reach out at szglabs.com.

