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:
|
||||
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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
============================================ */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue