From 0de6182f48f01a1a02ba750d282dbd485b340f66 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Thu, 16 Apr 2026 08:30:49 -0700 Subject: [PATCH] =?UTF-8?q?feat(scan):=20barcode=20miss=20fallback=20chain?= =?UTF-8?q?=20=E2=80=94=20Open=20Beauty=20Facts=20+=20Open=20Products=20Fa?= =?UTF-8?q?cts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a barcode is not found in Open Food Facts, the service now tries Open Beauty Facts and Open Products Facts before giving up. All three share the same API format; only the host URL differs. When all databases miss, the scan endpoint sets needs_manual_entry=true in the result. The frontend detects this, shows a calm informational message, and switches to manual entry mode automatically. Also fixes a latent bug where not-found scans showed 'Added: item to pantry' due to the success condition checking barcodes_found (always 1) instead of added_to_inventory. Closes #65 --- app/api/endpoints/inventory.py | 4 +- app/models/schemas/inventory.py | 1 + app/services/openfoodfacts.py | 88 ++++++++++++----------- frontend/src/components/InventoryList.vue | 23 ++++-- frontend/src/services/api.ts | 19 ++++- 5 files changed, 86 insertions(+), 49 deletions(-) diff --git a/app/api/endpoints/inventory.py b/app/api/endpoints/inventory.py index a6a57bf..e65705b 100644 --- a/app/api/endpoints/inventory.py +++ b/app/api/endpoints/inventory.py @@ -361,6 +361,7 @@ async def scan_barcode_text( else: result_product = None + product_found = product_info is not None return BarcodeScanResponse( success=True, barcodes_found=1, @@ -370,7 +371,8 @@ async def scan_barcode_text( "product": result_product, "inventory_item": InventoryItemResponse.model_validate(inventory_item) if inventory_item else None, "added_to_inventory": inventory_item is not None, - "message": "Added to inventory" if inventory_item else "Product not found in database", + "needs_manual_entry": not product_found, + "message": "Added to inventory" if inventory_item else "Not found in any product database — add manually", }], message="Barcode processed", ) diff --git a/app/models/schemas/inventory.py b/app/models/schemas/inventory.py index dcea59b..600dc7d 100644 --- a/app/models/schemas/inventory.py +++ b/app/models/schemas/inventory.py @@ -136,6 +136,7 @@ class BarcodeScanResult(BaseModel): product: Optional[ProductResponse] inventory_item: Optional[InventoryItemResponse] added_to_inventory: bool + needs_manual_entry: bool = False message: str diff --git a/app/services/openfoodfacts.py b/app/services/openfoodfacts.py index d181fb3..e2fd374 100644 --- a/app/services/openfoodfacts.py +++ b/app/services/openfoodfacts.py @@ -15,64 +15,68 @@ logger = logging.getLogger(__name__) class OpenFoodFactsService: """ - Service for interacting with the OpenFoodFacts API. + Service for interacting with the Open*Facts family of databases. - OpenFoodFacts is a free, open database of food products with - ingredients, allergens, and nutrition facts. + Primary: OpenFoodFacts (food products). + Fallback chain: Open Beauty Facts (personal care) → Open Products Facts (household). + All three databases share the same API path and JSON format. """ BASE_URL = "https://world.openfoodfacts.org/api/v2" USER_AGENT = "Kiwi/0.1.0 (https://circuitforge.tech)" + # Fallback databases tried in order when OFFs returns no match. + # Same API format as OFFs — only the host differs. + _FALLBACK_DATABASES = [ + "https://world.openbeautyfacts.org/api/v2", + "https://world.openproductsfacts.org/api/v2", + ] + + async def _lookup_in_database(self, barcode: str, base_url: str) -> Optional[Dict[str, Any]]: + """Try one Open*Facts database. Returns parsed product dict or None.""" + try: + async with httpx.AsyncClient() as client: + response = await client.get( + f"{base_url}/product/{barcode}.json", + headers={"User-Agent": self.USER_AGENT}, + timeout=10.0, + ) + if response.status_code == 404: + return None + response.raise_for_status() + data = response.json() + if data.get("status") != 1: + return None + return self._parse_product_data(data, barcode) + except httpx.HTTPError as e: + logger.debug("HTTP error for %s at %s: %s", barcode, base_url, e) + return None + except Exception as e: + logger.debug("Lookup failed for %s at %s: %s", barcode, base_url, e) + return None + async def lookup_product(self, barcode: str) -> Optional[Dict[str, Any]]: """ - Look up a product by barcode in the OpenFoodFacts database. + Look up a product by barcode, trying OFFs then fallback databases. Args: barcode: UPC/EAN barcode (8-13 digits) Returns: - Dictionary with product information, or None if not found - - Example response: - { - "name": "Organic Milk", - "brand": "Horizon", - "categories": ["Dairy", "Milk"], - "image_url": "https://...", - "nutrition_data": {...}, - "raw_data": {...} # Full API response - } + Dictionary with product information, or None if not found in any database. """ - try: - async with httpx.AsyncClient() as client: - url = f"{self.BASE_URL}/product/{barcode}.json" + result = await self._lookup_in_database(barcode, self.BASE_URL) + if result: + return result - response = await client.get( - url, - headers={"User-Agent": self.USER_AGENT}, - timeout=10.0, - ) + for db_url in self._FALLBACK_DATABASES: + result = await self._lookup_in_database(barcode, db_url) + if result: + logger.info("Barcode %s found in fallback database: %s", barcode, db_url) + return result - if response.status_code == 404: - logger.info(f"Product not found in OpenFoodFacts: {barcode}") - return None - - response.raise_for_status() - data = response.json() - - if data.get("status") != 1: - logger.info(f"Product not found in OpenFoodFacts: {barcode}") - return None - - return self._parse_product_data(data, barcode) - - except httpx.HTTPError as e: - logger.error(f"HTTP error looking up barcode {barcode}: {e}") - return None - except Exception as e: - logger.error(f"Error looking up barcode {barcode}: {e}") - return None + logger.info("Barcode %s not found in any Open*Facts database", barcode) + return None def _parse_product_data(self, data: Dict[str, Any], barcode: str) -> Dict[str, Any]: """ diff --git a/frontend/src/components/InventoryList.vue b/frontend/src/components/InventoryList.vue index 4d7ca93..1e73c18 100644 --- a/frontend/src/components/InventoryList.vue +++ b/frontend/src/components/InventoryList.vue @@ -450,7 +450,7 @@ import { storeToRefs } from 'pinia' import { useInventoryStore } from '../stores/inventory' import { useSettingsStore } from '../stores/settings' import { inventoryAPI } from '../services/api' -import type { InventoryItem } from '../services/api' +import type { InventoryItem, BarcodeScanResponse } from '../services/api' import { formatQuantity } from '../utils/units' import EditItemModal from './EditItemModal.vue' import ConfirmDialog from './ConfirmDialog.vue' @@ -710,13 +710,20 @@ async function handleScannerGunInput() { true ) - if (result.success && result.barcodes_found > 0) { - const item = result.results[0] + const item = result.results[0] + if (item?.added_to_inventory) { scannerResults.value.push({ type: 'success', - message: `Added: ${item.product_name || 'item'} to ${scannerLocation.value}`, + message: `Added: ${item.product?.name || 'item'} to ${scannerLocation.value}`, }) await refreshItems() + } else if (item?.needs_manual_entry) { + // Barcode not found in any database — guide user to manual entry + scannerResults.value.push({ + type: 'warning', + message: `Barcode ${barcode} not found. Fill in the details below.`, + }) + scanMode.value = 'manual' } else { scannerResults.value.push({ type: 'error', @@ -731,7 +738,7 @@ async function handleScannerGunInput() { } finally { scannerLoading.value = false scannerBarcode.value = '' - scannerGunInput.value?.focus() + if (scanMode.value === 'gun') scannerGunInput.value?.focus() } } @@ -1378,6 +1385,12 @@ function getItemClass(item: InventoryItem): string { border: 1px solid var(--color-info-border); } +.result-warning { + background: var(--color-warning-bg, #fffbeb); + color: var(--color-warning-dark, #92400e); + border: 1px solid var(--color-warning-border, #fcd34d); +} + /* ============================================ EXPORT CARD ============================================ */ diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index f0f9937..2b3ce43 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -101,6 +101,23 @@ export interface InventoryItem { updated_at: string } +export interface BarcodeScanResult { + barcode: string + barcode_type: string + product: Product | null + inventory_item: InventoryItem | null + added_to_inventory: boolean + needs_manual_entry: boolean + message: string +} + +export interface BarcodeScanResponse { + success: boolean + barcodes_found: number + results: BarcodeScanResult[] + message: string +} + export interface InventoryItemUpdate { quantity?: number unit?: string @@ -225,7 +242,7 @@ export const inventoryAPI = { location: string = 'pantry', quantity: number = 1.0, autoAdd: boolean = true - ): Promise { + ): Promise { const response = await api.post('/inventory/scan/text', { barcode, location,