Bulk Import

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

7 rows in this file are quarantined, not errored. They carry source_type = 'opening_balance' inherited from the INV1 export, and INV1's opening balance was the constant 100 posted to every active product by quick_opening.py. Rule R-10.19 diverts legacy opening balances rather than replaying them. They can only enter the ledger by being re-signed as an i3 opening batch. Open the batch →
Commit is disabled for ledger-posting templates. GATE 0 has held 4 of 7 days. An opening-balance or adjustment import at an unsigned location would write against a balance nobody has signed for. Non-ledger templates (SKU catalogue, locations, suppliers, cost basis) commit normally. GATE 0 tracker →
job IMP-2026-0729-014 · preview prv_7c41e9a2 · 412 ms

1 · Choose template

Nine templates. The three with a red edge write to the ledger and require a signature before they can commit.

9 templates

Opening balance

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.

Download v2· 11 fields · 4 required3 imports

Count sheet

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.

Download v1· 8 fields · 5 required12 imports

Stock adjustment

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.

Download v1· 9 fields · 6 required5 imports

SKU catalogue

Style groups, variants, composites, barcodes, Shopify product GID. Never posts stock. A rename lands in sku_aliases and never orphans a tag.

Download v3· 14 fields · 3 required18 imports

Locations and zones

Warehouses, stores, virtual and third-party locations, zones, bins, Shopify location ids. Codes are validated against L10; invented codes are rejected.

Download v2· 10 fields · 4 required6 imports

Suppliers

Vendor, payment terms, lead-time days, incoterm, contacts. Feeds module 06.

Download v2· 12 fields · 3 required4 imports

Supplier invoices

PI and commercial invoice lines. Keys through item_codes, because a PO line often has no SKU yet (ruling L15).

Download v1· 13 fields · 5 required2 imports

Cost basis

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.

Download v1· 7 fields · 4 required3 imports

Influencer contacts

Handle, tier, platform URLs, manager, shipping address. Feeds module 11.

Download v2· 11 fields · 3 required9 imports

2 · Source

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.

Opening balance

Drag and drop a CSV or XLSX

or press U to browse · max 20 MB · headers detected automatically

Inventory_Sync_2026.xlsx 4.18 MB · sheet MCC of 6 · 1,894 data rows · header found at row 7 · delimiter n/a sha256 9f2c41ab8e07d3556c1b9042ff77ae13c8b6d5f0a2e94317bb0cd85e6a3f21d4 · re-checked at commit

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.

3 · Map columns

Auto-detect proposes, you confirm. A required field left unmapped blocks the next step.

9 of 11 mapped
Source column
i3 field
ASKU_NameMSR31-07
98%required
BTypeStock
91%required
COpening_Stock14
96%required
DDISPLAY3
89%
ETRIAL0
38%TRIAL was an i2 stock type. i3 has 16 and TRIAL is not among them.
FTOTAL STOCK17
99%a total is a derivation, never an input
GUnit_Cost_AED1,250.00
94%thousands separators present. A malformed cost is an error, never a silent zero.
HLocationMCC
99%required
IAdjustments-2
62%history, not an opening level
JNotesrecount 18-07, matched
71%
Klegacy_source_typeopening_balance
44%7 rows carry this. Mapping it to legacy_marker is what routes them to quarantine.
Identifier normalisation is a single function applied to every code column on every path. i2's importer upper-cased location and did not upper-case sku, so hq_319 was accepted while mst-crm-42-blk was rejected as an unknown SKU, and operators fixed the rejects by hand.

4 · Validate

Every row is parsed, normalised and checked once, then stored. Hover a status to read the validator message.

1,894rows parsed 1,782 ok 74 warn 31 error 7 quarantined preview_id prv_7c41e9a2 · 412 ms · stored, not recomputed
RowStatusSKULocationStock typeQty inCurrentDeltaValue AEDValidator message
8OKMSR31-07MCCSTOCK140+14+4,270virgin key · one opening row will be written
9OKMSR31-07MCCDISPLAY_GOOD30+3+915the DISPLAY column becomes its own key, not a subtotal
10OKMSR31-12MCCSTOCK70+7+2,135virgin key
11OKMIN-DIF-004MCCSTOCK0000counted zero · recorded as a verification, posts no ledger row
14WARNLTC04-03MCCSTOCK110+11+1,650no unit cost in the file · value will be sourced from cost_ledger at commit
19WARNLTC07-02MCCDISPLAY_GOOD10+1+390diff vs Shopify is 6 units · a note is required before sign-off (R-10.15)
23ERRORMST-CRM-42-BLUMCCSTOCK6n/an/an/asku_not_found · not in skus and not in sku_aliases · nearest match MST-CRM-42-BLK
27ERRORLTC01-01MCCTRIAL2n/an/an/astock_type_unknown · TRIAL was one of i2's 22 types · i3 has 16 and TRIAL is not one
31ERRORMIN-CNDL-007MCCSTOCK1,250.00n/an/an/aqty_not_integer · a cost landed in the quantity column · not coerced, not defaulted
38ERRORMSR31-04MIRDIF_CCSTOCK9n/an/an/alocation_unknown · not an i3 location code · the real set is HQ_319, HQ_325, MCC, YAS, CCZ, AJM, BAS
44ERRORMST-CRM-38-TANMCCSTOCK4400key_not_virgin · this key already carries a balance · an opening balance applies once, to a virgin key (R-10.13)
52ERRORMSR31-07MCCSTOCK140n/an/aduplicate_key · row 8 already claims this (sku, location, stock_type)
61QUARANTINELTC02-05HQ_319STOCK1000heldheldlegacy_opening_balance · exactly 100 units, INV1's constant · re-sign as an i3 batch or discard
62QUARANTINELTC09-01HQ_319STOCK1000heldheldlegacy_opening_balance · idempotency key opening-baseline:LTC09-01 carries no date and can never be superseded
63QUARANTINEMIN-VAS-005HQ_319STOCK1000heldheldlegacy_opening_balance · source quick_opening.py, note "Baseline opening stock"
71OKSTRAP-MSK05MCCSTOCK150+15+1,125virgin key
78WARNGIFTBOX_LUX_02MCCSTOCK4000composite SKU · a composite never holds stock (L2) · row accepted and decomposed to its components
84OKJB_PEARL_03MCCDISPLAY_GOOD20+2+640virgin key
Showing 18 of 1,894 · error rows are exported to prv_7c41e9a2_errors.csv with the validator code on each row, so the fix can be made in the source and the file re-uploaded rather than patched by hand.

5 · Diff against current balances

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.

1,782 keys will change
LocationStock typeKeys touchedUnits beforeUnits afterDelta unitsDelta AEDVirgin keysNon-virginEffect
MCCSTOCK1,24602,913+2,913+986,4101,2460clean bootstrap
MCCDISPLAY_GOOD4940908+908+329,7404940clean bootstrap
MCCDISPLAY_DAMAGED18041+41+14,880180clean bootstrap
MCCRESERVED24022+22+8,120240clean bootstrap
MCCSTOCK637370006rejected · not virgin
HQ_319STOCK7000070quarantined · legacy
Total effect on commit1,78203,884+3,884+1,339,1501,7826needs 2 signatures

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.

MCC only
0 1.5K 3.0K 4.5K MCC · +3,884 units across 4 stock types · AED 1,339,150 · this import +3,884 YAS · unchanged by this import CCZ · unchanged by this import AJM · unchanged by this import BAS · unchanged by this import HQ_319 · 7 quarantined rows · zero effect unless re-signed HQ_325 · unchanged by this import MCC YAS CCZ AJM BAS HQ_319 HQ_325

Top movers in this file

The twelve rows carrying the most value. If an operator recognises none of these, the mapping is wrong.

AED 1.34M total
SKUStock typeUnitsUnit costValue AEDShare
MST-CRM-42-BLKSTOCK9630529,2802.2%
MSR31-07STOCK8830526,8402.0%
MSR31-12STOCK7430522,5701.7%
MST-CRM-38-TANSTOCK6828819,5841.5%
JB_PEARL_03DISPLAY_GOOD5232016,6401.2%
MSR31-04STOCK6126215,9821.2%
LTC09-01STOCK4434014,9601.1%
LTC04-03STOCK9615014,4001.1%
MIN-VAS-005DISPLAY_GOOD3834012,9201.0%
LTC01-01STOCK1428812,4960.9%
MIN-CNDL-007STOCK1846211,4080.9%
STRAP-MSK05STOCK1527511,4000.9%
6 rows are rejected because their key already carries a balance. An opening balance is a level applied once to a virgin key, not a delta. i2's importer posted the CSV number as qty_delta while its own docstring said "Positive = set stock level" in the very next sentence, so a corrected re-run under a new date double-added.

6 · Commit

One transaction. The commit reads import_rows and re-checks the file hash only to detect substitution.

above the confirmation threshold
Large ledger-posting import. 1,782 rows, +3,884 units, AED 1.34M of value, all at MCC. This is a Phase 0 opening batch, so committing stages it into OB-2026-MCC-01 and it still needs two signatures before a single ledger row exists. Reversal writes compensating rows and never deletes; the original job stays in history with its result.
Template Opening balance v2 · source Inventory_Sync_2026.xlsx sheet MCC · job IMP-2026-0729-014 · preview prv_7c41e9a2.
1,782 rows commit. 74 warn rows commit with a recorded default and are listed in the job log. 31 error rows are skipped and exported to prv_7c41e9a2_errors.csv. 7 quarantined rows are held and appear on opening-stock under their own heading.
Target batch OB-2026-MCC-01 · effective 01-08-2026 00:00 GST · signers Fatima A. (location manager) and Mahmoud F. (finance).
Required above 1,000 rows or AED 100K of value movement. Both thresholds are crossed.
On commit
1. Insert 1,782 opening_balance_lines
2. Compute the three-way diff against Shopify and i2
3. Move batch OB-2026-MCC-01 to diffed
4. Notify both signers
5. Write nothing to ledger_entries until both sign

Import history

Every job, its outcome, and its reversal if it has one. A reversed job is never removed.

WhenJobTemplateSourceRowsResultNet unitsNet AEDUserLog
29-07 13:52IMP-2026-0729-014Opening balanceSheet · MCC1,894PREVIEWED+3,884+1,339,150Ahmad Y.Downloadthis job
28-07 16:18IMP-2026-0728-013Opening balanceSheet · CCZ962COMMITTED+2,349+812,400Ahmad Y.DownloadSigned
28-07 11:04IMP-2026-0728-012Count sheetCSV upload214COMMITTED-23-8,410Sanjay P.DownloadSession
27-07 09:41IMP-2026-0727-011Cost basisCSV upload1,697COMMITTED0+0Mahmoud F.Downloadcost_ledger
26-07 18:30IMP-2026-0726-010Opening balanceSheet · BAS611COMMITTED+1,472+498,220Ahmad Y.DownloadSigned
26-07 09:12IMP-2026-0726-009Opening balanceRFID sweep export1,904COMMITTED+8,318+2,104,880Ramesh K.DownloadHQ_325 signed
25-07 14:47IMP-2026-0725-008Stock adjustmentCSV upload42REVERSED00Ahmad Y.Downloadby IMP-...-008R
25-07 15:02IMP-2026-0725-008RStock adjustmentreversal42COMMITTED+31+9,240Ahmad Y.Download42 compensating rows
24-07 10:22IMP-2026-0724-007SKU catalogueCSV upload2,159COMMITTED00Ahmad Y.Download44 aliases
23-07 08:55IMP-2026-0723-006Locations and zonesCSV upload644 SKIPPED00Sara H.DownloadMIRDIF_CC rejected
22-07 17:31IMP-2026-0722-005SuppliersCSV upload28COMMITTED00Sara H.DownloadSuppliers
21-07 12:08IMP-2026-0721-004Opening balanceCSV upload218FAILED00Ahmad Y.Downloadhash mismatch at commit

Row outcomes across the last 12 jobs

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.

9,930 rows
0 750 1.5K 2.3K IMP-014 · 1,782 ok IMP-014 · 74 warn IMP-014 · 31 error IMP-014 · 7 quarantined legacy opening balances IMP-013 · 962 ok · CCZ opening balance IMP-012 · 214 ok · HQ_325 count sheet IMP-011 · 1,697 ok · cost basis, no quantity moved IMP-010 · 611 ok · BAS opening balance IMP-009 · 1,904 ok · HQ_325 RFID sweep export IMP-008 · 42 rows, subsequently reversed IMP-008R · 42 compensating rows · a reversal is itself an import job IMP-007 · 2,159 ok · the whole SKU catalogue IMP-006 · 60 ok IMP-006 · 4 rejected · invented location codes including MIRDIF_CC IMP-005 · 28 ok · suppliers IMP-004 · 218 rows never committed · file hash changed between preview and commit · HTTP 409 014 013 012 011 010 009 008 008R 007 006 005 004
committed warn, committed with a recorded default error, skipped and exported quarantined legacy opening balance reversal job 0.31% of all rows have ever errored
Job IMP-2026-0721-004 is the case that justifies the whole design: the file was edited between preview and commit, the stored file_sha256 no longer matched, and the commit returned 409 instead of writing 218 unreviewed rows. Under i2's route, which re-parses the uploaded file on the apply pass, it would have committed silently.

Why the commit never re-reads the file

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.

A malformed number is an error

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.

Reversal is compensation

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.

Shortcuts U upload M re-detect mapping V validate D diff E errors only ⌘⏎ commit ⌘K palette All shortcuts ↗

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.

No import jobs yet

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.

Opening stock

No rows match the preview filter

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.

Commit refused: the file changed after preview

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.

Audit log

You can preview but not commit

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.

Request the feature

Object storage is in maintenance

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.

Storage status

Ledger-posting imports are held until GATE 0 closes

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.

GATE 0 tracker
Connected to Opening stock Stock count Count session Live inventory Classic table Locations SKU detail Merge and aliases Suppliers Cost and price Ledger corrections Drift Reports Audit log Settings