PRD: WPC Product Bundles (WOOSB) — Edge Server & Thin Client Parity
Status: Draft — plugin source reviewed (WPC Product Bundles Premium 8.5.6 in
.cursor/woo-product-bundle-premium-856/)
Grilled decisions: Q1 1 — tilltypebundlewhenwoosb_idsmeta present; native Woobundlewithout WOOSB meta stays one-tap. Q2 1 — conditional picker: one-tap default composition when no variables and no optional rows; picker whenhas_variablesorhas_optional. Q3 1 — parent owns customer total on basket/Till Order line;bundle_children[]hold per-kit breakdown for kitchen/receipt (fixed mode child prices may be reference-only). Q4 1 — merge basket lines when parent catalog product id + bundle configuration fingerprint match (same pattern as composite configuration). Q5 1 —catalog.composite_configuregates Configure bundle catalog editing (same as WOOCO). Q6 1 — sell-first release: ship B1–B6 (ingest through kitchen/EOD) before B7–B8 (catalog editor + push); website-defined bundles sellable on till first. Q7 1 — layout text rows (h1/p/etc.) shown in picker as plain-text headings; excluded frombundle_childrenand merge fingerprint. Q8 1 — Bundle fulfillment picker uses same modal contract as Composite fulfillment picker (full-screen on#/, blocks checkout until confirm/cancel). Q9 1 —woosb_before_text/woosb_after_textshown in picker only as plain text (strip HTML); not kitchen/receipt/basket row in v1 (same rule as Composite operator copy). Q10 1 — same as composite downstream display: Kitchen board indentsbundle_childrenunder parent; Receipt lists children under parent when ESC/POS supports nested lines; EODorders_jsonincludes parent row plus nested child lines (name, qty, line total). QC-A 1 — Recipe bundle: each staging meal prep dish is a WOOSB bundle of existing catalog component products; no component creation inside the designer. QC-B 1 — Component products are existing catalog products (standalone simple or variable), pre-created via the normal New product wizard before bundle configuration. QC-C 2 — Configure bundle lives in Catalog Manage (WOOSB B7); the designer shows only a component status badge and a → Configure shortcut per dish card — no component editor inside the designer itself. Repository: WPPosTill
Canonical glossary:[CONTEXT.md](../../CONTEXT.md)
Plugin reference: WPC Product Bundles (WOOSB) · source:wpc-product-bundles.php
Contrast (implemented): WPCleaverPro.md (WOOCO /composite) ·[edge-server-wooco-composites-table.md](./edge-server-wooco-composites-table.md)·[thin-client-composite-fulfillment-picker.md](./thin-client-composite-fulfillment-picker.md)
Scope: Edge Server SQLite + Woo bootstrap/push + Thin Client POS/catalog — WOOSB only (not native Woobundle, notgrouped, not WOOCO)
Problem Statement
The store runs WPC Product Bundles for WooCommerce (Premium) — plugin prefix woosb, Woo product type woosb, admin label Smart Bundle. This is a different product from WPC Composite Products (WOOCO) (composite + wooco_components), which WPPosTill already supports end-to-end (fulfillment picker, composite_children on Till Order lines, conversion screen, wooco_composites / composite_items materialization).
WOOSB bundles are fixed product lists (simple, variation, or variable parents) with per-item default quantities, optional/custom-qty rows, bundle-level discounts, and fixed vs summed pricing. On the website, add-to-cart expands a parent line into linked child lines with WOOSB cart metadata; checkout persists _woosb_ids / _woosb_parent_id on order items.
Today WPPosTill:
- Downloads bundle parents at catalog bootstrap but stores no
woosb_*meta columns onproducts(export schema has WOOCO meta only). - Treats
type === 'bundle'(and likelywoosbrows mapped tobundle) as one-tap add — parent flat line only, no component breakdown ([CONTEXT.md](../../CONTEXT.md)Non-composite kit types). - Has no bundle definition API, picker, pricing engine, order-line shape, catalog editor, or push mapper for WOOSB meta.
Kitchen cannot sell “Meal Deal” bundles with optional sides, variation picks, or correct discounted totals; EOD and kitchen cards cannot show what to make inside a bundle.
Goals
- Ingest & persist WOOSB parent meta from WooCommerce REST / export into Edge Server
products(same authority model as WOOCO). - Resolve bundle definitions for the till: explicit bundled product ids, variation constraints, optional/custom qty rules, stock flags.
- Sell at checkout via a Bundle fulfillment picker when the bundle requires configuration (
has_variables,has_optional); otherwise fast-path add with default composition. - Price bundle lines using WOOSB semantics: fixed parent price vs summed children with discount % or fixed discount amount.
- Persist Till Order
line_itemswith structured bundle children (parallel tocomposite_children, distinct type discriminator). - Edit bundle composition in catalog management (new screen or extension — not the WOOCO conversion screen).
- Push WOOSB meta back to Woo on staff edit / reconcile (parity with catalog push).
Non-goals (clarify plugin identity)
| Product | Plugin | Woo type | Primary meta | WPPosTill today |
|---|---|---|---|---|
| WPC Composite | WOOCO | composite | wooco_components | Full parity |
| WPC Product Bundles | WOOSB (this PRD) | woosb | woosb_ids + pricing meta | One-tap flat add only |
| Native WooCommerce Bundle | core / other | bundle | _bundle_data etc. | One-tap flat add; out of scope unless store uses it |
| Grouped | core | grouped | children ids | One-tap; out of scope |
Verify at bootstrap: Woo REST may return type: "woosb" or legacy bundle for the same plugin depending on API version. v1 maps woosb REST type → till bundle only when meta.woosb_ids is present; native bundles without WOOSB meta stay on the legacy one-tap path until a separate PRD.
Plugin data model summary
Storage is WordPress post meta only — no custom tables. Settings: woosb_settings, woosb_localization (site options; not per-product).
Product type
- Class:
WC_Product_Woosb(includes/class-product.php) get_type()→woosb- Registered via
product_type_selectorfilter (“Smart Bundle” in admin)
Composition — woosb_ids
Primary meta key. v7+ stores a PHP/JSON associative array keyed by stable string keys (4-char alphanumeric, e.g. a3f9). Legacy string format still parsed:
{id}/{key}/{qty},{id2}/{qty2},...
Product item (typical):
{
"k7m2": {
"id": 1665,
"sku": "BK331",
"qty": 1,
"optional": "1",
"min": 0,
"max": 3,
"attrs": { "attribute_pa_size": "large" },
"terms": { "attribute_pa_color": ["red", "blue"] }
}
}
| Field | Meaning |
|---|---|
id | Bundled catalog product id (variation id when a specific variation is bundled) |
sku | Denormalized SKU; used when woosb_use_sku filter is true |
qty | Default quantity per one bundle parent qty |
optional | When truthy, row is custom quantity — customer/staff may change qty within min/max; omitted/false = fixed qty (must match default at cart) |
min / max | Per-item bounds when optional is set; if absent, inherited from parent meta woosb_limit_each_min, woosb_limit_each_max, or woosb_limit_each_min_default (= default qty as min) |
attrs | Selected variation attributes (serialized on add-to-cart) |
terms | Premium: restrict which variation term values are offered for a variable parent row |
Text/heading items (layout only — not purchasable):
{
"h1ab": { "type": "h1", "text": "Choose your drink" }
}
type: h1–h6, p, span, none. No id. Skipped in cart validation except display.
Not used: category-sourced components (WOOCO product_cat + other[]). WOOSB is explicit product list only.
Parent-level meta keys
| Meta key | Values / role |
|---|---|
woosb_ids | Composition (array or legacy string) |
woosb_disable_auto_price | on = fixed bundle price (parent regular/sale price); off = dynamic (sum children − discount) |
woosb_discount | Percentage 0–100 off summed item prices (ignored when fixed price or woosb_discount_amount set) |
woosb_discount_amount | Fixed currency amount subtracted from summed total |
woosb_manage_stock | on = bundle participates in stock calc from children |
woosb_exclude_unpurchasable | yes / no / unset / default — skip OOS/unpurchasable items in totals & stock |
woosb_limit_whole_min / woosb_limit_whole_max | When bundle has optional items: min/max count of optional rows selected |
woosb_total_limits | on enables price total guardrails |
woosb_total_limits_min / woosb_total_limits_max | Min/max currency total of selected items (dynamic pricing) |
woosb_limit_each_min / woosb_limit_each_max | Default per-item min/max when item row omits them |
woosb_limit_each_min_default | on → default min = item qty |
woosb_shipping_fee | e.g. each — affects needs_shipping |
woosb_layout | list / grid (frontend) |
woosb_custom_price | Display price HTML override |
woosb_before_text / woosb_after_text | Operator copy on product page |
Pricing modes (sell-time)
- Dynamic (auto price) —
woosb_disable_auto_price !== 'on'- Child line unit prices = product price (sale or regular per
bundled_price_fromsetting) × bundle discount %. - Parent cart line price = 0 or −discount_amount (adjustment line); display total in
woosb_pricemeta on parent. - Parent tax status set
nonefor summed bundles.
- Child line unit prices = product price (sale or regular per
- Fixed bundle price —
woosb_disable_auto_price === 'on'- Parent carries Woo regular/sale price.
- Bundled children often priced at 0 in cart (
woosb_fixed_priceflag) but may show reference prices in UI.
Discount precedence: woosb_discount_amount wins over woosb_discount percentage.
Quantity rules
| Rule | Source | Behavior |
|---|---|---|
| Fixed item qty | item without optional | Submitted qty must equal qty |
| Custom item qty | optional + min/max | Staff/customer pick qty in range |
| Optional selection count | woosb_limit_whole_min/max | Count of optional rows with qty > 0 |
| Total price bounds | woosb_total_limits + min/max | Sum of selected item display prices |
| Parent quantity | cart | Child cart qty = woosb_qty × parent_qty |
| Variable products | has_variables() | Requires variation selection before add |
| Nested bundles | — | Items of type woosb / bundle / composite / grouped blocked from being bundled children |
Stock / inventory
- Bundle in stock when every required (non-optional) item has enough stock for
qty × order_qty(optional items useminor 0 for worst-case check). get_stock_quantity: when managing stock and no optional items, parent stock ≈min(floor(component_stock / component_qty))across required managed items; optional bundles often skip aggregation.- Component stock reduced on order (standard Woo child lines).
update_stock_statushook propagates child stock changes to containing bundles.
Cart / order line structure
Parent cart item (class-woosb.php):
woosb_ids— serialized selection string or array snapshotwoosb_keys[]— cart keys of child lineswoosb_fixed_price,woosb_discount,woosb_discount_amountwoosb_price— computed display total (dynamic mode)
Child cart item:
woosb_parent_id,woosb_parent_key,woosb_qty(per-kit qty)- Same discount/fixed flags copied from parent
Order item meta (hidden keys prefixed _):
_woosb_ids,_woosb_parent_id,_woosb_price
Admin manual orders duplicate parent + children via add_order_item_meta in class-backend.php.
WooCommerce hooks / APIs used (reference)
| Area | Hooks / APIs |
|---|---|
| Product type | product_type_selector, woocommerce_product_data_tabs, woocommerce_process_product_meta_woosb |
| Cart | woocommerce_add_to_cart_validation, woocommerce_add_cart_item_data, woocommerce_add_to_cart, woocommerce_before_calculate_totals, woocommerce_get_cart_item_from_session |
| Order | woocommerce_checkout_create_order_line_item, woocommerce_order_item_visible |
| Stock | woocommerce_product_set_stock_status, woocommerce_variation_set_stock_status |
| REST / export | woocommerce_product_export_meta_value, woocommerce_product_import_pre_insert_product_object for woosb_ids |
| Admin AJAX | wp_ajax_woosb_get_search_results, woosb_add_text, woosb_import_export |
Till integration should use WooCommerce REST GET/PUT /products/:id meta_data keys above — not admin-ajax.
Gap analysis vs current WPPosTill WOOCO work
| Capability | WOOCO (composite) | WOOSB (gap) |
|---|---|---|
Meta columns on products | meta_wooco_* + CSV headers | None for woosb_* |
| Shared parser module | shared/wooco/ | Missing shared/woosb/ |
| Definition read API | GET /api/products/:id/composite | Missing …/bundle |
| Materialized reporting tables | wooco_composites, composite_items | None (optional woosb_bundle_items follow-on) |
| Checkout UI | Composite fulfillment picker | One-tap parent only |
| Basket / order shape | composite_children | No bundle_children |
| Pricing | only / include / exclude + component discounts | No WOOSB fixed/sum/discount |
| Catalog editor | Composite conversion screen | No bundle editor |
| Push mapper | productMetaPushMapper WOOCO keys | No WOOSB keys |
| Type inference | inferTillProductType → composite from meta | No woosb inference |
| Kitchen / receipt | Parent + indented children (Till) | Bundle shows parent name only |
| Component source | Categories + explicit ids | Explicit products only |
| Picker entry | Always for composite | Conditional — only when has_variables or has_optional |
Reuse (do not conflate): Till session auth, catalog bootstrap pipeline, GET /api/products, basket merge patterns, EOD nested line display, catalog push queue, permission gates — extend with bundle-specific types.
Solution overview
flowchart TB
woo[WooCommerce REST meta woosb_*]
products[(products master)]
shared[shared/woosb parser + pricing]
api[GET /api/products/:id/bundle]
picker[Bundle fulfillment picker]
basket[bundle basket line + bundle_children]
order[Till Order line_items]
catalog[Bundle configuration screen]
push[catalog push woosb meta]
woo --> products
products --> shared
shared --> api
api --> picker
picker --> basket
basket --> order
catalog --> products
products --> push
push --> woo
User stories
Edge Server — ingest & schema
- As a developer, I want
woosb_idsand parent pricing/stock meta stored onproductsat bootstrap, so bundle definitions survive offline. - As a developer, I want Woo export CSV headers extended with
Meta: woosb_*columns, so CSV re-bootstrap matches REST ingest. - As a developer, I want till
typebundlewhen RESTtypeiswoosbor legacybundleandwoosb_idsmeta exists, so POS grid logic has a single discriminator. - As a maintainer, I want invalid or empty
woosb_idsflaggedinvalid_bundle_definitionon read API, so broken kits are visible in catalog.
Edge Server — definition API
- As kitchen staff, I want
GET /api/products/:id/bundlereturning normalized items (product id, name, sku, default qty, optional, min/max, variable flag, resolved variation options), so the picker does not parse raw meta. - As kitchen staff, I want the API to include
pricingMode(fixeddynamic),discountPercent,discountAmount, andfixedPricesummary, so the picker shows correct totals. - As kitchen staff, I want
beforeText/afterTextand non-product text rows returned aslayoutRows, so operator copy matches the website. - As kitchen staff, I want missing bundled product ids flagged
unavailable, so incomplete sync is obvious. - As a developer, I want bundle resolution implemented in
shared/woosb/and called from the Edge route, so rules are testable and not duplicated in Vue.
Thin Client — POS discovery & picker
- As kitchen staff, I want a Bundle badge on grid cards for
type === 'bundle'with valid WOOSB meta, so I distinguish kits from simple items. - As kitchen staff, when a bundle has no variables and no optional items, I want one-tap add using default
woosb_idscomposition, so fixed meal deals stay fast. - As kitchen staff, when a bundle
has_variablesorhas_optional, I want tapping the grid to open a Bundle fulfillment picker, so I choose variations and optional qtys. - As kitchen staff, I want required bundled rows always included with fixed qty, so I cannot submit an invalid kit.
- As kitchen staff, I want optional rows with qty 0 omitted from the sale, so add-ons are truly optional.
- As kitchen staff, I want per-item qty clamped to
min/max, and optional-item count clamped tolimit_whole_min/max, so website rules match the till. - As kitchen staff, I want a running estimated total using WOOSB dynamic/fixed pricing, so customers see the correct bundle price.
- As kitchen staff, I want Add to basket disabled until variable parents have a chosen variation and required stock is available.
- As kitchen staff, I want Edit bundle on a basket line to reopen the picker with prior selections.
- As kitchen staff, I want parent line quantity increments to scale child per-kit qtys (
woosb_qty × parent_qty), matching Woo cart sync.
Checkout, kitchen, EOD
- As kitchen staff, I want Till Order
line_itemsto storeline_kind: 'bundle'withbundle_children[](product id, name, sku, qty, unit price), so the kitchen knows what to make. - As kitchen staff, I want the Kitchen board to indent bundle children under the parent (same UX as composite children).
- As a manager, I want EOD snapshots to include bundle parent + nested child lines, so reporting matches composite behavior.
- As kitchen staff, I want Receipt ESC/POS to list bundled items under the parent when configured, so counter tickets are readable.
Catalog management
- As kitchen staff, I want Configure bundle entry (separate from Configure composite), so I do not mix WOOCO and WOOSB editors.
- As kitchen staff, I want to search/add bundled products by name/SKU, reorder rows, set default qty, toggle custom quantity, and set min/max, mirroring wp-admin Smart Bundle UI.
- As kitchen staff, I want to set fixed vs dynamic pricing and discount fields, so LAN edits match WPClever.
- As kitchen staff, I want saves to
PATCHthe parent row and enqueue catalog push withwoosb_*meta when not Local_Only. - As kitchen staff without
catalog.edit, I want bundle configuration blocked server-side (403), consistent with composite rules.
Push & authority
- As a store operator, I want staff bundle edits pushed to WooCommerce
meta_data, so the website stays aligned with the Edge master. - As a developer, I want
mapProductRowToWooPayloadto includewoosb_idsJSON and parent meta keys, without sending invalid Woo product type if REST requireswoosb.
Security & capabilities
- As a store operator, I want
GET /api/products/:id/bundlebehind Till session bearer (same as composite definition reads). - As a store operator, I want bundle configuration behind
catalog.editand checkout picker behind normal till permissions (grid/cart automatic).
Architecture
Edge Server
| Module | Responsibility |
|---|---|
| productSchema | Add meta_woosb_* columns + CSV header mapping |
| WooCommerceSync / bootstrap | Ingest woosb_* from REST meta_data |
| shared/woosb | parseWoosbBundle, resolveBundleItems, validateBundleSelection, priceBundleSelection, serializeWoosbIds |
| bundleDefinitionApi | GET /api/products/:id/bundle |
| productMetaPushMapper | Outbound woosb_* keys |
| orderRepository / receiptOrderMapper | Persist and render bundle_children |
| woosbBundleItems (optional v1.1) | Materialized child rows for reporting — mirror composite_items pattern |
Thin Client
| Module | Responsibility |
|---|---|
| bundleDefinitionClient | Fetch GET …/bundle; degraded parse fallback from csv meta (last resort) |
| BundleFulfillmentPicker.vue | Modal picker (reuse composite picker shell patterns; separate component) |
| useBasket / PosView | line_kind: 'bundle', merge fingerprint, edit flow |
| BundleConfigurationView.vue | Catalog editor screen |
| ProductTypeControl | Distinguish Single / Composite / Bundle where product type is shown |
Till order line shape (proposed)
{
"product_id": "1200",
"name": "Lunch Deal",
"sku": "LD01",
"quantity": 2,
"line_total": 19.98,
"line_kind": "bundle",
"bundle_selection": {
"woosb_ids": "1665/k7m2/1,1700/x9p1/2",
"pricing_mode": "dynamic",
"discount_percent": 10
},
"bundle_children": [
{ "product_id": "1665", "name": "Wrap", "sku": "WR1", "quantity": 2, "unit_price": 4.50 },
{ "product_id": "1700", "name": "Chips", "sku": "CH1", "quantity": 4, "unit_price": 2.00 }
]
}
bundle_selection stores the WOOSB ids snapshot for edit/merge fingerprinting (parallel to composite configuration).
Pricing & quantity — implementation rules (must mirror plugin)
Implement priceBundleSelection against class-woosb.php before_calculate_totals and add_to_cart_validation:
- Dynamic: Sum child
get_price× per-kit qty × parent qty; apply%discount per child; subtract fixed discount once on parent allocation; round perwoosb_round_price(store currency decimals). - Fixed: Line total = parent
regular_price/sale_price× parent qty; children contribute 0 to line_total but remain on kitchen breakdown. - Validation: Reject when required item missing, fixed qty mismatch, optional min/max violated, whole optional count out of range, total_limits exceeded, or nested bundle types in selection.
- exclude_unpurchasable: When
yes, omit OOS lines from validation totals; whenno, block add with explicit error.
Migration / bootstrap
- Schema migration adds nullable
meta_woosb_*TEXT columns; no SQLite rewrite of existing rows required. - Re-bootstrap or incremental product sync pulls
meta_datakeys from Woo REST; map into columns andcsv['Meta: woosb_*']mirrors. - Type normalization: If
typefrom Woo iswoosb, persist tillbundle+ retainremote_type: woosbin snapshot if needed for push. - No automatic picker enablement until
shared/woosbtests pass against fixtures exported from the plugin’swoosb_import_exportJSON shape. - Existing flat basket lines for bundle parents (legacy one-tap) are not retrofitted; new sales use bundle line shape after ship.
Out of scope (v1)
- Native WooCommerce core
bundletype (_bundle_data) without WOOSB meta - Grouped products (
grouped+children) - WOOCO changes (already shipped)
- Category-sourced bundle components (WOOSB does not have them)
- Global
woosb_settings/woosb_localizationadmin on till - Shipping package splitting (
woocommerce_cart_shipping_packagesparity) - Coupon restriction (
woocommerce_coupon_is_valid_for_product) replication - WPC Price by Quantity tier integration inside bundles
- Web Order ingest mapping of Woo
_woosb_*order meta (till-first; website orders stay parent-flat in v1) - Nested bundle inside bundle
woosb_bundle_itemsmaterialized table (optional reporting slice v1.1)- Android-specific picker window (modal on
#/only, same as composite v1)
Implementation slices (tracer bullets)
Release plan (grill Q6 1): Phase A = B1–B6 (sell at till). Phase B = B7–B8 (Configure bundle + push). Phase B ships immediately after Phase A before marking operator-facing v1 complete.
| Slice | Delivers | Extend |
|---|---|---|
| B1 — Meta ingest | meta_woosb_* columns; bootstrap + CSV; tests with plugin export JSON | productSchema.ts, WooCommerceSync.ts |
| B2 — shared/woosb core | Parse woosb_ids array + legacy string; BundleDefinition types; fixtures from .cursor/woo-product-bundle-premium-856/ | New shared/woosb/ (mirror shared/wooco/) |
| B3 — Definition API | GET /api/products/:id/bundle + Till session auth | productApi.ts, pattern from compositeKitApi.ts |
| B4 — Fast-path add | Fixed bundles (no optional/variable) one-tap with default composition + pricing | PosView.vue, basket composable |
| B5 — Bundle picker | Modal UI + validation + priceBundleSelection | Composite fulfillment picker PRD patterns; new Vue module |
| B6 — Order lines | Persist line_kind: 'bundle' + bundle_children[] on Till Order checkout; Bundle kitchen display (indented children, name + qty only); Receipt nested lines when ESC/POS route supports them; EOD record parent + nested child rows in orders_json (grill Q10 1 — mirror Composite kitchen display / EOD composite shape) | orderRepository, receiptOrderMapper, kitchen card renderer, EOD snapshot mapper; ADR follow-on |
| B7 — Catalog editor | Configure bundle screen + PATCH + push | Composite conversion screen navigation shell only |
| B8 — Push parity | productMetaPushMapper WOOSB keys; inferTillProductType / parity checks | wooProductTypePush.test.ts patterns |
Recommended TDD order: B2 → B3 → B4 → B5 → B6 (red-green per slice; isolated unit tests in shared/woosb/*.test.ts, server/**/bundle*.test.ts, client/**/bundle*.test.ts).
Testing decisions
| Module | Coverage |
|---|---|
| parseWoosbBundle | Array fixture; legacy string id/key/qty; text-only rows; optional min/max inheritance |
| validateBundleSelection | Fixed qty reject; optional bounds; whole min/max; total_limits |
| priceBundleSelection | Dynamic + % discount; dynamic + fixed amount; fixed parent price |
| bundleDefinitionApi | Missing child product; variable expansion |
| order persist | Parent qty 2 scales children; EOD JSON nested lines |
Further notes
- Glossary entries in
CONTEXT.md: WOOSB bundle, Bundle fulfillment picker, bundle basket line, Bundle kitchen display, Bundle children — distinct from WOOCO terms (Q10 1 aligns kitchen/receipt/EOD with composite patterns). - Consider ADR for bundle till order lines (sibling to ADR 0005) before B6.
- Plugin source reference copy:
.cursor/woo-product-bundle-premium-856/woo-product-bundle-premium/(read-only; do not modify). - WPCleaverPro.md documents WOOCO only; do not merge WOOSB shapes into that file — cross-link from both.
Phase C — Meal Prep Bundle Designer Integration
Overview
When a meal prep staging product has bundle components configured via Configure bundle (WOOSB B7, Catalog Manage), Meal Prep Designer Save creates the resulting meal prep menu product as type: bundle with woosb_ids — instead of a type: simple product. The woosb_ids on the menu product references the same component product IDs as the staging product; no new component products are created by the designer. Staging dishes without components configured continue to produce type: simple menu products, preserving backwards compatibility with existing weekly menus and the full designer PRD (thin-client-meal-prep-designer-screen.md).
Configure bundle is not available inside the designer. Staff configure components on a staging product via Catalog Manage (WOOSB B7) before or after placing the dish in the designer. The designer reflects the current configuration state — it does not enforce that components are configured before Save.
C1 — Component status badge in designer (UI slice)
What ships:
Each dish card in MealPrepDesignerView gains a compact bundle component status badge below the product name and SKU row:
- Green badge — “N components” when the staging product has
componentCount > 0. - Amber badge — “⚠ Not configured” when
componentCount === 0. - A tappable → Configure bundle link on each card opens the staging product in Catalog Manage:
- Electron: calls
openCatalogManageForProduct(stagingProductId)(same IPC pattern as Manage this product from POS grid) to focus the catalog management window with that product pre-selected. - Web / Android Capacitor: navigates to
#/catalog?productId={id}.
- Electron: calls
- No component editor exists inside the designer. Badge and link are display-only.
- Cards in all three columns (Monday, Wednesday, pool) show the badge.
- If
componentCountis absent on the response (legacy Edge Server before C2 ships), badge is omitted; → Configure link still renders.
Server changes:
GET /api/meal-prep-designer/session— extend each product summary inpoolProducts,mondayProducts,wednesdayProductswithcomponentCount: number(see C2 for full implementation detail).
Client changes:
MealPrepPoolProductSummarytype (shared) gainscomponentCount?: number.MealPrepDesignerSessionResponseproduct summary type gainscomponentCount?: number.MealPrepDesignerView.vuedish card template updated with badge and → Configure link.- Electron:
openCatalogManageForProduct(id)called from → Configure link handler.
Shared changes:
shared/mealPrepDesigner/sessionTypes.ts— addcomponentCount?: numberto product summary.
Test pointers:
- Unit: badge renders “3 components” (green class) from
componentCount: 3; “⚠ Not configured” (amber class) fromcomponentCount: 0. - Unit: badge is absent when
componentCountisundefined(legacy response). - Unit: → Configure link invokes
openCatalogManageForProductwith the correct staging product id. - Contract: pool cards in all three columns include badge data when session endpoint returns
componentCount.
C2 — Session endpoint enhancement
What ships:
GET /api/meal-prep-designer/session returns componentCount: number per product in poolProducts, mondayProducts, and wednesdayProducts. This is the sole data path for designer bundle-awareness; the designer makes no separate call to GET /api/products/:id/bundle.
componentCount equals the number of non-layout bundled items in woosb_ids — items with a numeric id field, excluding rows with type h1–h6, p, span, none. componentCount: 0 is always returned (never omitted) when meta_woosb_ids is null, empty, or unparseable.
Server changes:
- Session query: for each product included in the response, JOIN or sub-select on
productsformeta_woosb_ids(added by WOOSB B1). - Count helper: use
parseWoosbBundlefromshared/woosb/(B2) to count non-layout items when B2 is available. Lightweight fallback when B2 has not yet shipped: parsemeta_woosb_idsTEXT as JSON/legacy string, count entries whose value has a numericidfield and notypeproperty (ortypenot in layout set). - Return
componentCount: 0(not omitted) for null or emptymeta_woosb_ids.
Shared changes:
shared/mealPrepDesigner/sessionTypes.ts—componentCount?: numberon product summary (already noted in C1; no additional change).
Test pointers:
- Integration: session route with a staging product having three WOOSB product items →
componentCount: 3. - Integration: staging product with one layout heading and two product items →
componentCount: 2(layout row excluded). - Integration: staging product with null
meta_woosb_ids→componentCount: 0. - Edge case: malformed
meta_woosb_idsTEXT →componentCount: 0; no 500 error.
C3 — Save creates bundle product when components exist
What ships:
mealPrepMenuProductFactory.createMealPrepMenuProductFromStaging is extended: when the staging product has componentCount > 0 and a non-empty woosb_ids value, the created menu product is written with type: 'bundle' and meta_woosb_ids copied from the staging row. When componentCount === 0 or woosb_ids is absent, the menu product is type: 'simple' as today (backwards compatible).
Menu product woosb_ids references the same component product IDs as the staging product. The factory does not create, modify, or validate component products — that responsibility belongs to WOOSB B7 (Configure bundle) and the component product rows themselves.
The re-save freeze rule from designer PRD Q10 4 applies to woosb_ids: on re-save of an existing menu product, woosb_ids / type is not refreshed from staging — only categories and meal_prep_menu metadata may change. Staging bundle edits after first create apply to menu products via the future Publish workflow only.
meal_prep_menu table addition:
The meal_prep_menu table gains a nullable integer column bundle_component_count:
NULLfor rows created before this migration (legacy).0for placements where the staging product had no WOOSB components at save time → simple menu product.N > 0for placements where the staging product had N components at save time → bundle menu product.
Server changes:
CreateMealPrepMenuProductInputinterface gainswoosbIds?: string | nullandcomponentCount?: number.createMealPrepMenuProductFromStaging: whencomponentCount > 0andwoosbIdstruthy, passtype: 'bundle'andmeta_woosb_ids: woosbIdstocreateStaffProduct; otherwise omit both (defaults totype: 'simple').- Save service (meal prep designer save service): before the transaction, read
meta_woosb_idsand derivedcomponentCountfrom the staging product row; pass to factory. meal_prep_menuSQLite migration:ALTER TABLE meal_prep_menu ADD COLUMN bundle_component_count INTEGER(nullable; no default required — existing rows getNULL).meal_prep_menurepositoryupsert: includebundle_component_countin insert/update.
Shared changes:
- None required. Save payload (
shared/mealPrepDesigner/savePayload.ts) is unchanged —woosb_idsis resolved server-side from the staging row at save time, not sent from the client.
Test pointers:
- Unit: factory with
componentCount: 3,woosbIds: '1665/k7m2/1'→ product row hastype = 'bundle',meta_woosb_ids = '1665/k7m2/1'. - Unit: factory with
componentCount: 0→ product row hastype = 'simple';meta_woosb_idsnot set. - Unit: factory with
componentCount: undefined(legacy call) → product row hastype = 'simple'(no regression). - Integration:
POST /api/meal-prep-designer/savewith staging product that hasmeta_woosb_ids→ created menu product rowtype = 'bundle';meal_prep_menurowbundle_component_count > 0. - Integration: re-save of same week with bundle staging product → existing menu product
meta_woosb_idsnot overwritten (Q10 4 freeze). - Integration: staging product with no
meta_woosb_ids→ menu producttype = 'simple';meal_prep_menu.bundle_component_count = 0. - Regression: existing plain meal prep save (no WOOSB) continues to produce
type: simplemenu product.
C4 — Staging product type in catalog
What ships:
When a staging meal prep product has bundle components configured via WOOSB Configure bundle (B7), its type on the Edge Server SQLite master is bundle — driven by inferTillProductType returning bundle when meta_woosb_ids is present (WOOSB PRD Q1 1). No new logic is required in Phase C; this slice confirms the interaction between the existing WOOSB type-inference and meal prep staging products.
POS sell grid filter: Staging bundle products are Published draft / Local_Only. The existing sell-grid draft filter already excludes them. No new filter required for bundle staging rows.
Label printing: The type: bundle field on a staging or menu product does not affect template selection. Categories drive template selection via label-category-templates.json. A staging product in Meal Preps or a menu product in Meal Prep - Monday / Meal Prep - Wednesday prints via mealprep-label regardless of type (designer PRD Q11 1).
No new server, client, or shared changes required. This is a confirmatory slice.
Server changes: None.
Client changes: None.
Shared changes: None.
Test pointers:
- Verify:
inferTillProductTypereturnsbundlefor a staging product row withmeta_woosb_idspopulated (covered by WOOSB B8 tests; cite reference only). - Verify: staging product with
type: bundleis absent from POS sell grid (existing draft-filter test extended with a bundle-typed staging row). - Verify:
Print Labelon a staging bundle product inMeal Preps→ resolvesmealprep-label(existing label-category template test extended).
C5 — POS sell path for bundle menu products
What ships:
When staff tap a published Meal Prep – Monday or Meal Prep – Wednesday menu product whose type is bundle, the standard WOOSB sell path applies automatically:
- B4 fast path (one-tap add): when the bundle has no optional rows and no variable products (
has_optional = false,has_variables = false) — default composition added immediately. - B5 Bundle fulfillment picker: when
has_optional = trueorhas_variables = true— picker opens on grid tap, blocking checkout until confirm or cancel.
Kitchen board: bundle_children are indented under the parent line (Q10 1 — mirrors Composite kitchen display). Quantities reflect per-kit qty × parent quantity.
Receipt: bundle children are listed under the parent when the ESC/POS route supports nested lines (B6 rule — same as composite receipt).
No new code beyond general WOOSB B4–B5 sell path. Meal prep menu products with type: bundle participate in the standard WOOSB checkout flow without special-casing. This slice confirms that the sell path is complete for meal prep bundles once B4–B5 ship.
Server changes: None specific to meal prep — WOOSB B4–B6 order-line shape covers this.
Client changes: None specific to meal prep — WOOSB B4–B5 picker and basket composable cover this.
Shared changes: None.
Test pointers:
- Integration: a
Meal Prep - Mondayproduct withtype: bundle, fixed composition, no optional rows → one-tap add producesline_kind: 'bundle'withbundle_childrenmatching component ids. - Integration: kitchen card for the above order → bundle children indented under parent (Q10 1).
- Integration:
Meal Prep - Wednesdayproduct withhas_optional: true→ grid tap opens Bundle fulfillment picker. - Regression:
Meal Prep - Mondayproduct withtype: simple→ unchanged one-tap add (nobundle_children).
Dependency map
| Phase C slice | Depends on |
|---|---|
| C1 (badge) | WOOSB B1 (meta_woosb_ids column on products) |
| C2 (session count) | WOOSB B1; B2 (shared/woosb/parseWoosbBundle) preferred but not hard-blocked |
| C3 (factory) | WOOSB B1; B7 (Configure bundle) for staff to populate woosb_ids on staging; C2 (session count drives factory input) |
| C4 (type inference) | WOOSB B1, B8 (inferTillProductType) — no new work; confirmatory |
| C5 (sell path) | WOOSB B4–B6 (fast-path add, picker, order lines) |
C1 + C2 can ship as read-only enhancements as soon as B1 lands — the badge is informational and does not gate Save.
C3 can ship after B1. C5 requires B4–B5 to be complete. B7 (Configure bundle) must exist for the badge to show meaningful counts, but C3 will gracefully produce type: simple menu products for staging rows without woosb_ids until B7 is used.
User stories
Component configuration
- As a chef, I want to configure bundle components on a staging meal prep product via Configure bundle in Catalog Manage, so that the designer can reflect which proteins, veg, carbs, and sauces make up each dish.
- As a manager, I want each dish card in the Meal Prep Designer to show a bundle component status badge — “N components” (green) or “⚠ Not configured” (amber) — so that I can see at a glance which dishes are fully defined before saving the weekly menu.
- As a manager, I want a → Configure bundle tappable link on each dish card to open the staging product in Catalog Manage without leaving the designer window, so that I can configure missing components without rebuilding my planning session.
Designer save — mixed simple/bundle weeks
- As a manager, when I save a week where some dishes have bundle components configured and others do not, I want bundle dishes to produce
type: bundlemenu products and unconfigured dishes to producetype: simplemenu products, so that each dish sells correctly at the till without requiring a uniform component-first workflow. - As a manager, I want the designer’s Save to be backwards compatible with existing weekly menus that have no WOOSB components, so that weeks saved before Phase C ships are unaffected.
Customer at the till
- As kitchen staff, when I tap a Meal Prep – Monday or Meal Prep – Wednesday bundle product on the POS grid, I want one-tap add for fixed-composition dishes and the Bundle fulfillment picker for dishes with optional or variable rows, so that meal prep bundles sell as correctly as any other Smart Bundle.
Kitchen and receipt
- As kitchen staff, I want the Kitchen board to indent bundle component lines under the parent meal prep dish line, so that prep staff know exactly which components to assemble for each order.
- As kitchen staff, I want the Receipt to list bundle components under the parent meal prep dish (when the ESC/POS route supports nested lines), so that counter tickets are readable for bundle meal preps.
Out of scope (v1)
- Component products created or selected inside the designer — component configuration remains in WOOSB B7 Configure bundle only.
- Bundle pricing overrides per weekly menu — menu products inherit staging WOOSB pricing meta as-is; pricing changes require editing the staging bundle directly in Catalog Manage.
- Nutritional rollup across bundle children — existing
meta__nutritionon the staging product is copied to the menu product on first create per designer PRD Q10 4; per-component nutrition is not aggregated. - Publish button implementation in the designer — already stubbed as a separate PRD; Phase C does not affect the Publish extension point.
- Variation selection inside the designer — variation picks belong to the Bundle fulfillment picker at sell time (B5), not the planning stage.
- Validating that all staging bundle dishes have
componentCount > 0before allowing Save — the badge is advisory only; managers may Save a week containing “⚠ Not configured” dishes.