# Demo instance tooling (VM 118 `musterlager-demo`, https://edge.logship.de)

Scripts that fill a freshly cloned LogShip instance with demo data **through the app's own
server routes** (sign-in → cookie jar → `/api/...`), so every record looks like one created in
the UI. All are idempotent (lookup by name / search key first) and can be re-run after a failure.
Run them on the instance itself (`BASE=http://127.0.0.1:3001`) with the `demo` admin login from
`/root/demo-credentials.txt`.

| Script | Purpose |
|--------|---------|
| `seed-demo-data.sh` | generic demo set: merchant "Muster Shop GmbH" (org, warehouse, bins, merchant user), 10 products with prices, opening stock, 6 consignees, 6 sales orders in mixed states, 3 shipments, 1 invoice |
| `import-merchant-catalog.sh` | onboard a merchant (lead) and import a catalog JSON with images (Strapi), net/gross prices, master data (SKU = search key, UPC, weight/dims, `group1` = brand, `group2` = reference, `classification` = kind) and opening stock; `LIMIT=n` for a test run |
| `seed-orders-returns.sh` | ~100 sales orders for a merchant in mixed states (draft, prepared, completed/picking, shipped, invoiced, voided; `MIX=shipped` = mostly shipped with dummy DHL-style tracking numbers + parcel type + shipping date + IsCommissioned via `PUT /api/inouts/tracking`, a few printed-but-unshipped shipments via `isPrinted`, very few voided), 1–5 lines, qty 1–3, dates spread over 30 days, consignees from an anonymised JSON across DE/AT/IT/FR/UK/ES/NL/CH/BE/LU; shipments per customer, invoices, RETURNS (completed RMA + customer return receipt into bin `RET-01-01`) and demo photos (`make-demo-photo.py`) attached to orders, RMAs and return receipts through the app's Strapi attachment model |
| `build-consignees.py` | anonymised consignee list from two production samples (names sampled in random order, addresses): first/last names, street names, house numbers and postal/city/country triples are recombined independently, so no row is a real person or address; 21 European countries (built-in city/postal/street pools for countries without a sample); raw samples deleted after use. Output committed as `kazaar/consignees.json` (200) |
| `rename-consignees.sh` | re-anonymise existing consignees in place (name/e-mail/address via REST), so orders referencing them show the new identity |
| `make-demo-photo.py` | renders a photo-like JPEG (product image on a table, caption bar, camera stamp; `--parcel` draws a return parcel with label) for attachments |
| `hess/build-catalog.py` | catalog for the lead Hess (Livipur Kinder. Spiel. Versand GmbH, livipur.de — plentymarkets): fetches `/rest/io/variations?itemsPerPage=500&page=N` (unauthenticated, 1 227 variations / 916 items) + `/rest/io/categorytree`, writes `catalog.json` (value = shop article number, brand = manufacturer in `group1`, category path in `group2`, top category in `classification`, in-store EAN-13 `20…` from the variation id, estimated weight/dims, stock 0 for unsalable articles) and `masters.json` (one master per item with 2+ variations, `parentValue` on the variations) |
| `create-master-articles.sh` | master ("summary") articles from `masters.json`: quick-store (name + image, no prices) → products/update → `[id]/flags` (`isSummary` true, `isStocked/isSold/isPurchased` false) |
| `set-parent-ids.sh` | writes a catalog field (`KEY`, e.g. `parentValue` or `shopItemId`) into `M_Product.ParentId` of every article of an org (REST, idempotent) — the variant ↔ master link the "Varianten" card reads |
| `add-purchase-prices.sh` | purchase price list version (Einkauf 1000004) for every article with a gross price: `gross / VAT × MARGIN` (Kazaar: margin 1, Hess: 0.55) |
| `vonroc/build-catalog.py` | catalog for the demo merchant VONROC (VONROC B.V., Zwolle — tools/garden/home/water, vonroc.de — **Magento 2**): public `POST /graphql` with header `Store: de`, `products(search:"")` paged; the shop's Varnish fails any response above ~40 KB ("Backend fetch failed"), so pass 1 fetches everything but the description 10 per page and pass 2 the descriptions 4 per page (resumable via `catalog-raw.json`). value/sku = shop SKU, `upc` = real EAN (`ean_code`), brand VONROC in `group1`, category path in `group2`, top category in `classification`; weights/dims estimated (the feed has none); `masters.json` groups SKUs by model code (CS508DC: colour suffixes `_WHITE` and battery-set prefixes `S_/S2_/S3_` are variants, master key `<code>-VAR` when the plain code is itself an article) |
| `vonroc/topup-stock.sh` | physical inventory that gives every stocked article of an org with qty ≤ LOW an on-hand stock (zero articles into a fresh bin, low ones counted up in their bin); `KEEP_ZERO` articles stay empty |
| `vonroc/zero-open-order-stock.sh` | counts N articles that sit on open (completed, unshipped) orders down to 0 so the dashboard shows orders blocked by missing stock |
| `vonroc/return-reasons.txt` | tool-specific RMA reasons for `seed-orders-returns.sh REASONS_FILE=…` (`reason|RETURN` or `reason|BOUNCE`) |
| `kazaar/build-catalog.py` | builds the catalog JSON for the lead Kazaar Fragrances from the storefront GraphQL feed (`catalog-raw.json`, 3 pages of `Products(collection_slug:"alle-produkte")`): every fragrance as 30/50 ml × 20/30/50 % (843 articles), oils with their real 3/5/8 ml, car scents/refills/bundles skipped |

Catalog entry shape: `value, name, grossPrice, netPrice, upc, sku, weight, length, width, height,
group1, group2, classification, description, documentNote, imageUrl, stock`. **Brand** lives in
`M_Product.Group1` until a real brand entity exists in iDempiere; the "inspired by" reference in
`Group2`; the shop's own variant SKU in `SKU`, barcode in `UPC`.

Gotchas learned: never put `+` into a `/api/filters/...` OData filter (`MovementType eq 'C+'` arrives as `'C '` → no match → duplicate documents on re-runs; filter by doc type instead); `rma/store` needs `salesRepId` and `AD_Org_ID` on each `rmaLines` entry (nested lines are passed through verbatim); `attachments/[table]/[id]/files` wants the Strapi **documentId**; Strapi filter URLs need `curl -g`; `partners/partners/store` needs `languageId`; `orders/store` needs
`paymentTermId` + `docStatusId:"DR"`; iDempiere REST wants `POReference` casing; the storefront
blocks `/products.json` but its own `/api/graphql` answers unauthenticated (schema: `Products`,
`Product`, `Variation`, `Vendor`); the clone needed `05-apply-env-overrides.sh` first, otherwise
routes using `config.api.idempieretoken` answer 401 after the REST secret rotation.

Sequence gotcha: `quick-store` creates products with `value: ''`, so iDempiere draws the search key
from the client's `DocumentNo_M_Product` sequence — on a template clone that sequence (1000199…)
collides with the legacy billing products whose Value is numeric → "eindeutige Daten erforderlich"
after a few hundred creates. Bump it first:
`update adempiere.ad_sequence set currentnext=5000000 where name='DocumentNo_M_Product' and currentnext<5000000`.

**Variant model (2026-10-26, Hess):** no schema change — the master article is a normal `M_Product`
with `IsSummary = Y` and `IsStocked/IsSold/IsPurchased = N` (pure structure entity, never stocked,
picked, ordered or priced); each variation carries the master's search key in `M_Product.ParentId`
(varchar 30, otherwise unused; `shopify_parent_product_id` belongs to the Shopify sync and stays
untouched). Frontend: `GET /api/materials/products/[id]/variants` → `ProductVariantsCard` on the
product edit page + "Variante von" column on the products list. Order for a merchant: import catalog →
`create-master-articles.sh` → `set-parent-ids.sh KEY=parentValue` → `add-purchase-prices.sh`.
Hess on the demo: org 1000018 / warehouse 1000018 / partner 1045794, 60 bins HE-01-01…HE-12-05,
1 227 articles + 114 masters, user `hess` (password in `/root/demo-credentials.txt`).
VONROC on the demo (2026-10-28): org 1000026 / warehouse 1000022 / partner 1045802 (country NL 105), 60 bins
VR-01-01…VR-12-05, user `vonroc` (role 1000026), 1 062 articles + 136 masters, purchase prices = sales net
(`add-purchase-prices.sh MARGIN=1`), orders `VONROC-ORD-1001…` with `MIX=shipped`, returns with photos built from
the original product images. iDempiere auto-grants every new org to the Hess Admin role 1000022 too — deactivate
that `ad_role_orgaccess` row + `DELETE /api/v1/caches` after onboarding. Order sources (demo only): list 1000003 got
`13|otto` + `14|adobe-commerce` (SQL insert into ad_ref_list + cache reset), five `c_ordersource` rows 1000029…1000033
(Adobe Commerce / OTTO / Amazon / eBay / Shopify) created through `/api/settings/order-sources/store`, and every order
assigned by SQL (35/25/15/15/10 %) with ExternalOrderId + amazon/ebay/shopify order-id dummies — `c_order` has the ES
trigger, so `set search_path to adempiere, public` first.

**Hess as a second 3PL on the same instance (2026-10-26):** role `Hess Admin` 1000022 = SQL copy of the admin role
1000000 (same window/process/form/workflow/doc-action/info access, `FrontendMenu = a`, but `IsAccessAllOrgs = N`,
org access 0 + hess 1000018 + its merchants). User `hess` was moved from the merchant role 1000021 (left inactive)
to that role. Three fictional fashion-label merchants were onboarded **as hess** through the wizard route
(`import-merchant-catalog.sh` with an empty catalog, `ADMIN_USER=hess ROLE_ID=1000022 ORG_ID=1000018`): nordwind
(org 1000023), marlen (1000024), velvetvine (1000025), 10 bins each, no articles. Prerequisites found the hard way:
the wizard's warehouse step copies the ship-to address of the operator org's partner, so `AD_Org.C_BPartner_ID`
of the 3PL org must be set and that partner (+ its locations) must live in an org the admin role can read (hess's
partner was moved from org 1000000 to 1000018); a merchant name with `&` breaks the `/api/filters` OData lookup.
Known gap: the wizard's step 10 (org-access isolation) only removes grants the *calling* role can see — run as a
restricted admin it left the new orgs granted to the other merchant roles (mustershop/kazaar); removed afterwards via
REST as `demo`. New orgs are auto-granted to every role by iDempiere, so after onboarding check `ad_role_orgaccess`.
