cd ../projects

AI-Assisted Order Intake for an Industrial Manufacturer

"Analyzing a manual purchase-order workflow at a French precast concrete plant, then shipping the smallest useful slice of it: ingestion, reference matching and price verification, with the operator as the decision-maker."

Diagram of the order intake pipeline: a client PDF order with its own references on the left, arrows of decreasing confidence pointing to internal catalog references on the right, each labeled exact ref, learned, confirm or operator

1. The Problem

A French precast concrete manufacturer receives purchase orders as PDFs, in every format its clients feel like sending. Each order must be manually re-keyed into the internal system — and the hard part isn't typing, it's translation: clients order using their own commercial references, while the factory operates on internal references tied to physical components. One client line item can map to a different internal article, at a price that must match a negotiated contract.

Today this mapping lives in operators' heads. It's slow, error-prone, and doesn't scale down well when a plant is under cost pressure.

2. First Steps: Analysis Before Code

Before writing anything, I spent the first phase on the workflow itself: how orders arrive, where the reference ambiguity actually sits, what an operator checks before trusting a line, and what happens when a price doesn't match the contract.

That analysis produced the core design constraint: the AI must never be the decision-maker. Reference matching in an industrial context has real financial consequences — a wrong match ships the wrong product at the wrong price. So the system is designed around confidence scoring with a human validation fallback, not autonomous processing.

3. The Architecture

The pipeline, on Railway + Supabase:

  • PDF ingestion — purchase orders are parsed into structured line items, whatever the client's layout. The LLM (Claude) only ever produces a structured extraction; it never decides anything downstream. Documents that aren't purchase orders — an invoice, a quote — are recognized as such and rejected instead of being extracted into nonsense.
  • Reference matching — each client reference is matched against the internal catalog. Matches carry a confidence score and a status; the mapping table is learned and versioned, so a validated match today becomes a high-confidence suggestion tomorrow.
  • Price verification — extracted prices are checked against the client's contract terms, maintained as Excel files on the business side. Discrepancies are flagged, never silently accepted.
  • Operator validation UI — the operator sees the parsed order side by side with the proposed internal translation, confirms high-confidence lines in one action, and resolves ambiguous ones explicitly. Every decision feeds back into the mapping knowledge base — but only on validation, so a hypothesis merely looked at never becomes a rule.

The same principle I apply across my SaaS work holds here: LLMs extract and suggest, a deterministic layer decides what gets applied, and anything uncertain goes to a human with full context.

4. Confidence Is a Structure, Not a Number

The matching layer is a cascade, entirely deterministic — no model call, no database access. It stops at the first tier that succeeds: factory reference printed on the order, then a barcode learned from a previous order, then the per-client mapping memory, and only then text similarity. A tier never invents a certainty it doesn't have; the doubt is carried by the status, not erased by a threshold.

Every threshold that can turn a suggestion into a pre-validated line lives in one versioned object, and that version is stamped onto each line's stored candidates:

/**
 * Calibration constants. Grouped and versioned: the version is stamped onto
 * every line's stored candidates, so a score read back in three months is
 * still comparable to the one that produced it. Changing any value here
 * means bumping `version`.
 */
export const MATCH_CONFIG = {
  version: 'match.2026-08',
  /** Minimum score for the best candidate to be pre-selected (amber). */
  fuzzyMin: 0.85,
  /** Minimum gap to the runner-up: a doubt between two articles stays red. */
  fuzzyGap: 0.15,
  /** Share of the score dimensional agreement can contribute. */
  dimBoost: 0.35,
  /** Candidates kept for display and audit. */
  topN: 4,
} as const;

Versioning the calibration matters more than the values themselves. Scores get stored, read back weeks later, and argued about — "why was this line green in July?" is only answerable if you know which calibration produced it.

The last tier is where most systems overreach. A text score alone is not evidence: a high score against a catalog of ~3,500 descriptions means very little if the runner-up is just as high. So similarity pre-selects only when it is both high and isolated — otherwise the line goes red and the operator chooses, with the top candidates and the reason shown as-is:

// Tier 4: text similarity. It pre-selects only if it is both high AND isolated.
const candidates = bestCandidates(line.label, snap.scorable);
const [first, second] = candidates;

if (first !== undefined) {
  const gap = first.score - (second?.score ?? 0);

  if (first.score >= MATCH_CONFIG.fuzzyMin && gap >= MATCH_CONFIG.fuzzyGap) {
    return { ...base, candidates, method: 'FUZZY', status: 'AMBER',
      productId: first.productId, score: first.score,
      reason: `Close designation at ${pct(first.score)}, ahead of the next by ${pct(gap)}` };
  }

  return { ...base, candidates, method: null, status: 'RED',
    productId: null, score: first.score,
    reason: first.score < MATCH_CONFIG.fuzzyMin
      ? `Best similarity ${pct(first.score)}, below the ${pct(MATCH_CONFIG.fuzzyMin)} threshold`
      : `Two candidates too close (${pct(gap)} apart): the operator decides` };
}

Two candidates 0.90 and 0.89 apart is not a 90 %-confident match — it's a coin flip with good manners. Encoding that as a red line rather than an amber suggestion is the whole difference between a tool an operator trusts and one they learn to click through.

5. Contract Prices Live in Excel — and Keep Moving

The negotiated prices aren't in a database anyone controls: they're Excel files, maintained by the business side, updated whenever commercial terms change. The naive reading is that this is a data-quality problem to be fixed before building. It isn't — those files are the reference, and they will still be Excel files next year.

So the system treats them as a living source of truth rather than a one-off seed:

  • Re-imported on change. A new version of a file is dropped in and imported again; nothing is hand-copied into the app.
  • Versioned. Every import is recorded with what it created, updated and deactivated, price changes keeping their previous value and date. The uploaded bytes are stored with the import, so a commit replays the source rather than a stored diff.
  • Analyzed before applied. Import runs as a dry run first: the operator sees the diff — 87 more prices than last time, and exactly which ones — and then commits. The diff is recomputed against the current database at commit time, so a stale plan can't be written.
  • Checked against the latest. Price verification always runs against the most recent imported version, never a snapshot frozen at parse time. Re-running the match on an order after a contract update is one click, and doesn't re-pay for the document extraction.

The interesting part is the parsing tolerance. Real business files drift: sheets get renamed, columns move, the header cell gets overwritten. The importer is deliberately forgiving about shape and strict about meaning:

// A sheet is accepted as a contract as soon as 3 of the 6 expected headers
// match. Real files show up with a corrupted first cell ('39', 'Article1267121'
// instead of 'Article') — refusing them outright would be worse than reading them.
const picked = pickSheet(file.sheets, { expect: EXPECT, minMatch: 3, maxScan: 15 });

if (picked === null) {
  b.error(
    file.relPath,
    'file not imported: no sheet matches the expected contract format',
    `sheets present: ${file.sheets.map((s) => s.name).join(', ') || '(none)'} ; ` +
      `expected columns: ${EXPECT.join(', ')}`,
  );
  return null;
}

// Tolerance stops at the price column: that one cannot be guessed.
const { sheet, header } = picked;
if (header.cols.get(H.price) === undefined) {
  b.error(file.relPath, `sheet "${sheet.name}" not imported: Price column not found`);
  return null;
}

Anomalies are surfaced, not absorbed. The first pass over the real referential reported 629 inconsistencies, which is another way of saying it reported nothing usable. Most of them — 496 — were the same price exported twice and differing below one cent through float round-tripping: noise. Suppressing those left 133 genuine duplicates: the same reference entered twice in the same file at two clearly different prices, each listed with its file and row number, ready to be sent back to the commercial teams as a work item.

That asymmetry is deliberate. Sub-cent divergence between two exports of one negotiated price is a rounding artifact. Two different prices for one reference in one file is a data-entry error, whatever its size. Merging both into a single "conflict" count would have taught the operator to ignore the count.

6. Urgency as a Design Constraint

The client context called for the fastest possible time-to-value. That isn't a scheduling detail — it changes what you build.

A big-bang system (full ERP integration, every document type, every edge case) would have been a defensible architecture and worthless for months. Instead the roadmap ships the smallest immediately useful tool first: load the referentials, parse a real PDF order, match its references, check its prices, let an operator validate. That slice is useful on day one — it removes re-keying and mental lookup from one order — and everything after it is an addition, not a rewrite.

Concretely, the sequencing was:

  • Referentials first — catalog, contracts, general tariffs, surcharges, with their import diffs. Nothing downstream is trustworthy without them, and this is also what exposes the data problems early, while they're cheap to react to.
  • Then extraction — PDF in, structured order out, validated against 10 real orders in 10 different formats, scanned documents included.
  • Then matching and prices — the cascade above, plus contract resolution and discrepancy severity.
  • Then the operator screen — deployed behind a shared password, usable on a laptop, consistent color code across every screen.
  • Only then the rest: provenance across factories, refinements, integrations.

The discipline this imposes is deciding, for every feature, whether an operator gains something today. Factory provenance for each line was built because an operator reading an order couldn't tell where the goods would ship from. ERP write-back wasn't, yet — the operator can already stop re-keying blind.

7. Where It Stands

Referential loading, PDF extraction, matching, price verification and the validation screen are running on the company's real data — roughly 3,500 products, ~17,500 negotiated prices across ~80 client contracts — and on real orders. Extraction takes between 20 seconds and 3 minutes depending on the document, so the UI tracks progress explicitly and lets several orders be dropped in sequence rather than pretending it's instant.

It is early. The matching calibration has been tuned on a modest corpus and will move; the learned mapping table is only as good as the validations it has seen, which is precisely why it starts empty and fills up from operator decisions rather than from a guess. What's deliberately absent is autonomy: nothing is applied without a human, and no line is green unless the system can name the evidence.

8. Why This Matters

Order intake is the archetype of "boring" back-office work where AI assistance pays off immediately — not by replacing the operator, but by turning re-keying and mental lookup into review and confirmation. The design goal is measured in operator minutes per order and in matching errors caught before they reach production.

A follow-up article will cover the ingestion engine itself, and what real-world PDFs did to my parsing assumptions.

Code extracts are lightly edited for readability: comments translated from French, client references, entity names and prices replaced with placeholders.

Have a manual back-office workflow worth automating?

Let's Talk