mar-pod-b2b / module-b2b-demo-data
Installs a complete, realistic B2B demo dataset across every Marpod B2B Advanced module and automatically cleans it up after a configurable retention period.
Package info
gitlab.com/mar-pod-b2b/module-b2b-demo-data
Type:magento2-module
pkg:composer/mar-pod-b2b/module-b2b-demo-data
Requires
- php: ^8.5
- magento/framework: ^103.0
- magento/module-authorization: ^100.4
- magento/module-backend: ^102.0
- magento/module-cron: ^100.4
- magento/module-ui: ^101.2
- magento/module-user: ^101.2
- mar-pod/b2b-advanced: ^1.0
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-13 20:43:45 UTC
README
Installs a complete, realistic demo dataset across every Marpod B2B Advanced module — companies, credit accounts, price lists, shared catalogs, quotes, quote templates, approval rules, budgets, company hierarchy, sales representative assignments, and procurement integration — plus a temporary backend admin account scoped only to the B2B admin menu, so a sales or solutions team can hand a prospect a working sandbox on both the storefront and the merchant admin side, without hand-seeding data or exposing the rest of the store. Installed data is tracked per run and automatically removed after a configurable retention period, so a demo instance never accumulates stale fixtures.
Status
Phase 1-6 implemented — this is the full planned installer scope. The data model
(marpod_b2b_demo_data_run + marpod_b2b_demo_data_entity), the installer, the cleaner, the
cron job, and the CLI commands are implemented and wired up. The installer seeds:
- one company, one customer (as company admin with every permission), and one credit account (Phase 1);
- one base price list with one repriced SKU, assigned to the company, and one custom shared catalog with that same SKU assigned and indexed (Phase 2);
- a second "delegate" customer (company member, not admin), one submitted quote with one item, one approval rule group with an amount-threshold condition, and one active approval delegate from the admin customer to the delegate customer (Phase 3);
- one pending purchase request, one hard monthly budget partially reserved against that request, and a two-level company hierarchy (an auto-created root unit plus one "Demo Branch" unit, with the delegate customer assigned to the branch and one inherited pricing override pointing at the Phase 2 price list) (Phase 4);
- one sales representative assignment and one procurement integration session (Phase 5);
- one restricted backend admin account, scoped to the B2B admin menu only, so a prospect can be handed real admin access without seeing the rest of the store (Phase 6 — see below).
Not yet implemented: the "Auto Reinstall After Cleanup" behavior — the config field and its
default exist, but CleanupExpiredRuns does not yet act on it.
Test coverage: complete for everything implemented above. DemoDataCleaner has full PHPUnit
unit coverage (every entity type's dispatch routing, reverse-of-creation-order processing, the
active-run guard, and the failed-cleanup/status-transition path). DemoDataInstaller's isolated
logic (the ttlDays guard, generated password shape, expiry date math, and
computeRestrictedAdminResources()) is unit tested the same way; its install() orchestration
itself is deliberately not unit tested, since mocking ~15 modules' repositories would only prove
setters were called with values typed twice — that path, and the admin UI end to end (install,
extend, and the real confirm-modal clean flow), are instead covered by tests that run against a
live Magento instance: a Playwright suite for the admin UI, plus every phase's own manual
CLI/browser verification recorded throughout this README. See Testing below for exactly what
each suite covers and how to run it.
Phase 6: restricted backend admin
Unlike every earlier fixture (a storefront customer, or a real existing admin account borrowed
for the sales-rep/session fields), Phase 6 creates a genuinely new, temporary backend admin
account — real admin_user + authorization_role rows, not a storefront customer — scoped so
its holder can explore the merchant/admin side of B2B (Company Management, Quotes, Pricing,
Approvals, Budgets, Company Hierarchy, Procurement, ...) without seeing Sales, Catalog, Customers,
System, or any other unrelated core admin section.
- Toggle:
Create Restricted Admin Userin Configuration (default Yes). When off, the installer records'Restricted backend admin: not created (disabled in configuration)'in the run's notes and skips this fixture entirely. - Scope is computed from the live ACL tree, not a hardcoded resource list. The new role is
granted every ACL resource that is
Marpod_B2bCore::mainor inherits from it, exceptMarpod_B2bDemoData::manageand anything under it — deliberately excluded so the prospect using this account can't also install more demo runs or clean up (and thereby delete) the very account they're using. Because this walks the real ACL tree at install time (Magento\Framework\Acl\Builder), any new B2B module added later is covered automatically; nothing here needs updating when the B2B suite grows. - Login: username is the lowercased marker (e.g.
demo-a1b2c3d4), with a random password — shown once in the run's Notes, exactly like the storefront customer/delegate credentials. - Verified in a real browser session, not just at the database level: logged in as a freshly
installed restricted admin and confirmed (a) the left nav shows only "B2B" — no Dashboard,
Sales, Catalog, Customers, Content, or System; (b) every real B2B feature screen (Companies,
Pricing, Shared Catalogs, Quotes, Approvals, Budgets, Procurement, ...) is reachable; (c) "Demo
Data" does not appear in the B2B submenu; (d) the account's
authorization_rulerows confirmMarpod_B2bDemoData::manageisdenywhile the rest of the B2B tree isallow. Then, back in as a full admin, cleaned up the run through the admin grid and confirmed theadmin_userrow, bothauthorization_rolerows (the role definition and its "this user has this role" assignment row — Magento models a role assignment as its own row, cascaded automatically when the role definition is deleted), and everyauthorization_rulerow for that role were all gone. - Uninstall/cleanup implementation note:
Magento\Authorization\Model\Role::delete()already cascades both the role'sauthorization_rulerows and any user-assignment rows nested under it (Magento's own resource model does this in_afterDelete()), so the cleaner only needs to delete theadmin_userrow and the role — no direct SQL required here, unlike the quote/ purchase-request/credit-account fixtures.
Assumption: the pricing, shared-catalog, and quote fixtures reprice/reference SKU 24-MB01
(Magento sample data's "Joust Duffle Bag"). The installer will throw NoSuchEntityException if
that SKU does not exist on the target instance — see Requirements.
Cleanup notes specific to Phase 3: the quote (and its item, cascaded at the DB level) has no
repository delete method, so it's removed via a direct SQL delete on marpod_b2b_quote, same as
credit_account and customer. The approval rule group and approval delegate both have
deleteById() on their repositories and are removed that way; the group's condition row cascades
from the group's deletion.
Cleanup notes specific to Phase 4: the purchase request has no repository delete method
either, so it's removed via direct SQL on marpod_b2b_purchase_request (its item, approval-step,
and approval-history rows all cascade at the DB level). The budget is removed via its
repository's deleteById(), which cascades the reservation row (the reservation is not recorded
in the entity registry at all — its FK cascades from either the budget or the purchase request,
so whichever is deleted first already removes it). The company hierarchy unit, branch, and
pricing override are deliberately not recorded or explicitly deleted either: every company gets
exactly one root unit automatically the moment it's saved (see
Marpod\B2bCompanyHierarchy\Observer\CreateRootUnitOnCompanySavedObserver), and both the branch
unit (via parent_unit_id) and the override (via unit_id) cascade transitively once the
company itself is deleted at the end of cleanup. Calling HierarchyManagementInterface::deleteUnit()
explicitly would in fact be wrong here — it throws if the unit still has children, which the root
unit always does once a branch exists under it.
Cleanup notes and design decisions specific to Phase 5:
- Which admin user becomes the sales representative is never hardcoded.
install()already accepts an optional$adminUserId(recorded on the run for audit purposes); if given, that admin is assigned as the primary sales rep. If not given (e.g. a cron-triggered install with no admin context), the installer falls back to the lowest-user_idadmin account on the instance. If somehow no admin user exists at all, the sales rep assignment is skipped entirely and the run's notes say so — the installer never fails just because this one optional fixture couldn't be created. - The procurement fixture deliberately stops at one session; it does not send a signed inbound
message.
b2b-full-stack-test-suite/fixture-setup.phpgoes further and also callsProcurementMessageSignerInterface::sign()against a real webhook signing secret — but doing that requires writingmarpod_b2b/b2b_procurement_integration/signing_secret, a single global config value shared by the whole instance, not scoped to a company or a demo run. Overwriting it on everyinstall()(a normal thing to run repeatedly against a demo environment) would silently invalidate any other integration already configured against that secret, and — unlike a data row — a changed config value cannot be "recorded" in the entity registry and precisely reverted bycleanup(). A session ininitiatedstatus is enough to show the procurement integration feature exists without touching shared, non-reversible instance configuration. - Both new entities have
deleteById()on their repositories and are removed that way — no direct SQL needed for Phase 5.
Phases 1-5 verified end-to-end this way as they were built: setup:di:compile clean, install
creating and recording every entity described above, cleanup removing all of it including every
cascaded child row. See the Phase 6 section above for the latest full-install verification
(fifteen entity rows, including the restricted admin) and the real-browser ACL check. No automated
test suite (PHPUnit unit/integration or Playwright) exists yet for this module.
Planned design
- Hard dependency on
mar-pod/b2b-advanced. Rather than treating each of the 19 B2B feature modules as an optional integration (which would require either a fragile capability-check layer or a small bridge module per feature), this module depends directly on theb2b-advancedmetapackage, which already requires all of them. The installer can therefore call every feature module's service contracts directly, the same waypackages/b2b-full-stack-test-suite/fixture-setup.phpalready does for manual testing. - Exact entity tracking, not heuristic lookup.
fixture-teardown.phpfinds what to delete by matching a VAT-ID/name prefix or email domain — good enough for a manually-run script, too fragile for unattended cleanup. This module instead records every entity it creates:marpod_b2b_demo_data_run— one row per install:run_id, a uniquemarker(e.g.DEMO-<run_id>),status(active/cleaning/cleaned/failed),created_at,expires_at,cleaned_at, the admin user who triggered it.marpod_b2b_demo_data_entity—run_id,entity_type,entity_idfor every row the installer created. Cleanup deletes exactly what was recorded, in dependency order (orders and credit reservations released before the credit account and company, same orderingfixture-teardown.phpalready uses) — never by guessing from a naming pattern.
- Random, single-use demo credentials. Unlike the internal test fixtures (which reuse a fixed password), each install generates random passwords for its demo customer accounts, since this data may actually be shown to a prospect. Credentials are displayed once in the admin grid after install and are not otherwise stored in plain text.
- Cron-driven cleanup. A scheduled job runs on a short interval, finds runs whose
expires_athas passed, and cleans them up. An optional "auto-reinstall" setting can immediately seed a fresh run after cleanup so a demo environment is never left empty. - Admin-only. Every install/cleanup action requires the
Marpod_B2bDemoData::manageACL resource. This is never exposed on the storefront or through an unauthenticated endpoint — installing or deleting real data must stay behind admin authentication.
Configuration
Stores > Configuration > B2B > Configuration > Demo Data (marpod_b2b/b2b_demo_data),
website-scoped:
| Field | Path | Default | Notes |
|---|---|---|---|
| Retention (Days) | marpod_b2b/b2b_demo_data/retention_days | 2 | How long a demo data run stays installed before automatic cleanup. Used both by the CLI/admin install action (when no explicit --ttl-days is given) and by Extend. |
| Auto Cleanup Enabled | marpod_b2b/b2b_demo_data/auto_cleanup_enabled | Yes | When disabled, expired runs accumulate until cleaned manually or re-enabled. |
| Auto Reinstall After Cleanup | marpod_b2b/b2b_demo_data/auto_reinstall | No | Field exists; CleanupExpiredRuns does not act on it yet — see Status. |
| Create Restricted Admin User | marpod_b2b/b2b_demo_data/create_admin_user | Yes | Creates the Phase 6 backend admin account scoped to the B2B menu. When off, that fixture is skipped. |
Admin UI and CLI (both implemented)
A grid at B2B > Demo Data (marpod_b2b_demo_data/run/index) lists every run — marker,
status, created/expires/cleaned timestamps, dedicated Admin Login / Admin Password
columns for the run's restricted backend admin (blank when create_admin_user is disabled), and
the full Notes text (including every other generated login). The dedicated columns exist so the
restricted admin's credentials can be copied straight from the grid and handed to a prospect,
without parsing them out of the Notes column. A toolbar button installs a new run — its success
message also states the admin login/password directly — and each active run's row has "Clean Now"
(with a confirmation dialog, since it's destructive) and "Extend" (pushes expires_at out by the
configured retention period, measured from whichever is later: now, or the run's current
expiry). Both row actions and the grid data itself were verified against a live instance,
including confirming the underlying data (creation, extension, and full cleanup with the correct
generated credentials) matches what the grid displays.
CLI, for scripting outside the admin UI:
bin/magento marpod:b2b:demo-data:install [--ttl-days=N]— installs one run. Unlike the admin UI action (which passes the logged-in admin's ID), the CLI has no admin session to default to, so the sales rep fixture falls back to the lowest-user_idadmin account.bin/magento marpod:b2b:demo-data:cleanup [--run-id=N]— cleans up one run, or every run already past its expiry when--run-idis omitted (the same logic the cron uses).marpod:b2b:demo-data:listis not implemented; use the admin grid, or inspectmarpod_b2b_demo_data_run/marpod_b2b_demo_data_entitydirectly.
Implementation note: row/toolbar actions are plain confirm-then-GET links (matching how
Magento's own core ui_component grids implement destructive row actions, e.g. CMS Block/Page
delete) rather than POST forms — appropriate here because the install/clean/extend actions have
no form fields to submit, just an id. Each controller implements
\Magento\Framework\App\Action\HttpGetActionInterface and is protected by the
Marpod_B2bDemoData::manage ACL resource; Magento's admin URL "secret key" mechanism (present on
every rendered admin link, verified on every dispatch) is what actually prevents these from being
triggered by a forged cross-site link — the same protection every other admin GET action in this
project relies on.
Testing
Unit tests cover DemoDataCleaner fully (dispatch routing for every entity type, reverse-of-
creation-order processing, the active-run guard, and the failed-cleanup/status-transition path)
and the isolated, side-effect-free logic in DemoDataInstaller (the ttlDays guard, the
generated password's shape, the expiry date math, and — the highest-stakes piece of this whole
module — which ACL resources the restricted backend admin ends up with). DemoDataInstaller's
own install() orchestration is deliberately not unit tested: it's a straight-line dispatch
across roughly fifteen other modules' repositories, and mocking all of it would only assert that
setters were called with values typed twice in the test — it wouldn't catch a real regression.
That path is exercised instead by the live install/cleanup verification described throughout this
README (run against a real Magento instance for every phase), matching how this project already
treats CreditLedger::reserveForOrder()/getAvailableBalance() in module-b2b-company-credit.
Run from a Magento project where this package is installed as a path repository:
vendor/bin/phpunit -c dev/tests/unit/phpunit.xml.dist packages/module-b2b-demo-data/Test/Unit
Writing this test suite caught a real bug: DemoDataCleaner::cleanup()'s catch (\Throwable)
block wrapped whatever it caught into a LocalizedException, whose $cause parameter only
accepts ?\Exception — so a caught \Error (a TypeError, for instance) would itself crash
while trying to report the original failure. Fixed by only passing the cause through when it's
actually an \Exception.
A Playwright suite covers the admin UI end to end against a real instance: clicking "Install New
Demo Data Set" and confirming the new row's Notes column shows every phase's fixture (company,
customer, delegate, price list, shared catalog, quote, approval group, purchase request, budget,
company hierarchy, sales rep, procurement session, and the restricted backend admin); "Extend"
changing the Expires At cell; and "Clean Now" — including the confirmation dialog Magento renders
for it (Magento_Ui/js/modal/confirm, a real DOM modal, not a native confirm()) — marking the
row cleaned and removing its row actions. A global-teardown.js safety net force-cleans
whatever run the suite created if a test fails partway through, since a freshly-installed run
isn't yet past its retention period and so wouldn't be picked up by a plain, no-argument
marpod:b2b:demo-data:cleanup sweep.
cd packages/module-b2b-demo-data/Test/Playwright
npm install
npx playwright install chromium
npm test
Set PLAYWRIGHT_BASE_URL when Magento is not available at http://localhost:8090.
Requirements
- PHP 8.5
- Magento Open Source or Adobe Commerce 2.4.9
mar-pod/b2b-advanced(pulls in every Marpod B2B module this installer seeds data into)- Magento sample data (or a catalog that otherwise defines SKU
24-MB01) — the pricing and shared-catalog fixtures reprice and assign this SKU
Installation
Run from the Magento project root:
composer require mar-pod-b2b/module-b2b-demo-data:^1.0
bin/magento module:enable Marpod_B2bDemoData
bin/magento setup:upgrade
bin/magento setup:di:compile
bin/magento cache:clean
Uninstallation
Run the cleanup command (or wait for the cron) to remove any active demo data run before removing the module — the module's own tables are dropped on uninstall, but entities it created in other modules' tables are only removed by the cleaner, not by uninstalling this module.
bin/magento marpod:b2b:demo-data:cleanup
bin/magento module:disable Marpod_B2bDemoData
composer remove mar-pod-b2b/module-b2b-demo-data
bin/magento setup:upgrade