Skip to content

Store ↔ ERP sync — how a part becomes a product

Decided 2026-09-24. Supersedes nothing; this is the first written design for the link between the InvenTree ERP and the WooCommerce storefront. Companion to sandbox-workflow and store-social-login. Tasks: store #274.

The goal, in one sentence

Tick one box on an ERP part, and everything mechanical happens by itself — the only human work left is a photograph and a sentence of copy.

The decision

  1. The ERP owns facts. The store owns presentation. Neither generates the other's content.
  2. Facts flow one way, ERP → store. Orders flow the other way, store → ERP, which is what order_sync already does. Nothing is bidirectional.
  3. We build it ourselves. No usable off-the-shelf connector exists.
  4. The sync runs on the host, not inside InvenTree. See "Why not a plugin" below.
  5. Availability published to the store is unallocated_stock − safety_buffer, never raw stock, and the buffer is a real production reserve rather than decoration.

What the research actually said

Three questions were researched before any of this was designed, because the hard rules require checking that it does not already exist.

Is the ownership split standard? Yes, unambiguously. It matches conventional master-data management practice: the ERP owns transactional and technical data, the storefront owns channel-specific content. One-way sync with the ERP as master is the norm, and bidirectional product sync is a documented failure mode rather than a matter of taste. A separate PIM is not warranted below roughly 500 SKUs or three sales channels, so it is not on this roadmap.

Does a connector already exist? No. The only InvenTree↔WooCommerce repository is four commits, zero stars and syncs nothing but order status. InvenTree's own maintainer has said publicly there is little interest in WooCommerce. The mature reference implementation is inventree-shopify (MIT), which is worth reading as a pattern and cannot be used directly. Middleware (n8n, Activepieces) would work but is not what comparable shops do, and it adds a service to run for what is a few hundred lines of Python.

Is timer-push the right mechanism? It is workable and widely used, but it carries a real, documented race — see "The race" below. Event-driven is the modern preference and is closed to us for a network reason, so the race has to be handled deliberately rather than ignored.

Who owns what

ERP owns Store owns
Part number / SKU identity (IPN) Marketing description and short description
Engineering description (SOP-002 rules) Product photography
Stock and availability Categories, tags, SEO
Price and quantity price breaks Whether the listing is published
Weight and dimensions
Tax class
Lead time
MOQ and unit of measure
HS / customs codes
Whether we intend to sell it online

The test that decides an ambiguous field: if the two systems disagree, does someone lose money? Price yes — an order would sync at a price the customer never agreed to. Marketing copy no — there is no "correct" version to reconcile against, so it simply needs an owner, and that owner is WooCommerce, which has the editor, the media library and the revisions.

The engineering description and the storefront description are not the same sentence and must never be forced into one field:

  • ERP: BATTERY LIPO 3.7V 100MAH 401520 W/ PROTECTION 1.25MM CONN — an identifier, so the right part gets picked in a BOM.
  • Store: "A 3.7 V lithium cell small enough for a wearable, with a protection circuit and a JST connector already fitted."

Why not an InvenTree plugin

Both research passes recommended building this as an InvenTree plugin using EventMixin, which is the community norm and how inventree-shopify works. All the required mixins — EventMixin, ScheduleMixin, APICallMixin, ValidationMixin — are confirmed present in our 1.5.4 build, and we already ship a custom plugin (sop_link) with an install path documented in ~/erp-config/plugins/README.md. So the route is open and proven here.

We are not taking it, because the two containers cannot reach each other. InvenTree sits on torrentvpn_default; the store's WordPress sits on embeddedera-store_internal, which is deliberately internal. A plugin that POSTs to WooCommerce would require punching a hole between the ERP and a network that was sealed on purpose. That trade is not worth sub-five-second latency for two SKUs.

The host can already reach both, and config/systemd/ee-store-catalog-observe.timer already exists in the store repo for this exact job — installed, written, and switched off. Using it costs nothing and touches no network boundary.

The one thing the plugin route genuinely buys that the host cannot is ValidationMixin refusing to save a storefront part with no image or no price break. That is a separate, tiny plugin that talks to nothing, and it can be added later without revisiting any of this.

The race, and what we do about it

The failure is concrete and documented, not theoretical:

A customer buys one unit at 14:59:30 and WooCommerce decrements its stock. The sync runs at 15:00:00 and pushes the ERP's unallocated_stock — which has not yet learned about that order, because order_sync runs on its own timer — straight over the top. The store is back to the pre-sale number and the unit is sold twice.

Set to 5 cells on both batteries, 2026-09-24 (registry_version 2026-09-24.1). With 85 unallocated the store publishes 80.

The mitigation is that the safety buffer must be wider than plausible in-flight volume. The buffer therefore does two jobs at once: it reserves cells for production, and it absorbs orders that WooCommerce knows about and the ERP does not yet. At our volume — two SKUs, 85 units, a store with no traffic — a small buffer covers both comfortably.

ponytail: the buffer is a blunt instrument with a known ceiling. The precise fix is to subtract genuinely in-flight quantity, which order_sync's own SQLite state already knows (orders WooCommerce has that the ERP has not allocated). Do that when order volume makes the buffer uncomfortable, not before.

Two smaller rules that come from the same research:

  • The push must be idempotent. Skip the write when the ERP number has not changed. A repeated identical write is how drift and ghost adjustments start.
  • Use wc_update_product_stock($product, $qty, 'set'), not set_stock_quantity() plus a manual save(). It issues direct, optimised queries and narrows the window.

Phases

Phase 0 — prerequisites. Create a WooCommerce REST API key (none exists yet; the route is live and returns 401) and store it 0600 under ~/.config/, per the hard rule on API keys and SOP-020. Write the ERP sale price breaks for the two batteries so the ERP is actually the price master it is supposed to be.

Phase 1 — stop hand-writing the registry. config/erp-mapping-registry.json is a hand-typed restatement of facts the ERP already holds, and it is the one place a typo silently points a SKU at the wrong part. Generate it instead, with defaults so a new sellable part needs no decisions: multiplier 1, order sync on, availability on, reservations off, standard buffer. The registry_version bump stays deliberate, because order replay depends on it — so the flow is tick the box, the tool proposes a one-line diff, a human commits it.

Phase 2 — availability. A writer pushes unallocated_stock − safety_buffer into WooCommerce. This is a new writer: refresh-erp-catalog.py stays observation-only, because it asserts that stock, price and release are unchanged and that guarantee is worth keeping. Then enable the existing five-minute timer and alert on a missed cycle.

Phase 3 — the draft listing. Ticking the box creates a WooCommerce draft with SKU, name, price breaks, category and release = pending already filled in. It does not write the storefront. A human opens it in WordPress, writes the copy, adds the photographs, publishes. This is the phase that makes part #30 as cheap as part #3.

Phase 4 — optional. A ValidationMixin plugin that refuses to mark a part for the storefront when it has no image and no price break, so the ERP catches the omission at the point of data entry.

Guardrails

  • Never hand-edit stock in WooCommerce. It is a derived value and the next sync will obliterate it, silently, while orders keep arriving against the stale number. This is one of the most commonly reported ways these integrations go wrong. Stock changes happen in the ERP.
  • Never delete a retired SKU's registry entry. order_sync resolves historical orders through the registry; deleting a mapping breaks replay of the orders that used it. See store-quantity-pricing.
  • _ee_listing_release stays pending until a test order proves the loop end to end. Publishing is a judgement; everything before it should be free.
  • salable is not the storefront flag. In InvenTree, Salable means "may appear on a sales order". EE-DEMO-ASSEMBLY is quote-required: salable, and must never reach the shop. The storefront trigger is a storefront key in the part's metadata JSON, which is confirmed present and empty.
  • The sync must pair tiers: a sandbox store talks to the sandbox ERP, production to production. The 8300 pin is a tier check written as a hard-coded port, and it is in two places, not one: refresh-erp-catalog.py rejects any ERP but 127.0.0.1:8300, and order_sync.erp_config() does the same with an even stricter test (scheme, host, port, path, no query, no credentials). Both are correct today — nothing is tested against production — but as written the live store can never reach the production ERP, so this is the one structural change go-live depends on. Found 2026-09-24 while auditing what remains; the second one was not previously recorded.

What this does not remove

Once the mechanical steps are gone, adding a product costs a photograph and two sentences. That is the floor, and no further automation moves it. Worth knowing, because part 198 has no image at all today and SOP-002 requires one — the bottleneck is already the camera, not the keyboard.

Log

  • 2026-09-24 — Designed. Ownership split validated against standard practice; plugin route rejected on container network isolation; timer-push adopted with the buffer absorbing the documented overwrite race.