From fb18a9c78c72f7ea09e6d471e340e7406166ca3b Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Thu, 16 Apr 2026 07:28:21 -0700 Subject: [PATCH] feat: partial consumption tracking and waste/disposal logging (#12 #60) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #12 — partial consume: - POST /inventory/items/{id}/consume now accepts optional {quantity} body; decrements by that amount and only marks status=consumed when quantity reaches zero (store.partial_consume_item) - OFFs barcode scan pre-fills sub-unit quantity when product data includes a pack size (number_of_units or 'N x ...' quantity string) - Consume button shows quantity-aware label and opens ActionDialog with number input for multi-unit items ('use some or all') - consumeItem() in api.ts now returns InventoryItem and accepts optional quantity param #60 — disposal logging: - Migration 031: adds disposal_reason TEXT column to inventory_items (status='discarded' was already in the CHECK constraint) - POST /inventory/items/{id}/discard endpoint with optional DiscardRequest body (free text or preset reason) - Calm framing: 'item not used' not 'wasted'; reason presets avoid blame language ('went bad before I could use it', 'too much — had excess') - Muted discard button (X icon, tertiary color) — not alarming Shared: - New ActionDialog.vue component for dialogs with inline inputs (quantity stepper or reason dropdown); keeps ConfirmDialog simple - disposal_reason field added to InventoryItemResponse Closes #12 Closes #60 --- app/api/endpoints/inventory.py | 56 ++++- app/db/migrations/031_disposal_reason.sql | 4 + app/db/store.py | 29 ++- app/models/schemas/inventory.py | 10 + app/services/openfoodfacts.py | 41 ++++ frontend/src/components/ActionDialog.vue | 275 ++++++++++++++++++++++ frontend/src/components/InventoryList.vue | 129 ++++++++-- frontend/src/services/api.ts | 18 +- 8 files changed, 539 insertions(+), 23 deletions(-) create mode 100644 app/db/migrations/031_disposal_reason.sql create mode 100644 frontend/src/components/ActionDialog.vue 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 @@ + + + + + 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 @@ - +