kiwi/app/api/endpoints/inventory.py
pyr0ball 17e62c451f feat: visual label capture for unenriched barcodes (kiwi#79)
When a barcode scan finds no product in FDC/OFF, paid-tier users now see a
"Capture label" offer instead of a dead-end "add manually" prompt.

Backend:
- Migration 036: captured_products local cache table (keyed by barcode,
  UPSERT on conflict so re-capture refreshes rather than errors)
- store.get_captured_product / save_captured_product (with JSON decode for
  ingredient_names and allergens)
- app/services/label_capture.py: wraps cf-core VisionRouter (caption API);
  graceful fallback to zero-confidence mock when stub/error; JSON fence
  stripping; confidence clamped to [0,1]; KIWI_LABEL_CAPTURE_MOCK=1 for tests
- New schemas: LabelCaptureResponse, LabelConfirmRequest, LabelConfirmResponse
- POST /inventory/scan/label-capture — image to extraction (paid+ gate, 403)
- POST /inventory/scan/label-confirm — save confirmed product + optional
  inventory add
- Both scan endpoints now: check captured_products cache before FDC/OFF;
  set needs_visual_capture=True for gap products on paid tier; BarcodeScanResult
  gains needs_visual_capture field
- visual_label_capture feature gate added to tiers.py (paid)

Tests: 42 new tests (service, store/migration, API endpoints) — 367 total passing

Frontend:
- InventoryList.vue: capturePhase state machine (offer => uploading => reviewing)
- Offer card appears after scan gap (calm UX: no urgency, Discard always visible)
- Review form: pre-populated from extraction; amber label highlights for
  unread fields (confidence < 0.7); comma-separated ingredients/allergens
- api.ts: LabelCaptureResult + LabelConfirmRequest types; captureLabelPhoto()
  and confirmLabelCapture() API methods
2026-04-24 17:57:25 -07:00

760 lines
31 KiB
Python

"""Inventory API endpoints — products, items, barcode scanning, tags, stats."""
from __future__ import annotations
import asyncio
import logging
import uuid
from pathlib import Path
from typing import Any, Dict, List, Optional
import aiofiles
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from pydantic import BaseModel
from app.cloud_session import CloudUser, _auth_label, get_session
log = logging.getLogger(__name__)
from app.db.session import get_store
from app.services.expiration_predictor import ExpirationPredictor
_predictor = ExpirationPredictor()
from app.db.store import Store
from app.models.schemas.inventory import (
BarcodeScanResponse,
BulkAddByNameRequest,
BulkAddByNameResponse,
BulkAddItemResult,
DiscardRequest,
InventoryItemCreate,
InventoryItemResponse,
InventoryItemUpdate,
InventoryStats,
PartialConsumeRequest,
ProductCreate,
ProductResponse,
ProductUpdate,
TagCreate,
TagResponse,
)
from app.models.schemas.label_capture import LabelConfirmRequest
router = APIRouter()
# ── Helpers ───────────────────────────────────────────────────────────────────
def _user_constraints(store) -> list[str]:
"""Load active dietary constraints from user settings (comma-separated string)."""
raw = store.get_setting("dietary_constraints") or ""
return [c.strip() for c in raw.split(",") if c.strip()]
def _enrich_item(item: dict, user_constraints: list[str] | None = None) -> dict:
"""Attach computed fields: opened_expiry_date, secondary_state/uses/warning/discard_signs."""
from datetime import date, timedelta
opened = item.get("opened_date")
if opened:
days = _predictor.days_after_opening(item.get("category"))
if days is not None:
try:
opened_expiry = date.fromisoformat(opened) + timedelta(days=days)
item = {**item, "opened_expiry_date": str(opened_expiry)}
except ValueError:
pass
if "opened_expiry_date" not in item:
item = {**item, "opened_expiry_date": None}
# Secondary use window — check sell-by date (not opened expiry).
# Apply dietary constraint filter (e.g. wine suppressed for halal/alcohol-free).
sec = _predictor.secondary_state(item.get("category"), item.get("expiration_date"))
sec = _predictor.filter_secondary_by_constraints(sec, user_constraints or [])
item = {
**item,
"secondary_state": sec["label"] if sec else None,
"secondary_uses": sec["uses"] if sec else None,
"secondary_warning": sec["warning"] if sec else None,
"secondary_discard_signs": sec["discard_signs"] if sec else None,
}
return item
# ── Products ──────────────────────────────────────────────────────────────────
@router.post("/products", response_model=ProductResponse, status_code=status.HTTP_201_CREATED)
async def create_product(body: ProductCreate, store: Store = Depends(get_store)):
product, _ = await asyncio.to_thread(
store.get_or_create_product,
body.name,
body.barcode,
brand=body.brand,
category=body.category,
description=body.description,
image_url=body.image_url,
nutrition_data=body.nutrition_data,
source=body.source,
source_data=body.source_data,
)
return ProductResponse.model_validate(product)
@router.get("/products", response_model=List[ProductResponse])
async def list_products(store: Store = Depends(get_store)):
products = await asyncio.to_thread(store.list_products)
return [ProductResponse.model_validate(p) for p in products]
@router.get("/products/{product_id}", response_model=ProductResponse)
async def get_product(product_id: int, store: Store = Depends(get_store)):
product = await asyncio.to_thread(store.get_product, product_id)
if not product:
raise HTTPException(status_code=404, detail="Product not found")
return ProductResponse.model_validate(product)
@router.get("/products/barcode/{barcode}", response_model=ProductResponse)
async def get_product_by_barcode(barcode: str, store: Store = Depends(get_store)):
from app.db import store as store_module # avoid circular
product = await asyncio.to_thread(
store._fetch_one, "SELECT * FROM products WHERE barcode = ?", (barcode,)
)
if not product:
raise HTTPException(status_code=404, detail="Product not found")
return ProductResponse.model_validate(product)
@router.patch("/products/{product_id}", response_model=ProductResponse)
async def update_product(
product_id: int, body: ProductUpdate, store: Store = Depends(get_store)
):
updates = body.model_dump(exclude_none=True)
if not updates:
product = await asyncio.to_thread(store.get_product, product_id)
else:
import json
sets = ", ".join(f"{k} = ?" for k in updates)
values = []
for k, v in updates.items():
values.append(json.dumps(v) if isinstance(v, dict) else v)
values.append(product_id)
await asyncio.to_thread(
store.conn.execute,
f"UPDATE products SET {sets}, updated_at = datetime('now') WHERE id = ?",
values,
)
store.conn.commit()
product = await asyncio.to_thread(store.get_product, product_id)
if not product:
raise HTTPException(status_code=404, detail="Product not found")
return ProductResponse.model_validate(product)
@router.delete("/products/{product_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_product(product_id: int, store: Store = Depends(get_store)):
existing = await asyncio.to_thread(store.get_product, product_id)
if not existing:
raise HTTPException(status_code=404, detail="Product not found")
await asyncio.to_thread(
store.conn.execute, "DELETE FROM products WHERE id = ?", (product_id,)
)
store.conn.commit()
# ── Inventory items ───────────────────────────────────────────────────────────
@router.post("/items", response_model=InventoryItemResponse, status_code=status.HTTP_201_CREATED)
async def create_inventory_item(
body: InventoryItemCreate,
store: Store = Depends(get_store),
session: CloudUser = Depends(get_session),
):
log.info("add_item auth=%s tier=%s product_id=%s", _auth_label(session.user_id), session.tier, body.product_id)
item = await asyncio.to_thread(
store.add_inventory_item,
body.product_id,
body.location,
quantity=body.quantity,
unit=body.unit,
sublocation=body.sublocation,
purchase_date=str(body.purchase_date) if body.purchase_date else None,
expiration_date=str(body.expiration_date) if body.expiration_date else None,
notes=body.notes,
source=body.source,
)
# RETURNING * omits joined columns (product_name, barcode, category).
# Re-fetch with the products JOIN so the response is fully populated (#99).
full_item = await asyncio.to_thread(store.get_inventory_item, item["id"])
return InventoryItemResponse.model_validate(full_item)
@router.post("/items/bulk-add-by-name", response_model=BulkAddByNameResponse)
async def bulk_add_items_by_name(body: BulkAddByNameRequest, store: Store = Depends(get_store)):
"""Create pantry items from a list of ingredient names (no barcode required).
Uses get_or_create_product so re-adding an existing product is idempotent.
"""
results: list[BulkAddItemResult] = []
for entry in body.items:
try:
product, _ = await asyncio.to_thread(
store.get_or_create_product, entry.name, None, source="manual"
)
item = await asyncio.to_thread(
store.add_inventory_item,
product["id"],
entry.location,
quantity=entry.quantity,
unit=entry.unit,
source="manual",
)
results.append(BulkAddItemResult(name=entry.name, ok=True, item_id=item["id"]))
except Exception as exc:
results.append(BulkAddItemResult(name=entry.name, ok=False, error=str(exc)))
added = sum(1 for r in results if r.ok)
return BulkAddByNameResponse(added=added, failed=len(results) - added, results=results)
@router.get("/items", response_model=List[InventoryItemResponse])
async def list_inventory_items(
location: Optional[str] = None,
item_status: str = "available",
store: Store = Depends(get_store),
):
items = await asyncio.to_thread(store.list_inventory, location, item_status)
constraints = await asyncio.to_thread(_user_constraints, store)
return [InventoryItemResponse.model_validate(_enrich_item(i, constraints)) for i in items]
@router.get("/items/expiring", response_model=List[InventoryItemResponse])
async def get_expiring_items(days: int = 7, store: Store = Depends(get_store)):
items = await asyncio.to_thread(store.expiring_soon, days)
constraints = await asyncio.to_thread(_user_constraints, store)
return [InventoryItemResponse.model_validate(_enrich_item(i, constraints)) for i in items]
@router.get("/items/{item_id}", response_model=InventoryItemResponse)
async def get_inventory_item(item_id: int, store: Store = Depends(get_store)):
item = await asyncio.to_thread(store.get_inventory_item, item_id)
if not item:
raise HTTPException(status_code=404, detail="Inventory item not found")
constraints = await asyncio.to_thread(_user_constraints, store)
return InventoryItemResponse.model_validate(_enrich_item(item, constraints))
@router.patch("/items/{item_id}", response_model=InventoryItemResponse)
async def update_inventory_item(
item_id: int, body: InventoryItemUpdate, store: Store = Depends(get_store)
):
updates = body.model_dump(exclude_none=True)
if "purchase_date" in updates and updates["purchase_date"]:
updates["purchase_date"] = str(updates["purchase_date"])
if "expiration_date" in updates and updates["expiration_date"]:
updates["expiration_date"] = str(updates["expiration_date"])
if "opened_date" in updates and updates["opened_date"]:
updates["opened_date"] = str(updates["opened_date"])
item = await asyncio.to_thread(store.update_inventory_item, item_id, **updates)
if not item:
raise HTTPException(status_code=404, detail="Inventory item not found")
constraints = await asyncio.to_thread(_user_constraints, store)
return InventoryItemResponse.model_validate(_enrich_item(item, constraints))
@router.post("/items/{item_id}/open", response_model=InventoryItemResponse)
async def mark_item_opened(item_id: int, store: Store = Depends(get_store)):
"""Record that this item was opened today, triggering secondary shelf-life tracking."""
from datetime import date
item = await asyncio.to_thread(
store.update_inventory_item,
item_id,
opened_date=str(date.today()),
)
if not item:
raise HTTPException(status_code=404, detail="Inventory item not found")
constraints = await asyncio.to_thread(_user_constraints, store)
return InventoryItemResponse.model_validate(_enrich_item(item, constraints))
@router.post("/items/{item_id}/consume", response_model=InventoryItemResponse)
async def consume_item(
item_id: int,
body: Optional[PartialConsumeRequest] = None,
store: Store = Depends(get_store),
):
"""Consume an inventory item fully or partially.
When body.quantity is provided, decrements by that amount and only marks
status=consumed when quantity reaches zero. Omit body to consume all.
"""
from datetime import datetime, timezone
now = datetime.now(timezone.utc).isoformat()
if body is not None:
item = await asyncio.to_thread(
store.partial_consume_item, item_id, body.quantity, now
)
else:
item = await asyncio.to_thread(
store.update_inventory_item,
item_id,
status="consumed",
consumed_at=now,
)
if not item:
raise HTTPException(status_code=404, detail="Inventory item not found")
constraints = await asyncio.to_thread(_user_constraints, store)
return InventoryItemResponse.model_validate(_enrich_item(item, constraints))
@router.post("/items/{item_id}/discard", response_model=InventoryItemResponse)
async def discard_item(
item_id: int,
body: DiscardRequest = DiscardRequest(),
store: Store = Depends(get_store),
):
"""Mark an item as discarded (not used, spoiled, etc).
Optional reason field accepts free text or a preset label
('not used', 'spoiled', 'excess', 'other').
"""
from datetime import datetime, timezone
item = await asyncio.to_thread(
store.update_inventory_item,
item_id,
status="discarded",
consumed_at=datetime.now(timezone.utc).isoformat(),
disposal_reason=body.reason,
)
if not item:
raise HTTPException(status_code=404, detail="Inventory item not found")
constraints = await asyncio.to_thread(_user_constraints, store)
return InventoryItemResponse.model_validate(_enrich_item(item, constraints))
@router.delete("/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_inventory_item(item_id: int, store: Store = Depends(get_store)):
existing = await asyncio.to_thread(store.get_inventory_item, item_id)
if not existing:
raise HTTPException(status_code=404, detail="Inventory item not found")
await asyncio.to_thread(
store.conn.execute, "DELETE FROM inventory_items WHERE id = ?", (item_id,)
)
store.conn.commit()
# ── Barcode scanning ──────────────────────────────────────────────────────────
class BarcodeScanTextRequest(BaseModel):
barcode: str
location: str = "pantry"
quantity: float = 1.0
auto_add_to_inventory: bool = True
def _captured_to_product_info(row: dict) -> dict:
"""Convert a captured_products row to the product_info dict shape used by
the barcode scan flow (mirrors what OpenFoodFactsService returns)."""
macros: dict = {}
for field in ("calories", "fat_g", "saturated_fat_g", "carbs_g", "sugar_g",
"fiber_g", "protein_g", "sodium_mg", "serving_size_g"):
if row.get(field) is not None:
macros[field] = row[field]
return {
"name": row.get("product_name") or row.get("barcode", "Unknown Product"),
"brand": row.get("brand"),
"category": None,
"nutrition_data": macros,
"ingredient_names": row.get("ingredient_names") or [],
"allergens": row.get("allergens") or [],
"source": "visual_capture",
}
def _gap_message(tier: str, has_visual_capture: bool) -> str:
if has_visual_capture:
return "We couldn't find this product. Photograph the nutrition label to add it."
return "Not found in any product database — add manually"
@router.post("/scan/text", response_model=BarcodeScanResponse)
async def scan_barcode_text(
body: BarcodeScanTextRequest,
store: Store = Depends(get_store),
session: CloudUser = Depends(get_session),
):
"""Scan a barcode from a text string (e.g. from a hardware scanner or manual entry)."""
log.info("scan auth=%s tier=%s barcode=%r", _auth_label(session.user_id), session.tier, body.barcode)
from app.services.openfoodfacts import OpenFoodFactsService
from app.services.expiration_predictor import ExpirationPredictor
from app.tiers import can_use
predictor = ExpirationPredictor()
has_visual_capture = can_use("visual_label_capture", session.tier, session.has_byok)
# 1. Check local captured-products cache before hitting FDC/OFF
cached = await asyncio.to_thread(store.get_captured_product, body.barcode)
if cached and cached.get("confirmed_by_user"):
product_info: dict | None = _captured_to_product_info(cached)
product_source = "visual_capture"
else:
off = OpenFoodFactsService()
product_info = await off.lookup_product(body.barcode)
product_source = "openfoodfacts"
inventory_item = None
if product_info and body.auto_add_to_inventory:
product, _ = await asyncio.to_thread(
store.get_or_create_product,
product_info.get("name", body.barcode),
body.barcode,
brand=product_info.get("brand"),
category=product_info.get("category"),
nutrition_data=product_info.get("nutrition_data", {}),
source=product_source,
source_data=product_info,
)
exp = predictor.predict_expiration(
product_info.get("category", ""),
body.location,
product_name=product_info.get("name", body.barcode),
tier=session.tier,
has_byok=session.has_byok,
)
# Use OFFs pack size when detected; caller-supplied quantity is a fallback
resolved_qty = product_info.get("pack_quantity") or body.quantity
resolved_unit = product_info.get("pack_unit") or "count"
inventory_item = await asyncio.to_thread(
store.add_inventory_item,
product["id"], body.location,
quantity=resolved_qty,
unit=resolved_unit,
expiration_date=str(exp) if exp else None,
source="barcode_scan",
)
result_product = ProductResponse.model_validate(product)
else:
result_product = None
product_found = product_info is not None
needs_capture = not product_found and has_visual_capture
return BarcodeScanResponse(
success=True,
barcodes_found=1,
results=[{
"barcode": body.barcode,
"barcode_type": "text",
"product": result_product,
"inventory_item": InventoryItemResponse.model_validate(inventory_item) if inventory_item else None,
"added_to_inventory": inventory_item is not None,
"needs_manual_entry": not product_found and not needs_capture,
"needs_visual_capture": needs_capture,
"message": "Added to inventory" if inventory_item else _gap_message(session.tier, needs_capture),
}],
message="Barcode processed",
)
@router.post("/scan", response_model=BarcodeScanResponse)
async def scan_barcode_image(
file: UploadFile = File(...),
auto_add_to_inventory: bool = Form(True),
location: str = Form("pantry"),
quantity: float = Form(1.0),
store: Store = Depends(get_store),
session: CloudUser = Depends(get_session),
):
"""Scan a barcode from an uploaded image. Requires Phase 2 scanner integration."""
log.info("scan_image auth=%s tier=%s", _auth_label(session.user_id), session.tier)
from app.tiers import can_use
has_visual_capture = can_use("visual_label_capture", session.tier, session.has_byok)
temp_dir = Path("/tmp/kiwi_barcode_scans")
temp_dir.mkdir(parents=True, exist_ok=True)
temp_file = temp_dir / f"{uuid.uuid4()}_{file.filename}"
try:
async with aiofiles.open(temp_file, "wb") as f:
await f.write(await file.read())
from app.services.barcode_scanner import BarcodeScanner
from app.services.openfoodfacts import OpenFoodFactsService
from app.services.expiration_predictor import ExpirationPredictor
barcodes = await asyncio.to_thread(BarcodeScanner().scan_image, temp_file)
if not barcodes:
return BarcodeScanResponse(
success=False, barcodes_found=0, results=[],
message="No barcodes detected in image"
)
off = OpenFoodFactsService()
predictor = ExpirationPredictor()
results = []
for bc in barcodes:
code = bc["data"]
# Check local visual-capture cache before hitting FDC/OFF
cached = await asyncio.to_thread(store.get_captured_product, code)
if cached and cached.get("confirmed_by_user"):
product_info: dict | None = _captured_to_product_info(cached)
product_source = "visual_capture"
else:
product_info = await off.lookup_product(code)
product_source = "openfoodfacts"
inventory_item = None
if product_info and auto_add_to_inventory:
product, _ = await asyncio.to_thread(
store.get_or_create_product,
product_info.get("name", code),
code,
brand=product_info.get("brand"),
category=product_info.get("category"),
nutrition_data=product_info.get("nutrition_data", {}),
source=product_source,
source_data=product_info,
)
exp = predictor.predict_expiration(
product_info.get("category", ""),
location,
product_name=product_info.get("name", code),
tier=session.tier,
has_byok=session.has_byok,
)
resolved_qty = product_info.get("pack_quantity") or quantity
resolved_unit = product_info.get("pack_unit") or "count"
inventory_item = await asyncio.to_thread(
store.add_inventory_item,
product["id"], location,
quantity=resolved_qty,
unit=resolved_unit,
expiration_date=str(exp) if exp else None,
source="barcode_scan",
)
product_found = product_info is not None
needs_capture = not product_found and has_visual_capture
results.append({
"barcode": code,
"barcode_type": bc.get("type", "unknown"),
"product": ProductResponse.model_validate(product_info) if product_info else None,
"inventory_item": InventoryItemResponse.model_validate(inventory_item) if inventory_item else None,
"added_to_inventory": inventory_item is not None,
"needs_manual_entry": not product_found and not needs_capture,
"needs_visual_capture": needs_capture,
"message": "Added to inventory" if inventory_item else _gap_message(session.tier, needs_capture),
})
return BarcodeScanResponse(
success=True, barcodes_found=len(barcodes), results=results,
message=f"Processed {len(barcodes)} barcode(s)"
)
finally:
if temp_file.exists():
temp_file.unlink()
# ── Visual label capture (kiwi#79) ────────────────────────────────────────────
@router.post("/scan/label-capture")
async def capture_nutrition_label(
file: UploadFile = File(...),
barcode: str = Form(...),
store: Store = Depends(get_store),
session: CloudUser = Depends(get_session),
):
"""Photograph a nutrition label for an unenriched product (paid tier).
Sends the image to the vision model and returns structured nutrition data
for user review. Fields extracted with confidence < 0.7 should be
highlighted in amber in the UI.
"""
from app.tiers import can_use
from app.models.schemas.label_capture import LabelCaptureResponse
from app.services.label_capture import extract_label, needs_review as _needs_review
if not can_use("visual_label_capture", session.tier, session.has_byok):
raise HTTPException(status_code=403, detail="Visual label capture requires a Paid tier or higher.")
log.info("label_capture tier=%s barcode=%r", session.tier, barcode)
image_bytes = await file.read()
extraction = await asyncio.to_thread(extract_label, image_bytes)
return LabelCaptureResponse(
barcode=barcode,
product_name=extraction.get("product_name"),
brand=extraction.get("brand"),
serving_size_g=extraction.get("serving_size_g"),
calories=extraction.get("calories"),
fat_g=extraction.get("fat_g"),
saturated_fat_g=extraction.get("saturated_fat_g"),
carbs_g=extraction.get("carbs_g"),
sugar_g=extraction.get("sugar_g"),
fiber_g=extraction.get("fiber_g"),
protein_g=extraction.get("protein_g"),
sodium_mg=extraction.get("sodium_mg"),
ingredient_names=extraction.get("ingredient_names") or [],
allergens=extraction.get("allergens") or [],
confidence=extraction.get("confidence", 0.0),
needs_review=_needs_review(extraction),
)
@router.post("/scan/label-confirm")
async def confirm_nutrition_label(
body: LabelConfirmRequest,
store: Store = Depends(get_store),
session: CloudUser = Depends(get_session),
):
"""Confirm and save a user-reviewed label extraction.
Saves the product to the local cache so future scans of the same barcode
resolve instantly without another capture. Optionally adds the item to
the user's inventory.
"""
from app.tiers import can_use
from app.models.schemas.label_capture import LabelConfirmResponse
from app.services.expiration_predictor import ExpirationPredictor
if not can_use("visual_label_capture", session.tier, session.has_byok):
raise HTTPException(status_code=403, detail="Visual label capture requires a Paid tier or higher.")
log.info("label_confirm tier=%s barcode=%r", session.tier, body.barcode)
# Persist to local visual-capture cache
await asyncio.to_thread(
store.save_captured_product,
body.barcode,
product_name=body.product_name,
brand=body.brand,
serving_size_g=body.serving_size_g,
calories=body.calories,
fat_g=body.fat_g,
saturated_fat_g=body.saturated_fat_g,
carbs_g=body.carbs_g,
sugar_g=body.sugar_g,
fiber_g=body.fiber_g,
protein_g=body.protein_g,
sodium_mg=body.sodium_mg,
ingredient_names=body.ingredient_names,
allergens=body.allergens,
confidence=body.confidence,
confirmed_by_user=True,
)
product_id: int | None = None
inventory_item_id: int | None = None
if body.auto_add:
predictor = ExpirationPredictor()
nutrition = {}
for field in ("calories", "fat_g", "saturated_fat_g", "carbs_g", "sugar_g",
"fiber_g", "protein_g", "sodium_mg", "serving_size_g"):
val = getattr(body, field, None)
if val is not None:
nutrition[field] = val
product, _ = await asyncio.to_thread(
store.get_or_create_product,
body.product_name or body.barcode,
body.barcode,
brand=body.brand,
category=None,
nutrition_data=nutrition,
source="visual_capture",
source_data={},
)
product_id = product["id"]
exp = predictor.predict_expiration(
"",
body.location,
product_name=body.product_name or body.barcode,
tier=session.tier,
has_byok=session.has_byok,
)
inv_item = await asyncio.to_thread(
store.add_inventory_item,
product_id, body.location,
quantity=body.quantity,
unit="count",
expiration_date=str(exp) if exp else None,
source="visual_capture",
)
inventory_item_id = inv_item["id"]
return LabelConfirmResponse(
ok=True,
barcode=body.barcode,
product_id=product_id,
inventory_item_id=inventory_item_id,
message="Product saved" + (" and added to inventory" if body.auto_add else ""),
)
# ── Tags ──────────────────────────────────────────────────────────────────────
@router.post("/tags", response_model=TagResponse, status_code=status.HTTP_201_CREATED)
async def create_tag(body: TagCreate, store: Store = Depends(get_store)):
cur = await asyncio.to_thread(
store.conn.execute,
"INSERT INTO tags (name, slug, description, color, category) VALUES (?,?,?,?,?) RETURNING *",
(body.name, body.slug, body.description, body.color, body.category),
)
store.conn.commit()
import sqlite3; store.conn.row_factory = sqlite3.Row
return TagResponse.model_validate(store._row_to_dict(cur.fetchone()))
@router.get("/tags", response_model=List[TagResponse])
async def list_tags(
category: Optional[str] = None, store: Store = Depends(get_store)
):
if category:
tags = await asyncio.to_thread(
store._fetch_all, "SELECT * FROM tags WHERE category = ? ORDER BY name", (category,)
)
else:
tags = await asyncio.to_thread(
store._fetch_all, "SELECT * FROM tags ORDER BY name"
)
return [TagResponse.model_validate(t) for t in tags]
# ── Stats ─────────────────────────────────────────────────────────────────────
@router.post("/recalculate-expiry")
async def recalculate_expiry(
session: CloudUser = Depends(get_session),
store: Store = Depends(get_store),
) -> dict:
"""Re-run the expiration predictor over all available inventory items.
Uses each item's stored purchase_date and current location. Safe to call
multiple times — idempotent per session.
"""
def _run(s: Store) -> tuple[int, int]:
return s.recalculate_expiry(tier=session.tier, has_byok=session.has_byok)
updated, skipped = await asyncio.to_thread(_run, store)
return {"updated": updated, "skipped": skipped}
@router.get("/stats", response_model=InventoryStats)
async def get_inventory_stats(store: Store = Depends(get_store)):
def _stats():
rows = store._fetch_all(
"""SELECT status, location, COUNT(*) as cnt
FROM inventory_items GROUP BY status, location"""
)
total = sum(r["cnt"] for r in rows)
available = sum(r["cnt"] for r in rows if r["status"] == "available")
expired = sum(r["cnt"] for r in rows if r["status"] == "expired")
expiring = len(store.expiring_soon(7))
locations = {}
for r in rows:
if r["status"] == "available":
locations[r["location"]] = locations.get(r["location"], 0) + r["cnt"]
return {
"total_items": total,
"available_items": available,
"expiring_soon": expiring,
"expired_items": expired,
"locations": locations,
}
return InventoryStats.model_validate(await asyncio.to_thread(_stats))