catalogue
catalogue
viewing as Public demo viewerPublished collections, newest first.
-
Report: catalogue completeness scorecard, August 2026 15 Aug 2026 09:43Report: catalogue completeness scorecard, August 2026.md text/markdown · v1 · raw
Scope
Data completeness across all 4,062 active SKUs, pulled from the catalogue health dashboard on 2026-08-12.
Completeness
Field Complete Notes Image 98.5% (61 missing) See Gotcha: products with no image fall back to placeholder All 14 allergens set 94.7% (214 with an 'unknown') Chasing spec sheets GTIN 89% Deli/weighed lines legitimately null weight_grams sane 99.7% (12 suspect) Unit-slip audit in progress vat_rate set 100% Enforced at activation (Gotcha: VAT rate is per-product, not global) category leaf 100% Enforced Trend
Imageless SKUs down from 94 in July to 61 - Tom's photography push is working. Allergen 'unknown' flat because new deli lines keep arriving faster than spec sheets.
Risks
- The 214 allergen-unknown lines are the real concern - they're a safety surface, not cosmetic. Prioritised in Meeting: catalogue data-quality review, 2026-08-04.
- 12 suspect weights are almost all decimal/unit slips at ingest; Ben auditing against supplier pack sizes.
Actions
Femi: clear 38 stale drafts by 2026-09-01. Tom: top 30 imageless. Ben: close out weight audit and the MOQ unit backfill (Gotcha: supplier MOQ unit is inconsistent in older rows).
-
Spec: catalogue search ranking 15 Aug 2026 09:43Spec: catalogue search ranking.md text/markdown · v1 · raw
Purpose
How Storefront product search ranks results. Postgres full-text plus a few business boosts.
Index
catalogue.productshas a generatedtsvectorover name, brand, category name, and supplier name, refreshed on write. GTIN and SKU get exact-match lookups that short-circuit ranking (scan a barcode, get the product).Ranking signals
- Exact SKU/GTIN match - always top.
- Text relevance (
ts_rank). - Boosts: in-stock lines above out-of-stock; active above seasonal-out; own-shot image above placeholder (a small nudge, since imageless lines look worse - see Gotcha: products with no image fall back to placeholder).
- Tie-break: trailing-30-day sales volume from the Data Platform, refreshed nightly.
Exclusions
status in ('draft','discontinued')never appear.- Allergen-filtered searches apply the Reference: allergen data model rules strictly -
unknownis excluded from 'free from' filters (see Gotcha: allergen 'unknown' must never render as free-from).
Known gaps
- No synonym handling yet - 'aubergine' won't find 'eggplant'. Backlog.
- Misspellings aren't fuzzy-matched. Trigram search is scoped but not built.
-
ADR-012: catalogue and suppliers are separate services 15 Aug 2026 09:43ADR-012: catalogue and suppliers are separate services.md text/markdown · v1 · raw
Status
Accepted, 2026-03-02.
Context
SKU/product data and supplier/procurement data were originally one schema. As we grew past ~3,000 SKUs and ~100 suppliers, coupling started to hurt: price-list ingest churn was locking product reads, and the two domains change for different reasons and at different rates.
Decision
Split into two services with separate schemas:
catalogue.*(products, prices as seen by customers, images, allergens, GTINs) andsuppliers.*(suppliers, contracts, price lists, intake, scorecards). They share Postgres but not tables; cross-references are by ID only (catalogue.products.supplier_id->suppliers.suppliers.id).Consequences
- Supplier price-list ingest no longer contends with Storefront product reads.
- The sell-price recompute is the one hot path that spans both - it reads supplier price lists and writes catalogue prices. It runs as an async job, not inline (see How-to: recalculate sell price from cost).
- There is no FK across schemas; referential integrity on
supplier_idis enforced in application code, not the database. This is a known sharp edge - a deleted supplier can orphan products, so we soft-delete suppliers only. - Reporting joins the two in the Data Platform, not in the app.
Alternatives considered
Keep one schema (rejected - coupling). Fully separate databases (rejected - the reporting joins and shared transactions in the recompute weren't worth the operational cost yet).
-
Spec: GTIN / barcode handling in the catalogue 15 Aug 2026 09:42Spec: GTIN / barcode handling in the catalogue.md text/markdown · v1 · raw
Purpose
How we store and validate barcodes so the WMS scan-to-pick and customer barcode search both work.
Storage
catalogue.products.gtinholds the primary barcode as a 14-char string, left-padded with zeros (GTIN-14 canonical form). A GTIN-13 is stored as0+ the 13 digits.- A product can have multiple barcodes (inner + outer case, supplier re-pack). Extras live in
catalogue.product_barcodeswith abarcode_type(each|inner|case).
Validation
- Check-digit validated on entry (mod-10). A failing check digit is rejected, not stored.
- Duplicates across different SKUs are blocked - a GTIN identifies one product. Exception: a genuine supplier re-pack of the same product keeps the same GTIN and that's fine because it's the same SKU.
Gotchas
- Weighed goods from suppliers sometimes carry variable-weight EAN-13s starting
02or2- the last digits encode price/weight, not identity. We do NOT store these as the product GTIN. See Gotcha: variable-weight barcodes are not product identifiers. - Some deli lines have no barcode at all.
gtinis nullable; the WMS falls back to SKU pick.
Review
Femi owns this; changes to GTIN normalisation need Cara's sign-off because the WMS scanner config depends on the padding rule.
Review blocks
heading: Purposeparagraph: How we store and validate barcodes so the WMS scan-to-pick and customer barcode search both work.heading: Storagelist_item: `catalogue.products.gtin` holds the primary barcode as a 14-char string, left-padded with zeros (GTIN-14 canonical form). A GTIN-13 is stored as `0` + the 13 digits.list_item: A product can have multiple barcodes (inner + outer case, supplier re-pack). Extras live in `catalogue.product_barcodes` with a `barcode_type` (`each` \| `inner` \| `case`).heading: Validationlist_item: Check-digit validated on entry (mod-10). A failing check digit is rejected, not stored.list_item: Duplicates across different SKUs are blocked - a GTIN identifies one product. Exception: a genuine supplier re-pack of the same product keeps the same GTIN and that's fine because it's the same SKU.heading: Gotchaslist_item: Weighed goods from suppliers sometimes carry variable-weight EAN-13s starting `02` or `2` - the last digits encode price/weight, not identity. We do NOT store these as the product GTIN. See [[Gotcha: variable-weight barcodes are not product identifiers]].list_item: Some deli lines have no barcode at all. `gtin` is nullable; the WMS falls back to SKU pick.heading: Reviewparagraph: Femi owns this; changes to GTIN normalisation need Cara's sign-off because the WMS scanner config depends on the padding rule. -
Runbook: discontinue a product line 15 Aug 2026 09:42Runbook: discontinue a product line.md text/markdown · v1 · raw
When
A supplier drops a product, a line stops selling, or we deliberately range it out.
Steps
- Check live stock in the WMS. If there's sellable stock, decide: sell-through (keep active until zero) or immediate pull.
- Set
catalogue.products.status = 'discontinued'anddiscontinued_at = now(). This removes it from search and blocks new basket adds. The row stays forever - see Gotcha: never reuse a discontinued SKU number. - Check for substitutes. If there's a like-for-like replacement, set
replaced_by_skuso the Storefront can suggest it. See How-to: set up a product substitution. - Notify customers with the SKU in a saved order template - Dev's support flow emails them the substitute.
- Tell Cara so the WMS pick-face can be freed once stock hits zero.
- Leave the supplier link intact. Discontinuing a product doesn't off-board the supplier.
Gotchas
- Don't discontinue a product that's a component of an active bundle - the bundle breaks. Check
catalogue.bundle_itemsfirst. - Seasonal lines are NOT discontinued, they're set
status = 'seasonal_out'. See Decision: seasonal ranges use a status, not deletion.