Handbook
How this system works, what each menu does, and how to read the data. If you are new, start with Core ideas.
Core ideas (read this first)
This system is built on one rule: every stock change leaves a permanent trace. Stock is never edited or deleted. The "current stock" you see is the running total of an append-only ledger — a list of movements that only ever grows. A correction is a new row that reverses an old one, never a deletion.
- Immutable ledger. The
stock_ledgertable forbidsUPDATEandDELETE. Nothing can be silently changed after the fact. - Balance cache. The number you see is read from a fast cache (
stock_balances), not by summing history each time. The cache is updated in the same transaction as the ledger row, so it is always consistent. - Unknown ≠ zero. A product with no opening stock yet shows
—, never0. Zero means "we counted zero"; the dash means "we have never established a balance." Unknown products raise a No initial balance anomaly until stock arrives. - Reason ≠ channel. Every movement records why (reason: sale, bonus, damaged…) and through where (channel: Shopee, TikTok, offline, warehouse). These are never mixed — mixing them is what made the old spreadsheet impossible to audit.
- FEFO. Outbound stock leaves nearest-expiry batch first ("First-Expire, First-Out"), automatically spanning batches. The operator never chooses a batch — the system does.
- Trade scope. Every row is classified Online (marketplace sale), Offline (manual warehouse sale), or Internal (inbound, stock-take, reversal) — derived from the channel, so it can never be mis-entered.
How to read a ledger row
The Ledger page can show each row as a plain-language sentence. The shape is:
−12 SALE via SHOPEE (Online) · AURA MASK · B2405 · ORDER #4471 · 2 Jun 14:20- − or + — minus is stock going out, plus is stock coming in.
- reason via channel (scope) — the why, the through- where, and the derived category.
- source — what caused the row:
ORDER,RETURN,MANUAL,OPNAME,INBOUND, orIMPORT, plus a short id. - ↩ reversal — if present, this row reverses an earlier row (e.g. a cancel-after-ship). Click through to see the original.
The same row also appears in the table view with filters (reason, channel, scope, date) and "Older" pagination.
Dashboard
What it is: the home page. A quick read on what needs attention today.
How to read it:
- Urgency row — counts of open anomalies, TikTok claims due soon, batches expiring, and products with no initial balance. A non-zero count links to the page that explains it.
- Scope recap — how much stock went out online vs offline vs internal this period.
- Recent stock — the current balance per batch. A dash means unknown balance (not zero).
When to act: any red or amber number. Follow the link to Reconcile, Returns, or Products to resolve it.
Products
What it is: the master catalog — products, their batches, and bundle recipes.
How to read it: each product shows SKU, name, active status, and whether its balance is initialized. The Not initialized badge means balance is unknown (shows —); first inbound or a stock-take fixes it.
Who can change it: creating products, batches, and bundle recipes is admin only. Operators can view.
Gotcha: a bundle is a marketplace listing made of several products. Define it once here (e.g. bundle "PACK-A" = 1× Product X + 2× Product Y); there is no stock at the bundle level — stock lives on the components.
Inbound
What it is: record goods received from the contract manufacturer (maklon).
How to use: pick the product, enter the batch code, expiry date, and quantity received. The system finds or creates the batch and writes a +qty INBOUND_MAKLON row.
Side effect: the first inbound ever received for a product initializes its balance — from that moment it shows a real number instead of —.
Manual out
What it is: record stock leaving the warehouse outside of a marketplace order — a sample, a bonus, a promo, or damaged/expired goods pulled from a batch.
How to use: choose a reason and a channel. The form enforces the reason≠channel discipline for you:
- Sale / Bonus / Promo / Sample → must take a sales channel (Shopee / TikTok / Offline).
- Damaged / Expired → channel Warehouse, and you may pick the specific batch to remove (overrides FEFO).
How to read the result: FEFO picks the batch automatically (except Damaged/Expired). If there isn't enough stock, a negative row is written deliberately and a Negative balance anomaly is raised — the leak is surfaced, not hidden.
Orders
What it is: marketplace orders and their lifecycle. Orders arrive from the event pipeline (simulator now, a real webhook later).
How to read status:
- Reserved — order placed, goods not yet sent. No ledger row yet.
- Shipped / In transit — goods physically out. A
SALEledger row was written. - Delivered — reached the customer.
- Cancelled — if cancelled before ship, nothing touched the ledger; if cancelled after ship, a reversing row returned the goods to their original batch.
- Return requested / Returned — see Returns.
Drill-down: select an order to see its items and the exact ledger rows it produced — the traceability link from an order to its stock movements.
Returns
What it is: the inspection queue for returned goods.
How to use: for each return, decide the condition:
- Restock — goods are good; they go back to the original batch (
+RETURN_RESTOCK). - Damaged — goods are real but unsellable; they go into a quarantine batch (non-sellable) so they stay traceable without polluting sellable stock.
- Lost — no goods return; nothing is added to stock. The loss is recorded on the return so the discrepancy has a story.
TikTok claim deadline: TikTok returns have a 40-day claim window. When it is within 7 days and the return is still un-inspected, a "Nd left" warning badge appears — inspect before the window closes.
Stock-take (opname)
What it is: count physical stock and reconcile it against the records.
How to use:
- Start a stock-take. The system snapshots the current recorded balance per batch.
- Count blind. Enter what you physically see — the recorded figure is hidden during entry so you are not biased.
- Reveal variance. See recorded vs counted vs variance (counted − recorded).
- Drill into a product to see every movement since the last stock-take, grouped by reason — the story of where the units went (this many as SALE, this many BONUS, this many lost in returns).
- Finalize (admin). For each non-zero variance an
OPNAME_ADJUSTMENTrow is written so the ledger now matches the physical count. After finalize, balance == what you counted.
Reconcileadmin only
What it is: the anomaly scanner and its worklist. Admin only.
How to use: click Run reconciliation. The scanner writes one anomaly per issue found. Then work the list: drill each to its source (order / return / ledger row / product) and Resolve with a note once handled.
What it scans: the scan is point-in-time — it reads the current state of balances/products plus forward deadline windows. It is not tied to one day; you may run it any time, as often as needed. The Balance drift check sums the ledger server-side (one grouped query), so it stays fast as history grows.
Thresholds are tunable. Open the Thresholdspanel to set how many days trigger a stuck reservation, the TikTok claim window, and the expiry warning. Saved thresholds apply to the next scan. Defaults: 7 / 7 / 30 days.
Empty state: "Nothing to reconcile" with a check means the books are consistent. See Anomaly types below for what each flag means.
Ledger
What it is: the full audit trail — every movement ever recorded. This is the evidence.
How to use: filter by reason, channel, trade scope, product, batch, or date. Use Older to page back in time (pagination is keyset — it stays fast however far back you go).
Two views: the default table, or ?view=sentence for the plain-language row sentences (see How to read a ledger row).
Read-only: you cannot edit anything here. To correct a mistake, use a reversal (Manual out / cancel-after-ship) or a stock-take — never a hand edit.
Import (legacy spreadsheet)admin only
What it is: absorb the client's old spreadsheet as starting history. Admin only.
How to use: paste the wide/pivot format (product, remaining stock, then per-date blocks of RETUR / SHOPEE / MANUAL / TIKTOK). Pick how to treat the MANUAL column — it mixes offline sales + bonuses + samples at source:
- Offline SALE (default) — import as offline sale with an "unverified" note. Honest that the granularity was lost.
- Generic adjustment — mark as legacy, excluded from net sales.
Always dry-run first. The preview shows exactly how many rows will be created and which anomalies appear before you commit. Re-importing the same file does not duplicate rows (idempotent). Empty "remaining stock" products import with unknown balance (the old "red rows") — correct, because their balance genuinely is unknown.
Export
What it is: download a multi-sheet XLSX to hand to an auditor, accountant, or client.
How to use: pick the sheets you want and a date range:
- Current Stock — snapshot per product/batch at export time (unknown balance shows
—, not 0). - Period Summary — online vs offline vs internal within the range.
- Stock-Take Report — variance + per-reason drill for one stock-take.
- Ledger — the raw audit trail within the range.
Read-only. Export is a projection of the ledger, not a new source of truth. Never edit it and import it back as a "correction" — that would break immutability. Corrections happen only via reversal or stock-take.
Simulatoradmin only
What it is: inject marketplace events to test or demo the pipeline. Admin only.
How to use: inject one event at a time (new order → ship → deliver → cancel → return), or paste a CSV/JSON batch. Then click Process pending to apply them.
Why it matters: the simulator writes to the same event table a real marketplace webhook will use. When a live API is connected later, nothing in the core changes — it just inserts into the same table with source='WEBHOOK'.
Anomaly types (what each means)
The Reconcile scanner raises these. Each is drillable to its source and resolvable with a note.
- Negative balance — a batch went below zero (stock went out that wasn't there). Investigate the shortfall; usually a missed inbound or an over-allocation.
- Balance drift — the cache disagrees with the ledger sum (computed server-side). Should never happen in a healthy system; signals a cache/trigger bug. Run
rebuild_balances()after investigating. - Stuck reservation — an order still Reserved past the stuck threshold (default 7 days, tunable). Did it ship without the event arriving, or is it dead?
- TikTok claim due — a TikTok return's 40-day claim window closes within the claim-window threshold (default 7 days, tunable) and it is un-inspected. Inspect now.
- Expiry warning — a batch with stock expires within the expiry threshold (default 30 days, tunable). Sell or remove it before it expires.
- Orphan movement — a ledger row with no source, or a listed SKU that didn't resolve to a known product/bundle.
- Unbalanced reversal — a reversing row whose quantity doesn't match the original it points at.
- No initial balance — an active product with no opening stock established yet (the spreadsheet's red rows). Enter stock via inbound or a stock-take to clear it.
Roles
- ADMIN — full access: master data (products, batches, bundles), stock-take finalize, daily reconciliation, the legacy importer, and the simulator.
- OPERATOR — day-to-day work: inbound, manual out, returns inspection, stock-take counting, and the simulator. Cannot reach admin-only pages (they are hidden in the nav and redirect if hand-typed).
Action-level limits (e.g. only an admin can finalize a stock-take or resolve an anomaly) are enforced inside each action, not just the nav.