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:
parent
fb18a9c78c
commit
0de6182f48
5 changed files with 86 additions and 49 deletions
|
|
@ -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",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,63 +15,67 @@ 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
|
|
||||||
|
|
||||||
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
|
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]:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue