diff --git a/app/api/endpoints/inventory.py b/app/api/endpoints/inventory.py index 482868a..a6a57bf 100644 --- a/app/api/endpoints/inventory.py +++ b/app/api/endpoints/inventory.py @@ -22,10 +22,12 @@ from app.models.schemas.inventory import ( BulkAddByNameRequest, BulkAddByNameResponse, BulkAddItemResult, + DiscardRequest, InventoryItemCreate, InventoryItemResponse, InventoryItemUpdate, InventoryStats, + PartialConsumeRequest, ProductCreate, ProductResponse, ProductUpdate, @@ -239,13 +241,52 @@ async def mark_item_opened(item_id: int, store: Store = Depends(get_store)): @router.post("/items/{item_id}/consume", response_model=InventoryItemResponse) -async def consume_item(item_id: int, store: Store = Depends(get_store)): +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") + return InventoryItemResponse.model_validate(_enrich_item(item)) + + +@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="consumed", + 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") @@ -305,10 +346,14 @@ async def scan_barcode_text( 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=body.quantity, + quantity=resolved_qty, + unit=resolved_unit, expiration_date=str(exp) if exp else None, source="barcode_scan", ) @@ -383,10 +428,13 @@ async def scan_barcode_image( 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=quantity, + quantity=resolved_qty, + unit=resolved_unit, expiration_date=str(exp) if exp else None, source="barcode_scan", ) diff --git a/app/db/migrations/031_disposal_reason.sql b/app/db/migrations/031_disposal_reason.sql new file mode 100644 index 0000000..5289d79 --- /dev/null +++ b/app/db/migrations/031_disposal_reason.sql @@ -0,0 +1,4 @@ +-- Migration 031: add disposal_reason for waste logging (#60) +-- status='discarded' already exists in the CHECK constraint from migration 002. +-- This column stores free-text reason (optional) and calm-framing presets. +ALTER TABLE inventory_items ADD COLUMN disposal_reason TEXT; diff --git a/app/db/store.py b/app/db/store.py index 1a30f38..49f94ba 100644 --- a/app/db/store.py +++ b/app/db/store.py @@ -218,7 +218,8 @@ class Store: def update_inventory_item(self, item_id: int, **kwargs) -> dict[str, Any] | None: allowed = {"quantity", "unit", "location", "sublocation", - "expiration_date", "opened_date", "status", "notes", "consumed_at"} + "expiration_date", "opened_date", "status", "notes", "consumed_at", + "disposal_reason"} updates = {k: v for k, v in kwargs.items() if k in allowed} if not updates: return self.get_inventory_item(item_id) @@ -231,6 +232,32 @@ class Store: self.conn.commit() return self.get_inventory_item(item_id) + def partial_consume_item( + self, + item_id: int, + consume_qty: float, + consumed_at: str, + ) -> dict[str, Any] | None: + """Decrement quantity by consume_qty. Mark consumed when quantity reaches 0.""" + row = self.get_inventory_item(item_id) + if row is None: + return None + remaining = max(0.0, round(row["quantity"] - consume_qty, 6)) + if remaining <= 0: + self.conn.execute( + "UPDATE inventory_items SET quantity = 0, status = 'consumed'," + " consumed_at = ?, updated_at = datetime('now') WHERE id = ?", + (consumed_at, item_id), + ) + else: + self.conn.execute( + "UPDATE inventory_items SET quantity = ?, updated_at = datetime('now')" + " WHERE id = ?", + (remaining, item_id), + ) + self.conn.commit() + return self.get_inventory_item(item_id) + def expiring_soon(self, days: int = 7) -> list[dict[str, Any]]: return self._fetch_all( """SELECT i.*, p.name as product_name, p.category diff --git a/app/models/schemas/inventory.py b/app/models/schemas/inventory.py index 9e6ccbf..dcea59b 100644 --- a/app/models/schemas/inventory.py +++ b/app/models/schemas/inventory.py @@ -93,6 +93,15 @@ class InventoryItemUpdate(BaseModel): 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): @@ -111,6 +120,7 @@ class InventoryItemResponse(BaseModel): opened_expiry_date: Optional[str] = None status: str notes: Optional[str] + disposal_reason: Optional[str] = None source: str created_at: str updated_at: str diff --git a/app/services/openfoodfacts.py b/app/services/openfoodfacts.py index dbe31cd..d181fb3 100644 --- a/app/services/openfoodfacts.py +++ b/app/services/openfoodfacts.py @@ -114,6 +114,9 @@ class OpenFoodFactsService: allergens = product.get("allergens_tags", []) labels = product.get("labels_tags", []) + # Pack size detection: prefer explicit unit_count, fall back to serving count + pack_quantity, pack_unit = self._extract_pack_size(product) + return { "name": name, "brand": brand, @@ -124,9 +127,47 @@ class OpenFoodFactsService: "nutrition_data": nutrition_data, "allergens": allergens, "labels": labels, + "pack_quantity": pack_quantity, + "pack_unit": pack_unit, "raw_data": product, # Store full response for debugging } + def _extract_pack_size(self, product: Dict[str, Any]) -> tuple[float | None, str | None]: + """Return (quantity, unit) for multi-pack products, or (None, None). + + OFFs fields tried in order: + 1. `number_of_units` (explicit count, highest confidence) + 2. `serving_quantity` + `product_quantity_unit` (e.g. 6 x 150g yoghurt) + 3. Parse `quantity` string like "4 x 113 g" or "6 pack" + + Returns None, None when data is absent, ambiguous, or single-unit. + """ + import re + + # Field 1: explicit unit count + unit_count = product.get("number_of_units") + if unit_count: + try: + n = float(unit_count) + if n > 1: + return n, product.get("serving_size_unit") or "unit" + except (ValueError, TypeError): + pass + + # Field 2: parse quantity string for "N x ..." pattern + qty_str = product.get("quantity", "") + if qty_str: + m = re.match(r"^(\d+(?:\.\d+)?)\s*[xX×]\s*", qty_str.strip()) + if m: + n = float(m.group(1)) + if n > 1: + # Try to get a sensible sub-unit label from the rest + rest = qty_str[m.end():].strip() + unit_label = re.sub(r"[\d.,\s]+", "", rest).strip()[:20] or "unit" + return n, unit_label + + return None, None + def _extract_nutrition_data(self, product: Dict[str, Any]) -> Dict[str, Any]: """ Extract nutrition facts from product data. diff --git a/frontend/src/components/ActionDialog.vue b/frontend/src/components/ActionDialog.vue new file mode 100644 index 0000000..9d388af --- /dev/null +++ b/frontend/src/components/ActionDialog.vue @@ -0,0 +1,275 @@ + + + + + + {{ title }} + + + + {{ message }} + + + + {{ inputLabel }} + + + {{ inputUnit }} + + + Use all ({{ inputMax }} {{ inputUnit }}) + + + + + + {{ inputLabel }} + + — skip — + {{ opt }} + + + + + + + + + + + + + diff --git a/frontend/src/components/InventoryList.vue b/frontend/src/components/InventoryList.vue index cb2f7b0..4d7ca93 100644 --- a/frontend/src/components/InventoryList.vue +++ b/frontend/src/components/InventoryList.vue @@ -354,11 +354,28 @@ - + + + + + + @@ -400,6 +417,22 @@ @cancel="confirmDialog.show = false" /> + + { actionDialog.onConfirm(v); actionDialog.show = false }" + @cancel="actionDialog.show = false" + /> + {}, }) +const actionDialog = reactive({ + show: false, + title: '', + message: '', + type: 'primary' as 'primary' | 'danger' | 'warning' | 'secondary', + confirmText: 'Confirm', + inputType: null as 'quantity' | 'select' | null, + inputLabel: '', + inputMax: 1, + inputUnit: '', + inputOptions: [] as string[], + onConfirm: (_v: number | string | undefined) => {}, +}) + // Toast Notification const toast = reactive({ show: false, @@ -585,24 +633,65 @@ async function markAsOpened(item: InventoryItem) { } } -async function markAsConsumed(item: InventoryItem) { - showConfirm( - `Mark ${item.product_name || 'item'} as consumed?`, - async () => { +function markAsConsumed(item: InventoryItem) { + const isMulti = item.quantity > 1 + const label = item.product_name || 'item' + Object.assign(actionDialog, { + show: true, + title: 'Mark as Used', + message: isMulti + ? `How much of ${label} did you use?` + : `Mark ${label} as used?`, + type: 'primary', + confirmText: isMulti ? 'Use' : 'Mark as Used', + inputType: isMulti ? 'quantity' : null, + inputLabel: 'Amount used:', + inputMax: item.quantity, + inputUnit: item.unit, + inputOptions: [], + onConfirm: async (val: number | string | undefined) => { + const qty = isMulti ? (val as number) : undefined try { - await inventoryAPI.consumeItem(item.id) + await inventoryAPI.consumeItem(item.id, qty) await refreshItems() - showToast(`${item.product_name || 'item'} marked as consumed`, 'success') - } catch (err) { - showToast('Failed to mark item as consumed', 'error') + const verb = qty !== undefined && qty < item.quantity ? 'partially used' : 'marked as used' + showToast(`${label} ${verb}`, 'success') + } catch { + showToast('Could not update item', 'error') } }, - { - title: 'Mark as Consumed', - type: 'primary', - confirmText: 'Mark as Consumed', - } - ) + }) +} + +function markAsDiscarded(item: InventoryItem) { + const label = item.product_name || 'item' + Object.assign(actionDialog, { + show: true, + title: 'Item Not Used', + message: `${label} — what happened to it?`, + type: 'secondary', + confirmText: 'Log It', + inputType: 'select', + inputLabel: 'Reason (optional):', + inputMax: 1, + inputUnit: '', + inputOptions: [ + 'went bad before I could use it', + 'too much — had excess', + 'changed my mind', + 'other', + ], + onConfirm: async (val: number | string | undefined) => { + const reason = typeof val === 'string' && val ? val : undefined + try { + await inventoryAPI.discardItem(item.id, reason) + await refreshItems() + showToast(`${label} logged as not used`, 'info') + } catch { + showToast('Could not update item', 'error') + } + }, + }) } // Scanner Gun Functions @@ -1201,6 +1290,16 @@ function getItemClass(item: InventoryItem): string { background: var(--color-warning-bg); } +/* "Item not used" discard button — muted, not alarming */ +.btn-icon-discard { + color: var(--color-text-tertiary); +} + +.btn-icon-discard:hover { + color: var(--color-text-secondary); + background: var(--color-bg-secondary); +} + /* Opened badge — distinct icon prefix signals this is after-open expiry */ .expiry-opened { letter-spacing: 0; diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index a946dcd..f0f9937 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -96,6 +96,7 @@ export interface InventoryItem { status: string source: string notes: string | null + disposal_reason: string | null created_at: string updated_at: string } @@ -235,10 +236,21 @@ export const inventoryAPI = { }, /** - * Mark item as consumed + * Mark item as consumed fully or partially. + * Pass quantity to decrement; omit to consume all. */ - async consumeItem(itemId: number): Promise { - await api.post(`/inventory/items/${itemId}/consume`) + async consumeItem(itemId: number, quantity?: number): Promise { + const body = quantity !== undefined ? { quantity } : undefined + const response = await api.post(`/inventory/items/${itemId}/consume`, body) + return response.data + }, + + /** + * Mark item as discarded (not used, spoiled, etc). + */ + async discardItem(itemId: number, reason?: string): Promise { + const response = await api.post(`/inventory/items/${itemId}/discard`, { reason: reason ?? null }) + return response.data }, /**
{{ message }}