Net unit change by location
This import touches one location. The other bars are shown so the shape of the estate is visible and an accidental multi-location file is obvious at a glance.
Six steps, not five: Diff sits between validation and commit, because a validator tells you the file is well formed and only a diff tells you what the file will do to your stock · the commit reads import_rows and never re-reads the file · reversal writes compensating rows and deletes nothing
Nine templates. The three with a red edge write to the ledger and require a signature before they can commit.
The Phase 0 seed. SKU x location x stock type x level. Posts one ledger row per non-zero key, only to a virgin key, and only after two signatures.
Offline count results back into an open session. Requires a session code, and every row must resolve to a line already in that session's frozen scope.
Signed deltas with a mandatory reason code from count_reason_codes. There is no free-text reason, because a free-text reason cannot be aggregated or audited.
Style groups, variants, composites, barcodes, Shopify product GID. Never posts stock. A rename lands in sku_aliases and never orphans a tag.
Warehouses, stores, virtual and third-party locations, zones, bins, Shopify location ids. Codes are validated against L10; invented codes are rejected.
Vendor, payment terms, lead-time days, incoterm, contacts. Feeds module 06.
PI and commercial invoice lines. Keys through item_codes, because a PO line often has no SKU yet (ruling L15).
Unit cost per SKU with an effective date. Lands in cost_ledger (ruling L14), never in the quantity ledger. A cost revision moves value with zero quantity.
Handle, tier, platform URLs, manager, shipping address. Feeds module 11.
A file, an Excel tab, or the Google Sheet that is currently the incumbent truth. The parse is stored; the file is never read again.
or press U to browse · max 20 MB · headers detected automatically
This workbook has six per-location tabs and a multi-row preamble before the real header, so the parser searches for the SKU_Name and Opening_Stock header cells rather than assuming fixed column indices. That logic is ported from INV1's tools/opening_count_2026/sheet_fetcher.py, which is the one part of the incumbent pipeline that was already right. Retail tabs carry 18 columns and the warehouse tab carries 20.
Auto-detect proposes, you confirm. A required field left unmapped blocks the next step.
Every row is parsed, normalised and checked once, then stored. Hover a status to read the validator message.
| Row | Status | SKU | Location | Stock type | Qty in | Current | Delta | Value AED | Validator message |
|---|---|---|---|---|---|---|---|---|---|
| 8 | OK | MSR31-07 | MCC | STOCK | 14 | 0 | +14 | +4,270 | virgin key · one opening row will be written |
| 9 | OK | MSR31-07 | MCC | DISPLAY_GOOD | 3 | 0 | +3 | +915 | the DISPLAY column becomes its own key, not a subtotal |
| 10 | OK | MSR31-12 | MCC | STOCK | 7 | 0 | +7 | +2,135 | virgin key |
| 11 | OK | MIN-DIF-004 | MCC | STOCK | 0 | 0 | 0 | 0 | counted zero · recorded as a verification, posts no ledger row |
| 14 | WARN | LTC04-03 | MCC | STOCK | 11 | 0 | +11 | +1,650 | no unit cost in the file · value will be sourced from cost_ledger at commit |
| 19 | WARN | LTC07-02 | MCC | DISPLAY_GOOD | 1 | 0 | +1 | +390 | diff vs Shopify is 6 units · a note is required before sign-off (R-10.15) |
| 23 | ERROR | MST-CRM-42-BLU | MCC | STOCK | 6 | n/a | n/a | n/a | sku_not_found · not in skus and not in sku_aliases · nearest match MST-CRM-42-BLK |
| 27 | ERROR | LTC01-01 | MCC | TRIAL | 2 | n/a | n/a | n/a | stock_type_unknown · TRIAL was one of i2's 22 types · i3 has 16 and TRIAL is not one |
| 31 | ERROR | MIN-CNDL-007 | MCC | STOCK | 1,250.00 | n/a | n/a | n/a | qty_not_integer · a cost landed in the quantity column · not coerced, not defaulted |
| 38 | ERROR | MSR31-04 | MIRDIF_CC | STOCK | 9 | n/a | n/a | n/a | location_unknown · not an i3 location code · the real set is HQ_319, HQ_325, MCC, YAS, CCZ, AJM, BAS |
| 44 | ERROR | MST-CRM-38-TAN | MCC | STOCK | 4 | 4 | 0 | 0 | key_not_virgin · this key already carries a balance · an opening balance applies once, to a virgin key (R-10.13) |
| 52 | ERROR | MSR31-07 | MCC | STOCK | 14 | 0 | n/a | n/a | duplicate_key · row 8 already claims this (sku, location, stock_type) |
| 61 | QUARANTINE | LTC02-05 | HQ_319 | STOCK | 100 | 0 | held | held | legacy_opening_balance · exactly 100 units, INV1's constant · re-sign as an i3 batch or discard |
| 62 | QUARANTINE | LTC09-01 | HQ_319 | STOCK | 100 | 0 | held | held | legacy_opening_balance · idempotency key opening-baseline:LTC09-01 carries no date and can never be superseded |
| 63 | QUARANTINE | MIN-VAS-005 | HQ_319 | STOCK | 100 | 0 | held | held | legacy_opening_balance · source quick_opening.py, note "Baseline opening stock" |
| 71 | OK | STRAP-MSK05 | MCC | STOCK | 15 | 0 | +15 | +1,125 | virgin key |
| 78 | WARN | GIFTBOX_LUX_02 | MCC | STOCK | 4 | 0 | 0 | 0 | composite SKU · a composite never holds stock (L2) · row accepted and decomposed to its components |
| 84 | OK | JB_PEARL_03 | MCC | DISPLAY_GOOD | 2 | 0 | +2 | +640 | virgin key |
The step v2 does not have. A validator says the file is well formed. Only a diff says what the file will do to your stock, and that is the thing a person can actually approve.
| Location | Stock type | Keys touched | Units before | Units after | Delta units | Delta AED | Virgin keys | Non-virgin | Effect |
|---|---|---|---|---|---|---|---|---|---|
| MCC | STOCK | 1,246 | 0 | 2,913 | +2,913 | +986,410 | 1,246 | 0 | clean bootstrap |
| MCC | DISPLAY_GOOD | 494 | 0 | 908 | +908 | +329,740 | 494 | 0 | clean bootstrap |
| MCC | DISPLAY_DAMAGED | 18 | 0 | 41 | +41 | +14,880 | 18 | 0 | clean bootstrap |
| MCC | RESERVED | 24 | 0 | 22 | +22 | +8,120 | 24 | 0 | clean bootstrap |
| MCC | STOCK | 6 | 37 | 37 | 0 | 0 | 0 | 6 | rejected · not virgin |
| HQ_319 | STOCK | 7 | 0 | 0 | 0 | 0 | 7 | 0 | quarantined · legacy |
| Total effect on commit | 1,782 | 0 | 3,884 | +3,884 | +1,339,150 | 1,782 | 6 | needs 2 signatures | |
This import touches one location. The other bars are shown so the shape of the estate is visible and an accidental multi-location file is obvious at a glance.
The twelve rows carrying the most value. If an operator recognises none of these, the mapping is wrong.
| SKU | Stock type | Units | Unit cost | Value AED | Share |
|---|---|---|---|---|---|
| MST-CRM-42-BLK | STOCK | 96 | 305 | 29,280 | 2.2% |
| MSR31-07 | STOCK | 88 | 305 | 26,840 | 2.0% |
| MSR31-12 | STOCK | 74 | 305 | 22,570 | 1.7% |
| MST-CRM-38-TAN | STOCK | 68 | 288 | 19,584 | 1.5% |
| JB_PEARL_03 | DISPLAY_GOOD | 52 | 320 | 16,640 | 1.2% |
| MSR31-04 | STOCK | 61 | 262 | 15,982 | 1.2% |
| LTC09-01 | STOCK | 44 | 340 | 14,960 | 1.1% |
| LTC04-03 | STOCK | 96 | 150 | 14,400 | 1.1% |
| MIN-VAS-005 | DISPLAY_GOOD | 38 | 340 | 12,920 | 1.0% |
| LTC01-01 | STOCK | 142 | 88 | 12,496 | 0.9% |
| MIN-CNDL-007 | STOCK | 184 | 62 | 11,408 | 0.9% |
| STRAP-MSK05 | STOCK | 152 | 75 | 11,400 | 0.9% |
One transaction. The commit reads import_rows and re-checks the file hash only to detect substitution.
Every job, its outcome, and its reversal if it has one. A reversed job is never removed.
| When | Job | Template | Source | Rows | Result | Net units | Net AED | User | Log | |
|---|---|---|---|---|---|---|---|---|---|---|
| 29-07 13:52 | IMP-2026-0729-014 | Opening balance | Sheet · MCC | 1,894 | PREVIEWED | +3,884 | +1,339,150 | Ahmad Y. | Download | this job |
| 28-07 16:18 | IMP-2026-0728-013 | Opening balance | Sheet · CCZ | 962 | COMMITTED | +2,349 | +812,400 | Ahmad Y. | Download | Signed |
| 28-07 11:04 | IMP-2026-0728-012 | Count sheet | CSV upload | 214 | COMMITTED | -23 | -8,410 | Sanjay P. | Download | Session |
| 27-07 09:41 | IMP-2026-0727-011 | Cost basis | CSV upload | 1,697 | COMMITTED | 0 | +0 | Mahmoud F. | Download | cost_ledger |
| 26-07 18:30 | IMP-2026-0726-010 | Opening balance | Sheet · BAS | 611 | COMMITTED | +1,472 | +498,220 | Ahmad Y. | Download | Signed |
| 26-07 09:12 | IMP-2026-0726-009 | Opening balance | RFID sweep export | 1,904 | COMMITTED | +8,318 | +2,104,880 | Ramesh K. | Download | HQ_325 signed |
| 25-07 14:47 | IMP-2026-0725-008 | Stock adjustment | CSV upload | 42 | REVERSED | 0 | 0 | Ahmad Y. | Download | by IMP-...-008R |
| 25-07 15:02 | IMP-2026-0725-008R | Stock adjustment | reversal | 42 | COMMITTED | +31 | +9,240 | Ahmad Y. | Download | 42 compensating rows |
| 24-07 10:22 | IMP-2026-0724-007 | SKU catalogue | CSV upload | 2,159 | COMMITTED | 0 | 0 | Ahmad Y. | Download | 44 aliases |
| 23-07 08:55 | IMP-2026-0723-006 | Locations and zones | CSV upload | 64 | 4 SKIPPED | 0 | 0 | Sara H. | Download | MIRDIF_CC rejected |
| 22-07 17:31 | IMP-2026-0722-005 | Suppliers | CSV upload | 28 | COMMITTED | 0 | 0 | Sara H. | Download | Suppliers |
| 21-07 12:08 | IMP-2026-0721-004 | Opening balance | CSV upload | 218 | FAILED | 0 | 0 | Ahmad Y. | Download | hash mismatch at commit |
Each bar is one job, scaled to its row count. A job that is mostly amber is a mapping problem; a job that is mostly red is a source-data problem; a job with any purple is carrying legacy opening balances.
i2's blueprints/opening_stock.py is one route serving two modes. The preview branch returns the first 200 parsed rows and stores nothing. The apply branch reads request.files.get("csv") from a fresh form submission and re-runs the entire parse. Nothing binds the two, so the committed file need not be the previewed file.
-- i2, blueprints/opening_stock.py:60 and again at :112 f = request.files.get("csv") # preview pass ... f = request.files.get("csv") # apply pass, a NEW read ledger.write(..., qty_delta=r["qty"], ...) -- and the docstring, two consecutive sentences: -- "qty is the SIGNED delta to apply." -- "Positive = set stock level." -- Those are different numbers the moment the key is non-zero.
i3 stores the parse in import_rows and the commit takes a preview_id. The file hash is re-checked only to detect substitution, and a mismatch is a 409, not a silent re-parse. The opening line carries qty_counted, a level, and the posted delta is level - balance_before where balance_before must be zero.
i2's _aed_to_fils returns 0 on any ValueError, so 1,250.00 with a thousands separator silently becomes free stock. i3 marks the row error, names the field and the offending value, and exports it. Nothing is coerced, nothing is defaulted.
v2's commit step says "rollback is manual". i3 ships POST /api/imports/<job>/reverse, which creates a new job with reversal_of set and writes one negating ledger row per originally committed row, keyed import_rev:<job>:<row_no>.
Events in, never edits. The original rows stay, the hash chain stays intact, and the history shows both jobs.
Parsing 1,894 rows and diffing each key against the current balance. Large opening files take longer than a validator would, because the diff is the point.
Start by downloading a template. Every template ships with a field dictionary and sample rows, so the first file is well formed before it is uploaded rather than after three rejected attempts.
You are filtered to status = error and location = HQ_319. All 31 errors are at MCC; the 7 rows at HQ_319 are quarantined rather than errored, which is a different status.
The stored file_sha256 for job IMP-2026-0729-014 is 9f2c41ab... and the object now hashes to c14e77b2.... Nothing was written. Re-upload the file and preview it again, so that what is approved is what commits. HTTP 409.
Ledger-posting templates need role >= manager plus the import_commit feature. You resolve to role = manager without that feature. Non-ledger templates commit normally.
i2 gated this on an ADMIN_API_TOKEN read from request.args.get("token"), so the credential travelled in the URL and was logged by every proxy in the path. i3 uses SSO plus a derived persona and never accepts a secret in a query string.
Uploads are paused for about 9 minutes. Jobs already parsed are safe: their rows live in Postgres, not in the file, so preview, diff and commit all still work for IMP-2026-0729-014. Only new uploads and log downloads are affected.
GATE 0 has held 4 of 7 consecutive days. Opening balance, count sheet and stock adjustment all write to the ledger, and at an unsigned location that means writing against a balance nobody has signed for. Preview, diff and staging all work; only the ledger write waits.