TL;DR
- An unmodelled repair backlog becomes a pile of dusty things you feel bad about. Ask me how I know.
- Four ticket states:
open,blocked,done,abandoned.blockedandabandonedare the two that do the work. abandonedis a feature. A queue you can’t quit is a queue that grows forever, and “I’ll get to it” is not a state.- Parts are modelled as purchases and uses separately, because you buy a bag of ten capacitors and use two. Otherwise your cost-per-repair is nonsense.
- Labour gets logged per worker with an hourly rate, and that number flows all the way back into what I’m willing to bid on a lot.
- The no-go rule does more for profitability than any repair skill I have: if parts and time exceed roughly 60% of realistic resale, or resale is under about $120, it doesn’t get a ticket.
- Other people work this queue. Four access roles keep them on inventory and tickets and away from anything financial.
Why a pile forms
Buying surplus means buying broken things, on purpose, because broken is cheap and some broken things are trivially fixable. A cassette deck with a perished belt is a $4 part and twenty minutes. A workstation that won’t POST is often one reseated stick of RAM.
The trap is that “some broken things are trivially fixable” is indistinguishable, at the moment of purchase, from “some broken things will sit on a shelf for eleven months.” Both look like opportunity. And a shelf of maybes has a specific psychological cost: every time you walk past it, you feel behind, so you stop looking at it, so it grows.

This is what the decision looks like in the physical world. Cover off, five minutes with a torch, and the question is not “can I fix this” — it’s “is fixing this the best available use of a Saturday.” Those are wildly different questions and I spent about a year answering the first one when I should have been answering the second.
The fix isn’t discipline. It’s a data model that makes the decision not to repair something a first-class, recorded, respectable outcome.
Four states
open ──→ blocked ──→ open ──→ done
│
└──────────────────────→ abandoned
open— this is a live project I intend to finish.blocked— I can’t proceed, and the reason is written down. Waiting on a part, waiting on a tool, waiting to learn something.done— fixed and tested.abandoned— explicitly not happening.
The ticket carries a title, notes, opened_at, closed_at, and estimated_hours. It links to inventory items through a join table, because one repair frequently covers several units (four identical desktops, same fault) and one unit can accumulate several tickets over its life.
blocked matters because the alternative is lying. A ticket that’s actually waiting on a $6 belt from a specialist supplier is not “open” — I’m not going to work on it today and no amount of looking at it changes that. Separating “I could work on this now” from “I’m waiting on the world” makes the open list a real to-do list. Mine dropped by about half the moment I added the state, and nothing changed on the shelf.
abandoned matters because a queue you can’t quit grows forever. Abandoning writes a reason: not worth it, too specialised, parts unavailable, I tried and failed. The unit goes to dead or gets parted out. It’s a closed ticket, not a haunting.
The second-order benefit surprised me: the abandoned list is the most useful document in the system for deciding what to buy. Read six months of abandonment reasons and the pattern is glaring — I abandon a particular class of consumer electronics almost every time, and I should therefore stop buying it. That’s a purchasing insight I only got by recording my own failures in a structured way.
Parts: purchases and uses
Two tables, and merging them is the mistake almost everyone makes first.
part_purchase is a buying event: I bought a bag of fifty electrolytic capacitors for $9.40. part_use is a consumption event: this repair used four of them.
If you model parts as a single “parts cost” field on the ticket, two things break. First, your cost-per-repair is either wildly overstated (the whole bag charged to one job) or fabricated (a per-unit price you made up). Second, you have no idea what’s on the shelf, so you re-order things you already have — which, for a hobbyist-scale parts inventory, is most of the wastage.
With purchases and uses separate, cost-per-part falls out as arithmetic, remaining stock is derivable, and a repair’s true parts cost is the sum of its uses. It also means the bag of capacitors that’s still half-full six months later is visible as an asset, which it is.
Labour, and why it flows upstream
There’s a worker table and a labor_log table. Time gets logged against a ticket, attributed to a worker, at that worker’s rate.
This exists because I am not the only person who touches the bench, and because unpriced labour is the fastest way to convince yourself an unprofitable business is profitable. Four hours of your own time on a $60 item isn’t a good margin, it’s a bad hourly wage with extra steps.
The genuinely useful consequence is that labour flows upstream into purchasing. When the sourcing pipeline evaluates a lot of computers, it deducts a prep-labour estimate — roughly half an hour per unit at the bench rate, for wipe, reimage, and test — before working out what I should bid. That’s not a rounding error. On a lot of twenty desktops it’s a meaningful chunk of the ceiling, and lots that look marginally profitable at zero labour cost correctly show up as skips.
If I could go back and add one field to the very first version of this system, it’d be an hourly rate.
The no-go rule
Here’s the rule that has done more for profitability than every soldering skill I’ve picked up:
If expected resale is under roughly $120, or parts plus time exceed roughly 60% of realistic resale, it does not get a ticket.
Rejected at intake. Parted out, or sold as-is with the fault disclosed and priced accordingly, or recycled.
Both halves matter. The absolute floor exists because every repair carries fixed overhead — diagnosis, sourcing a part, testing, photographing, listing, packing — that doesn’t scale down. Below about $120 the overhead eats the repair regardless of how easy the fix is.
The percentage ceiling exists because repairs overrun. Consistently. The estimate is optimistic roughly always, so a job estimated at 55% of resale lands closer to 80%, and one estimated at 70% is a donation.
Applying this rule is where a bit of agent assistance genuinely helps. Given a model number and a symptom, an agent can pull the known failure patterns for that family of device, find whether the part is still available, get a rough resale range from comparable sales, and produce a go/no-go with the arithmetic shown. It’s not making the call — it’s assembling the four facts the call needs, in about a minute instead of twenty, which means I actually apply the rule instead of eyeballing it.
The honest version of “AI helps with repairs” is: it doesn’t diagnose anything. It’s very good at remembering that this deck model always needs the same idler wheel and at telling me a part has been unobtainable since 2019 before I’ve spent an evening finding that out myself.
Letting someone else near it
The bench stopped being a solo thing a while ago. Someone else does a chunk of the machine prep — wipe, reimage, test, grade, label — and needs somewhere to record what they did. Which means handing another person a login to a system that also holds what I paid for everything, what it’s worth, and a pile of notes I’d rather not have to explain.
The lazy option is one shared login and a request to please not click the scary buttons. I’ve done that. It works right up until someone clicks a scary button, and then it’s my fault, because I’m the one who set it up that way.
So: four roles, enforced at the app boundary with the identity provider doing authentication:
admin— everything. Me.user— all reads, but writes only to inventory items and repair tickets. No money-facing endpoints at all: no repricing, no expenses, no tax, no contacts. This is the bench role.finance— all reads, and exactly two write endpoints (reconciliation and a mileage sync). The role for whoever is helping me square the numbers at year end: everything visible, almost nothing mutable.auditor— all reads, zero writes.
Two design notes that I think generalise.
Read is broad, write is narrow. I deliberately didn’t hide cost data from the bench role. Partly it’s practical — knowing an item cost $8 versus $800 changes how carefully you handle it — and partly because permission systems that try to redact data leak it anyway through totals and edge cases, while permission systems that restrict actions are much easier to reason about and verify.
Enumerate write endpoints, don’t categorise them. The finance role can hit two specific endpoints. Not “financial endpoints” as a category — two named routes. Categories drift as you add features; a new endpoint quietly inherits permissions nobody decided to grant. An explicit list means adding a route grants nothing until someone adds it to the list.
Where the bench meets the queue
The repair queue connects to the physical bench through the inventory status machine. A unit in processing is on the bench. Machines that get imaged check themselves in when they finish — the post-install script posts service tag, hardware identifier, and specs back to the tracker, which matches or claims the inventory record and attaches a spec card.
That auto check-in is the difference between a bench log that’s accurate and one that’s aspirational. Nobody has to remember to record that unit seventeen was imaged; the unit reports it. And because the serial arrives from the machine rather than from someone reading a sticker, it’s right.
The label gets printed from that record, which closes the loop: physical unit → QR code → record → repair tickets → parts and labour → cost basis → margin.
Gotchas
A queue without an exit is a guilt generator. abandoned isn’t admitting defeat, it’s how the open list stays meaningful.
Estimate hours at ticket creation, then compare. estimated_hours versus logged hours, over dozens of tickets, is how you calibrate. Mine runs about 1.6× optimistic and I now apply that multiplier deliberately.
Blocked tickets need the reason in the row, not in your head. “Blocked” with no reason is indistinguishable from “abandoned but sad about it”.
Parts on the shelf are inventory. If you don’t track uses separately from purchases, you will re-buy things you own. Repeatedly.
Price other people’s labour before you price your own. It’s easier to be honest about someone else’s hourly rate, and once it’s in the model your own becomes hard to keep pretending is free.
Grant writes per endpoint. The role that can only touch two routes should be defined by those two route names, so that next month’s new route defaults to closed.
Where this sits
Four states, tickets joined to units, parts split into purchases and uses, labour logged per worker at a real rate that flows back into bidding, a no-go rule applied at intake, and four access roles so the bench can work without the books being exposed to accidents.
The pile is gone. Not because I repair faster — because most of what used to join the pile now gets an honest “no” on the day it arrives, and that decision is recorded where it can teach me what to stop buying.