feat: generic shopping names — group products by shopping_name
- Add shopping_name column to products table - Add computeShoppingName() PHP auto-assign function: * Curated keyword map: all salumi/cold cuts → 'Affettato' * Bring! catalog back-translation: 'Latte di Montagna' → 'Latte' * Fallback: first significant token capitalized - Migrate all 210 existing products with auto-computed shopping_name - saveProduct() auto-computes shopping_name on every create/update - smartShopping() groups items by shopping_name: most urgent item is representative, others listed as variants (e.g. 'Affettato' shows Mortadella, Speck, Nduja, Salame, Prosciutto, Schinkenspeck as one row) - _productOnBring() also checks shopping_name for Bring! match detection - addToInventory auto-remove: uses shopping_name-based Bring! key - useFromInventory auto-add: sends shopping_name to Bring! (not raw name), specific product name goes into specification field - Frontend renderSmartItem: shows shopping_name as title, specific product name(s) in italic subtitle line below - _syncOnBringFlags: matches on both name and shopping_name
This commit is contained in:
+156
-23
@@ -633,32 +633,39 @@ function saveProduct(PDO $db): void {
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-compute shopping_name unless the caller explicitly provides one.
|
||||
// A caller may pass shopping_name=null or omit it to always trigger auto-compute.
|
||||
$shoppingName = array_key_exists('shopping_name', $input) && $input['shopping_name'] !== null && $input['shopping_name'] !== ''
|
||||
? $input['shopping_name']
|
||||
: computeShoppingName($input['name'], $input['category'] ?? '', $input['brand'] ?? '');
|
||||
|
||||
if (!empty($input['id'])) {
|
||||
// Update existing
|
||||
$stmt = $db->prepare("
|
||||
UPDATE products SET name=?, brand=?, category=?, image_url=?, unit=?,
|
||||
default_quantity=?, notes=?, barcode=?, package_unit=?, updated_at=CURRENT_TIMESTAMP
|
||||
WHERE id=?
|
||||
default_quantity=?, notes=?, barcode=?, package_unit=?, shopping_name=?,
|
||||
updated_at=CURRENT_TIMESTAMP WHERE id=?
|
||||
");
|
||||
$stmt->execute([
|
||||
$input['name'], $input['brand'] ?? '', $input['category'] ?? '',
|
||||
$input['image_url'] ?? '', $input['unit'] ?? 'pz',
|
||||
$input['default_quantity'] ?? 1, $input['notes'] ?? '',
|
||||
$input['barcode'] ?? null, $input['package_unit'] ?? '', $input['id']
|
||||
$input['barcode'] ?? null, $input['package_unit'] ?? '',
|
||||
$shoppingName, $input['id']
|
||||
]);
|
||||
echo json_encode(['success' => true, 'id' => $input['id']]);
|
||||
} else {
|
||||
// Insert new
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO products (barcode, name, brand, category, image_url, unit, default_quantity, notes, package_unit)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO products (barcode, name, brand, category, image_url, unit, default_quantity, notes, package_unit, shopping_name)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
");
|
||||
$barcode = !empty($input['barcode']) ? $input['barcode'] : null;
|
||||
$stmt->execute([
|
||||
$barcode, $input['name'], $input['brand'] ?? '',
|
||||
$input['category'] ?? '', $input['image_url'] ?? '',
|
||||
$input['unit'] ?? 'pz', $input['default_quantity'] ?? 1,
|
||||
$input['notes'] ?? '', $input['package_unit'] ?? ''
|
||||
$input['notes'] ?? '', $input['package_unit'] ?? '', $shoppingName
|
||||
]);
|
||||
echo json_encode(['success' => true, 'id' => $db->lastInsertId()]);
|
||||
}
|
||||
@@ -808,14 +815,18 @@ function addToInventory(PDO $db): void {
|
||||
// Auto-remove from Bring! if product is on the shopping list
|
||||
$removedFromBring = false;
|
||||
try {
|
||||
$stmt = $db->prepare("SELECT name FROM products WHERE id = ?");
|
||||
$stmt = $db->prepare("SELECT name, shopping_name FROM products WHERE id = ?");
|
||||
$stmt->execute([$productId]);
|
||||
$prodName = $stmt->fetchColumn();
|
||||
if ($prodName) {
|
||||
$prod = $stmt->fetch();
|
||||
if ($prod) {
|
||||
$prodName = $prod['name'];
|
||||
// Use shopping_name for Bring! removal — Bring! was added with the generic name
|
||||
$displayName = $prod['shopping_name'] ?: computeShoppingName($prodName);
|
||||
$auth = bringAuth();
|
||||
if ($auth) {
|
||||
$listUUID = $auth['bringListUUID'];
|
||||
$bringKey = italianToBring($prodName);
|
||||
// Primary Bring! key: catalog key of the generic shopping name
|
||||
$bringKey = italianToBring($displayName);
|
||||
$listData = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}");
|
||||
if ($listData && isset($listData['purchase'])) {
|
||||
// Token-based matching — same logic as _productOnBring() in smart_shopping
|
||||
@@ -828,26 +839,33 @@ function addToInventory(PDO $db): void {
|
||||
fn($t) => mb_strlen($t) > 2 && !in_array($t, $stop)
|
||||
));
|
||||
};
|
||||
// Tokens from both the generic name and the specific product name
|
||||
$displayTokens = $tokenize($displayName);
|
||||
$prodTokens = $tokenize($prodName);
|
||||
$keyTokens = $tokenize($bringKey);
|
||||
$displayFirst = $displayTokens[0] ?? '';
|
||||
$prodFirst = $prodTokens[0] ?? '';
|
||||
$keyFirst = $keyTokens[0] ?? '';
|
||||
foreach ($listData['purchase'] as $item) {
|
||||
$rawName = $item['name'] ?? '';
|
||||
// 1. Exact match on translated catalog key or original Italian name
|
||||
if (strcasecmp($rawName, $bringKey) === 0 || strcasecmp($rawName, $prodName) === 0) {
|
||||
// 1. Exact match on catalog key, generic name, or specific product name
|
||||
if (strcasecmp($rawName, $bringKey) === 0
|
||||
|| strcasecmp($rawName, $displayName) === 0
|
||||
|| strcasecmp($rawName, $prodName) === 0) {
|
||||
bringRequest('PUT', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}",
|
||||
http_build_query(['uuid' => $listUUID, 'remove' => $rawName]));
|
||||
$removedFromBring = true;
|
||||
break;
|
||||
}
|
||||
// 2. Token-based fuzzy: first significant word must match
|
||||
if ($prodFirst || $keyFirst) {
|
||||
// 2. Token-based fuzzy: first significant word must match any of our names
|
||||
if ($displayFirst || $prodFirst || $keyFirst) {
|
||||
$rawTokens = $tokenize($rawName);
|
||||
$rawFirst = $rawTokens[0] ?? '';
|
||||
if ($rawFirst && (
|
||||
$rawFirst === $displayFirst ||
|
||||
$rawFirst === $prodFirst ||
|
||||
$rawFirst === $keyFirst ||
|
||||
in_array($displayFirst, $rawTokens) ||
|
||||
in_array($prodFirst, $rawTokens) ||
|
||||
in_array($keyFirst, $rawTokens)
|
||||
)) {
|
||||
@@ -1040,8 +1058,8 @@ function useFromInventory(PDO $db): void {
|
||||
$totalLeft = (float)($stmt->fetchColumn() ?: 0);
|
||||
|
||||
if ($totalLeft <= 0) {
|
||||
// Get product name and brand for Bring!
|
||||
$stmt = $db->prepare("SELECT name, brand FROM products WHERE id = ?");
|
||||
// Get product name, brand and shopping_name for Bring!
|
||||
$stmt = $db->prepare("SELECT name, brand, shopping_name FROM products WHERE id = ?");
|
||||
$stmt->execute([$productId]);
|
||||
$product = $stmt->fetch();
|
||||
|
||||
@@ -1050,7 +1068,9 @@ function useFromInventory(PDO $db): void {
|
||||
$auth = bringAuth();
|
||||
if ($auth) {
|
||||
$listUUID = $auth['bringListUUID'];
|
||||
$bringName = italianToBring($product['name']);
|
||||
// Use the generic shopping name for Bring! (e.g. "Latte", "Affettato")
|
||||
$genericName = $product['shopping_name'] ?: computeShoppingName($product['name'], '', $product['brand']);
|
||||
$bringName = italianToBring($genericName);
|
||||
|
||||
// Check if already on the Bring! list
|
||||
$alreadyOnList = false;
|
||||
@@ -1068,8 +1088,10 @@ function useFromInventory(PDO $db): void {
|
||||
// Already on the list, skip adding
|
||||
$addedToBring = false;
|
||||
} else {
|
||||
// Build specification from product name (variant info, not brand)
|
||||
$spec = $product['name'] ? $product['name'] : '';
|
||||
// Specification: specific product name (and brand) so the user knows which variant
|
||||
$spec = $genericName !== $product['name']
|
||||
? $product['name'] . ($product['brand'] ? ' · ' . $product['brand'] : '')
|
||||
: ($product['brand'] ?: $product['name']);
|
||||
$body = http_build_query([
|
||||
'uuid' => $listUUID,
|
||||
'purchase' => $bringName,
|
||||
@@ -3556,6 +3578,72 @@ function italianToBring(string $italianName): string {
|
||||
return $italianName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-compute a generic shopping/Bring! name for a product.
|
||||
*
|
||||
* Priority:
|
||||
* 1. Curated keyword map — groups cured meats, etc. that the catalog doesn't unify
|
||||
* 2. Bring! catalog back-translation — "Latte di Montagna" → "Milch" → "Latte"
|
||||
* 3. First significant token capitalized
|
||||
*
|
||||
* The returned string is always a valid Bring! catalog name where possible,
|
||||
* so that italianToBring(computeShoppingName($n)) resolves to a catalog key.
|
||||
*/
|
||||
function computeShoppingName(string $name, string $category = '', string $brand = ''): string {
|
||||
$lower = mb_strtolower(trim($name));
|
||||
$stop = ['di','del','della','dei','degli','delle','da','in','con','per','su',
|
||||
'a','e','il','lo','la','i','gli','le','un','uno','una','al','alle','agli','allo',
|
||||
'parzialmente','scremato','uht','bio','light','freschi','fresca','fresco'];
|
||||
$tokens = array_values(array_filter(
|
||||
preg_split('/\s+/', preg_replace('/[^\p{L}\s]/u', ' ', $lower)),
|
||||
fn($w) => mb_strlen($w) > 2 && !in_array($w, $stop)
|
||||
));
|
||||
|
||||
// 1. Curated keyword → canonical group name.
|
||||
// These handle products that map to distinct Bring! entries but belong together
|
||||
// (all cured/cold-cut meats → "Affettato", which is in the Bring! catalog).
|
||||
$keywordMap = [
|
||||
// Cold cuts / affettati — group them all under "Affettato" (catalog: Aufschnitt)
|
||||
'mortadella' => 'Affettato',
|
||||
'nduja' => 'Affettato',
|
||||
'salame' => 'Affettato',
|
||||
'salami' => 'Affettato',
|
||||
'coppa' => 'Affettato',
|
||||
'capicola' => 'Affettato',
|
||||
'speck' => 'Affettato',
|
||||
'schinkenspeck' => 'Affettato',
|
||||
'schinken' => 'Affettato',
|
||||
'prosciutto' => 'Affettato',
|
||||
// Items that have their own Bring! entry — keep specific
|
||||
'bresaola' => 'Bresaola',
|
||||
'pancetta' => 'Pancetta',
|
||||
'salsiccia' => 'Salsiccia',
|
||||
'wurstel' => 'Wurstel',
|
||||
];
|
||||
|
||||
foreach ($tokens as $token) {
|
||||
if (isset($keywordMap[$token])) {
|
||||
return $keywordMap[$token];
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Bring! back-translation: run italianToBring() — if it found a catalog key,
|
||||
// back-translate to Italian to get the canonical catalog name (e.g. "Latte").
|
||||
$bringKey = italianToBring($name);
|
||||
if ($bringKey !== $name) {
|
||||
$italian = bringToItalian($bringKey);
|
||||
if ($italian && mb_strtolower($italian) !== $lower) {
|
||||
return $italian;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback: capitalize the first meaningful token.
|
||||
if (!empty($tokens)) {
|
||||
return mb_strtoupper(mb_substr($tokens[0], 0, 1)) . mb_substr($tokens[0], 1);
|
||||
}
|
||||
return ucfirst($name);
|
||||
}
|
||||
|
||||
function bringGetList(): void {
|
||||
$auth = bringAuth();
|
||||
if (!$auth) {
|
||||
@@ -3815,7 +3903,13 @@ function smartShoppingCached(PDO $db): void {
|
||||
* product "Muesli Frutta Secca" (which has "frutta" as a secondary token, not the first).
|
||||
* Mirrors JS _matchBringToSmart / _syncOnBringFlags logic.
|
||||
*/
|
||||
function _productOnBring(string $productName, array $bringItems): bool {
|
||||
function _productOnBring(string $productName, array $bringItems, string $shoppingName = ''): bool {
|
||||
// Check by shopping_name first (covers catalog-matched generic names like "Latte", "Affettato")
|
||||
if ($shoppingName !== '') {
|
||||
if (isset($bringItems[mb_strtolower($shoppingName)])) return true;
|
||||
$snKey = italianToBring($shoppingName);
|
||||
if (isset($bringItems[mb_strtolower($snKey)])) return true;
|
||||
}
|
||||
// Exact key match (both German raw and Italian translated keys are stored)
|
||||
if (isset($bringItems[mb_strtolower($productName)])) return true;
|
||||
static $stop = ['di','del','della','dei','degli','dalle','delle','da','in','con','per','su',
|
||||
@@ -3853,7 +3947,8 @@ function smartShopping(PDO $db): void {
|
||||
|
||||
// 1. Get all products with their inventory and transaction history
|
||||
$products = $db->query("
|
||||
SELECT p.id, p.name, p.brand, p.category, p.unit, p.default_quantity, p.package_unit
|
||||
SELECT p.id, p.name, p.brand, p.category, p.unit, p.default_quantity, p.package_unit,
|
||||
p.shopping_name
|
||||
FROM products p
|
||||
ORDER BY p.name
|
||||
")->fetchAll();
|
||||
@@ -4149,8 +4244,11 @@ function smartShopping(PDO $db): void {
|
||||
if ($useCount >= 8) $score += 15;
|
||||
elseif ($useCount >= 5) $score += 10;
|
||||
|
||||
// Is already on Bring? (fuzzy token match — mirrors JS _findSimilarItem logic)
|
||||
$onBring = _productOnBring($p['name'], $bringItems);
|
||||
// Compute generic shopping name for this product
|
||||
$shoppingName = $p['shopping_name'] ?: computeShoppingName($p['name'], $p['category'], $p['brand']);
|
||||
|
||||
// Is already on Bring? check both product name and generic shopping name
|
||||
$onBring = _productOnBring($p['name'], $bringItems, $shoppingName);
|
||||
|
||||
// "Just restocked" suppression: if bought in the last 3 days AND stock is above 50%
|
||||
// of reference qty, skip non-expiry urgency flags. The product doesn't need rebuying yet.
|
||||
@@ -4161,6 +4259,7 @@ function smartShopping(PDO $db): void {
|
||||
$items[] = [
|
||||
'product_id' => $pid,
|
||||
'name' => $p['name'],
|
||||
'shopping_name' => $shoppingName,
|
||||
'brand' => $p['brand'] ?: '',
|
||||
'category' => $p['category'] ?: '',
|
||||
'unit' => $unit,
|
||||
@@ -4182,9 +4281,43 @@ function smartShopping(PDO $db): void {
|
||||
'score' => $score,
|
||||
'on_bring' => $onBring,
|
||||
'locations' => $inv ? $inv['locations'] : '',
|
||||
'variants' => [],
|
||||
];
|
||||
}
|
||||
|
||||
// Group items by shopping_name: keep the most urgent representative per group,
|
||||
// collect the rest as variants so the UI can show "Affettato (Mortadella, Speck, Nduja)".
|
||||
$grouped = [];
|
||||
foreach ($items as $item) {
|
||||
$sn = $item['shopping_name'];
|
||||
if (!isset($grouped[$sn])) {
|
||||
$grouped[$sn] = $item;
|
||||
} else {
|
||||
// Merge: keep the higher-score item as the representative
|
||||
if ($item['score'] > $grouped[$sn]['score']) {
|
||||
$demoted = [
|
||||
'product_id' => $grouped[$sn]['product_id'],
|
||||
'name' => $grouped[$sn]['name'],
|
||||
'brand' => $grouped[$sn]['brand'],
|
||||
'urgency' => $grouped[$sn]['urgency'],
|
||||
];
|
||||
$variants = array_merge([$demoted], $grouped[$sn]['variants']);
|
||||
$grouped[$sn] = $item;
|
||||
$grouped[$sn]['variants'] = $variants;
|
||||
} else {
|
||||
$grouped[$sn]['variants'][] = [
|
||||
'product_id' => $item['product_id'],
|
||||
'name' => $item['name'],
|
||||
'brand' => $item['brand'],
|
||||
'urgency' => $item['urgency'],
|
||||
];
|
||||
}
|
||||
// on_bring is true if ANY variant in the group is already on Bring!
|
||||
if ($item['on_bring']) $grouped[$sn]['on_bring'] = true;
|
||||
}
|
||||
}
|
||||
$items = array_values($grouped);
|
||||
|
||||
// Sort by score descending (most urgent first)
|
||||
usort($items, fn($a, $b) => $b['score'] - $a['score']);
|
||||
|
||||
|
||||
@@ -1963,6 +1963,14 @@ body {
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.smart-item-specific {
|
||||
font-size: 0.73rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 1px;
|
||||
line-height: 1.3;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.smart-brand {
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
|
||||
+36
-7
@@ -7167,12 +7167,13 @@ function _syncOnBringFlags() {
|
||||
for (const si of smartShoppingItems) {
|
||||
const siLower = si.name.toLowerCase();
|
||||
const siFirst = _nameTokens(si.name)[0];
|
||||
const siShoppingLower = (si.shopping_name || '').toLowerCase();
|
||||
const siShoppingFirst = si.shopping_name ? _nameTokens(si.shopping_name)[0] : null;
|
||||
si.on_bring = !!(
|
||||
shoppingItems.find(bi => bi.name.toLowerCase() === siLower) ||
|
||||
(siFirst && shoppingItems.find(bi => {
|
||||
const biFirst = _nameTokens(bi.name)[0];
|
||||
return biFirst === siFirst;
|
||||
}))
|
||||
(siShoppingLower && shoppingItems.find(bi => bi.name.toLowerCase() === siShoppingLower)) ||
|
||||
(siFirst && shoppingItems.find(bi => _nameTokens(bi.name)[0] === siFirst)) ||
|
||||
(siShoppingFirst && shoppingItems.find(bi => _nameTokens(bi.name)[0] === siShoppingFirst))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7292,6 +7293,27 @@ function renderSmartItem(item) {
|
||||
const catIcon = CATEGORY_ICONS[mapToLocalCategory(item.category, item.name)] || '📦';
|
||||
const globalIdx = smartShoppingItems.indexOf(item);
|
||||
|
||||
// Generic vs specific name logic
|
||||
const shoppingName = item.shopping_name || item.name;
|
||||
const isGeneric = shoppingName !== item.name;
|
||||
const variants = item.variants || [];
|
||||
|
||||
// Build title line: generic name (and brand only if not grouped)
|
||||
let nameLine = `<div class="smart-item-name">${escapeHtml(shoppingName)}`;
|
||||
if (!isGeneric && item.brand) nameLine += ` <small class="smart-brand">${escapeHtml(item.brand)}</small>`;
|
||||
nameLine += `</div>`;
|
||||
|
||||
// Build subtitle: specific product + brand when grouped, plus any variants
|
||||
let specificLine = '';
|
||||
if (isGeneric || variants.length > 0) {
|
||||
let specifics = [];
|
||||
specifics.push(item.name + (item.brand ? ` (${item.brand})` : ''));
|
||||
for (const v of variants) {
|
||||
specifics.push(v.name + (v.brand ? ` (${v.brand})` : ''));
|
||||
}
|
||||
specificLine = `<div class="smart-item-specific">${escapeHtml(specifics.join(' · '))}</div>`;
|
||||
}
|
||||
|
||||
// Stock bar
|
||||
const pct = Math.min(100, Math.max(0, item.pct_left));
|
||||
const barColor = pct <= 15 ? '#ef4444' : pct <= 30 ? '#f97316' : pct <= 50 ? '#eab308' : '#22c55e';
|
||||
@@ -7333,7 +7355,8 @@ function renderSmartItem(item) {
|
||||
${!item.on_bring ? `<input type="checkbox" class="smart-check" data-idx="${globalIdx}">` : ''}
|
||||
<span class="smart-item-icon">${catIcon}</span>
|
||||
<div class="smart-item-info">
|
||||
<div class="smart-item-name">${escapeHtml(item.name)}${item.brand ? ` <small class="smart-brand">${escapeHtml(item.brand)}</small>` : ''}</div>
|
||||
${nameLine}
|
||||
${specificLine}
|
||||
<div class="smart-item-reasons">${item.reasons.map(r => `<span>${escapeHtml(r)}</span>`).join(' · ')}</div>
|
||||
<div class="smart-item-badges">
|
||||
<span class="smart-urgency-badge" style="color:${u.color}">${u.icon} ${u.label}</span>
|
||||
@@ -7362,9 +7385,15 @@ async function addSmartToBring() {
|
||||
const idx = parseInt(cb.dataset.idx);
|
||||
const item = smartShoppingItems[idx];
|
||||
if (item) {
|
||||
const shoppingName = item.shopping_name || item.name;
|
||||
const isGeneric = shoppingName !== item.name;
|
||||
// When generic, use specific product name + brand as the specification
|
||||
const spec = isGeneric
|
||||
? (item.name + (item.brand ? ` · ${item.brand}` : ''))
|
||||
: _urgencyToSpec(item.urgency, item.brand);
|
||||
itemsToAdd.push({
|
||||
name: item.name,
|
||||
specification: _urgencyToSpec(item.urgency, item.brand),
|
||||
name: shoppingName,
|
||||
specification: spec,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user