Skip to content
Back to all work
AI Systems / Data Engineering2026

OfferGrade

A working demonstration of supplier-offer intelligence — reads whatever price list arrives, normalises it, and refuses to score the lines it isn't sure about.

Role
Architect & sole engineer
Next.js 16React 19TypeScriptPrismaSQLiteRecharts

The application

Screens, captured from the running app

Booted against its seeded database and walked route by route — these are the real pages, not mockups.

OfferGrade dashboard showing offers scored, lines cleared to buy, lines held for review, savings against last paid, a verdict distribution chart and review load per supplier.
The headline pairs what was cleared with what was held. Review load is broken out per supplier, which turns 'their files are messy' into a number someone can act on.
Inbound supplier files listed with format, language, header-row position, parse rate and content hash.
What actually arrives — four formats, seven languages, header rows anywhere but row one, and eight sheets with no header at all.
Scored offers table with quoted price, normalised EUR price, delta against last paid, a confidence bar, score and verdict with per-line reasoning.
Every line carries its own reasoning. A buyer will not act on a bare score of 74, but they will act on “cheapest of five, 11% under last paid”.
Per-product price comparison showing every live offer ranked cheapest first against what was last paid.
The screen the whole system exists to produce. The gap to the second-cheapest matters as much as the cheapest — €0.02 and €4 are very different negotiating positions.
Review queue grouped by the reason each line was withheld, with the weakest extracted field shown per row.
Lines the pipeline refused to score, grouped by why. This queue is the feature — a normaliser that is confidently wrong is worse than one that admits it doesn't know.
Scoring rules page showing the gates, the four weights that sum to one, the verdict bands and the documented override.
Weights, bands and the one deliberate override, written down rather than buried in a function.

A demo, built to show the architecture behind a confidential client platform without exposing it. Every supplier, product and price here is invented. The normalisation and scoring behaviour is the real thing.

Why it exists

The production version of this runs daily for a European distributor and can't be shown — no source, no screenshots, no data. That leaves a gap: the interesting part of that system isn't the dashboard, it's a set of decisions about when not to answer, and those are impossible to describe convincingly in prose.

So this is the same architecture over invented data: a hundred and forty products, eight suppliers with different formatting habits, forty-six inbound files across a fortnight, and three hundred and seventy-two scored lines.

The problem being modelled

Suppliers email price lists in whatever shape suits them. In this seed that means four formats — XLSX, CSV, PDF, and prices typed into the body of the mail — across seven languages, in four currencies. Eight of the forty-six sheets have no header row at all, and most of the rest carry it somewhere other than row one.

Assuming row one is a header is the single most common way a supplier importer silently loses a product.

The decision the whole thing turns on

A confident wrong answer is worse than an admitted unknown.

Three gates run before any scoring, and they win outright:

  • The price column can't be identified. Several numeric columns, none resolvable to the buyable price. Guessing here is how a case price or a weight ends up scored as a unit price.
  • The figure is a retail price. Classified as RRP rather than trade. An RRP that slips through reads as the best deal on the board.
  • Extraction confidence is below 70%. Usually pack size or the price itself came out of the file too uncertain to act on.

A gated line has no score at all. It is not a low score — it is an abstention, and it goes to a human queue. In this seed that is 42% of all lines, concentrated almost entirely in the two suppliers with the worst formatting: one sits at 89% review, another at 59%, while the tidiest supplier sits at 30%.

That number is not flattering and it is not tuned. It is the honest output, and expressing it per supplier is what turns "their files are messy" into a cost someone can negotiate about.

Scoring, once a line is clear

Four weights that sum to one, visible on their own page because a buyer who can't see why a line scored 74 won't trust the 74:

| Weight | Share | Why | |---|---|---| | Price vs last paid | 34% | The most reliable benchmark we own — it already reflects our volumes and terms | | Supplier rank | 24% | Cheapest of six means something; cheapest of one is scored neutral, not good | | Price vs market reference | 22% | Catches the case where our own last price was a bad one | | Supplier reliability | 20% | Weighted lowest because late delivery is the one problem a buyer can chase |

Scores band into BUY / NEGOTIATE / REVIEW / IGNORE, with one documented override: a line that is the confirmed cheapest of three or more offers and sits 8% or more under last paid is promoted from NEGOTIATE to BUY even when a weak reliability score drags the total down. A buyer can chase a late delivery; they cannot re-find a price that has gone.

Every line carries its reasons in plain language — "cheapest of five live offers", "11.2% under last paid", "supplier delivers on time 74% of the time" — because that is what someone acts on, not the number.

What's on screen

Six routes: the dashboard, the inbound file log with per-file shape and parse rate, the full scored-offer table, per-product cross-supplier comparison, the review queue grouped by withholding reason, and the scoring rules themselves.

Next.js 16 App Router with React Server Components, Prisma over SQLite, Recharts. The seed is deterministic, so re-running it produces the same figures rather than reshuffling every number on the page.

You have a process thatshould be a system.

Tell me what arrives, who has to act on it, and where it currently falls over. That conversation is usually enough to scope the build.