Appearance
Product Bundle Management (Admin)
Flow ID: AD-24 Module(s): product_bundles, Product domain (Bundle sub-domain) Complexity: High Last Updated: 2026-04-04
Business Context
Product bundles define rule-based discounts that apply when specific product combinations appear in the customer's cart. Admin users create bundles, assign product references, configure pricing strategies, and manage multi-language display text. The system uses a builder-reference-to-criteria compilation pipeline: admins add high-level product references, then the system generates concrete criteria (Cartesian product of product codes) that the cart labeling engine matches at checkout.
Feature gated by registry key PRODUCT_BUNDLES.ENABLED (toggled in Settings > Other Settings).
Architecture
The bundle system spans three layers:
| Layer | Components |
|---|---|
| Legacy HMVC | ecommercen/product_bundles/ -- controllers (UI + API), models (storage, labeling, pricing) |
| Modern Domain | src/Domains/Product/Bundle/ -- 6 sub-entities with full read/write services |
| Modern REST | src/Rest/Product/Controllers/Bundle*.php -- 6 REST controllers with OpenAPI |
| Value Objects | src/ProductBundles/ -- enums, reference types, reference set algebra, Cartesian generator |
Admin Controllers
| Controller | Role | Lines |
|---|---|---|
AdvProductBundlesAdmin | UI pages (list, add, edit) -- renders Vue mount points | 82 |
AdvApiProductBundlesAdmin | Internal REST API with HATEOAS _links for the Vue SPA | 723 |
Both controllers require AUTH_ROLE_ADVISABLE, AUTH_ROLE_ADMIN, or AUTH_ROLE_PRODUCTS, and check PRODUCT_BUNDLES.ENABLED in the constructor.
Admin Menu
Located in MARKETING group. Roles: ADVISABLE, ADMIN, PRODUCTS, MARKETING. Visibility gated by PRODUCT_BUNDLES.ENABLED in application/libraries/AdminMenu.php.
Database Schema
6 Tables
sql
shop_product_bundles -- Master bundle record
shop_product_bundles_builder_references_lp -- High-level product/category/vendor references
shop_product_bundles_criteria -- Compiled criteria (product code combinations)
shop_product_bundles_display -- MUI display strings (per lang + global)
shop_product_bundle_criteria_references_lp -- Criteria-to-reference mapping
shop_product_bundle_pricing -- Pricing tiers (per-bundle or per-criterion)Key Columns
shop_product_bundles
| Column | Type | Description |
|---|---|---|
id | int PK | Bundle ID |
internal_name | varchar(255) | Admin-facing name |
is_active | tinyint(1) | Active flag (0/1) |
weight | int | Priority weight (higher = processed first) |
bundle_behavior | tinyint(1) | 1=OneTime, 2=Recurring |
pricing_strategy | tinyint(1) | 1=Constant, 2=Scalar |
last_modified | timestamp | Auto-updated on change |
shop_product_bundles_builder_references_lp
| Column | Type | Description |
|---|---|---|
bundle_id | int FK | Parent bundle |
reference_id | int | Product/category/vendor ID |
reference_type | int | 1=ProductId, 2=ProductCodeId, 3=CategoryId, 4=VendorId |
quantity | int | Required quantity |
Unique constraint on (bundle_id, reference_id, reference_type) -- enforces UPSERT semantics.
shop_product_bundle_pricing
| Column | Type | Description |
|---|---|---|
bundle_id | int FK | Parent bundle |
criterion_id | int FK nullable | Null = default bundle pricing; non-null = criterion-specific |
discount | decimal(10,2) | Discount percentage (e.g. 10.00 = 10%) |
match_quantity | int | For Scalar strategy: quantity threshold tier |
shop_product_bundles_display
| Column | Type | Description |
|---|---|---|
bundle_id | int FK | Parent bundle |
message_key | varchar(255) | Key: cart_message, badge, badge_message, badge_component_type |
message_value | varchar(255) | Localized value |
lang | varchar(2) | Language code or * for global keys |
Unique constraint on (bundle_id, message_key, lang) -- UPSERT on display update.
Enums & Value Objects
BundleBehavior (src/ProductBundles/BundleBehavior.php)
| Value | Name | Description |
|---|---|---|
| 1 | OneTime | Bundle applies once per cart, even if multiple qualifying sets exist |
| 2 | Recurring | Bundle applies to every qualifying set found in the cart (recursive matching) |
PricingStrategy (src/ProductBundles/PricingStrategy.php)
| Value | Name | Description |
|---|---|---|
| 1 | Constant | Single fixed discount percentage; exactly one pricing row with match_quantity=1 |
| 2 | Scalar | Multiple tiered pricing rows; discount scales with match_quantity thresholds |
ReferenceType (src/ProductBundles/Reference/ReferenceType.php)
| Value | Name | Description |
|---|---|---|
| 1 | ProductId | References a product by its product_id |
| 2 | ProductCodeId | References a specific product code (SKU) |
| 3 | CategoryId | References a product category |
| 4 | VendorId | References a vendor/brand |
All enums use NamedEnumTrait which provides toArray() (value-to-name map) and fromName() reverse lookup.
Admin API Endpoints (HATEOAS)
The admin Vue SPA communicates exclusively through AdvApiProductBundlesAdmin using HATEOAS-driven navigation. All responses include _links arrays with rel, href, and type fields.
Endpoint Catalog
| Method | Endpoint | Description |
|---|---|---|
| GET | api_product_bundles_admin/listBundles | Paginated listing with search, sort, filters |
| GET | api_product_bundles_admin/build/{id} | Full bundle builder payload (all sub-entities + editor configs) |
| GET | api_product_bundles_admin/searchReferences | Product search for reference selector |
| POST | api_product_bundles_admin/createNewBundle | Create empty bundle, redirect to edit |
| POST | api_product_bundles_admin/setActiveState | Bulk toggle active/inactive |
| POST | api_product_bundles_admin/updateMainProperties/{id} | Update name, weight, behavior, strategy |
| POST | api_product_bundles_admin/updatePricingStrategy | Change strategy + auto-fix pricing rows |
| POST | api_product_bundles_admin/updatePricing | Batch update pricing configurations |
| POST | api_product_bundles_admin/upsertBuilderReference/{bundleId} | Add/update single builder reference |
| DELETE | api_product_bundles_admin/removeBuilderReference/{bundleId}/{refId}/{refType} | Remove builder reference |
| POST | api_product_bundles_admin/upsertBundleBuilderReferences/{bundleId} | Bulk replace all references |
| POST | api_product_bundles_admin/generateCriteriaFromBuilderReferences/{id} | Compile criteria from references |
| DELETE | api_product_bundles_admin/deleteCriterion/{criterionId} | Delete a compiled criterion |
| POST | api_product_bundles_admin/addNewScalarPricingToBundle/{id}/{criterionId?} | Add scalar pricing tier |
| DELETE | api_product_bundles_admin/removeScalarPricingFromBundle/{priceId} | Remove scalar pricing tier |
| POST | api_product_bundles_admin/updateDisplay/{bundleId} | Update MUI display strings |
| DELETE | api_product_bundles_admin/deleteBundle/{bundleId} | Delete bundle and all related data |
List Filters
The listBundles endpoint supports these filter parameters:
| Filter Key | Values | Description |
|---|---|---|
active_state | active, inactive | Filter by activation status |
bundle_behavior | 1, 2 | Filter by behavior enum value |
pricing_strategy | 1, 2 | Filter by pricing strategy enum value |
Search applies to internal_name and message_value (display text). Sortable by any listed column.
Admin Workflow
1. Bundle Listing Page
Controller: AdvProductBundlesAdmin::index() renders the Vue mount point. Vue Component: ProductBundleListing.vue -- fetches listBundles, displays sortable/filterable table with columns: ID, internal name, weight, behavior, last modified, pricing strategy, actions.
Actions per row: Edit (navigate to builder), Activate/Deactivate (toggle is_active), Delete.
2. Bundle Creation
- Admin clicks "Create New Bundle"
createNewBundle()inserts a row with emptyinternal_nameandis_active=falsefixBundlePrices()ensures the new bundle has a valid default pricing row- Response includes
web:self-editlink -- Vue redirects to the builder page
3. Bundle Builder Page
Controller: AdvProductBundlesAdmin::edit($id) renders the Vue mount point with editBundleId. Vue Component: ProductBundleBuilder.vue -- five section editors, all auto-syncing via useLazyFetch composable.
The build/{id} endpoint returns the complete bundle state plus editor initialization configs:
json
{
"bundle": { "id", "internal_name", "is_active", "weight", "bundle_behavior", "pricing_strategy",
"builder_references": [...], "criteria": [...], "pricing": [...], "display": {...} },
"behaviors": { "1": "OneTime", "2": "Recurring" },
"pricing_strategies": { "1": "Constant", "2": "Scalar" },
"reference_types": { "1": "ProductId", "2": "ProductCodeId", "3": "CategoryId", "4": "VendorId" },
"mui_langs": { "el": "Greek", "en": "English", ... },
"mui_editor": { "_links": [...] },
"rule_set_editor": { "criterion_reference_selector": { "_links": [...] }, "_links": [...] },
"pricing_editor": { "_links": [...] },
"_links": [...]
}Section A: Main Properties
Fields: internal_name (text), bundle_behavior (select), weight (number). Auto-syncs to updateMainProperties/{id} on change via reactive useLazyFetch.
Section B: Display Editor (DisplayEditor.vue)
Per-language tabs with fields:
- Translatable (per language):
cart_message,badge_message - Global (lang=
*):badge_component_type-- options:no_selection,badge_message,product_list,price_scale
Auto-syncs to updateDisplay/{bundleId} via UPSERT (INSERT ... ON DUPLICATE KEY UPDATE).
Section C: Rule Set Editor (RuleSetEditor.vue)
Two tabs:
- Builder References -- high-level product assignments with quantity. Table columns: reference_id, reference_type, quantity, actions (upsert/remove). "Add" opens
CriterionReferenceSelectormodal for product search. - Compiled Criteria -- auto-generated criteria from builder references. Table columns: id, hash, internal_name, weight, reference_type, actions (delete).
"Rebuild criteria" action triggers generateCriteriaFromBuilderReferences/{id}.
Section D: Pricing Editor (PricingEditor.vue)
Strategy selector + strategy-specific controls:
- Constant: Single discount percentage input
- Scalar: Table of
(match_quantity, discount)rows with add/remove actions
Changing strategy via updatePricingStrategy auto-fixes pricing rows (e.g. Constant enforces exactly 1 row with match_quantity=1).
Section E: Activation
Toggle switch for is_active. Part of main properties auto-sync.
Criteria Compilation Pipeline
The compilation process (generateCriteriaFromBuilderReferences) converts high-level builder references into concrete matching criteria:
- Fetch builder references -- filter to
ProductIdtype - Resolve product codes -- each product ID maps to one or more product codes (SKUs)
- Cartesian product --
CartesianProductGenerator::generate()produces all combinations of product codes across referenced products - Generate criteria -- each combination becomes a criterion with
reference_type=ProductCodeId,internal_name= joined product codes (e.g. "SKU001 + SKU002") - Match existing -- hash-based comparison (
md5of sorted reference IDs) preserves existing criterion IDs and their associated pricing - Persist -- transaction: insert new criteria + upsert criterion references
Example: Bundle references Product A (2 SKUs: A1, A2) + Product B (1 SKU: B1) generates 2 criteria:
- Criterion 1: A1 + B1
- Criterion 2: A2 + B1
Reference Set Algebra (BundleReferenceSet)
The BundleReferenceSet class (src/ProductBundles/Reference/BundleReferenceSet.php) provides immutable set operations used by the cart labeling engine:
| Method | Description |
|---|---|
addReferenceByType() | Add reference, merging quantities if same ID exists |
subtract() | Returns [remaining, missing] tuple after removing matching references |
isSubsetOf() / isSupersetOf() | Containment tests with quantity awareness |
countSubsetTimes() | How many times the other set fits in this set (used for Recurring behavior) |
isEquivalentTo() | Bidirectional subset check |
findReferenceByType() | Lookup by type with exact or loose matching |
All operations are quantity-aware -- ProductReference(A, 3).hasEnoughQuantity(ProductReference(A, 2)) returns true.
Models (Legacy HMVC)
AdvProductBundleStorageModel
Table: shop_product_bundles (plus 5 related tables). Purpose: All admin CRUD operations. The "write side" of the bundle system.
Key methods:
fetchBundles($bundles)-- loads complete bundle graph (references, localizations, criteria, pricing, criteria-references) and compiles each criterion with itsBundleReferenceSetsearchBundles(search, filters, sort, limit)-- listing withDbSearchableQuery/DbSortableQuerysupportcreateBundle(name, isActive)-- insert bundle rowupsertBuilderReference(bundleId, refId, type, qty)--INSERT ... ON DUPLICATE KEY UPDATEgenerateCriteriaFromBuilderReferences(bundleId)-- the Cartesian compilation pipeline (transactional)fixBundlePrices(bundles)-- ensures Constant bundles have exactly 1 pricing row withmatch_quantity=1deleteBundles(bundleIds)-- cascading delete across all 6 tables (transactional)touchBundles(bundleIds)-- updateslast_modifiedtimestamp after any mutation
AdvProductBundleLabelingModel
Purpose: Labels cart contents with bundle information at checkout. The "read/match side".
Key methods:
setup(cartContents, bundles)-- initialize statelabelCartContents()-- sort by weight, build cart reference set, iterate bundles, match criteria, label itemsprocessBundle()-- recursive forRecurringbehavior, single-pass forOneTime
Labeling attaches to each cart item's options.appliedBundles[]:
json
{
"bundleId": 1,
"bundlePricingStrategy": 1,
"criterionId": 5,
"pricingIds": [10, 11],
"quantity": 1,
"mui": { "cart_message": "Bundle deal!", "badge_message": "Save 10%" },
"matchedCriterionHasExactlyOneProduct": false,
"instances": 1,
"resolvedPricingId": 10,
"resolvedPricingDiscount": 10.00,
"resolvedPricingMatchQuantity": 1
}AdvProductBundlePricingModel
Purpose: Applies resolved bundle discounts to live product data (final prices).
Reads from LIVEDATA_PRICE_FIELD_READ (final_price), computes per-item save amount, writes back to LIVEDATA_PRICE_FIELD_WRITE (final_price). Sets bundle_previous_price and bundle_save_price on affected items.
Discount formula: savePrice = (price * instances/cartQty) * (discount/100)
Modern Domain Layer
Full read/write service pattern registered in src/Domains/Product/container.php:
| Sub-entity | Table | Domain Path |
|---|---|---|
| Bundle | shop_product_bundles | src/Domains/Product/Bundle/ |
| Display | shop_product_bundles_display | src/Domains/Product/Bundle/Display/ |
| Criteria | shop_product_bundles_criteria | src/Domains/Product/Bundle/Criteria/ |
| CriteriaReference | shop_product_bundle_criteria_references_lp | src/Domains/Product/Bundle/CriteriaReference/ |
| BuilderReference | shop_product_bundles_builder_references_lp | src/Domains/Product/Bundle/BuilderReference/ |
| Pricing | shop_product_bundle_pricing | src/Domains/Product/Bundle/Pricing/ |
Each sub-entity has: Entity, Repository, RepositoryConfigurator, Service, ListRequest, WriteData, WriteService, WriteRepository, Validator.
Relations (RepositoryConfigurator)
php
'display' => ONE_TO_MANY → DisplayRepository (bundle_id)
'criteria' => ONE_TO_MANY → CriteriaRepository (bundle_id)
'builderReferences' => ONE_TO_MANY → BuilderReferenceRepository (bundle_id)
'pricing' => ONE_TO_MANY → PricingRepository (bundle_id)REST API (Modern)
6 REST controllers in src/Rest/Product/Controllers/:
| Controller | Route Prefix | Read | Write |
|---|---|---|---|
Bundle | /rest/product/bundle | index, show, item | store, update, destroy |
BundleDisplay | /rest/product/bundle-display | index, show, item | store, update, destroy |
BundleCriteria | /rest/product/bundle-criteria | index, show, item | store, update, destroy |
BundleCriteriaReference | /rest/product/bundle-criteria-reference | index, show, item | store, update, destroy |
BundleBuilderReference | /rest/product/bundle-builder-reference | index, show, item | store, update, destroy |
BundlePricing | /rest/product/bundle-pricing | index, show, item | store, update, destroy |
All use HandlesRestfulActions + HandlesWriteActions trait. Routes support language prefix (/{lang}/rest/...).
Filters & Sorts (Bundle controller)
- Filters:
id(exact),internalName(partial),isActive(exact),bundleBehavior(exact),pricingStrategy(exact) - Sorts:
id,internalName,isActive,weight,lastModified - Includes:
display,criteria,builderReferences,pricing
Frontend (Vue 2)
Entry point: assets/admin/js/product-bundles.js -- registers ProductBundleListing and ProductBundleBuilder components.
| Component | File | Purpose |
|---|---|---|
ProductBundleListing | productBundles/components/ProductBundleListing.vue | Listing with search, filters, sort, activate/deactivate |
ProductBundleBuilder | productBundles/components/ProductBundleBuilder.vue | Full bundle editor with 5 sections |
DisplayEditor | productBundles/components/builder/DisplayEditor.vue | MUI editor with per-language tabs |
RuleSetEditor | productBundles/components/builder/RuleSetEditor.vue | Builder references + compiled criteria tabs |
CriterionReferenceSelector | productBundles/components/builder/CriterionReferenceSelector.vue | Product search modal for adding references |
PricingEditor | productBundles/components/builder/PricingEditor.vue | Strategy selector + Constant/Scalar editors |
All editors use useLazyFetch composable for auto-syncing changes to the backend with debouncing. The builder reference quantity update uses a 200ms debounced AbortController pattern.
Localization via useLocalization(['product_bundles'], ['product_bundles.']).
Business Rules
| Rule | Description |
|---|---|
| Feature gate | PRODUCT_BUNDLES.ENABLED registry key must be truthy |
| Weight priority | Higher weight bundles are evaluated first during cart labeling |
| OneTime vs Recurring | OneTime stops after first criteria match; Recurring re-checks recursively |
| Constant pricing fix | Auto-enforced: exactly 1 pricing row with match_quantity=1 |
| Scalar pricing | Multiple tiers; highest match_quantity <= instances wins |
| Criterion hash | MD5 of sorted reference IDs -- used to preserve criteria across rebuilds |
| Cascading delete | Bundle deletion removes all 5 related table rows in a transaction |
| Touch on mutation | Every write operation updates last_modified via touchBundles() |
| Pricing fallback | If no criterion-specific pricing found, falls back to default bundle pricing (criterion_id=NULL) |
| Builder reference UPSERT | INSERT ... ON DUPLICATE KEY UPDATE quantity -- adding same product updates quantity |
Configuration
| Setting | Location | Description |
|---|---|---|
PRODUCT_BUNDLES.ENABLED | Registry (Admin > Settings > Other) | Feature toggle |
Key File Paths
| Component | Path |
|---|---|
| Admin UI controller | ecommercen/product_bundles/controllers/AdvProductBundlesAdmin.php |
| Admin API controller | ecommercen/product_bundles/controllers/AdvApiProductBundlesAdmin.php |
| Storage model | ecommercen/product_bundles/models/AdvProductBundleStorageModel.php |
| Labeling model | ecommercen/product_bundles/models/AdvProductBundleLabelingModel.php |
| Pricing model | ecommercen/product_bundles/models/AdvProductBundlePricingModel.php |
| BundleBehavior enum | src/ProductBundles/BundleBehavior.php |
| PricingStrategy enum | src/ProductBundles/PricingStrategy.php |
| ReferenceType enum | src/ProductBundles/Reference/ReferenceType.php |
| BundleReferenceSet | src/ProductBundles/Reference/BundleReferenceSet.php |
| CartesianProductGenerator | src/ProductBundles/Utilities/CartesianProductGenerator.php |
| Domain entities | src/Domains/Product/Bundle/ (6 sub-directories) |
| REST controllers | src/Rest/Product/Controllers/Bundle*.php (6 files) |
| REST resources | src/Rest/Product/Resources/Bundle*/ (6 directories) |
| DI registration (domain) | src/Domains/Product/container.php (lines 216-262) |
| DI registration (REST) | src/Rest/Product/container.php (lines 148-188) |
| Routes (REST) | application/config/rest_routes.php (lines 462-508, 1380-1426) |
| Routes (admin) | application/config/routes.php (lines 674-681) |
| Admin menu entry | application/config/admin_menu.php (line 862) |
| Vue entry point | assets/admin/js/product-bundles.js |
| Vue listing | assets/admin/js/productBundles/components/ProductBundleListing.vue |
| Vue builder | assets/admin/js/productBundles/components/ProductBundleBuilder.vue |
| SQL schema | database/initial/initial.sql (lines 1610-1673) |
Related Flows
- CF-24 Product Bundles -- customer-facing bundle application (cart labeling + pricing)
- CF-05 Cart -- bundle pricing applied during cart processing
- CF-02 Product Detail -- related bundles on PDP
- AD-02 Product Management -- products referenced as bundle builder references
- AD-13 Settings --
PRODUCT_BUNDLES.ENABLEDtoggle in Other Settings