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
189 lines
5.8 KiB
Python
189 lines
5.8 KiB
Python
"""Pydantic schemas for inventory management (integer IDs, SQLite-compatible)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import date, datetime
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
|
# ── Tags ──────────────────────────────────────────────────────────────────────
|
|
|
|
class TagCreate(BaseModel):
|
|
name: str = Field(..., max_length=100)
|
|
slug: str = Field(..., max_length=100)
|
|
description: Optional[str] = None
|
|
color: Optional[str] = Field(None, max_length=7)
|
|
category: Optional[str] = None
|
|
|
|
|
|
class TagResponse(BaseModel):
|
|
id: int
|
|
name: str
|
|
slug: str
|
|
description: Optional[str]
|
|
color: Optional[str]
|
|
category: Optional[str]
|
|
created_at: str
|
|
updated_at: str
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
# ── Products ──────────────────────────────────────────────────────────────────
|
|
|
|
class ProductCreate(BaseModel):
|
|
name: str = Field(..., max_length=500)
|
|
barcode: Optional[str] = Field(None, max_length=50)
|
|
brand: Optional[str] = None
|
|
category: Optional[str] = None
|
|
description: Optional[str] = None
|
|
image_url: Optional[str] = None
|
|
nutrition_data: Dict[str, Any] = Field(default_factory=dict)
|
|
source: str = "manual"
|
|
source_data: Dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
|
class ProductUpdate(BaseModel):
|
|
name: Optional[str] = None
|
|
brand: Optional[str] = None
|
|
category: Optional[str] = None
|
|
description: Optional[str] = None
|
|
image_url: Optional[str] = None
|
|
nutrition_data: Optional[Dict[str, Any]] = None
|
|
|
|
|
|
class ProductResponse(BaseModel):
|
|
id: int
|
|
barcode: Optional[str]
|
|
name: str
|
|
brand: Optional[str]
|
|
category: Optional[str]
|
|
description: Optional[str]
|
|
image_url: Optional[str]
|
|
nutrition_data: Dict[str, Any]
|
|
source: str
|
|
created_at: str
|
|
updated_at: str
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
# ── Inventory Items ───────────────────────────────────────────────────────────
|
|
|
|
class InventoryItemCreate(BaseModel):
|
|
product_id: int
|
|
quantity: float = Field(default=1.0, gt=0)
|
|
unit: str = "count"
|
|
location: str
|
|
sublocation: Optional[str] = None
|
|
purchase_date: Optional[date] = None
|
|
expiration_date: Optional[date] = None
|
|
notes: Optional[str] = None
|
|
source: str = "manual"
|
|
|
|
|
|
class InventoryItemUpdate(BaseModel):
|
|
quantity: Optional[float] = Field(None, gt=0)
|
|
unit: Optional[str] = None
|
|
location: Optional[str] = None
|
|
sublocation: Optional[str] = None
|
|
purchase_date: Optional[date] = None
|
|
expiration_date: Optional[date] = None
|
|
opened_date: Optional[date] = None
|
|
status: Optional[str] = None
|
|
notes: Optional[str] = None
|
|
disposal_reason: Optional[str] = None
|
|
|
|
|
|
class PartialConsumeRequest(BaseModel):
|
|
quantity: float = Field(..., gt=0, description="Amount to consume from this item")
|
|
|
|
|
|
class DiscardRequest(BaseModel):
|
|
reason: Optional[str] = Field(None, max_length=200)
|
|
|
|
|
|
class InventoryItemResponse(BaseModel):
|
|
id: int
|
|
product_id: int
|
|
product_name: Optional[str] = None
|
|
barcode: Optional[str] = None
|
|
category: Optional[str] = None
|
|
quantity: float
|
|
unit: str
|
|
location: str
|
|
sublocation: Optional[str]
|
|
purchase_date: Optional[str]
|
|
expiration_date: Optional[str]
|
|
opened_date: Optional[str] = None
|
|
opened_expiry_date: Optional[str] = None
|
|
secondary_state: Optional[str] = None
|
|
secondary_uses: Optional[List[str]] = None
|
|
secondary_warning: Optional[str] = None
|
|
secondary_discard_signs: Optional[str] = None
|
|
status: str
|
|
notes: Optional[str]
|
|
disposal_reason: Optional[str] = None
|
|
source: str
|
|
created_at: str
|
|
updated_at: str
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
# ── Barcode scan ──────────────────────────────────────────────────────────────
|
|
|
|
class BarcodeScanResult(BaseModel):
|
|
barcode: str
|
|
barcode_type: str
|
|
product: Optional[ProductResponse]
|
|
inventory_item: Optional[InventoryItemResponse]
|
|
added_to_inventory: bool
|
|
needs_manual_entry: bool = False
|
|
needs_visual_capture: bool = False # Paid tier offer when no product data found
|
|
message: str
|
|
|
|
|
|
class BarcodeScanResponse(BaseModel):
|
|
success: bool
|
|
barcodes_found: int
|
|
results: List[BarcodeScanResult]
|
|
message: str
|
|
|
|
|
|
# ── Bulk add by name ─────────────────────────────────────────────────────────
|
|
|
|
class BulkAddItem(BaseModel):
|
|
name: str = Field(..., min_length=1, max_length=200)
|
|
quantity: float = Field(default=1.0, gt=0)
|
|
unit: str = "count"
|
|
location: str = "pantry"
|
|
|
|
|
|
class BulkAddByNameRequest(BaseModel):
|
|
items: List[BulkAddItem] = Field(..., min_length=1)
|
|
|
|
|
|
class BulkAddItemResult(BaseModel):
|
|
name: str
|
|
ok: bool
|
|
item_id: Optional[int] = None
|
|
error: Optional[str] = None
|
|
|
|
|
|
class BulkAddByNameResponse(BaseModel):
|
|
added: int
|
|
failed: int
|
|
results: List[BulkAddItemResult]
|
|
|
|
|
|
# ── Stats ─────────────────────────────────────────────────────────────────────
|
|
|
|
class InventoryStats(BaseModel):
|
|
total_items: int
|
|
available_items: int
|
|
expiring_soon: int
|
|
expired_items: int
|
|
locations: Dict[str, int]
|