Filing inbound documents with AI
Everyone agrees documents should be attached to the record. Almost nobody does it, because it's slow and it's the last thing between them and the next task. So make it faster than not doing it.
Why the paper doesn't get filed
A packing list arrives with a delivery. A bill of lading, a vendor invoice copy, a customer PO, a signed receipt. Each one should end up attached to the transaction it belongs to, where the next person who has a question can find it.
To do that manually, somebody has to scan it, work out what kind of document it is, read the reference number off it, search the ERP for the matching record, open that record, find the attach control, browse to the file, decide which File Cabinet folder it belongs in, and save.
That's a couple of minutes per document, performed by someone in receiving who has a truck waiting. So it doesn't happen. Not out of carelessness — the cost is real and the benefit accrues to somebody else, later. Roughly one document in three never made it onto the record.
You cannot fix that with a policy. You fix it by making the right path the fast path.
What it looks like now
One drop zone. Someone drops in a scan — often a single multi-page PDF containing six unrelated documents, because that's how a scanner works.
- The file goes to an LLM, which returns document boundaries, a classification for each, the reference number it can see, and the vendor.
- The browser splits the multi-page scan into individual documents at those boundaries.
- Each proposed document appears with its type, reference, and target record for the user to confirm or correct.
- On commit: find or create the vendor's folder, save the file with a consistent name, attach it to the resolved transaction, and write a log row.
The confirm step is not a formality and shouldn't be removed. It's the reason people trust it — and it's the difference between a tool that files documents and a tool that files documents wrong without telling anyone.
The adoption result was the surprise. Usage went from about two-thirds compliance to effectively everything, and part of the reason people gave was that watching it work is satisfying. Drop a stack of paper, watch it sort itself into named documents pointing at the right orders. That's not a rational argument for adoption and it doesn't need to be. If the tool is faster and mildly enjoyable, people use it.
The AI is the easy half
Classifying a document and pulling a reference number off it is close to a solved problem now. Send the file, get structured output back.
The hard part is what happens next: a human-written reference is not a database key.
The reference on the paper might be a purchase order with a suffix from a partial shipment. It might be a sales order written by hand without its prefix, in a system with four order prefixes. A bill of lading typically prints only the fulfillment number, when what you want is the order. A vendor's invoice number might carry leading zeros in their system and not in yours.
So the resolver expands what it was given into plausible variants before searching:
const raw = String(ref).toUpperCase().replace(/\s+/g, '');
const stripped = raw.replace(/[-_]\d{1,3}$/, ''); // trailing -02 partials
const variants = new Set([raw, stripped]);
if (targetType === 'salesorder') {
// handwritten "93039" could be any of the order prefixes in use
if (/^\d+$/.test(raw)) PREFIXES.forEach(p => variants.add(p + raw));
const m = raw.match(/^SO[BHS]?(\d+)$/);
if (m) PREFIXES.forEach(p => variants.add(p + m[1]));
}
Three rules make that behave rather than just widen the net:
Exact wins. If the reference exactly as written matches a real record, take it and demote the cross-prefix siblings to passive alternates. Otherwise every ambiguous digit string forces the user to pick, and a tool that asks a question every time is a tool people stop using.
Walk the lineage when the direct lookup fails. A bill of lading shows a fulfillment number but belongs on the order — so follow the fulfillment back to what it was created from. A packing list shows an order number but belongs on the fulfillment — so walk forward to the fulfillments created from that order, newest first. Both fallbacks only fire when the direct match came up empty, which keeps them cheap.
Don't apply one document type's rules to another. Purchase order references get a PO prefix tried; vendor bill references never do, because a bill's number is the vendor's invoice number and prefixing it produces confident nonsense.
A human-written reference is not a database key.
Three File Cabinet traps
1. A name collision silently replaces the file
This is the one that can destroy data.
Create a file with a name that already exists in the same folder and NetSuite does not error and does not rename — it replaces the contents of the existing file. Two packing lists filed the same day under the same convention, and the first one is gone with no record that it ever existed.
So a collision must never reach file.create unconfirmed. Check first, and if the name is taken, stop and ask:
const clash = nameExists(finalName);
if (clash.length) {
if (!body.confirmSuffix) {
return { ok:false, nameConflict:{ existingFileId: clash[0].id,
existingName: finalName,
folderName: folder.name } };
}
// confirmed: find the first free _2, _3, ... suffix
}
The user sees which file they'd have overwritten and chooses. Never auto-suffix silently either — sometimes the right answer is that they're re-filing the same document and the existing one should stay.
2. Folder names drift, and you get duplicates
Vendor folders are found or created by name. Two things will duplicate them if you let them.
Case. Acme Supply and ACME SUPPLY are the same vendor and must match, so compare case-insensitively on both sides.
And display names. If the account shows entity numbering, the display formula returns the internal ID prefixed to the name — so what looks like a vendor name is actually 7317 Acme Supply Inc., and matching on it creates a second folder next to the one a human made:
// strip a leading numeric id so folder matching compares real names
const cleanEntityName = (s) =>
String(s || '').replace(/^\d+\s+/, '').trim();
Rule for any find-or-create: normalise both sides before comparing, and make creation the last resort rather than the default. A duplicate folder is not an error anyone notices; it's just a slow decay in how findable everything is.
3. Retries create duplicates unless you design against them
Filing is two operations — save the file, attach it to the record — and the second one can fail on its own. If the user retries and the code runs both steps again, they now have two copies of the file and one attachment.
Two guards handle it. The retry path takes an existing file ID and only re-attaches, never re-creating. And commit checks a content hash of the file against previously filed documents, so the same scan submitted twice is blocked with a pointer to where it already lives rather than filed again.
Content hashing is the right identity here because filenames, folders, and even reference numbers can legitimately differ for the same physical document. The bytes can't.
What to log
Every commit writes a row recording the file, the document type, the reference, the vendor, the target record, which folder was used and whether it was created or reused, the model's confidence, the raw AI response, and the content hash.
The AI response and the confidence are there for a specific reason: when someone eventually says "it filed this in the wrong place," you can see what the model actually proposed versus what the user confirmed. Usually the answer is that the model was right and the reference on the paper was wrong, and that's worth knowing before anyone concludes the tool is unreliable.
Where AI belongs in this
Reading unstructured paper and proposing structure is exactly what these models are good at. Deciding what to do with the result is not their job — that's a resolver with explicit rules, a human confirmation, and a write path designed to be idempotent.
Most of the engineering in this build is in the second half. That ratio is worth expecting on any AI project touching an ERP: the interesting part is small, and the part that makes it safe to run every day is not.
Related: the freight you quoted versus the freight you paid.