feat(scan): barcode miss fallback chain — Open Beauty Facts + Open Products Facts

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
This commit is contained in:
pyr0ball 2026-04-16 08:30:49 -07:00
parent fb18a9c78c
commit 0de6182f48
5 changed files with 86 additions and 49 deletions

View file

@ -361,6 +361,7 @@ async def scan_barcode_text(
else: else:
result_product = None result_product = None
product_found = product_info is not None
return BarcodeScanResponse( return BarcodeScanResponse(
success=True, success=True,
barcodes_found=1, barcodes_found=1,
@ -370,7 +371,8 @@ async def scan_barcode_text(
"product": result_product, "product": result_product,
"inventory_item": InventoryItemResponse.model_validate(inventory_item) if inventory_item else None, "inventory_item": InventoryItemResponse.model_validate(inventory_item) if inventory_item else None,
"added_to_inventory": inventory_item is not 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", message="Barcode processed",
) )

View file

@ -136,6 +136,7 @@ class BarcodeScanResult(BaseModel):
product: Optional[ProductResponse] product: Optional[ProductResponse]
inventory_item: Optional[InventoryItemResponse] inventory_item: Optional[InventoryItemResponse]
added_to_inventory: bool added_to_inventory: bool
needs_manual_entry: bool = False
message: str message: str

View file

@ -15,64 +15,68 @@ logger = logging.getLogger(__name__)
class OpenFoodFactsService: 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 Primary: OpenFoodFacts (food products).
ingredients, allergens, and nutrition facts. 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" BASE_URL = "https://world.openfoodfacts.org/api/v2"
USER_AGENT = "Kiwi/0.1.0 (https://circuitforge.tech)" 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]]: 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: Args:
barcode: UPC/EAN barcode (8-13 digits) barcode: UPC/EAN barcode (8-13 digits)
Returns: Returns:
Dictionary with product information, or None if not found Dictionary with product information, or None if not found in any database.
Example response:
{
"name": "Organic Milk",
"brand": "Horizon",
"categories": ["Dairy", "Milk"],
"image_url": "https://...",
"nutrition_data": {...},
"raw_data": {...} # Full API response
}
""" """
try: result = await self._lookup_in_database(barcode, self.BASE_URL)
async with httpx.AsyncClient() as client: if result:
url = f"{self.BASE_URL}/product/{barcode}.json" return result
response = await client.get( for db_url in self._FALLBACK_DATABASES:
url, result = await self._lookup_in_database(barcode, db_url)
headers={"User-Agent": self.USER_AGENT}, if result:
timeout=10.0, logger.info("Barcode %s found in fallback database: %s", barcode, db_url)
) return result
if response.status_code == 404: logger.info("Barcode %s not found in any Open*Facts database", barcode)
logger.info(f"Product not found in OpenFoodFacts: {barcode}") return None
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
def _parse_product_data(self, data: Dict[str, Any], barcode: str) -> Dict[str, Any]: def _parse_product_data(self, data: Dict[str, Any], barcode: str) -> Dict[str, Any]:
""" """

View file

@ -450,7 +450,7 @@ import { storeToRefs } from 'pinia'
import { useInventoryStore } from '../stores/inventory' import { useInventoryStore } from '../stores/inventory'
import { useSettingsStore } from '../stores/settings' import { useSettingsStore } from '../stores/settings'
import { inventoryAPI } from '../services/api' import { inventoryAPI } from '../services/api'
import type { InventoryItem } from '../services/api' import type { InventoryItem, BarcodeScanResponse } from '../services/api'
import { formatQuantity } from '../utils/units' import { formatQuantity } from '../utils/units'
import EditItemModal from './EditItemModal.vue' import EditItemModal from './EditItemModal.vue'
import ConfirmDialog from './ConfirmDialog.vue' import ConfirmDialog from './ConfirmDialog.vue'
@ -710,13 +710,20 @@ async function handleScannerGunInput() {
true 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({ scannerResults.value.push({
type: 'success', type: 'success',
message: `Added: ${item.product_name || 'item'} to ${scannerLocation.value}`, message: `Added: ${item.product?.name || 'item'} to ${scannerLocation.value}`,
}) })
await refreshItems() 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 { } else {
scannerResults.value.push({ scannerResults.value.push({
type: 'error', type: 'error',
@ -731,7 +738,7 @@ async function handleScannerGunInput() {
} finally { } finally {
scannerLoading.value = false scannerLoading.value = false
scannerBarcode.value = '' 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); 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 EXPORT CARD
============================================ */ ============================================ */

View file

@ -101,6 +101,23 @@ export interface InventoryItem {
updated_at: string 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 { export interface InventoryItemUpdate {
quantity?: number quantity?: number
unit?: string unit?: string
@ -225,7 +242,7 @@ export const inventoryAPI = {
location: string = 'pantry', location: string = 'pantry',
quantity: number = 1.0, quantity: number = 1.0,
autoAdd: boolean = true autoAdd: boolean = true
): Promise<any> { ): Promise<BarcodeScanResponse> {
const response = await api.post('/inventory/scan/text', { const response = await api.post('/inventory/scan/text', {
barcode, barcode,
location, location,