A garment export order passes through fifteen stages between a buyer asking for a price and a container leaving the port. Colours, quote sheet, fabric sample, techpack, trims, proto sample, costing, salesman sample, bulk order, time and action calendar, pre-production sample, full production, shipment sample, inspection, final shipment.
The obvious way to model that is a status column. stage = 'costing'. Fifteen values, one field, done in an afternoon.
I built a custom ERP for an international buying house on Frappe and ERPNext, and the status column is the first thing I would warn anyone away from. It is not wrong so much as it is lossy, and in a system whose entire purpose is answering "what happened and when," lossy is fatal.
What a status field cannot tell you
Here is a question the business asks constantly: the fabric sample stage took nineteen days — why?
With a status column, the honest answer is "no idea." The column knows the order is past fabric sample. It does not know when it entered, when the mill was contacted, when the first sample arrived, that it was rejected, that a second mill was approached, or that the buyer changed the colourway midway and reset the clock.
All of that happened. None of it was recorded, because a status column stores a position, and what the business needs is a history.
Worse, the column actively destroys information. Every transition overwrites the previous value. Two weeks later, the only record that the order was ever in fabric_sample is that it is now in techpack, and even that is an inference.
CAUTION — The tell
If you ever find yourself adding stage_entered_at, then previous_stage, then stage_changed_by, then a stage_history JSON blob — stop. You are rebuilding an event log one column at a time, badly. Build the log.
The passbook model
The alternative is the oldest data structure in commerce: a bank passbook. One row per dated event, append-only, never edited.
order_event
-----------
order_id EXP-2451
stage fabric_sample
event_type sample_requested
party Mill A
event_date 2026-03-04
recorded_by merchandiser.k
note 3 colourways, knit 180gsm
The current stage stops being a stored value and becomes a derived one — the latest event tells you where the order is. Everything else the business wants to know is a query over the same table:
- How long did each stage take? Difference between first and last event in the stage.
- Which orders are stuck? Orders whose most recent event is older than a threshold.
- Who is slow to respond? Group by party, measure the gap between request and response events.
- What actually happened to order EXP-2451? Select all events, order by date. Read it.
That last one is the one that matters most in practice and is impossible with a status column. When a buyer disputes a delay six weeks later, the system can produce a dated narrative instead of a shrug.
Competitive sourcing needs it, not just wants it
Here is where the status field stops being merely lossy and becomes actually wrong.
Fabric sourcing is competitive. The merchandiser does not approach one mill. They approach three or four in parallel, each of which develops a sample independently, on its own timeline, with its own price. Eventually one is selected and the rest are dropped.
A status column has to answer "what stage is this order in" with a single value while four suppliers are at four different points. There is no correct answer. Teams work around this by creating four child orders, or by cramming supplier state into a JSON blob, or — most often — by tracking it in a spreadsheet next to the ERP, which is how you end up with an ERP nobody trusts.
An event ledger has no difficulty with it at all. Each event names its party. Mill A's sample arriving and Mill C's sample being rejected are just two rows. Selection is another row, and the audit trail of why that mill — who else was in the running, what they quoted, when they responded — is preserved permanently rather than deleted at the moment of decision.
The parallel-then-select shape shows up everywhere once you look for it: vendor bids, candidate interviews, multi-lab sample testing. Any process where several things are in flight and one wins is a process a status column will fight you on.
Dates that stop being editable
A detail that sounds bureaucratic and turned out to be one of the most valuable rules in the system: 24 hours after a date is first entered, it locks for everyone except an administrator.
The reason is a specific human behaviour. A milestone is missed. Someone opens the record and quietly adjusts the planned date to match what actually happened, and now the calendar shows everything on time. Nothing was falsified maliciously — it was tidied. But the delay data the business needs to negotiate with that supplier next season has just evaporated.
The lock forces the conflict into the open. If a date needs to change after a day, that is a request with a reason attached, not an edit. Slippage becomes visible, which is uncomfortable exactly in proportion to how useful it is.
This is a general principle for operational systems: the fields people are tempted to quietly correct are the fields carrying your most valuable data. Protect those specifically.
Enforcement at the boundary, not in the UI
A Letter of Credit has an expiry. Ship after it and the buyer's bank can refuse payment on a container that has already left. It is a six-figure mistake and it is entirely preventable.
The naive fix is a warning banner. The real fix is that the shipment transition is blocked at the server: past the LC date, the operation fails with a specific error, regardless of which screen it came from, which user role initiated it, or whether it came through the API instead of the UI.
Same principle behind division scoping. The buying house runs two divisions — knits and wovens — and a knits merchandiser should not see woven orders. That could be a filter in the list view. It is instead enforced in the permission layer, so a knits user querying a woven order directly gets nothing back. Not a hidden row: an absent one.
The rule I keep coming back to: the UI is a convenience, never a control. Anything that must be true has to be true at the layer the UI is calling.
Versions instead of edits
Costing sheets get negotiated. The buyer pushes back, the factory revises, fabric prices move, currency moves. A costing sheet might go through five rounds before it is agreed.
Editing the sheet in place loses every round but the last — and the negotiation history is precisely what the merchandiser needs next time they are pricing with the same buyer.
So a revision is a duplicate. The previous version is marked superseded and becomes read-only. Nothing is overwritten. The sheet you agreed in March is still there in June, exactly as it was, with each round's numbers intact.
The pattern is the same as the event ledger — append, don't mutate — applied to documents rather than transitions. Anywhere the history has value, and in commercial systems it almost always does, the mutation is the bug.
Two cost models, because the domain has two
Knit garments are costed by fabric weight. Woven garments are costed by fabric length in metres. These are not display variants of one model; they are different arithmetic with different inputs, and the fabric type determines which applies.
The tempting move is one unified costing engine with a mode flag and a pile of conditionals. It looks like less code and it is a trap — every future change has to be reasoned about twice, and the conditionals accumulate until nobody can say what the woven path actually does.
Two explicit models, selected by fabric type. More code, dramatically less thinking per change. The general version of this: when the domain genuinely has two shapes, model two shapes. Unification is only a simplification when the things being unified are actually the same.
Where the leverage came from
The backend is Frappe and ERPNext with a custom app — 69 DocTypes across the export pipeline, commercial documents, calendars, masters, and sourcing. The frontend is Next.js acting as a backend-for-frontend, proxying the Frappe session so every call carries the real user's identity.
That last detail earns its keep. The frontend holds no separate user store and issues no tokens of its own. It forwards the session, which means Frappe's controller validation, permission rules, and automatic document history all keep working exactly as they do in the native desk interface. A modern UI over an old, well-tested permission model — without reimplementing the permission model, which is where custom frontends over ERPs usually go wrong.
Fifteen stages, a single ordered ledger of everything that happened, and a merchandiser who can answer "why is this late" without opening a spreadsheet.
The five rules
- Store events, derive state. A status field is a cache of the last event with the history thrown away.
- Append, never mutate, anywhere the history has commercial value.
- Lock the fields people are tempted to tidy. Those are your delay data.
- Enforce at the server. The UI is a convenience.
- Model the shapes the domain actually has, even when that is more code.
None of it is novel. All of it is the difference between an ERP the business trusts and one they keep a spreadsheet next to.