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
All checks were successful
Deploy production / deploy (push) Successful in 2m48s
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user