groceries: nightly AH/Jumbo catalogue import, unit-price normalisation, product groups and compare UI
All checks were successful
Deploy production / deploy (push) Successful in 2m48s

This commit is contained in:
2026-10-24 14:01:25 +00:00
parent 8dde5aa302
commit 427f4ed095
20 changed files with 34891 additions and 699 deletions

View File

@@ -1,12 +1,27 @@
import { sql } from 'drizzle-orm';
import { boolean, check, index, integer, numeric, text, uniqueIndex } from 'drizzle-orm/pg-core';
import {
type AnyPgColumn,
boolean,
check,
date,
index,
integer,
numeric,
real,
text,
uniqueIndex
} from 'drizzle-orm/pg-core';
import { groceriesSchema } from './schemas';
import { id, timestamps, timestamptz } from './helpers';
// Private grocery tracker (owner only, see requireOwner): the products Stan buys, which
// ones are favourites, and what they cost at each supermarket over time.
// Prices are append-only observations in cents, so price history and "cheapest now" both
// come from the same table. The latest observation per (product, store) is the current price.
// Private grocery price comparison (owner only, see requireOwner).
//
// `items` are store products as the supermarket sells them (one row per store SKU), filled
// nightly by scripts/groceries-import.ts. `groups` link equivalent items across stores and pack
// sizes ("Halfvolle melk" = AH + Jumbo + Plus house brand, any size), so unit prices compare.
// Grouping is a heuristic (EAN, then normalised name + brand class, then fuzzy name); Stan can
// fix it in the UI, which locks the item (`match_locked`) so the nightly regroup leaves it alone.
// `item_prices` keeps history: a row only when an item's price or promo price changes.
export const GROCERY_UNITS = ['piece', 'kg', 'l'] as const;
export type GroceryUnit = (typeof GROCERY_UNITS)[number];
@@ -32,66 +47,137 @@ export const groceryStores = groceriesSchema.table(
);
/**
* A product as Stan thinks of it ("Halfvolle melk 1 L"), not one store's SKU: the same
* product can be priced at several stores. `unit_amount` + `unit` (e.g. 1.5 + 'l') make
* unit prices comparable across pack sizes.
* A comparable product ("Halfvolle melk", house brand) across stores and sizes. Stats columns
* (item_count … search_text) are denormalised by refreshGroupStats() so the list stays fast.
*/
export const groceryProducts = groceriesSchema.table(
'products',
export const groceryGroups = groceriesSchema.table(
'groups',
{
id: id(),
/** Matcher key (brand class | unit | name tokens); null for groups made by hand. */
key: text('key'),
name: text('name').notNull(),
brand: text('brand'),
brand: text('brand'), // display brand; 'Huismerk' for AH/Jumbo/Plus own brands
category: text('category'),
size: text('size'), // as printed, e.g. "6 x 1 L"
unitAmount: numeric('unit_amount', { precision: 10, scale: 3, mode: 'number' }),
unit: text('unit').$type<GroceryUnit>(),
/** Name tokens without brand: groups sharing it are "similar" (e.g. house vs Campina melk). */
familyKey: text('family_key'),
favorite: boolean('favorite').notNull().default(false),
manual: boolean('manual').notNull().default(false),
mergedIntoId: integer('merged_into_id').references((): AnyPgColumn => groceryGroups.id, {
onDelete: 'set null'
}),
notes: text('notes'),
archivedAt: timestamptz('archived_at'),
itemCount: integer('item_count').notNull().default(0),
storeCount: integer('store_count').notNull().default(0),
storeIds: integer('store_ids')
.array()
.notNull()
.default(sql`'{}'::int[]`),
minUnitPriceCents: integer('min_unit_price_cents'),
minPriceCents: integer('min_price_cents'),
promoCount: integer('promo_count').notNull().default(0),
imageUrl: text('image_url'),
searchText: text('search_text').notNull().default(''),
...timestamps
},
(t) => [
uniqueIndex('products_name_brand_key').on(
sql`lower(${t.name})`,
sql`lower(coalesce(${t.brand}, ''))`
),
index('products_category_idx').on(t.category),
check('products_name_check', sql`length(btrim(${t.name})) > 0`),
check('products_unit_check', sql`${t.unit} IS NULL OR ${t.unit} IN ('piece','kg','l')`),
check('products_unit_amount_check', sql`${t.unitAmount} IS NULL OR ${t.unitAmount} > 0`)
uniqueIndex('groups_key_key').on(t.key),
index('groups_family_idx').on(t.familyKey),
index('groups_category_idx').on(t.category),
index('groups_store_ids_idx').using('gin', t.storeIds),
index('groups_search_trgm_idx').using('gin', sql`${t.searchText} gin_trgm_ops`),
index('groups_list_idx').on(t.storeCount, t.name),
check('groups_name_check', sql`length(btrim(${t.name})) > 0`),
check('groups_unit_check', sql`${t.unit} IS NULL OR ${t.unit} IN ('piece','kg','l')`),
check('groups_not_self_merged', sql`${t.mergedIntoId} IS NULL OR ${t.mergedIntoId} <> ${t.id}`)
]
);
/** One observed price of a product at a store (manual entry or a later import). */
export const groceryPrices = groceriesSchema.table(
'prices',
/** One store product (SKU) with its current price. Prices are in cents. */
export const groceryItems = groceriesSchema.table(
'items',
{
id: id(),
productId: integer('product_id')
.notNull()
.references(() => groceryProducts.id, { onDelete: 'cascade' }),
storeId: integer('store_id')
.notNull()
.references(() => groceryStores.id, { onDelete: 'cascade' }),
priceCents: integer('price_cents').notNull(),
unitPriceCents: integer('unit_price_cents'), // per product.unit, if known
promo: boolean('promo').notNull().default(false), // bonus / aanbieding price
observedAt: timestamptz('observed_at').notNull().defaultNow(),
source: text('source').notNull().default('manual'), // 'manual', 'ah-api', 'jumbo-api', …
storeProductId: text('store_product_id'), // e.g. AH webshopId, Jumbo SKU
storeProductId: text('store_product_id').notNull(), // AH webshopId, Jumbo SKU
name: text('name').notNull(),
brand: text('brand'),
sizeText: text('size_text'), // as the store prints it, e.g. "6 x 330 ml"
unit: text('unit').$type<GroceryUnit>(),
unitAmount: numeric('unit_amount', { precision: 12, scale: 4, mode: 'number' }),
/** 'size' = parsed from the pack size, 'store' = derived from the store's own unit price. */
unitSource: text('unit_source'),
priceCents: integer('price_cents'),
promoPriceCents: integer('promo_price_cents'),
promoLabel: text('promo_label'),
unitPriceCents: integer('unit_price_cents'),
promoUnitPriceCents: integer('promo_unit_price_cents'),
category: text('category'), // canonical category (shared across stores)
categoryPath: text('category_path'), // the store's own path
gtin: text('gtin'),
gtinCheckedAt: timestamptz('gtin_checked_at'),
imageUrl: text('image_url'),
url: text('url'),
notes: text('notes'),
available: boolean('available').notNull().default(true),
firstSeenAt: timestamptz('first_seen_at').notNull().defaultNow(),
lastSeenAt: timestamptz('last_seen_at').notNull().defaultNow(),
groupId: integer('group_id').references(() => groceryGroups.id, { onDelete: 'set null' }),
matchKey: text('match_key'),
matchMethod: text('match_method'), // 'ean' | 'key' | 'fuzzy' | 'single' | 'manual'
matchConfidence: real('match_confidence'),
matchLocked: boolean('match_locked').notNull().default(false),
...timestamps
},
(t) => [
index('prices_product_store_idx').on(t.productId, t.storeId, t.observedAt),
index('prices_store_idx').on(t.storeId),
check('prices_price_check', sql`${t.priceCents} >= 0`),
check('prices_unit_price_check', sql`${t.unitPriceCents} IS NULL OR ${t.unitPriceCents} >= 0`)
uniqueIndex('items_store_product_key').on(t.storeId, t.storeProductId),
index('items_group_idx').on(t.groupId),
index('items_gtin_idx').on(t.gtin),
index('items_name_trgm_idx').using('gin', sql`${t.name} gin_trgm_ops`),
check('items_unit_check', sql`${t.unit} IS NULL OR ${t.unit} IN ('piece','kg','l')`),
check('items_price_check', sql`${t.priceCents} IS NULL OR ${t.priceCents} >= 0`)
]
);
/** Price history: one row per item per day on which its price or promo changed. */
export const groceryItemPrices = groceriesSchema.table(
'item_prices',
{
id: id(),
itemId: integer('item_id')
.notNull()
.references(() => groceryItems.id, { onDelete: 'cascade' }),
observedOn: date('observed_on').notNull(),
priceCents: integer('price_cents'),
promoPriceCents: integer('promo_price_cents'),
promoLabel: text('promo_label'),
createdAt: timestamptz('created_at').notNull().defaultNow()
},
(t) => [uniqueIndex('item_prices_item_day_key').on(t.itemId, t.observedOn)]
);
/** One importer run per store (counts and errors), for the UI footer and debugging. */
export const groceryImportRuns = groceriesSchema.table(
'import_runs',
{
id: id(),
storeId: integer('store_id')
.notNull()
.references(() => groceryStores.id, { onDelete: 'cascade' }),
startedAt: timestamptz('started_at').notNull().defaultNow(),
finishedAt: timestamptz('finished_at'),
status: text('status').notNull().default('running'), // running | ok | error
requests: integer('requests').notNull().default(0),
fetched: integer('fetched').notNull().default(0),
inserted: integer('inserted').notNull().default(0),
priceChanges: integer('price_changes').notNull().default(0),
error: text('error')
},
(t) => [index('import_runs_store_idx').on(t.storeId, t.startedAt)]
);
export type GroceryStore = typeof groceryStores.$inferSelect;
export type GroceryProduct = typeof groceryProducts.$inferSelect;
export type GroceryPrice = typeof groceryPrices.$inferSelect;
export type GroceryGroup = typeof groceryGroups.$inferSelect;
export type GroceryItem = typeof groceryItems.$inferSelect;