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:
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",
)

View file

@ -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

View file

@ -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]:
"""

View file

@ -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
============================================ */

View file

@ -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<any> {
): Promise<BarcodeScanResponse> {
const response = await api.post('/inventory/scan/text', {
barcode,
location,