Turnkey ERP
ENRU
Case study - Custom ERP

A full ERP with Goldratt's Theory of Constraints in the code.

A complete back office for a seller of home textiles on Wildberries, a major e-commerce marketplace: 763 product cards, 4 brands, about 6,000 orders a day, production in Uzbekistan with a ~60-day cycle. The system covers restock planning on TOC buffers, a daily pulse board, 7 alert types, an economics dashboard with a month-end forecast, set combinatorics, ABC analysis, a supplier order in one click, and as of this year FBS order assembly with marking-code scanning and a warehouse register.

FastAPIPostgreSQL 16CeleryReact 18 WB APIMoySklad APITrueStats APIGoogle Sheets
20
backend modules, each with its own README
227
API endpoints in the portal
1,166+40
backend and frontend autotests, re-run daily in a clean DB
763
SKUs under management, 4 brands
~6,000
orders per day flowing through the system
3 APIs
WB + MoySklad + TrueStats, plus Google Sheets
01 Context and pain

Three systems, one owner's Excel, and a 60-day supply chain

A fast-growing seller of towels, bathrobes and textile sets. The numbers lived in three disconnected services. The most important decision of the business, what to restock and when, was made by hand.

Data scattered across 3 systems

The marketplace cabinet held orders and ads. MoySklad (an inventory SaaS) held warehouses and set recipes. TrueStats (a seller analytics service) held unit economics. Plus manual file exports. Nobody saw one picture.

Restock planning rested on one person

Which SKU to move to the marketplace warehouse, how much and when: the owner decided this daily, in a personal Excel, across 763 product cards in ~27 categories. The process did not scale and did not survive a vacation.

Seasonal goods, long supply legs

Production takes about 60 days, plus ~10 days of delivery, plus domestic legs. Order too late and you miss the season. Order too early and you freeze cash in stock. The margin for error is small and shrinks in season.

Problems surfaced by eye

Stockouts, order drops, negative reviews and data mismatches were noticed when someone happened to look. With 6,000 orders a day, "happened to look" is not a control system.

02 System architecture

A nightly conveyor: three APIs in, decisions out

Every night Celery jobs pull the marketplace, the inventory SaaS and the analytics service into PostgreSQL. Calculation modules turn raw rows into buffers, alerts and forecasts. People work in a React SPA or in a Google Sheets showcase that the backend renders for them.

NIGHTLY DATA CONVEYOR · TIMES ARE MSK Wildberries API orders · sales · stocks cards · adverts · reviews supplies · tariffs sync 03:30 MoySklad 9 warehouses · 1,464 items 349 set recipes sync 06:30 TrueStats 98 unit-economics metrics cost versions per barcode sync 08:00, T-1 gate Manual files seller analytics import producer stock CSV drag-and-drop, mapped Sync layer Celery Beat pipeline idempotent imports: a re-run changes 0 rows freshness gates: stale link stops the chain rate limits per API watchdog every 5 min journal of every run PostgreSQL 16 62 tables · 12 migrations Redis 7: cache + broker backups: 14 daily + 8 weekly Calculation modules DBM buffer zones 08:30 sufficiency contract 7 alert types · ABC set combinatorics 08:40 dashboard + forecast React SPA 17 routes · 10 themes virtualized tables Google Sheets showcase rendered for managers RAW writes only pulls edits every 10 min Alerts + Excel 7 alert types in the feed branded export everywhere Health page autotests daily 05:00 traffic lights: containers, memory, disk, backups test results + host status every 5 min
External systems ERP services Data store Self-monitoring

One night in the life of the system

7Docker services behind nginx
62tables, 12 migrations, one head
14backend modules, each with its own README
33Klines of Python + TypeScript
10UI themes on CSS tokens, zero hardcoded colors

Contracts between modules mean nothing is computed twice: sufficiency comes from one function, the 33/67 traffic light from one settings row, ABC classes from one service. The pulse board, the Google showcase and the alerts all consume the same numbers.

03 Flagship module

Goldratt's Theory of Constraints, running in production

The client asked to verify his own restock formulas and suggest improvements. His classic coverage math stayed in the table as visible columns. On top of it the system runs Dynamic Buffer Management (DBM): every SKU gets a target buffer, the buffer splits into traffic-light zones, and the buffer corrects itself from actual consumption. This is textbook TOC, implemented as code and tests.

B (target buffer) = V_plan × N days × 1.1 × M  ·  V_plan = V30 × trend clamp(V3/V30, 0.5..2.0) × Kseason

The ×1.1 is the client's paranoia margin on every buffer, in the spirit of Goldratt. M is the DBM multiplier: it starts at 1.00 and moves in steps of 1/3.

Green · 67% and up

The buffer is full enough. Do nothing. Twelve green days in a row will suggest shrinking the buffer: capital should not sleep in stock.

Yellow · 33 to 67%

Plan replenishment. The SKU enters the restock table with a computed quantity, not a guess.

Red · below 33%

Order now. Red days accumulate in the 12-day history; a red streak triggers a buffer increase recommendation.

Black · zero stock

Stock is zero while sales run. Lost revenue every day. Black counts as red in the streaks and screams at the top of the alert feed.

Daily at 08:30, right after the night syncs recompute the zone of every SKU, append the day to zone history Look at a 12-day window (3 × the 4-day WB replenishment leg) count red and green streaks; recommendations only on full history Buffer too small 80% or more of the 12 days spent in red or black recommend: +1/3 buffer Buffer too big 12 green days in a row floor: never below 1/3 recommend: -1/3 buffer Cooldown: 12 days of silence after any change the system never chases its own tail A human confirms: the ±1/3 button in the table row auto-apply exists but ships OFF by default: per Goldratt, a person approves every buffer change new M Target buffer of the SKU B = V_plan × N × 1.1 × M V_plan = speed × trend × season ×1.1 paranoia · M in steps of 1/3 buffer fill P = (stock + transit) / B 100% 67% 33% 0 green · ok yellow · plan red · order now black · no stock zones colored by the single 33/67 traffic light of the system
Daily DBM job Increase branch Decrease branch / human control Stabilizer Buffer math

The constants, and why they are what they are

ConstantValueReasoning
DBM window12 daysThree times the replenishment leg to the marketplace warehouse (4 days). A shorter window reacts to noise, a longer one reacts too late.
Increase trigger≥80% red of 12A buffer that lives in red is not a buffer. Recommendation: +1/3 of the target.
Decrease trigger12 green in a rowA buffer that never leaves green is frozen cash. Recommendation: -1/3, and only a full unbroken streak counts.
FloorM ≥ 1/3The multiplier never drops below one third. If a step would break the floor, the decrease is not even suggested.
Cooldown12 daysAfter any correction the system stays silent for a full window, so one decision is measured before the next one is proposed.
Paranoia×1.1The client's rule: +10% on every buffer. Encoded as a named constant, covered by tests.
Supply legs4 / 14 / 74 daysTo the marketplace warehouse / from the producer's stock / from production (60-day cycle + 10 delivery + domestic legs).
TOP rule90%The client's rule: an A-class SKU below 90% buffer always jumps to the top of the restock table, above any C-class SKU, and restocks to at least 90%.

Seasonality on top of the buffer

Manual beats auto

Each category has a season window and a coefficient. A manual coefficient set by the client always overrides the automatic hint. The hints come from last year's orders: weekly sums, 3-week smoothing, season = the segment above 50% of the peak.

Off-season floor in units

Out of season the speed formula is ignored. The category holds a fixed stock in units, distributed across SKUs by their share of orders over 90 days. No sales history at all: split evenly.

Planned exit deficit

In the closing window of the season the buffer ramps down linearly to a planned deficit. The season ends with empty shelves by design, not with a year of leftovers in stock.

Human in the loop

The system recommends. The person decides.

Auto-apply of buffer corrections ships OFF by default. The daily job only writes a recommendation into the row: a ±1/3 button with a tooltip explaining why. The manager clicks, the change is logged, the cooldown starts. That is Goldratt's own discipline: buffers adapt, humans approve.

Restock table: traffic-light buffer zones, TOP badges, DBM plus-minus one third button and a sticky supplier order block
The restock table: zone traffic lights on every SKU, TOP badges, the ±1/3 DBM button, sparklines with demand spikes over μ+2σ, and the sticky supplier order block on the right.
2,387lines in the TOC module
22REST endpoints
9dedicated tables
17tests on the rules: floor, cooldown, season across New Year
33/67one traffic light shared by buffers and alerts
04 Daily analytics

A pulse board for 763 SKUs and an economics dashboard that forecasts the month

The pulse board is the daily per-SKU control panel: sales funnel, ad economics, stock and prices, about 60 columns per SKU, virtualized so 763 rows scroll like silk. The dashboard answers the owner's question: how does the month end if we keep moving like this?

Pulse board

763 rows × ~60 columns with sticky SKU column and configurable traffic lights. A card mode for working by exception. Each SKU opens into a card: sales, ads, stock, prices and comments in 7 / 14 / 30-day windows.

Smart file import

The marketplace funnel export lands by drag-and-drop. Columns are matched by fuzzy mapping; a renamed column triggers a confirm dialog instead of a silent misread. Broken rows are rejected individually, never the whole file.

Cross-source reconciliation

TrueStats and marketplace revenue are compared daily. A gap over 5% raises a banner. In production this caught a real 13.31% mismatch on the first battle run.

Economics dashboard

Cabinet and category economics from file, API or both merged per SKU. A month-end forecast from the 15-day trend. Expense metrics invert their coloring: growing ad spend is red, not green. Response: 61 ms cached, 101 ms cold.

Adoption trick

Managers kept their Google spreadsheet

The backend renders showcase tabs into the familiar spreadsheet: main board, season view, distribution, price proposals, stock control, alerts. Writes are RAW only, so barcodes do not turn into 2.05E+12. Manual edits flow back by a diff-pull every 10 minutes, and the render never overwrites a human's cell. A button in the sheet posts new prices back through a token-checked webhook.

Pulse board: virtualized SKU table with traffic lights and card mode
The pulse board: card mode by default, full virtualized table one click away, traffic lights with thresholds the client tunes himself.
Economics dashboard with month-end forecast and category filters
The dashboard: cabinet economics, category breakdown, month-end forecast from the 15-day trend, filters recomputed on the fly.
~60columns per SKU on the pulse board
13.31%real data gap caught by reconciliation
61 / 101 msdashboard response, cached / cold
10 mindiff-pull cycle for manual sheet edits
05 Control loop

Seven alert types, and an ERP that tests itself every morning

Problems should find people, not wait to be found. Seven deterministic alert types watch the business; a health page with traffic lights watches the system itself.

AlertTriggerDetail
Urgent restock≤100 units left on the marketplaceAlways red, always pinned to the top of the feed.
Low sufficiencycoverage below thresholdUses the same sufficiency contract as the restock table. One formula, one truth.
Orders drop3-day speed vs 10-day speed, ≥30% downCatches a dying SKU in days, not weeks.
Orders spike3-day speed sharply upA spike is a restock signal too: sell it while it flies.
Negative reviewsnew 1-3 star reviewsPopup with the review texts, server-side pagination. 1,447 negatives were on file at acceptance.
Production start<90 days of runwayCounts the 60-day production cycle backwards from the projected stockout, softened off-season.
Data mismatchTrueStats vs WB gap >5%The system distrusts its own inputs before it lets you trust its outputs.

Episode lifecycle

An alert is an episode: active, resolved, reactivated same-day if the condition returns. Zero duplicates by design. Acknowledgements are per user, so one manager's "seen" does not hide the problem from another.

Stale-data gate

If marketplace data is older than 24 hours, the whole alert recalculation is skipped. No alerts computed on stale data: silence is more honest than noise.

388 episodes on day one

The first production run surfaced 388 active alert episodes. That is the backlog of problems that used to be invisible. The feed sorted them; urgent restock came first.

Trust by construction

The client sees the tests, every day

A cron job re-runs all 212 backend autotests every morning at 05:00 in a clean, separate database. The result lands in a table and shows on the Health page, next to traffic lights for containers, memory, disk and backup freshness. The ERP proves to its owner daily that it still works. Not a promise, a green light.

Alert feed with type chips, traffic-light severities and review popup
The alert feed: type chips, per-user acknowledge, urgent restock pinned on top, negative reviews open in a paginated popup.
Health page with system traffic lights and daily autotest results
The Health page: container, memory, disk and backup traffic lights, plus this morning's autotest run, visible to the client.
7alert types, one 33/67 traffic light
388active episodes surfaced on day one
212+26autotests re-run daily at 05:00
5 minhost status refresh on the Health page
06 Revenue engineering

Set combinatorics: 1,813 sell paths computed in 3.0 seconds

Towels sell alone and in sets. The same raw item can go out a dozen ways, each with its own price per unit, margin and stock cap. The client used to hold this combinatorics in his head. Now the system computes it, and explains every suggestion in a "Why" column.

Stage 1: every existing path

For each raw towel: all its sell variants, single or inside any of 349 set recipes pulled from MoySklad tech cards. Per variant: price per unit, margin, cap by current stock, the limiting component, and a greedy allocation plan. 294 raw items × 349 recipes = 1,813 variants, computed in 3.0 seconds and verified by hand against SQL on production data.

Stage 2: new combinations

The client's merchandising rule, encoded: pink does not sell alone, but alongside grey it flies. The engine parses the model base from the article, pairs a top color with a slow mover (same base), crosses bases, and estimates the economics of each candidate from analogs.

The "Why" column

Every candidate carries a human-readable rationale: which color pulls, which stock it unfreezes, what the expected per-unit economics are. Optimization is deliberately greedy, not an LP solver: the client can follow every step, and trust beats the last percent of optimality.

Set combinatorics: variants per raw item and new combination candidates with a Why column
Set combinatorics: variants per raw item on one tab, generated candidates with the "Why" rationale on the other, plus a manual set constructor.
1,813variants in one run
3.0 sfull recomputation
294 × 349raw items × set recipes
100%of candidates explain themselves
07 Focus

ABC analysis: 134 SKUs hold 80.2% of the profit

A floating ABC classification by three criteria at once: profit, revenue and orders, with configurable 80/95 thresholds guarded by a database-level check.

The first production run put numbers on the intuition: class A is 134 SKUs, 17.6% of the catalog, holding 80.2% of the profit. The classes are cross-checked against the analytics service's own ABC, and SKUs that are loss-making or lack data are shown in explicit buckets with the reason broken down, not silently dropped.

Classes are computed once and served by contract: the pulse board and the restock table pull the letter without recomputing anything. The TOP rule of the restock table feeds on exactly these classes.

Why it matters for restock

An A-class SKU below 90% of its buffer jumps to the top of the supplier order, above any C-class SKU with a worse percentage. The business protects the 134 SKUs that pay for everything else. That priority is a tested rule, not a habit in someone's head.

134SKUs in class A
17.6%of the catalog...
80.2%...holding this share of profit
3criteria: profit, revenue, orders
08 Closing the loop

Supplier order in one click. Honestly: checkboxes plus one button.

The buffer says what is missing. The order module says where to get it and by when, then produces the exact Excel file the producer in Uzbekistan expects.

  1. Two-pass quantity. Q(L) = max(0, ceil(V_plan × (N + L) × 1.1 - total stock)). Pass one checks the producer's warehouse: enough there means mode "from stock", lead 14 days. Otherwise mode "production", lead 74 days (60 production + 10 delivery + domestic legs), with a note when part of the quantity is available now.
  2. Urgency by thirds. Buffer coverage below 1/3: order now. Between 1/3 and 2/3: plan. Above 2/3: can wait. Production mode is scored one step stricter: a long leg leaves less room for error.
  3. Checkboxes that survive. The manager ticks rows in a sticky order block; ticks and quantity overrides live in the database, not in the browser tab. Generating the order does not reset them; only an explicit reset button does.
  4. One button, one file. "Create order" builds the Excel in the format agreed with the supplier: producer article from a mapping directory, color, mode, quantity, lead time, purchase cost, totals. Unmapped articles get a highlighted "not in directory" cell instead of a silent blank. The file downloads and lands in the order history with a full item snapshot.
  5. Messy input, clean data. The producer's stock arrives as a semi-structured CSV from the labeling system. A tolerant regex parser rejects broken lines individually; unmatched articles become "create a rule" tasks in the UI, and the mapping directory is maintained by the client, not by the developer.
14 / 74days lead: from stock / from production
90%TOP rule floor for A-class SKUs
1click from ticked rows to the supplier file
100%of orders kept in history with snapshots
09 FBS assembly

The marking code check moved to the moment of the scan

The warehouse packed orders by hand: the marketplace account, a pick list, Excel, a scanner into Excel, a manual replacement of hidden separators and an upload of the template back. The real loss of time started after that upload: the marketplace checks every code against the national traceability registry and rejects some of them, and people go looking for those items through the boxes. The module moves the check to the moment of the scan: the picker learns about a bad code while the item is still in their hands.

Supply screen: orders with statuses and the scanner panel with a verdict on the marking code
The supply page: on the left the orders with statuses and the matched row highlighted, on the right the scanner with a large photo of the item, the scan stages and the verdict on the code. The field always keeps focus: the picker holds a scanner and an item, not a mouse.
VerdictWhat the picker sees and does next
Code acceptedgreen, the order is closed, the label goes to the printer
Accepted uncheckedyellow: the registry did not answer, the code is accepted by setting and rechecked at night
Owned by another companyput it aside, take another item
Already soldthe code has left circulation: put it aside, take another item
Not in circulationthe code was never put into circulation: send it to quarantine
Blockedquarantine
Unknown to the registryquarantine
Duplicatered, with the number of the owning order: the label can be reprinted from there
Another productthe code belongs to another card: scan the barcode first

The order of the checks matters. A foreign sold code has several reasons to fail, and the picker needs to hear one: the one they can act on.

One entry point instead of modes

The server classifies the input itself, barcode, marking code or label, and moves the state of the order. A person holding an item has nothing to switch modes with.

A repeat returns the saved answer

Every press carries its own identifier, and a repeated request returns the same answer instead of a recomputed one. Otherwise the picker would see "this code is already scanned" in response to their own scan.

The import does not undo the shift

A repeated import refreshes the marketplace fields and never touches ours: status, batch, scans and codes. The nightly run does not erase what people did.

Quarantine instead of a silent loss

An order with a bad code goes to quarantine with a reason rather than disappearing from the list. The warehouse lead sees how many orders are stuck and why.

The warehouse lead manages the staff

The module admin right allows adding and disabling pickers without the portal administrator. Disabling only, never deletion: scans and shift reports hang on the person.

The shift report

A shift is a day in Moscow time. Per employee it shows what was picked, which codes were accepted, what is done, how many problems, how many rejected scans, and the first and last scan.

Silent degradation is unacceptable here. If the registry token dies, the module raises a system alert and keeps working in "accepted unchecked" mode, marking such codes honestly. It is exactly a silent failure that would cost the rejection at the marketplace this whole module exists to avoid.

FBS supplies list with a progress bar and seller statuses
The supplies list: a green progress bar, quarantine as its own column, the seller statuses of the marketplace and the assigned picker and packer.
Shift report: who packed how much and how many problems
The shift report with a total row: it answers the warehouse lead's question "who packed how much today" without getting anybody up from their desk.
10 Warehouses

Exclusion is a date, not a checkbox

There was no warehouse register in the system: the warehouse name lived as a bare string inside the daily stock snapshots, and there was nothing to switch a warehouse off with. The client kept a noticeable share of goods in warehouses that no longer exist, while replenishment, turnover and the daily pulse counted those goods as live stock.

Warehouse register with roles, clusters and the exclusion date
The warehouse register: role, region, cluster, stock and whether it takes part in the calculations. At the top sits the share of stock held in excluded warehouses: before this register those goods counted as live.

A date, not a boolean

The client asked to state from which day a warehouse is off and to recompute the history from that day. Stock snapshots older than the date stay as they were: the past is not rewritten.

The log answers the "why"

Two fields on the warehouse row only answer "who switched it off right now". The client's question was a different one, and the history answers it: who, when, from which date and for what reason.

A row is written on the fact of a change

Saving the same values again adds nothing to the log. Otherwise in a week it fills with noise and stops answering the original question.

Protected rows

Pseudo-warehouses for goods in transit and the drop-off points cannot be excluded: they hold no stock, they mark its movement. An attempt to switch such a row off is rejected by the server, not only by a guard on the front end.

11 How it was built

Twelve written specs, one pair of hands

Business analysis first. Every stage started as a self-contained written spec with data contracts, formulas and acceptance criteria. Implementation went step by step under iron rules written down after the previous ERP project. Each change lands as a small reviewable step.

  1. Scope agreed. The client spec covered data sources, the five-warehouse stock table, the buffer logic, alerts, the pulse board and set combinatorics. Data contracts and acceptance criteria were pinned down before the first line of code.
  2. All functional stages TZ-00 to TZ-10 were delivered: hardened infrastructure, portal with roles, three API syncs, pulse board, Google showcase, stock control with DBM, alerts, dashboard, sets, exports, ABC.
  3. UI redesign. A dark collapsible sidebar, card mode, 10 themes on CSS tokens. The contrast of every theme is checked by a test, not by eye.
  4. Acceptance is a document. A 630-line acceptance report, a requirements coverage matrix of 187 rows, and an honest section listing 18 shortcomings and deviations. Every claim in this case study traces to that report or to the repository.
Honest limits

What the system does not pretend to know

The automatic seasonal coefficient currently equals 1: the marketplace API keeps only about 6 rolling months of history, so year-over-year seasonality is physically unavailable. The system says "not enough history" and accumulates its own. The Google service key was not yet handed over at acceptance, so that module soft-skips instead of failing. Competitor parsing is coded but waits for a proxy. All three are written in the acceptance report, not discovered later.

Stack

PythonFastAPISQLAlchemy 2 asyncAlembic Celery + BeatPostgreSQL 16Redis 7 Docker Composenginx React 18TypeScriptViteTailwind Radix UIZustandTanStack Queryrecharts

Integrations

Wildberries APIMoySklad API TrueStats APIGoogle Sheets API

Delivery pace

62 database tables12 written specs 33K lines of code212+26 autotests
12 Outcome

Before and after

AreaBeforeAfter
Restock decisions The owner's personal Excel, daily, by feel. Held on one person. TOC buffers with traffic-light zones per SKU, self-correcting by ±1/3, every change confirmed by a human.
Data Three services plus manual exports. No single picture. One PostgreSQL, a nightly conveyor with freshness gates and idempotent imports.
Problem discovery By eye, when someone happened to look. 7 alert types; 388 previously invisible episodes surfaced on day one, urgent restock always on top.
Month economics Clear after the month ended. A dashboard with a month-end forecast from the 15-day trend, in 61 ms.
Set planning Combinatorics in the owner's head. 1,813 computed variants in 3.0 s, each new candidate with a written "Why".
Supplier order Assembled by hand for every order. Checkboxes plus one button: the supplier's exact Excel format, with history and snapshots.
Trust "It should be working." 212+26 autotests re-run every morning in a clean database, result visible to the client on the Health page.

Kirill Sedov

Business analyst and systems builder. I start with the business process and the data contract, then automate. This ERP went from agreed spec to production, and it runs the client's restock on Goldratt's buffers instead of gut feel.

Connect on LinkedIn