Sole Full-Stack Engineer Dec 2024 — 2026 tlcdealer.com

TLC Dealer

A warehouse worker, a phone, and a greasy label on an air conditioner. Everything else follows from getting that one scan right.

Next.js 16 Claude Vision Python · FastAPI PostgreSQL Redis Playwright Hetzner · Vercel

The brief was one sentence: “warranty submissions to the manufacturer are always late.” No spec, no requirements document. Late submission means a rejected claim, which means the dealer eats the repair and loses the commission — somewhere between 100,000 and a million lira a year depending on the dealer’s size, and for several of them the difference between hitting an annual bonus tier and missing it. Nobody had put a number on it before.

50+Dealers on contract
11.8KLines in the web app
10Tables · RLS isolated
3Scanning technologies
01

Three technologies for one label

Warranty filing depends on data printed on a sticker: a serial number, a model code, a lot code. Those stickers are creased, oily, and photographed in bad warehouse light. Pure OCR breaks on them. Pure barcode scanning isn’t enough either — the barcode only encodes the serial; the model code is printed text.

So the pipeline is layered, and the ordering is the whole point: the deterministic path runs first, and AI is the fallback.

CAPTURE Phone camera label photo + live barcode frame DETERMINISTIC — FIRST zxing · regex parser serial from barcode BTU + series from model code FALLBACK — ONLY IF NEEDED Claude Haiku vision forced tool use lot code + model code VALIDATE Guards /0 → /O correction catalogue match PERSIST Stock serial-level row per unit unresolved fields only
The model never sees a request it doesn’t need to. If the barcode and the regex already resolved a field, the prompt says so — fewer tokens, fewer chances to be wrong.

The parser knows the product

A model code isn’t an opaque string — it encodes the machine. The parser pulls cooling capacity in BTU straight out of it, and matches the product series across ten rule patterns. That means a scan doesn’t just record a number, it produces a unit the system actually understands.

GWH09AGAXB-K6DNA1C/I └┬┘ └┬┘ │ └── /I indoor · /O outdoor └── 09 → 9,000 BTU // series matched across 10 patterns AGA|AWA → Pular ACC|AFC → Fairy AVC → Airy GVH → Salon

Making the model behave

The vision endpoint uses forced tool use — the model is required to answer through a typed schema, so it physically cannot return prose that then has to be parsed. Every request reports its own cost in dollars, computed from token usage and returned with the result.

And there’s a guard for a failure mode learned the hard way: model codes end in /O for outdoor, and vision models read that letter as a zero. It’s warned about in the prompt and corrected after the fact. Belt and braces, because a wrong model code means a wrong warranty claim.

tool_choice: { type: "tool", name: "extract_label" } max_tokens: 150 // known failure mode, corrected after the fact if (model_code.endsWith("/0")) model_code = model_code.slice(0,-2) + "/O"; // returned to the caller _meta: { cost_usd, tokens, duration_ms }
Stock intake Scan — 1 unit read
Indoor
9K Pular Indoor
GWH09AGAXB-K6DNA1C/I · SN 4X1505B010952
In stockSold
Outdoor pair
GWH09AWAXB-K6DNA1C/O · not yet scanned
In stockAwaiting
Serial
4X1505B010952 barcode
Model
GWH09AGAXB-K6DNA1C/I vision
Lot
G0279 vision
Capacity
9,000 BTU derived
Series
Pular derived
Read time
420 ms
Model cost
$0.0004
Holder
Main dealer
Rescan Add to stock

02 — Intake

One label in, five fields out.

The barcode is read on device and gives up the serial for free. Only what is left goes to the model, under a forced tool schema so it cannot answer with prose — and the numbers that can be computed are never asked for at all.

  • Barcode and OCR run first; the model is the fallback
  • Forced tool use — structured output or nothing
  • Capacity and series parsed from the model code, not guessed
  • Per-scan cost returned with every response

Two fields are read, three are computed. The barcode gives the serial for free, so the model is told not to look for it — and capacity and series are parsed from the model code afterwards rather than asked for, which is both cheaper and impossible to hallucinate.

03

The domain is the hard part

Most inventory software counts quantities. This one can’t — a warranty claim is about that specific machine. Three modelling decisions carry the product:

Stock has two owners

When a main dealer hands units to a sub-dealer, the goods move but the ownership doesn’t — it’s consignment. So every unit carries both who owns it and who is currently holding it. A single owner field cannot express that, and getting it wrong means nobody can answer “where is my stock?”

Sub-dealers are modelled as tenants too, with a parent link — the dealer network is a tree, not a flat list.

products tenant_id -- who owns it holder_tenant_id -- who holds it now serial_no -- unique per tenant pair_product_id -- the other half tenants type dealer | sub_dealer parent_tenant_id -- the tree

An air conditioner is two things

A split unit is an indoor and an outdoor half, each with its own serial and its own sticker, sold as one machine and warrantied as one machine. The schema pairs them explicitly, so scanning one half can find and suggest the other — and so a sale can’t accidentally ship half a product.

This is the kind of thing you only learn by standing in the warehouse. It was never in the brief.

A sale has four independent states

Real sales don’t fit one status field. Payment can be partial while the invoice is still unissued, the installation is scheduled for next week, and the whole thing went through the wholesale channel rather than retail. Those are four orthogonal axes, and collapsing them into a single enum is how sales software starts lying to the people using it.

sales payment_status paid | pending | partial invoice_requested bool montage_status pending | done | n/a channel wholesale | retail end_customer_id -- traceable through a sub-dealer
Warranty filings Manufacturer portal · today
2 running
Filed — 12K Pular Outdoor
SN 4S2325B030049 · 09:12 · attempt 1/3
Done
Retrying — 9K Pular Indoor
SN 4X1505B010952 · attempt 2/3 · next 14:30
Retry
Failed — 9K Fairy Indoor
SN 4S2425C001120 · attempt 3/3 · needs a human
Failed error_log  portal returned 500 on step 4 of 6 screenshot_url — what the page actually looked like
Filed today
128
Retrying
2
Needs attention
1
sts_jobs status queued → processing → done | failed | retry attempt_count / max_attempts error_log -- what went wrong screenshot_url -- proof of what the page looked like

04 — Last mile

The portal has no API.

Scanning fixes the upstream cause of late filings — the serial and model data is now correct and captured on the day the unit arrives. Getting it into the manufacturer’s system is a browser problem.

Playwright workers drive it, fed from a job table built for things that fail. When a run breaks, “it didn’t work” is not a debuggable report — so the failure carries a screenshot of what the page actually showed.

  • Status machine, not a boolean — retry is a real state
  • Attempts counted against a maximum, then escalated to a person
  • Failures keep an error log and visual evidence
  • Invoicing follows the same shape: provider-agnostic, config in JSONB
05

Stack

Client

  • Next.js 16 · React 19
  • Tailwind v4
  • zxing-wasm · html5-qrcode
  • tesseract.js
  • Mobile-first — used one-handed

Backend

  • Python · FastAPI
  • PostgreSQL · RLS
  • Redis
  • Playwright workers
  • Vercel + Hetzner

AI

  • Claude Haiku vision
  • Forced tool use
  • Per-request cost accounting
  • Per-user rate limiting
  • Domain-specific output guards

Integrations

  • Manufacturer portal automation
  • Turkish e-invoice providers
  • e-Fatura / e-Arşiv
  • Per-tenant JSONB config
06

Outcome

Started with one pilot dealer on a paying monthly contract and expanded to 50+ dealers in the first quarter. Currently in discussion for a white-labelled rollout across a 200-dealer network.

The product lesson was learned by shipping the wrong thing first: I built more configuration than anyone wanted. Dealers didn’t want options, they wanted it already correct for their manufacturer. I’d ship narrower next time and let them tell me what’s missing.

The problem arrived as a sentence and left as a product with paying users, and there was nobody in between.