Just use AI to parse the PDF is where the work begins
Deerman Sales is a manufacturer's rep for Pfister. Staff receive purchase orders as PDFs from dozens of vendors, and every one of those PDFs has to become a set of line items that reconcile against manufacturer price files before anything gets ordered. The obvious move in 2026 is to hand the PDF to a model and ask for JSON. That works on the first day and then stops being the interesting part of the problem.
The demo that works
Point a capable model at a clean vendor PO and it will give you back line items: part code, description, quantity, unit price. It reads tables. It handles a logo in the header and a terms paragraph in the footer. On a well-formed document from a vendor whose template has not changed in four years, it is close to perfect, and it took an afternoon to wire up.
That demo is genuinely useful and it is also the reason this kind of project gets underestimated. The demo establishes that extraction is possible. It says nothing about whether the extracted data is correct, and correctness is the entire job. A purchase order that parses beautifully but assigns the wrong part number produces a real order for a real product that nobody wanted, shipped to a real jobsite.
So the shape of the work inverts almost immediately. You spend a small amount of time getting structure out of the document and a large amount of time deciding whether to believe it.
A dozen problems wearing one costume
From the outside, every failure reads the same: the PO came out wrong. Internally they are not one problem. They are a dozen unrelated problems that happen to share a symptom, and treating them as one thing is how you end up with a parser full of special cases that fight each other.
The parser's own field naming is non-deterministic. The real part code shows up under a different key depending on the vendor and sometimes depending on the run. So the first thing you cannot do is read a fixed key and trust it. On other documents the code is not in its own column at all: it leads the description, so the description field arrives with the part number glued to the front of it. On others the code is buried in the middle of the description, after a size and before a finish.
Then there is the input itself. OCR turns zeros into the letter O with some regularity, which means a code that exists in the manufacturer database arrives as a string that does not. Separately, some parts are legitimately all numeric and look exactly like a vendor's internal stock number, so any rule that says 'all digits means not a real part' is wrong. And on a handful of vendor templates the quantity and unit price columns are swapped relative to everyone else, which produces line items that are individually plausible and collectively absurd.
- 01UploadVendor PO arrives as a PDF
- 02ParseAI extraction to structured line items
- 03RecoverFix mislocated or mangled codes
- 04MatchAgainst manufacturer price files
- 05ReviewA human confirms flagged lines
- 06SendManufacturer PO + vendor confirmation
The parse step is one of six. Treating it as the whole job is why 'just use AI to read the PDF' stalls.
Every fix is its own pass
The temptation with a list like that is to write one smart normalizer that handles all of it. We tried a version of that early and it was worse than useless, because when it got a line wrong there was no way to tell which of its behaviors was responsible. A single function with six overlapping heuristics is a function you cannot debug from a bug report.
What we ship instead is a sequence of narrow recovery passes. Each one addresses exactly one failure mode, runs in a defined order, and is independently testable. The pass that pulls a leading code off the description does not know anything about OCR character confusion. The pass that repairs a zero-for-O substitution does not know anything about column ordering. When a new failure shows up, it becomes a new pass rather than a new branch inside an existing one.
This is slower to write and much faster to maintain. It also means shipping is incremental in a way clients can see: each release fixes a named category of document, not 'parsing, generally.'
The test is named after the document
Every recovery pass ships with a test built from the actual purchase order that exposed the problem, and the real document identifier goes in the test name. Not a synthetic fixture that demonstrates the concept. The specific PO, with its specific mangled line, reduced to the smallest input that still reproduces the failure.
The reason is that these bugs are not conceptual. They are physical facts about how one vendor's template renders and how OCR handled it that day. A synthetic fixture encodes your theory of the bug. A fixture cut from the real document encodes the bug. Six months later, when someone refactors the normalizer, the test that fails tells you which real customer document you just broke, and you can go look at it.
It also settles arguments. When a pass starts to look redundant, the test name is a pointer to the evidence that it was once necessary. We have deleted passes, but only after opening the original document and confirming that something upstream now handles it.
What done actually looks like
The pipeline that runs today is Next.js and Prisma against PostgreSQL on Railway, with Vitest carrying the fixture suite. Staff upload a vendor PDF, the parser returns structured line items, those items get normalized through the recovery passes, and then they are matched against manufacturer price files. Anything that does not reconcile is flagged into a review modal where a person corrects it before a manufacturer PO and vendor confirmation go out by email.
Note what is missing from that description: a claim that parsing is solved. It is not solved and will not be, because vendors change templates and scanners degrade. What is solved is the part that matters operationally — a wrong parse is caught before it becomes an order, and the categories of wrongness shrink over time as passes accumulate.
If you are scoping a document automation project, budget accordingly. The extraction is the cheap part. The expensive part is the machinery around it that decides which extractions to trust, and that machinery is where the project actually lives.