497 lines
20 KiB
TypeScript
497 lines
20 KiB
TypeScript
// DB side of the grocery importer (used by scripts/groceries-import.ts). Uses a plain postgres.js
|
|
// client so it runs outside SvelteKit. All writes are idempotent; re-running is safe.
|
|
import type { Sql } from 'postgres';
|
|
import type { PoliteFetcher } from './http';
|
|
import { clusterItems, type MatchItem } from './match';
|
|
import {
|
|
brandClass,
|
|
brandLabel,
|
|
displayName,
|
|
fold,
|
|
matchKey,
|
|
nameTokens,
|
|
normaliseUnit
|
|
} from './normalize';
|
|
import { AhClient, itemIsFood, SOURCES, type RawItem, type Source } from './sources';
|
|
|
|
type Log = (m: string) => void;
|
|
|
|
export async function storeId(sql: Sql, slug: string): Promise<number> {
|
|
const [s] = await sql<{ id: number }[]>`select id from groceries.stores where slug = ${slug}`;
|
|
if (!s) throw new Error(`unknown store ${slug}`);
|
|
return s.id;
|
|
}
|
|
|
|
function keyFor(name: string, brand: string | null, unit: string | null) {
|
|
const cls = brandClass(brand);
|
|
const tokens = nameTokens(name, brand);
|
|
return { cls, tokens, key: matchKey(cls, unit, tokens) };
|
|
}
|
|
|
|
/** Fetch a store's catalogue and upsert it; returns counts. */
|
|
export async function importStore(sql: Sql, source: Source, f: PoliteFetcher, log: Log) {
|
|
const sid = await storeId(sql, source.slug);
|
|
const [run] = await sql<{ id: number; started_at: Date }[]>`
|
|
insert into groceries.import_runs (store_id) values (${sid}) returning id, started_at`;
|
|
try {
|
|
const raw = await source.fetchAll(f, log);
|
|
if (BRAND_FROM_NAME.has(source.slug)) {
|
|
const n = await inferBrands(sql, sid, raw);
|
|
log(` ${source.slug}: brand inferred from the name for ${n} products`);
|
|
}
|
|
const [prev] = await sql<{ n: number }[]>`
|
|
select count(*)::int as n from groceries.items where store_id = ${sid} and available`;
|
|
const res = await upsertItems(sql, sid, raw, run.started_at);
|
|
// Only mark unseen items unavailable when the crawl looks complete.
|
|
let delisted = 0;
|
|
if (raw.length >= prev.n * 0.8) {
|
|
const r = await sql`
|
|
update groceries.items set available = false
|
|
where store_id = ${sid} and available and last_seen_at < ${run.started_at}`;
|
|
delisted = r.count;
|
|
}
|
|
await sql`
|
|
update groceries.import_runs set finished_at = now(), status = 'ok', requests = ${f.requests},
|
|
fetched = ${raw.length}, inserted = ${res.inserted}, price_changes = ${res.priceChanges}
|
|
where id = ${run.id}`;
|
|
return { fetched: raw.length, ...res, delisted, requests: f.requests, cacheHits: f.cacheHits };
|
|
} catch (e) {
|
|
await sql`
|
|
update groceries.import_runs set finished_at = now(), status = 'error', requests = ${f.requests},
|
|
error = ${String(e instanceof Error ? e.message : e).slice(0, 2000)}
|
|
where id = ${run.id}`;
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
async function upsertItems(sql: Sql, sid: number, raw: RawItem[], runStart: Date) {
|
|
let inserted = 0;
|
|
for (let i = 0; i < raw.length; i += 500) {
|
|
const rows = raw.slice(i, i + 500).map((r) => {
|
|
const n = normaliseUnit({
|
|
sizeText: r.sizeText,
|
|
title: r.name,
|
|
priceCents: r.priceCents,
|
|
promoPriceCents: r.promoPriceCents,
|
|
storeUnitPrice: r.storeUnitPrice,
|
|
storeUnitBasisCents: r.storeUnitBasisCents
|
|
});
|
|
return {
|
|
store_id: sid,
|
|
store_product_id: r.storeProductId,
|
|
name: r.name,
|
|
brand: r.brand,
|
|
size_text: r.sizeText,
|
|
unit: n.unit,
|
|
unit_amount: n.unitAmount,
|
|
unit_source: n.unitSource,
|
|
price_cents: r.priceCents,
|
|
promo_price_cents: r.promoPriceCents,
|
|
promo_label: r.promoLabel,
|
|
unit_price_cents: n.unitPriceCents,
|
|
promo_unit_price_cents: n.promoUnitPriceCents,
|
|
category: r.category,
|
|
category_path: r.categoryPath,
|
|
gtin: r.gtin,
|
|
image_url: r.imageUrl,
|
|
url: r.url,
|
|
available: r.available,
|
|
is_food: itemIsFood(r),
|
|
match_key: keyFor(r.name, r.brand, n.unit).key
|
|
};
|
|
});
|
|
const res = await sql<{ inserted: boolean }[]>`
|
|
insert into groceries.items ${sql(rows)}
|
|
on conflict (store_id, store_product_id) do update set
|
|
name = excluded.name, brand = excluded.brand, size_text = excluded.size_text,
|
|
unit = excluded.unit, unit_amount = excluded.unit_amount, unit_source = excluded.unit_source,
|
|
price_cents = excluded.price_cents, promo_price_cents = excluded.promo_price_cents,
|
|
promo_label = excluded.promo_label, unit_price_cents = excluded.unit_price_cents,
|
|
promo_unit_price_cents = excluded.promo_unit_price_cents, category = excluded.category,
|
|
category_path = excluded.category_path, gtin = coalesce(excluded.gtin, items.gtin),
|
|
image_url = excluded.image_url, url = excluded.url, available = excluded.available,
|
|
is_food = excluded.is_food, match_key = excluded.match_key, last_seen_at = now()
|
|
returning (xmax = 0) as inserted`;
|
|
inserted += res.filter((r) => r.inserted).length;
|
|
}
|
|
// Price history: a row when the price or promo differs from the item's last recorded row.
|
|
const hist = await sql<{ changed: boolean }[]>`
|
|
insert into groceries.item_prices (item_id, observed_on, price_cents, promo_price_cents, promo_label)
|
|
select i.id, (now() at time zone 'Europe/Amsterdam')::date, i.price_cents, i.promo_price_cents, i.promo_label
|
|
from groceries.items i
|
|
left join lateral (
|
|
select p.price_cents, p.promo_price_cents, p.promo_label from groceries.item_prices p
|
|
where p.item_id = i.id order by p.observed_on desc limit 1
|
|
) last on true
|
|
where i.store_id = ${sid} and i.last_seen_at >= ${runStart}
|
|
and (last is null
|
|
or last.price_cents is distinct from i.price_cents
|
|
or last.promo_price_cents is distinct from i.promo_price_cents
|
|
or last.promo_label is distinct from i.promo_label)
|
|
on conflict (item_id, observed_on) do update set
|
|
price_cents = excluded.price_cents, promo_price_cents = excluded.promo_price_cents,
|
|
promo_label = excluded.promo_label
|
|
returning exists (
|
|
select 1 from groceries.item_prices o
|
|
where o.item_id = item_prices.item_id and o.observed_on < item_prices.observed_on
|
|
) as changed`;
|
|
return { inserted, priceChanges: hist.filter((h) => h.changed).length };
|
|
}
|
|
|
|
/** Stores whose listings carry no (or little) brand: take it from the start of the name. */
|
|
const BRAND_FROM_NAME = new Set(['xenos', 'amazing-oriental']);
|
|
|
|
/**
|
|
* Fill in missing brands when the name starts with a known brand ("Lotus Biscoff …"). Known
|
|
* brands: those on >= 5 products at other stores, plus the store's own brands in this batch.
|
|
* House brands are never inferred. Returns how many items got a brand.
|
|
*/
|
|
export async function inferBrands(sql: Sql, sid: number, raw: RawItem[]): Promise<number> {
|
|
const rows = await sql<{ brand: string }[]>`
|
|
select brand from groceries.items
|
|
where brand is not null and store_id <> ${sid} and length(brand) >= 3
|
|
group by brand having count(*) >= 5`;
|
|
const known = new Map<string, string>();
|
|
for (const b of [...rows.map((r) => r.brand), ...raw.map((r) => r.brand)]) {
|
|
if (!b || b.trim().length < 3 || isHouseBrand(b)) continue;
|
|
const k = fold(b).replace(/\s+/g, ' ').trim();
|
|
if (!known.has(k)) known.set(k, b.trim());
|
|
}
|
|
const byFirstWord = new Map<string, string[]>();
|
|
for (const k of known.keys()) {
|
|
const w = k.split(' ')[0];
|
|
byFirstWord.set(w, [...(byFirstWord.get(w) ?? []), k]);
|
|
}
|
|
for (const list of byFirstWord.values()) list.sort((a, b) => b.length - a.length);
|
|
let n = 0;
|
|
for (const r of raw) {
|
|
if (r.brand) continue;
|
|
const name = fold(r.name).replace(/\s+/g, ' ').trim();
|
|
const first = name.split(' ')[0];
|
|
for (const k of byFirstWord.get(first) ?? []) {
|
|
// The brand must be followed by more words, so "Mango" alone never becomes a brand.
|
|
if (name.startsWith(k + ' ') && name.length > k.length + 2) {
|
|
r.brand = known.get(k)!;
|
|
n++;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return n;
|
|
}
|
|
const isHouseBrand = (b: string) => brandClass(b).startsWith('house');
|
|
|
|
/** Fill in PLUS EANs from the product detail action, `limit` products per run, once per SKU. */
|
|
/**
|
|
* Fill in EANs from a source's product-detail lookup (Source.gtinLookup), `limit` products per
|
|
* run, each SKU at most once per 180 days. One lookup per fetcher runs in parallel.
|
|
*/
|
|
export async function enrichGtins(
|
|
sql: Sql,
|
|
slug: string,
|
|
fetchers: PoliteFetcher[],
|
|
limit: number,
|
|
log: Log
|
|
) {
|
|
const make = SOURCES[slug]?.gtinLookup;
|
|
if (limit <= 0 || !fetchers.length || !make) return { checked: 0, found: 0 };
|
|
const sid = await storeId(sql, slug);
|
|
const todo = await sql<{ id: number; store_product_id: string; url: string | null }[]>`
|
|
select id, store_product_id, url from groceries.items
|
|
where store_id = ${sid} and gtin is null and available
|
|
and (gtin_checked_at is null or gtin_checked_at < now() - interval '180 days')
|
|
order by gtin_checked_at asc nulls first, id
|
|
limit ${limit}`;
|
|
const lookups = fetchers.map((f) => make(f));
|
|
let found = 0;
|
|
let errors = 0;
|
|
let next = 0;
|
|
let done = 0;
|
|
await Promise.all(
|
|
lookups.map(async (lookup) => {
|
|
while (next < todo.length) {
|
|
const t = todo[next++];
|
|
let gtin: string | null;
|
|
try {
|
|
gtin = await lookup(t.store_product_id, t.url);
|
|
} catch (e) {
|
|
errors++;
|
|
log(` ${slug} gtin ${t.store_product_id}: ${e instanceof Error ? e.message : e}`);
|
|
if (errors > 25 && found === 0) throw e; // something is wrong; stop hammering
|
|
continue;
|
|
}
|
|
if (gtin) found++;
|
|
await sql`update groceries.items set gtin = ${gtin}, gtin_checked_at = now() where id = ${t.id}`;
|
|
if (++done % 500 === 0)
|
|
log(` ${slug} gtin: ${done}/${todo.length} checked, ${found} found`);
|
|
}
|
|
})
|
|
);
|
|
return { checked: done, found };
|
|
}
|
|
|
|
/** Fill in AH EANs from the product detail endpoint, `limit` products per run (A-brands first). */
|
|
export async function enrichAhGtins(sql: Sql, f: PoliteFetcher, limit: number, log: Log) {
|
|
if (limit <= 0) return { checked: 0, found: 0 };
|
|
const sid = await storeId(sql, 'ah');
|
|
const todo = await sql<{ id: number; store_product_id: string }[]>`
|
|
select id, store_product_id from groceries.items
|
|
where store_id = ${sid} and gtin is null and available
|
|
and (gtin_checked_at is null or gtin_checked_at < now() - interval '90 days')
|
|
order by (lower(coalesce(brand, '')) like 'ah%') asc, gtin_checked_at asc nulls first, id
|
|
limit ${limit}`;
|
|
const ah = new AhClient(f);
|
|
let found = 0;
|
|
for (const [n, t] of todo.entries()) {
|
|
let gtin: string | null;
|
|
try {
|
|
gtin = await ah.gtin(t.store_product_id);
|
|
} catch (e) {
|
|
log(` ah gtin ${t.store_product_id}: ${e instanceof Error ? e.message : e}`);
|
|
if (n > 20 && found === 0) throw e; // something is wrong; stop hammering
|
|
continue;
|
|
}
|
|
if (gtin) found++;
|
|
await sql`update groceries.items set gtin = ${gtin}, gtin_checked_at = now() where id = ${t.id}`;
|
|
if ((n + 1) % 250 === 0) log(` ah gtin: ${n + 1}/${todo.length} checked, ${found} found`);
|
|
}
|
|
return { checked: todo.length, found };
|
|
}
|
|
|
|
// ---- regroup --------------------------------------------------------------------------------
|
|
|
|
type ItemRow = {
|
|
id: number;
|
|
store_id: number;
|
|
store_slug: string;
|
|
name: string;
|
|
brand: string | null;
|
|
unit: string | null;
|
|
gtin: string | null;
|
|
category: string | null;
|
|
group_id: number | null;
|
|
};
|
|
type GroupRow = { id: number; key: string | null; merged_into_id: number | null; manual: boolean };
|
|
|
|
/**
|
|
* Recompute groups for all unlocked items: cluster, map each cluster to a stable group (by key,
|
|
* following manual merges, else the group most of its items already had), create/update/delete
|
|
* groups, then refresh the denormalised stats. Locked items (moved by hand) are left alone.
|
|
*/
|
|
export async function regroup(sql: Sql, log: Log) {
|
|
return sql.begin(async (tx) => {
|
|
const items = await tx<ItemRow[]>`
|
|
select i.id, i.store_id, s.slug as store_slug, i.name, i.brand, i.unit, i.gtin, i.category, i.group_id
|
|
from groceries.items i join groceries.stores s on s.id = i.store_id
|
|
where not i.match_locked
|
|
order by i.id`;
|
|
const groups = await tx<
|
|
GroupRow[]
|
|
>`select id, key, merged_into_id, manual from groceries.groups`;
|
|
const byId = new Map(groups.map((g) => [g.id, g]));
|
|
const byKey = new Map(groups.filter((g) => g.key).map((g) => [g.key!, g]));
|
|
const resolve = (g: GroupRow | undefined) => {
|
|
for (let hops = 0; g?.merged_into_id && hops < 20; hops++) g = byId.get(g.merged_into_id);
|
|
return g;
|
|
};
|
|
|
|
const meta = new Map<number, ItemRow & { cls: string; tokens: string[]; key: string }>();
|
|
const matchItems: MatchItem[] = items.map((it) => {
|
|
const k = keyFor(it.name, it.brand, it.unit);
|
|
meta.set(it.id, { ...it, ...k });
|
|
return {
|
|
id: it.id,
|
|
storeId: it.store_id,
|
|
gtin: it.gtin,
|
|
key: k.key,
|
|
cls: k.cls,
|
|
unit: it.unit,
|
|
tokens: k.tokens
|
|
};
|
|
});
|
|
const clusters = clusterItems(matchItems);
|
|
|
|
const claimed = new Set<number>();
|
|
const keysInUse = new Set(clusters.map((c) => c.key));
|
|
type Plan = { cluster: (typeof clusters)[number]; groupId: number | null; info: GroupInfo };
|
|
const plans: Plan[] = [];
|
|
// Pass 1: exact key (or merged redirect).
|
|
for (const c of clusters) {
|
|
const g = resolve(byKey.get(c.key));
|
|
const info = groupInfo(
|
|
c.itemIds.map((id) => meta.get(id)!),
|
|
c.key
|
|
);
|
|
if (g) claimed.add(g.id);
|
|
plans.push({ cluster: c, groupId: g?.id ?? null, info });
|
|
}
|
|
// Pass 2: the group most items already belong to (keeps ids stable when names change).
|
|
for (const p of plans) {
|
|
if (p.groupId) continue;
|
|
const counts = new Map<number, number>();
|
|
for (const id of p.cluster.itemIds) {
|
|
const gid = meta.get(id)!.group_id;
|
|
if (gid) counts.set(gid, (counts.get(gid) ?? 0) + 1);
|
|
}
|
|
const best = [...counts].sort((a, b) => b[1] - a[1] || a[0] - b[0])[0];
|
|
const g = best ? byId.get(best[0]) : undefined;
|
|
if (
|
|
g &&
|
|
!g.manual &&
|
|
!g.merged_into_id &&
|
|
!claimed.has(g.id) &&
|
|
!(g.key && keysInUse.has(g.key))
|
|
) {
|
|
claimed.add(g.id);
|
|
p.groupId = g.id;
|
|
}
|
|
}
|
|
|
|
// Create missing groups.
|
|
const toCreate = plans.filter((p) => !p.groupId);
|
|
for (let i = 0; i < toCreate.length; i += 1000) {
|
|
const chunk = toCreate.slice(i, i + 1000);
|
|
const ids = await tx<{ id: number }[]>`
|
|
insert into groceries.groups ${tx(
|
|
chunk.map((p) => ({
|
|
key: p.cluster.key,
|
|
name: p.info.name,
|
|
brand: p.info.brand,
|
|
category: p.info.category,
|
|
unit: p.info.unit,
|
|
family_key: p.info.familyKey
|
|
}))
|
|
)} returning id`;
|
|
chunk.forEach((p, j) => (p.groupId = ids[j].id));
|
|
}
|
|
// Update automatic groups' descriptive fields (manual groups keep Stan's name).
|
|
const toUpdate = plans.filter((p) => !toCreate.includes(p) && !byId.get(p.groupId!)?.manual);
|
|
const seen = new Set<number>();
|
|
for (let i = 0; i < toUpdate.length; i += 1000) {
|
|
const rows = toUpdate
|
|
.slice(i, i + 1000)
|
|
.filter((p) => !seen.has(p.groupId!) && seen.add(p.groupId!))
|
|
.map((p) => {
|
|
const g = byId.get(p.groupId!)!;
|
|
// Only take the cluster key when no other group has it.
|
|
const key = g.key === p.cluster.key || !byKey.has(p.cluster.key) ? p.cluster.key : g.key;
|
|
return [
|
|
p.groupId!,
|
|
key,
|
|
p.info.name,
|
|
p.info.brand,
|
|
p.info.category,
|
|
p.info.unit,
|
|
p.info.familyKey
|
|
];
|
|
});
|
|
if (!rows.length) continue;
|
|
await tx`
|
|
update groceries.groups g set key = v.key, name = v.name, brand = v.brand,
|
|
category = v.category, unit = v.unit, family_key = v.family_key
|
|
from (values ${tx(rows as never)}) as v(id, key, name, brand, category, unit, family_key)
|
|
where g.id = v.id::int and (g.key is distinct from v.key or g.name is distinct from v.name
|
|
or g.brand is distinct from v.brand or g.category is distinct from v.category
|
|
or g.unit is distinct from v.unit or g.family_key is distinct from v.family_key)`;
|
|
}
|
|
|
|
// Assign items.
|
|
const assignments: [number, number, string, string, number | null][] = [];
|
|
for (const p of plans)
|
|
for (const id of p.cluster.itemIds) {
|
|
const m = p.cluster.method.get(id)!;
|
|
assignments.push([id, p.groupId!, meta.get(id)!.key, m.method, m.confidence]);
|
|
}
|
|
let moved = 0;
|
|
for (let i = 0; i < assignments.length; i += 2000) {
|
|
const r = await tx`
|
|
update groceries.items i set group_id = v.group_id::int, match_key = v.match_key,
|
|
match_method = v.method, match_confidence = v.confidence::real
|
|
from (values ${tx(assignments.slice(i, i + 2000) as never)}) as v(id, group_id, match_key, method, confidence)
|
|
where i.id = v.id::int and (i.group_id is distinct from v.group_id::int
|
|
or i.match_key is distinct from v.match_key or i.match_method is distinct from v.method
|
|
or i.match_confidence is distinct from v.confidence::real)`;
|
|
moved += r.count;
|
|
}
|
|
|
|
// Drop empty automatic groups (favourites, manual groups and merge tombstones stay).
|
|
const del = await tx`
|
|
delete from groceries.groups g
|
|
where not g.favorite and not g.manual and g.merged_into_id is null and g.notes is null
|
|
and not exists (select 1 from groceries.items i where i.group_id = g.id)
|
|
and not exists (select 1 from groceries.groups m where m.merged_into_id = g.id)`;
|
|
await refreshGroupStats(tx as unknown as Sql);
|
|
log(
|
|
`regroup: ${items.length} items in ${clusters.length} clusters; ${toCreate.length} groups created, ${moved} items (re)assigned, ${del.count} empty groups removed`
|
|
);
|
|
return { clusters: clusters.length, created: toCreate.length, moved, removed: del.count };
|
|
});
|
|
}
|
|
|
|
type GroupInfo = {
|
|
name: string;
|
|
brand: string | null;
|
|
category: string | null;
|
|
unit: string | null;
|
|
familyKey: string;
|
|
};
|
|
|
|
function groupInfo(
|
|
members: (ItemRow & { cls: string; tokens: string[]; key: string })[],
|
|
key: string
|
|
): GroupInfo {
|
|
// Representative: an item with the canonical key, preferring AH (cleanest titles), then Jumbo.
|
|
const order = (s: string) => (s === 'ah' ? 0 : s === 'jumbo' ? 1 : 2);
|
|
const rep =
|
|
[...members]
|
|
.filter((m) => m.key === key)
|
|
.sort(
|
|
(a, b) =>
|
|
order(a.store_slug) - order(b.store_slug) || a.name.length - b.name.length || a.id - b.id
|
|
)[0] ?? members[0];
|
|
const cats = new Map<string, number>();
|
|
for (const m of members) if (m.category) cats.set(m.category, (cats.get(m.category) ?? 0) + 1);
|
|
const category = [...cats].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1))[0]?.[0] ?? null;
|
|
return {
|
|
name: displayName(rep.name, rep.brand).slice(0, 200),
|
|
brand: brandLabel(rep.cls, rep.brand),
|
|
category,
|
|
unit: rep.unit,
|
|
familyKey: `${rep.unit ?? '?'}|${[...rep.tokens].sort().join(' ')}`
|
|
};
|
|
}
|
|
|
|
// Accent folding for search_text (no unaccent extension needed); the query side uses fold().
|
|
const ACCENTS_FROM = 'áàäâãéèëêíìïîóòöôõúùüûçñ';
|
|
const ACCENTS_TO = 'aaaaaeeeeiiiiooooouuuucn';
|
|
|
|
/** Recompute denormalised group stats (all groups, or just `ids`). */
|
|
export async function refreshGroupStats(sql: Sql, ids?: number[]) {
|
|
const only = ids ? sql`where g2.id = any(${ids}::int[])` : sql``;
|
|
await sql`
|
|
update groceries.groups g set
|
|
item_count = s.item_count, store_count = s.store_count, store_ids = s.store_ids,
|
|
min_unit_price_cents = s.min_unit, min_price_cents = s.min_price, promo_count = s.promo_count,
|
|
image_url = s.image_url, search_text = s.search_text, is_food = s.is_food
|
|
from (
|
|
select g2.id,
|
|
count(i.id)::int as item_count,
|
|
count(distinct i.store_id)::int as store_count,
|
|
coalesce(array_agg(distinct i.store_id) filter (where i.id is not null), '{}')::int[] as store_ids,
|
|
min(coalesce(i.promo_unit_price_cents, i.unit_price_cents)) as min_unit,
|
|
min(coalesce(i.promo_price_cents, i.price_cents)) as min_price,
|
|
(count(*) filter (where i.promo_price_cents is not null or i.promo_label is not null))::int as promo_count,
|
|
(array_agg(i.image_url order by i.store_id, i.id) filter (where i.image_url is not null))[1] as image_url,
|
|
coalesce(bool_or(i.is_food), true) as is_food,
|
|
translate(lower(concat_ws(' ', g2.name, g2.brand, g2.category,
|
|
string_agg(distinct concat_ws(' ', i.brand, i.name), ' '))), ${ACCENTS_FROM}, ${ACCENTS_TO}) as search_text
|
|
from groceries.groups g2
|
|
left join groceries.items i on i.group_id = g2.id and i.available
|
|
${only}
|
|
group by g2.id
|
|
) s
|
|
where g.id = s.id and (g.item_count, g.store_count, g.store_ids, g.min_unit_price_cents, g.min_price_cents,
|
|
g.promo_count, g.image_url, g.search_text, g.is_food) is distinct from
|
|
(s.item_count, s.store_count, s.store_ids, s.min_unit, s.min_price, s.promo_count, s.image_url, s.search_text, s.is_food)`;
|
|
}
|